diff --git a/.github/workflows/dotnet-sdk-tests.yml b/.github/workflows/dotnet-sdk-tests.yml index 4695cae7f2..cbf4949387 100644 --- a/.github/workflows/dotnet-sdk-tests.yml +++ b/.github/workflows/dotnet-sdk-tests.yml @@ -113,6 +113,14 @@ jobs: cache: "npm" cache-dependency-path: "./nodejs/package-lock.json" + - name: Install bubblewrap + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict + - name: Install Node.js dependencies (for CLI version extraction) working-directory: ./nodejs run: npm ci --ignore-scripts diff --git a/.github/workflows/go-sdk-tests.yml b/.github/workflows/go-sdk-tests.yml index 61d74d257e..896d384395 100644 --- a/.github/workflows/go-sdk-tests.yml +++ b/.github/workflows/go-sdk-tests.yml @@ -34,6 +34,14 @@ jobs: with: go-version: "1.24" + - name: Install bubblewrap + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict + - name: Run go fmt if: runner.os == 'Linux' working-directory: ./go diff --git a/.github/workflows/java-sdk-tests.yml b/.github/workflows/java-sdk-tests.yml index 5f3d7377a0..4e8908ed7a 100644 --- a/.github/workflows/java-sdk-tests.yml +++ b/.github/workflows/java-sdk-tests.yml @@ -49,6 +49,14 @@ jobs: with: node-version: 22 + - name: Install bubblewrap + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict + - name: Validate native host run: node copilot-native/scripts/validate-native-host.mjs ${{ matrix.classifier }} @@ -290,6 +298,13 @@ jobs: with: node-version: 22 + - name: Install bubblewrap + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict + - name: Test documentation version updater if: matrix.test-jdk == '25' run: ./scripts/test-update-documentation-versions.sh diff --git a/.github/workflows/nodejs-sdk-tests.yml b/.github/workflows/nodejs-sdk-tests.yml index 4c31f79cc4..260273a8d0 100644 --- a/.github/workflows/nodejs-sdk-tests.yml +++ b/.github/workflows/nodejs-sdk-tests.yml @@ -36,6 +36,13 @@ jobs: cache: "npm" cache-dependency-path: "./nodejs/package-lock.json" node-version: 22 + - name: Install bubblewrap + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict - name: Install dependencies run: npm ci --ignore-scripts diff --git a/.github/workflows/python-sdk-tests.yml b/.github/workflows/python-sdk-tests.yml index 1ea9739756..98800285a6 100644 --- a/.github/workflows/python-sdk-tests.yml +++ b/.github/workflows/python-sdk-tests.yml @@ -42,6 +42,14 @@ jobs: cache: "npm" cache-dependency-path: "./nodejs/package-lock.json" + - name: Install bubblewrap + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict + - name: Set up uv uses: astral-sh/setup-uv@v7 with: diff --git a/.github/workflows/rust-sdk-tests.yml b/.github/workflows/rust-sdk-tests.yml index 7fdac3b818..d9982927dd 100644 --- a/.github/workflows/rust-sdk-tests.yml +++ b/.github/workflows/rust-sdk-tests.yml @@ -40,6 +40,14 @@ jobs: toolchain: "1.94.0" components: rustfmt, clippy + - name: Install bubblewrap + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict + # Nightly rustfmt for unstable format options (group_imports, # imports_granularity, reorder_impl_items) — pinned in # `.rustfmt.nightly.toml`. @@ -148,6 +156,14 @@ jobs: with: toolchain: "1.94.0" + - name: Install bubblewrap + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install --yes apparmor-profiles bubblewrap + sudo install -m 0644 /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/bwrap-userns-restrict + sudo apparmor_parser --replace /etc/apparmor.d/bwrap-userns-restrict + - uses: Swatinem/rust-cache@42dc69e1aa15d09112580998cf2ef0119e2e91ae # v2 with: workspaces: "rust" diff --git a/dotnet/src/Client.cs b/dotnet/src/Client.cs index f2da0a48f7..1cd5c9ff7a 100644 --- a/dotnet/src/Client.cs +++ b/dotnet/src/Client.cs @@ -1046,6 +1046,11 @@ private async Task UpdateSessionOptionsForModeAsync(CopilotSession session, Sess IList? installedPlugins = null; IList? includedBuiltinSkills = null; + if (config.SandboxConfig is not null) + { + hasAnyPatch = true; + } + if (_options.Mode == CopilotClientMode.Empty) { skipCustomInstructions = config.SkipCustomInstructions ?? true; @@ -1076,6 +1081,7 @@ await session.Rpc.Options.UpdateAsync( coauthorEnabled: coauthorEnabled, manageScheduleEnabled: manageScheduleEnabled, installedPlugins: installedPlugins, + sandboxConfig: config.SandboxConfig, includedBuiltinSkills: includedBuiltinSkills, cancellationToken: cancellationToken).ConfigureAwait(false); #pragma warning restore GHCP001 @@ -1226,6 +1232,7 @@ public async Task CreateSessionAsync(SessionConfig config, Cance config.DisabledSkills, config.InfiniteSessions, config.SessionLimits, + SandboxConfig: config.SandboxConfig, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description ?? string.Empty)).ToList(), RequestElicitation: config.OnElicitationRequest != null, RequestMcpApps: config.EnableMcpApps ? true : null, @@ -1465,6 +1472,7 @@ public async Task ResumeSessionAsync(string sessionId, ResumeSes config.DisabledSkills, config.InfiniteSessions, config.SessionLimits, + SandboxConfig: config.SandboxConfig, Commands: config.Commands?.Select(c => new CommandWireDefinition(c.Name, c.Description ?? string.Empty)).ToList(), RequestElicitation: config.OnElicitationRequest != null, RequestMcpApps: config.EnableMcpApps ? true : null, @@ -2904,6 +2912,7 @@ internal record CreateSessionRequest( IList? DisabledSkills, InfiniteSessionConfig? InfiniteSessions, SessionLimitsConfig? SessionLimits, + SandboxConfig? SandboxConfig = null, IList? Commands = null, bool? RequestElicitation = null, bool? RequestMcpApps = null, @@ -3020,6 +3029,7 @@ internal record ResumeSessionRequest( IList? DisabledSkills, InfiniteSessionConfig? InfiniteSessions, SessionLimitsConfig? SessionLimits, + SandboxConfig? SandboxConfig = null, IList? Commands = null, bool? RequestElicitation = null, bool? RequestMcpApps = null, diff --git a/dotnet/src/Types.cs b/dotnet/src/Types.cs index 6ed05e3064..4fc4d25aa8 100644 --- a/dotnet/src/Types.cs +++ b/dotnet/src/Types.cs @@ -3225,6 +3225,7 @@ protected SessionConfigBase(SessionConfigBase? other) PluginDirectories = other.PluginDirectories is not null ? [.. other.PluginDirectories] : null; InstructionDirectories = other.InstructionDirectories is not null ? [.. other.InstructionDirectories] : null; SessionLimits = other.SessionLimits; + SandboxConfig = other.SandboxConfig; Streaming = other.Streaming; IncludeSubAgentStreamingEvents = other.IncludeSubAgentStreamingEvents; SystemMessage = other.SystemMessage; @@ -3619,6 +3620,12 @@ protected SessionConfigBase(SessionConfigBase? other) [Experimental(Diagnostics.Experimental)] public SessionLimitsConfig? SessionLimits { get; set; } + /// + /// Resolved sandbox configuration applied when the session is created or resumed. + /// + [Experimental(Diagnostics.Experimental)] + public SandboxConfig? SandboxConfig { get; set; } + /// /// Configuration for handling large tool outputs. When a tool produces /// output exceeding the configured size, the output is written to a temp diff --git a/dotnet/test/E2E/SessionConfigE2ETests.cs b/dotnet/test/E2E/SessionConfigE2ETests.cs index 1bc4c52eb9..e934d8cef0 100644 --- a/dotnet/test/E2E/SessionConfigE2ETests.cs +++ b/dotnet/test/E2E/SessionConfigE2ETests.cs @@ -21,6 +21,18 @@ public class SessionConfigE2ETests(E2ETestFixture fixture, ITestOutputHelper out private static readonly byte[] Png1X1 = Convert.FromBase64String( "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg=="); + private static async Task AssertNextShellExecutionResultAsync( + CopilotSession session, + string prompt, + string expected) + { + var eventCount = (await session.GetEventsAsync()).Count; + await session.SendAndWaitAsync(new MessageOptions { Prompt = prompt }); + var completion = Assert.Single( + (await session.GetEventsAsync()).Skip(eventCount).OfType()); + Assert.Contains(expected, completion.Data.Result?.Content ?? string.Empty, StringComparison.Ordinal); + } + [Fact] // TODO(BYOK): Anthropic Messages history diverged after enabling vision via SetModel. Verify // that model capability overrides work for provider-backed sessions before keeping this CAPI-only. @@ -524,6 +536,77 @@ public async Task Should_Apply_Session_Limits_On_Resume() } } + [Fact] + public async Task Should_Apply_Sandbox_Config_On_Create_And_Resume() + { + if (OperatingSystem.IsWindows()) + { + return; + } + + var enabledProbe = "/var/tmp/sandbox-create-enabled.txt"; + var disabledProbe = "/var/tmp/sandbox-create-disabled.txt"; + var resumeProbe = "/var/tmp/sandbox-resume-enabled.txt"; + var probes = new[] { enabledProbe, disabledProbe, resumeProbe }; + foreach (var probe in probes) File.Delete(probe); + await using var enabledSession = await CreateSessionAsync(new SessionConfig + { + WorkingDirectory = Ctx.WorkDir, + SandboxConfig = new SandboxConfig + { + Enabled = true, + UserPolicy = new SandboxConfigUserPolicy + { + Filesystem = new SandboxConfigUserPolicyFilesystem { DeniedPaths = [enabledProbe] }, + }, + }, + }); + await AssertNextShellExecutionResultAsync( + enabledSession, + "Check sandbox access for sandbox-create-enabled.txt.", + "sandbox-blocked"); + + await using var disabledSession = await CreateSessionAsync(new SessionConfig + { + WorkingDirectory = Ctx.WorkDir, + SandboxConfig = new SandboxConfig + { + Enabled = false, + UserPolicy = new SandboxConfigUserPolicy + { + Filesystem = new SandboxConfigUserPolicyFilesystem { DeniedPaths = [disabledProbe] }, + }, + }, + }); + await AssertNextShellExecutionResultAsync( + disabledSession, + "Check sandbox access for sandbox-create-disabled.txt.", + "sandbox-accessible"); + var sessionId = disabledSession.SessionId; + await SuspendAndUntrackSessionForResumeAsync(disabledSession); + + var session2 = await ResumeSessionAsync(sessionId, new ResumeSessionConfig + { + WorkingDirectory = Ctx.WorkDir, + SandboxConfig = new SandboxConfig + { + Enabled = true, + UserPolicy = new SandboxConfigUserPolicy + { + Filesystem = new SandboxConfigUserPolicyFilesystem { DeniedPaths = [resumeProbe] }, + }, + }, + }); + await AssertNextShellExecutionResultAsync( + session2, + "Check sandbox access for sandbox-resume-enabled.txt.", + "sandbox-blocked"); + + Assert.Equal(sessionId, session2.SessionId); + await session2.DisposeAsync(); + foreach (var probe in probes) File.Delete(probe); + } + [Fact] public async Task Should_Apply_Excluded_Built_In_Agents_On_Create() { diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 6edf168093..42be53558f 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -485,7 +485,18 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO ("EnableCitations", true), ("EnableFileChangeTracking", true), ("ExcludedBuiltInAgents", excludedAgents), - ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 12.5 })); + ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 12.5 }), + ("SandboxConfig", new SandboxConfig + { + Enabled = true, + UserPolicy = new SandboxConfigUserPolicy + { + Network = new SandboxConfigUserPolicyNetwork + { + Proxy = new SandboxConfigUserPolicyNetworkProxy { Url = "http://127.0.0.1:4321" }, + }, + }, + })); var createJson = JsonSerializer.Serialize(createRequest, createRequestType, options); using var createDocument = JsonDocument.Parse(createJson); @@ -494,6 +505,9 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO Assert.True(createRoot.GetProperty("enableFileChangeTracking").GetBoolean()); Assert.Equal("explore", createRoot.GetProperty("excludedBuiltinAgents")[0].GetString()); Assert.Equal(12.5, createRoot.GetProperty("sessionLimits").GetProperty("maxAiCredits").GetDouble()); + Assert.Equal( + "http://127.0.0.1:4321", + createRoot.GetProperty("sandboxConfig").GetProperty("userPolicy").GetProperty("network").GetProperty("proxy").GetProperty("url").GetString()); var resumeRequestType = GetNestedType(typeof(CopilotClient), "ResumeSessionRequest"); var resumeRequest = CreateInternalRequest( @@ -502,7 +516,18 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO ("EnableCitations", true), ("EnableFileChangeTracking", true), ("ExcludedBuiltInAgents", excludedAgents), - ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 7.25 })); + ("SessionLimits", new SessionLimitsConfig { MaxAiCredits = 7.25 }), + ("SandboxConfig", new SandboxConfig + { + Enabled = true, + UserPolicy = new SandboxConfigUserPolicy + { + Network = new SandboxConfigUserPolicyNetwork + { + Proxy = new SandboxConfigUserPolicyNetworkProxy { Url = "http://127.0.0.1:4322" }, + }, + }, + })); var resumeJson = JsonSerializer.Serialize(resumeRequest, resumeRequestType, options); using var resumeDocument = JsonDocument.Parse(resumeJson); @@ -511,6 +536,9 @@ public void SessionRequests_CanSerializeCitationAgentExclusionAndLimits_WithSdkO Assert.True(resumeRoot.GetProperty("enableFileChangeTracking").GetBoolean()); Assert.Equal("task", resumeRoot.GetProperty("excludedBuiltinAgents")[1].GetString()); Assert.Equal(7.25, resumeRoot.GetProperty("sessionLimits").GetProperty("maxAiCredits").GetDouble()); + Assert.Equal( + "http://127.0.0.1:4322", + resumeRoot.GetProperty("sandboxConfig").GetProperty("userPolicy").GetProperty("network").GetProperty("proxy").GetProperty("url").GetString()); } [Fact] diff --git a/go/client.go b/go/client.go index 4e44696a55..39c7ec6984 100644 --- a/go/client.go +++ b/go/client.go @@ -846,6 +846,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses req.EnableCitations = config.EnableCitations req.EnableFileChangeTracking = config.EnableFileChangeTracking req.SessionLimits = config.SessionLimits + req.SandboxConfig = config.SandboxConfig req.IsExperimentalMode = config.EnableExperimentalMode req.SkipCustomInstructions = config.SkipCustomInstructions req.CustomAgentsLocalOnly = config.CustomAgentsLocalOnly @@ -1123,6 +1124,7 @@ func (c *Client) CreateSession(ctx context.Context, config *SessionConfig) (*Ses CustomAgentsLocalOnly: config.CustomAgentsLocalOnly, CoauthorEnabled: config.CoauthorEnabled, ManageScheduleEnabled: config.ManageScheduleEnabled, + SandboxConfig: config.SandboxConfig, IncludedBuiltinSkills: config.IncludedBuiltinSkills, }); err != nil { return nil, err @@ -1218,6 +1220,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, req.EnableCitations = config.EnableCitations req.EnableFileChangeTracking = config.EnableFileChangeTracking req.SessionLimits = config.SessionLimits + req.SandboxConfig = config.SandboxConfig if config.Streaming != nil { req.Streaming = config.Streaming } @@ -1426,6 +1429,7 @@ func (c *Client) ResumeSessionWithOptions(ctx context.Context, sessionID string, CustomAgentsLocalOnly: config.CustomAgentsLocalOnly, CoauthorEnabled: config.CoauthorEnabled, ManageScheduleEnabled: config.ManageScheduleEnabled, + SandboxConfig: config.SandboxConfig, IncludedBuiltinSkills: config.IncludedBuiltinSkills, }); err != nil { restoreReplacedSession() diff --git a/go/client_test.go b/go/client_test.go index c6ab0808cb..1c75864170 100644 --- a/go/client_test.go +++ b/go/client_test.go @@ -585,6 +585,11 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { } createParams := make(chan json.RawMessage, 1) + updateParams := make(chan json.RawMessage, 2) + server.SetRequestHandler("session.options.update", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { + updateParams <- append(json.RawMessage(nil), params...) + return []byte(`{"success":true}`), nil + }) server.SetRequestHandler("session.create", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { createParams <- append(json.RawMessage(nil), params...) sessionID := sessionIDFromParams(t, params) @@ -596,11 +601,20 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { EnableCitations: Bool(true), EnableFileChangeTracking: Bool(true), SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(30)}, + SandboxConfig: &rpc.SandboxConfig{ + Enabled: true, + UserPolicy: &rpc.SandboxConfigUserPolicy{ + Network: &rpc.SandboxConfigUserPolicyNetwork{ + Proxy: &rpc.SandboxConfigUserPolicyNetworkProxy{URL: "http://127.0.0.1:4321"}, + }, + }, + }, }) if err != nil { t.Fatalf("CreateSession failed: %v", err) } - assertNewSessionOptions(t, <-createParams, true, true, "explore", 30) + assertNewSessionOptions(t, <-createParams, true, true, "explore", 30, "http://127.0.0.1:4321") + assertSandboxConfig(t, <-updateParams, "http://127.0.0.1:4321") resumeParams := make(chan json.RawMessage, 1) server.SetRequestHandler("session.resume", func(params json.RawMessage) (json.RawMessage, *jsonrpc2.Error) { @@ -613,11 +627,20 @@ func TestClient_ForwardsNewSessionOptionsToSessionRequests(t *testing.T) { EnableCitations: Bool(false), EnableFileChangeTracking: Bool(false), SessionLimits: &rpc.SessionLimitsConfig{MaxAiCredits: float64Ptr(15)}, + SandboxConfig: &rpc.SandboxConfig{ + Enabled: true, + UserPolicy: &rpc.SandboxConfigUserPolicy{ + Network: &rpc.SandboxConfigUserPolicyNetwork{ + Proxy: &rpc.SandboxConfigUserPolicyNetworkProxy{URL: "http://127.0.0.1:4322"}, + }, + }, + }, }) if err != nil { t.Fatalf("ResumeSessionWithOptions failed: %v", err) } - assertNewSessionOptions(t, <-resumeParams, false, false, "task", 15) + assertNewSessionOptions(t, <-resumeParams, false, false, "task", 15, "http://127.0.0.1:4322") + assertSandboxConfig(t, <-updateParams, "http://127.0.0.1:4322") } func assertCapiEnableWebSocketResponses(t *testing.T, params json.RawMessage) { @@ -644,6 +667,7 @@ func assertNewSessionOptions( expectedFileChangeTracking bool, expectedAgent string, expectedCredits float64, + expectedProxyURL string, ) { t.Helper() @@ -668,6 +692,38 @@ func assertNewSessionOptions( if limits["maxAiCredits"] != expectedCredits { t.Fatalf("expected sessionLimits.maxAiCredits=%v, got %v", expectedCredits, limits["maxAiCredits"]) } + assertDecodedSandboxConfig(t, decoded, expectedProxyURL) +} + +func assertSandboxConfig(t *testing.T, params json.RawMessage, expectedProxyURL string) { + t.Helper() + + var decoded map[string]any + if err := json.Unmarshal(params, &decoded); err != nil { + t.Fatalf("failed to unmarshal request params: %v", err) + } + assertDecodedSandboxConfig(t, decoded, expectedProxyURL) +} + +func assertDecodedSandboxConfig(t *testing.T, decoded map[string]any, expectedProxyURL string) { + t.Helper() + + sandbox, ok := decoded["sandboxConfig"].(map[string]any) + if !ok { + t.Fatalf("expected sandboxConfig object, got %T", decoded["sandboxConfig"]) + } + policy, ok := sandbox["userPolicy"].(map[string]any) + if !ok { + t.Fatalf("expected sandboxConfig.userPolicy object, got %T", sandbox["userPolicy"]) + } + network, ok := policy["network"].(map[string]any) + if !ok { + t.Fatalf("expected sandboxConfig.userPolicy.network object, got %T", policy["network"]) + } + proxy, ok := network["proxy"].(map[string]any) + if !ok || proxy["url"] != expectedProxyURL { + t.Fatalf("expected sandboxConfig proxy URL %q, got %#v", expectedProxyURL, network["proxy"]) + } } func float64Ptr(value float64) *float64 { diff --git a/go/internal/e2e/session_config_e2e_test.go b/go/internal/e2e/session_config_e2e_test.go index 2ce48e3b33..80b137fb8d 100644 --- a/go/internal/e2e/session_config_e2e_test.go +++ b/go/internal/e2e/session_config_e2e_test.go @@ -8,6 +8,7 @@ import ( "net/http" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -159,6 +160,34 @@ func float64Ref(value float64) *float64 { return &value } +func assertNextShellExecutionResult(t *testing.T, session *copilot.Session, prompt string, expected string) { + t.Helper() + + existingEvents, err := session.GetEvents(t.Context()) + if err != nil { + t.Fatalf("GetEvents before shell execution failed: %v", err) + } + if _, err := session.SendAndWait(t.Context(), copilot.MessageOptions{Prompt: prompt}); err != nil { + t.Fatalf("SendAndWait failed: %v", err) + } + + events, err := session.GetEvents(t.Context()) + if err != nil { + t.Fatalf("GetEvents failed: %v", err) + } + for _, event := range events[len(existingEvents):] { + completed, ok := event.Data.(*copilot.ToolExecutionCompleteData) + if !ok { + continue + } + if completed.Result == nil || !strings.Contains(completed.Result.Content, expected) { + t.Fatalf("Expected tool call %q result to contain %q, got %#v", completed.ToolCallID, expected, completed.Result) + } + return + } + t.Fatal("Expected tool.execution_complete after sandbox shell prompt") +} + func TestSessionConfigE2E(t *testing.T) { ctx := testharness.NewTestContext(t) client := ctx.NewClient() @@ -337,6 +366,73 @@ func TestSessionConfigNewOptionsE2E(t *testing.T) { assertSessionLimitsStatus(t, exchange, "30 AI credits") }) + t.Run("should apply sandbox config on create and resume", func(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("process sandboxing is not supported on Windows") + } + ctx.ConfigureForTest(t) + enabledProbe := "/var/tmp/sandbox-create-enabled.txt" + disabledProbe := "/var/tmp/sandbox-create-disabled.txt" + resumeProbe := "/var/tmp/sandbox-resume-enabled.txt" + t.Cleanup(func() { + _ = os.Remove(enabledProbe) + _ = os.Remove(disabledProbe) + _ = os.Remove(resumeProbe) + }) + + enabledSession, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + WorkingDirectory: ctx.WorkDir, + SandboxConfig: &rpc.SandboxConfig{ + Enabled: true, + UserPolicy: &rpc.SandboxConfigUserPolicy{ + Filesystem: &rpc.SandboxConfigUserPolicyFilesystem{DeniedPaths: []string{enabledProbe}}, + }, + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer enabledSession.Disconnect() + assertNextShellExecutionResult(t, enabledSession, "Check sandbox access for sandbox-create-enabled.txt.", "sandbox-blocked") + + disabledSession, err := client.CreateSession(t.Context(), &copilot.SessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + WorkingDirectory: ctx.WorkDir, + SandboxConfig: &rpc.SandboxConfig{ + Enabled: false, + UserPolicy: &rpc.SandboxConfigUserPolicy{ + Filesystem: &rpc.SandboxConfigUserPolicyFilesystem{DeniedPaths: []string{disabledProbe}}, + }, + }, + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + defer disabledSession.Disconnect() + assertNextShellExecutionResult(t, disabledSession, "Check sandbox access for sandbox-create-disabled.txt.", "sandbox-accessible") + + resumedSession, err := client.ResumeSessionWithOptions(t.Context(), disabledSession.SessionID, &copilot.ResumeSessionConfig{ + OnPermissionRequest: copilot.PermissionHandler.ApproveAll, + WorkingDirectory: ctx.WorkDir, + SandboxConfig: &rpc.SandboxConfig{ + Enabled: true, + UserPolicy: &rpc.SandboxConfigUserPolicy{ + Filesystem: &rpc.SandboxConfigUserPolicyFilesystem{DeniedPaths: []string{resumeProbe}}, + }, + }, + }) + if err != nil { + t.Fatalf("ResumeSessionWithOptions failed: %v", err) + } + defer resumedSession.Disconnect() + assertNextShellExecutionResult(t, resumedSession, "Check sandbox access for sandbox-resume-enabled.txt.", "sandbox-blocked") + + if resumedSession.SessionID != disabledSession.SessionID { + t.Errorf("Expected resumed session ID %q, got %q", disabledSession.SessionID, resumedSession.SessionID) + } + }) + t.Run("should apply excluded built in agents on create", func(t *testing.T) { ctx.ConfigureForTest(t) diff --git a/go/mode_empty.go b/go/mode_empty.go index 6e238c58c4..0fb6a4b9f5 100644 --- a/go/mode_empty.go +++ b/go/mode_empty.go @@ -230,6 +230,10 @@ func (c *Client) applyResumeDefaultsForMode(config *ResumeSessionConfig) { func (c *Client) updateSessionOptionsForMode(ctx context.Context, session *Session, base optBackInFields) error { patch := &rpc.SessionUpdateOptionsParams{} hasAny := false + if base.SandboxConfig != nil { + patch.SandboxConfig = base.SandboxConfig + hasAny = true + } if c.options.Mode == ModeEmpty { if base.SkipCustomInstructions != nil { patch.SkipCustomInstructions = base.SkipCustomInstructions @@ -307,5 +311,6 @@ type optBackInFields struct { CustomAgentsLocalOnly *bool CoauthorEnabled *bool ManageScheduleEnabled *bool + SandboxConfig *rpc.SandboxConfig IncludedBuiltinSkills []string } diff --git a/go/types.go b/go/types.go index 1d98e06158..7dcae1faa7 100644 --- a/go/types.go +++ b/go/types.go @@ -1398,6 +1398,12 @@ type SessionConfig struct { // Experimental: SessionLimits is part of an experimental runtime accounting // surface and may change or be removed in future SDK or CLI releases. SessionLimits *rpc.SessionLimitsConfig + // SandboxConfig is the resolved sandbox configuration applied when the + // session is created. + // + // Experimental: SandboxConfig is part of an experimental runtime sandboxing + // surface and may change or be removed in future SDK or CLI releases. + SandboxConfig *rpc.SandboxConfig // EnableExperimentalMode controls whether the session enables experimental // features. When nil, it defaults to false in [ModeEmpty]; otherwise the // runtime decides. @@ -1878,6 +1884,12 @@ type ResumeSessionConfig struct { // Experimental: SessionLimits is part of an experimental runtime accounting // surface and may change or be removed in future SDK or CLI releases. SessionLimits *rpc.SessionLimitsConfig + // SandboxConfig is the resolved sandbox configuration applied when the + // session is resumed. + // + // Experimental: SandboxConfig is part of an experimental runtime sandboxing + // surface and may change or be removed in future SDK or CLI releases. + SandboxConfig *rpc.SandboxConfig // EnableExperimentalMode controls whether the session enables experimental // features. When nil, it defaults to false in [ModeEmpty]; otherwise the // runtime decides. @@ -2501,6 +2513,7 @@ type createSessionRequest struct { EnableCitations *bool `json:"enableCitations,omitempty"` EnableFileChangeTracking *bool `json:"enableFileChangeTracking,omitempty"` SessionLimits *rpc.SessionLimitsConfig `json:"sessionLimits,omitempty"` + SandboxConfig *rpc.SandboxConfig `json:"sandboxConfig,omitempty"` IsExperimentalMode *bool `json:"isExperimentalMode,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` @@ -2598,6 +2611,7 @@ type resumeSessionRequest struct { EnableCitations *bool `json:"enableCitations,omitempty"` EnableFileChangeTracking *bool `json:"enableFileChangeTracking,omitempty"` SessionLimits *rpc.SessionLimitsConfig `json:"sessionLimits,omitempty"` + SandboxConfig *rpc.SandboxConfig `json:"sandboxConfig,omitempty"` IsExperimentalMode *bool `json:"isExperimentalMode,omitempty"` SkipCustomInstructions *bool `json:"skipCustomInstructions,omitempty"` CustomAgentsLocalOnly *bool `json:"customAgentsLocalOnly,omitempty"` diff --git a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java index ea2b0b67dd..32dfaf3f19 100644 --- a/java/sdk/src/main/java/com/github/copilot/CopilotClient.java +++ b/java/sdk/src/main/java/com/github/copilot/CopilotClient.java @@ -36,6 +36,7 @@ import com.github.copilot.rpc.CreateSessionResponse; import com.github.copilot.generated.rpc.SessionOptionsUpdateParams; import com.github.copilot.generated.rpc.SessionInstalledPlugin; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.ConnectResult; import com.github.copilot.generated.rpc.GitHubTelemetryNotification; import com.github.copilot.generated.rpc.ServerRpc; @@ -1031,7 +1032,8 @@ public CompletableFuture createSession(SessionConfig config) { return updateSessionOptionsForMode(session, config.getSkipCustomInstructions().orElse(null), config.getCustomAgentsLocalOnly().orElse(null), config.getCoauthorEnabled().orElse(null), - config.getManageScheduleEnabled().orElse(null), config.getIncludedBuiltinSkills()); + config.getManageScheduleEnabled().orElse(null), config.getSandboxConfig(), + config.getIncludedBuiltinSkills()); }).thenApply(v -> { if (tokenRegistration != null) { tokenRegistration.claim(session.getSessionId()); @@ -1211,8 +1213,8 @@ public CompletableFuture resumeSession(String sessionId, ResumeS return updateSessionOptionsForMode(session, config.getSkipCustomInstructions().orElse(null), config.getCustomAgentsLocalOnly().orElse(null), config.getCoauthorEnabled().orElse(null), - config.getManageScheduleEnabled().orElse(null), config.getIncludedBuiltinSkills()) - .thenApply(v -> { + config.getManageScheduleEnabled().orElse(null), config.getSandboxConfig(), + config.getIncludedBuiltinSkills()).thenApply(v -> { LoggingHelpers.logTiming(LOG, Level.FINE, "CopilotClient.resumeSession complete. Elapsed={Elapsed}, SessionId=" + sessionId, @@ -1248,7 +1250,21 @@ public CompletableFuture resumeSession(String sessionId, ResumeS CompletableFuture updateSessionOptionsForMode(CopilotSession session, Boolean skipCustomInstructions, Boolean customAgentsLocalOnly, Boolean coauthorEnabled, Boolean manageScheduleEnabled) { return updateSessionOptionsForMode(session, skipCustomInstructions, customAgentsLocalOnly, coauthorEnabled, - manageScheduleEnabled, null); + manageScheduleEnabled, null, null); + } + + CompletableFuture updateSessionOptionsForMode(CopilotSession session, Boolean skipCustomInstructions, + Boolean customAgentsLocalOnly, Boolean coauthorEnabled, Boolean manageScheduleEnabled, + SandboxConfig sandboxConfig) { + return updateSessionOptionsForMode(session, skipCustomInstructions, customAgentsLocalOnly, coauthorEnabled, + manageScheduleEnabled, sandboxConfig, null); + } + + CompletableFuture updateSessionOptionsForMode(CopilotSession session, Boolean skipCustomInstructions, + Boolean customAgentsLocalOnly, Boolean coauthorEnabled, Boolean manageScheduleEnabled, + List includedBuiltinSkills) { + return updateSessionOptionsForMode(session, skipCustomInstructions, customAgentsLocalOnly, coauthorEnabled, + manageScheduleEnabled, null, includedBuiltinSkills); } /** @@ -1272,6 +1288,8 @@ CompletableFuture updateSessionOptionsForMode(CopilotSession session, Bool * caller-supplied value, or {@code null} if not set * @param manageScheduleEnabled * caller-supplied value, or {@code null} if not set + * @param sandboxConfig + * caller-supplied sandbox configuration, or {@code null} if not set * @param includedBuiltinSkills * caller-supplied built-in skill allowlist, or {@code null} if not * set @@ -1279,7 +1297,7 @@ CompletableFuture updateSessionOptionsForMode(CopilotSession session, Bool */ CompletableFuture updateSessionOptionsForMode(CopilotSession session, Boolean skipCustomInstructions, Boolean customAgentsLocalOnly, Boolean coauthorEnabled, Boolean manageScheduleEnabled, - List includedBuiltinSkills) { + SandboxConfig sandboxConfig, List includedBuiltinSkills) { Boolean patchSkip = null; Boolean patchAgents = null; @@ -1287,7 +1305,7 @@ CompletableFuture updateSessionOptionsForMode(CopilotSession session, Bool Boolean patchSchedule = null; List patchPlugins = null; List patchSkills = null; - boolean hasAnyPatch = false; + boolean hasAnyPatch = sandboxConfig != null; if (options.getMode() == CopilotClientMode.EMPTY) { patchSkip = skipCustomInstructions != null ? skipCustomInstructions : true; @@ -1347,7 +1365,7 @@ CompletableFuture updateSessionOptionsForMode(CopilotSession session, Bool null, // shell null, // shellInitProfile null, // shellProcessFlags - null, // sandboxConfig + sandboxConfig, // sandboxConfig null, // sandboxConfigSource null, // logInteractiveShells null, // envValueMode diff --git a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java index 4254c04ec4..17a718044f 100644 --- a/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java +++ b/java/sdk/src/main/java/com/github/copilot/SessionRequestBuilder.java @@ -132,6 +132,7 @@ static CreateSessionRequest buildCreateRequest(SessionConfig config, String sess config.getEnableCitations().ifPresent(request::setEnableCitations); config.getEnableFileChangeTracking().ifPresent(request::setEnableFileChangeTracking); request.setSessionLimits(config.getSessionLimits()); + request.setSandboxConfig(config.getSandboxConfig()); experimentalModeForMode(mode, config.getEnableExperimentalMode().orElse(null)) .ifPresent(request::setIsExperimentalMode); if (config.getOnUserInputRequest() != null) { @@ -268,6 +269,7 @@ static ResumeSessionRequest buildResumeRequest(String sessionId, ResumeSessionCo config.getEnableCitations().ifPresent(request::setEnableCitations); config.getEnableFileChangeTracking().ifPresent(request::setEnableFileChangeTracking); request.setSessionLimits(config.getSessionLimits()); + request.setSandboxConfig(config.getSandboxConfig()); experimentalModeForMode(mode, config.getEnableExperimentalMode().orElse(null)) .ifPresent(request::setIsExperimentalMode); if (config.getOnUserInputRequest() != null) { diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java index 403893987d..842ff1a044 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/CreateSessionRequest.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.github.copilot.CopilotExperimental; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -86,6 +87,9 @@ public final class CreateSessionRequest { @JsonProperty("sessionLimits") private SessionLimitsConfig sessionLimits; + @JsonProperty("sandboxConfig") + private SandboxConfig sandboxConfig; + @JsonProperty("requestPermission") private Boolean requestPermission; @@ -465,6 +469,19 @@ public void setSessionLimits(SessionLimitsConfig sessionLimits) { this.sessionLimits = sessionLimits; } + /** Gets the sandbox configuration. @return the sandbox configuration */ + public SandboxConfig getSandboxConfig() { + return sandboxConfig; + } + + /** + * Sets the sandbox configuration. @param sandboxConfig the sandbox + * configuration + */ + public void setSandboxConfig(SandboxConfig sandboxConfig) { + this.sandboxConfig = sandboxConfig; + } + /** * Clears the enableSessionTelemetry setting, reverting to the default behavior. */ diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java index a55c3454e7..36c862aa8f 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionConfig.java @@ -16,6 +16,7 @@ import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.SessionEvent; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -55,6 +56,7 @@ public class ResumeSessionConfig { private Boolean enableCitations; private Boolean enableFileChangeTracking; private SessionLimitsConfig sessionLimits; + private SandboxConfig sandboxConfig; private Boolean enableExperimentalMode; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; @@ -526,6 +528,40 @@ public ResumeSessionConfig clearSessionLimits() { return this; } + /** + * Gets the resolved sandbox configuration. + * + * @return the sandbox configuration, or {@code null} if not set + */ + @CopilotExperimental + public SandboxConfig getSandboxConfig() { + return sandboxConfig; + } + + /** + * Sets the resolved sandbox configuration applied when the session is resumed. + * + * @param sandboxConfig + * the sandbox configuration + * @return this config instance for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig setSandboxConfig(SandboxConfig sandboxConfig) { + this.sandboxConfig = sandboxConfig; + return this; + } + + /** + * Clears the sandbox configuration, reverting to the default behavior. + * + * @return this instance for method chaining + */ + @CopilotExperimental + public ResumeSessionConfig clearSandboxConfig() { + this.sandboxConfig = null; + return this; + } + /** * Controls whether the session enables experimental features. * @@ -2048,6 +2084,7 @@ public ResumeSessionConfig clone() { copy.enableCitations = this.enableCitations; copy.enableFileChangeTracking = this.enableFileChangeTracking; copy.sessionLimits = this.sessionLimits; + copy.sandboxConfig = this.sandboxConfig; copy.enableExperimentalMode = this.enableExperimentalMode; copy.reasoningEffort = this.reasoningEffort; copy.reasoningSummary = this.reasoningSummary; diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java index 9b8e897fda..4e44a0dac2 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/ResumeSessionRequest.java @@ -12,6 +12,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.github.copilot.CopilotExperimental; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -88,6 +89,9 @@ public final class ResumeSessionRequest { @JsonProperty("sessionLimits") private SessionLimitsConfig sessionLimits; + @JsonProperty("sandboxConfig") + private SandboxConfig sandboxConfig; + @JsonProperty("requestPermission") private Boolean requestPermission; @@ -470,6 +474,19 @@ public void setSessionLimits(SessionLimitsConfig sessionLimits) { this.sessionLimits = sessionLimits; } + /** Gets the sandbox configuration. @return the sandbox configuration */ + public SandboxConfig getSandboxConfig() { + return sandboxConfig; + } + + /** + * Sets the sandbox configuration. @param sandboxConfig the sandbox + * configuration + */ + public void setSandboxConfig(SandboxConfig sandboxConfig) { + this.sandboxConfig = sandboxConfig; + } + /** * Clears the enableSessionTelemetry setting, reverting to the default behavior. */ diff --git a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java index 9f6ddb5efa..6cd10fbb3d 100644 --- a/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java +++ b/java/sdk/src/main/java/com/github/copilot/rpc/SessionConfig.java @@ -16,6 +16,7 @@ import com.github.copilot.CopilotExperimental; import com.github.copilot.generated.SessionEvent; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; /** @@ -59,6 +60,7 @@ public class SessionConfig { private Boolean enableCitations; private Boolean enableFileChangeTracking; private SessionLimitsConfig sessionLimits; + private SandboxConfig sandboxConfig; private Boolean enableExperimentalMode; private Boolean skipCustomInstructions; private Boolean customAgentsLocalOnly; @@ -628,6 +630,40 @@ public SessionConfig clearSessionLimits() { return this; } + /** + * Gets the resolved sandbox configuration. + * + * @return the sandbox configuration, or {@code null} if not set + */ + @CopilotExperimental + public SandboxConfig getSandboxConfig() { + return sandboxConfig; + } + + /** + * Sets the resolved sandbox configuration applied when the session is created. + * + * @param sandboxConfig + * the sandbox configuration + * @return this config instance for method chaining + */ + @CopilotExperimental + public SessionConfig setSandboxConfig(SandboxConfig sandboxConfig) { + this.sandboxConfig = sandboxConfig; + return this; + } + + /** + * Clears the sandbox configuration, reverting to the default behavior. + * + * @return this instance for method chaining + */ + @CopilotExperimental + public SessionConfig clearSandboxConfig() { + this.sandboxConfig = null; + return this; + } + /** * Controls whether the session enables experimental features. * @@ -2188,6 +2224,7 @@ public SessionConfig clone() { copy.enableCitations = this.enableCitations; copy.enableFileChangeTracking = this.enableFileChangeTracking; copy.sessionLimits = this.sessionLimits; + copy.sandboxConfig = this.sandboxConfig; copy.enableExperimentalMode = this.enableExperimentalMode; copy.skipCustomInstructions = this.skipCustomInstructions; copy.customAgentsLocalOnly = this.customAgentsLocalOnly; diff --git a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java index 925fd6d873..1ee0bc05a1 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionConfigE2ETest.java @@ -8,6 +8,7 @@ import static com.github.copilot.CopilotRequestTestSupport.newLlmClient; import static com.github.copilot.CopilotRequestTestSupport.setupCapiAuth; import static org.junit.jupiter.api.Assertions.*; +import static org.junit.jupiter.api.Assumptions.assumeFalse; import java.nio.charset.StandardCharsets; import java.nio.file.Files; @@ -24,6 +25,10 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.ToolExecutionCompleteEvent; +import com.github.copilot.generated.rpc.SandboxConfig; +import com.github.copilot.generated.rpc.SandboxConfigUserPolicy; +import com.github.copilot.generated.rpc.SandboxConfigUserPolicyFilesystem; import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.BlobAttachment; import com.github.copilot.rpc.MessageOptions; @@ -41,6 +46,18 @@ public class SessionConfigE2ETest { private static E2ETestContext ctx; + private static void assertNextShellExecutionResult(CopilotSession session, String prompt, String expected) + throws Exception { + int eventCount = session.getMessages().get(60, TimeUnit.SECONDS).size(); + session.sendAndWait(new MessageOptions().setPrompt(prompt)).get(60, TimeUnit.SECONDS); + var events = session.getMessages().get(60, TimeUnit.SECONDS); + var completions = events.subList(eventCount, events.size()).stream() + .filter(ToolExecutionCompleteEvent.class::isInstance).map(ToolExecutionCompleteEvent.class::cast) + .toList(); + assertEquals(1, completions.size(), "Expected one tool.execution_complete after sandbox shell prompt"); + assertTrue(completions.get(0).getData().result().content().contains(expected)); + } + @BeforeAll static void setup() throws Exception { ctx = E2ETestContext.create(); @@ -207,6 +224,67 @@ void testShouldApplySessionLimitsOnResume() throws Exception { } } + @Test + void testShouldApplySandboxConfigOnCreateAndResume() throws Exception { + assumeFalse(System.getProperty("os.name", "").toLowerCase().contains("win"), + "Process sandboxing is not supported on Windows"); + ctx.configureForTest("session_config", "should_apply_sandbox_config_on_create_and_resume"); + + try (CopilotClient client = ctx.createClient()) { + Path enabledProbePath = Path.of("/var/tmp/sandbox-create-enabled.txt"); + Path disabledProbePath = Path.of("/var/tmp/sandbox-create-disabled.txt"); + Path resumeProbePath = Path.of("/var/tmp/sandbox-resume-enabled.txt"); + List probes = List.of(enabledProbePath, disabledProbePath, resumeProbePath); + for (Path probe : probes) { + Files.deleteIfExists(probe); + } + String enabledProbe = enabledProbePath.toString(); + String disabledProbe = disabledProbePath.toString(); + String resumeProbe = resumeProbePath.toString(); + CopilotSession enabledSession = client + .createSession(new SessionConfig().setWorkingDirectory(ctx.getWorkDir().toString()) + .setSandboxConfig(new SandboxConfig(true, + new SandboxConfigUserPolicy(new SandboxConfigUserPolicyFilesystem(null, null, + List.of(enabledProbe), null), null, null, null), + null, null, null)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + assertNextShellExecutionResult(enabledSession, "Check sandbox access for sandbox-create-enabled.txt.", + "sandbox-blocked"); + CopilotSession disabledSession = client + .createSession(new SessionConfig().setWorkingDirectory(ctx.getWorkDir().toString()) + .setSandboxConfig(new SandboxConfig(false, + new SandboxConfigUserPolicy(new SandboxConfigUserPolicyFilesystem(null, null, + List.of(disabledProbe), null), null, null, null), + null, null, null)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + assertNextShellExecutionResult(disabledSession, "Check sandbox access for sandbox-create-disabled.txt.", + "sandbox-accessible"); + CopilotSession resumedSession = client.resumeSession(disabledSession.getSessionId(), + new ResumeSessionConfig().setWorkingDirectory(ctx.getWorkDir().toString()) + .setSandboxConfig(new SandboxConfig(true, + new SandboxConfigUserPolicy(new SandboxConfigUserPolicyFilesystem(null, null, + List.of(resumeProbe), null), null, null, null), + null, null, null)) + .setOnPermissionRequest(PermissionHandler.APPROVE_ALL)) + .get(); + + try { + assertNextShellExecutionResult(resumedSession, "Check sandbox access for sandbox-resume-enabled.txt.", + "sandbox-blocked"); + assertEquals(disabledSession.getSessionId(), resumedSession.getSessionId()); + } finally { + resumedSession.close(); + disabledSession.close(); + enabledSession.close(); + for (Path probe : probes) { + Files.deleteIfExists(probe); + } + } + } + } + @Test void testShouldApplyExcludedBuiltInAgentsOnCreate() throws Exception { ctx.configureForTest("session_config", "should_apply_excluded_built_in_agents_on_create"); diff --git a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java index 9d76d18ee2..21712e38ac 100644 --- a/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java +++ b/java/sdk/src/test/java/com/github/copilot/SessionRequestBuilderTest.java @@ -12,6 +12,7 @@ import org.junit.jupiter.api.Test; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.generated.rpc.SessionLimitsConfig; import com.github.copilot.rpc.AutoModeSwitchResponse; import com.github.copilot.rpc.CloudSessionOptions; @@ -1096,4 +1097,24 @@ void githubMcpToolConfigIsMappedAndSerializedForCreateAndResume() throws Excepti mapper.writeValueAsString(SessionRequestBuilder.buildCreateRequest(new SessionConfig(), "session-2")) .contains("\"githubMcpToolConfig\"")); } + + @Test + void sandboxConfigIsMappedAndSerializedForCreateAndResume() throws Exception { + var createSandbox = new SandboxConfig(true, null, false, null, null); + var resumeSandbox = new SandboxConfig(false, null, null, null, null); + var createRequest = SessionRequestBuilder + .buildCreateRequest(new SessionConfig().setSandboxConfig(createSandbox), "session-1"); + var resumeRequest = SessionRequestBuilder.buildResumeRequest("session-1", + new ResumeSessionConfig().setSandboxConfig(resumeSandbox)); + + assertSame(createSandbox, createRequest.getSandboxConfig()); + assertSame(resumeSandbox, resumeRequest.getSandboxConfig()); + var mapper = JsonRpcClient.getObjectMapper(); + assertTrue(mapper.writeValueAsString(createRequest) + .contains("\"sandboxConfig\":{\"enabled\":true,\"addCurrentWorkingDirectory\":false}")); + assertTrue(mapper.writeValueAsString(resumeRequest).contains("\"sandboxConfig\":{\"enabled\":false}")); + assertFalse( + mapper.writeValueAsString(SessionRequestBuilder.buildCreateRequest(new SessionConfig(), "session-2")) + .contains("\"sandboxConfig\"")); + } } diff --git a/java/sdk/src/test/java/com/github/copilot/UpdateSessionOptionsForModeTest.java b/java/sdk/src/test/java/com/github/copilot/UpdateSessionOptionsForModeTest.java index 24b4257a52..b7b8fb3cbc 100644 --- a/java/sdk/src/test/java/com/github/copilot/UpdateSessionOptionsForModeTest.java +++ b/java/sdk/src/test/java/com/github/copilot/UpdateSessionOptionsForModeTest.java @@ -16,6 +16,7 @@ import com.fasterxml.jackson.databind.JsonNode; import com.fasterxml.jackson.databind.ObjectMapper; +import com.github.copilot.generated.rpc.SandboxConfig; import com.github.copilot.rpc.CopilotClientMode; import com.github.copilot.rpc.CopilotClientOptions; @@ -183,6 +184,21 @@ void copilotCliMode_onlyCoauthorEnabled_patchSent() throws Exception { } } + @Test + void copilotCliMode_sandboxConfigSet_patchContainsSandboxConfig() throws Exception { + try (var pair = new AutoReplyPair()) { + var session = new CopilotSession("sess-1", pair.rpcClient); + var client = new CopilotClient(new CopilotClientOptions().setAutoStart(false)); + var sandboxConfig = new SandboxConfig(true, null, null, null, null); + + client.updateSessionOptionsForMode(session, null, null, null, null, sandboxConfig).get(); + + assertEquals("session.options.update", pair.lastMethod); + assertTrue(pair.lastParams.get("sandboxConfig").get("enabled").asBoolean()); + client.close(); + } + } + // ── EMPTY mode tests ────────────────────────────────────────────────────── @Test diff --git a/nodejs/src/client.ts b/nodejs/src/client.ts index 9b853aa597..b7a1aa26fb 100644 --- a/nodejs/src/client.ts +++ b/nodejs/src/client.ts @@ -1474,6 +1474,9 @@ export class CopilotClient { config: SessionConfigBase ): Promise { const patch: SessionUpdateOptionsParams = {}; + if (config.sandboxConfig !== undefined) { + patch.sandboxConfig = config.sandboxConfig; + } if (this.options.mode === "empty") { patch.skipCustomInstructions = config.skipCustomInstructions ?? true; patch.customAgentsLocalOnly = config.customAgentsLocalOnly ?? true; @@ -1673,6 +1676,7 @@ export class CopilotClient { enableCitations: config.enableCitations, enableFileChangeTracking: config.enableFileChangeTracking, sessionLimits: config.sessionLimits, + sandboxConfig: config.sandboxConfig, modelCapabilities: config.modelCapabilities, largeOutput: toWireLargeOutput(config.largeOutput), requestPermission: !!config.onPermissionRequest, @@ -1914,6 +1918,7 @@ export class CopilotClient { enableCitations: config.enableCitations, enableFileChangeTracking: config.enableFileChangeTracking, sessionLimits: config.sessionLimits, + sandboxConfig: config.sandboxConfig, tools: config.tools?.map((tool) => ({ name: tool.name, description: tool.description, diff --git a/nodejs/src/types.ts b/nodejs/src/types.ts index 616e15a467..6c06546a94 100644 --- a/nodejs/src/types.ts +++ b/nodejs/src/types.ts @@ -27,10 +27,22 @@ import type { ModelBillingTokenPrices, OpenCanvasInstance, RemoteSessionMode, + SandboxConfig, CurrentToolMetadata, } from "./generated/rpc.js"; import type { ToolSet } from "./toolSet.js"; export type { RemoteSessionMode } from "./generated/rpc.js"; +export type { + SandboxConfig, + SandboxConfigAuth, + SandboxConfigUserPolicy, + SandboxConfigUserPolicyExperimental, + SandboxConfigUserPolicyExperimentalSeatbelt, + SandboxConfigUserPolicyFilesystem, + SandboxConfigUserPolicyNetwork, + SandboxConfigUserPolicyNetworkProxy, + SandboxConfigUserPolicySeatbelt, +} from "./generated/rpc.js"; export type { CurrentToolMetadata } from "./generated/rpc.js"; export type { GitHubTokenAcquireReason, @@ -2290,6 +2302,13 @@ export interface SessionConfigBase { /** Per-property overrides for model capabilities, deep-merged over runtime defaults. */ modelCapabilities?: ModelCapabilitiesOverride; + /** + * Resolved sandbox configuration applied when the session is created or resumed. + * + * @experimental + */ + sandboxConfig?: SandboxConfig; + /** * Configuration for handling large tool outputs. When a tool produces * output exceeding the configured size, the output is written to a temp diff --git a/nodejs/test/client.test.ts b/nodejs/test/client.test.ts index 3ffda2fa71..b28e55aecc 100644 --- a/nodejs/test/client.test.ts +++ b/nodejs/test/client.test.ts @@ -1,14 +1,14 @@ /* eslint-disable @typescript-eslint/no-explicit-any */ import { EventEmitter } from "node:events"; -import { PassThrough } from "stream"; import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { PassThrough } from "stream"; import { describe, expect, it, onTestFinished, vi } from "vitest"; import { approveAll, - createAttributedPermissionResult, CopilotClient, + createAttributedPermissionResult, createCanvas, DisableBypassPermissionsModes, RuntimeConnection, @@ -1012,6 +1012,7 @@ describe("CopilotClient", () => { .mockImplementation(async (method: string, params: any) => { if (method === "session.create") return { sessionId: params.sessionId }; if (method === "session.resume") return { sessionId: params.sessionId }; + if (method === "session.options.update") return { success: true }; throw new Error(`Unexpected method: ${method}`); }); @@ -1021,6 +1022,10 @@ describe("CopilotClient", () => { enableFileChangeTracking: true, excludedBuiltinAgents: ["explore"], sessionLimits: { maxAiCredits: 30 }, + sandboxConfig: { + enabled: true, + userPolicy: { network: { allowOutbound: false } }, + }, }); await client.resumeSession(session.sessionId, { onPermissionRequest: approveAll, @@ -1028,6 +1033,7 @@ describe("CopilotClient", () => { enableFileChangeTracking: false, excludedBuiltinAgents: ["task"], sessionLimits: { maxAiCredits: 15 }, + sandboxConfig: { enabled: false }, }); const createPayload = spy.mock.calls.find( @@ -1036,14 +1042,29 @@ describe("CopilotClient", () => { const resumePayload = spy.mock.calls.find( ([method]) => method === "session.resume" )![1] as any; + const updatePayloads = spy.mock.calls + .filter(([method]) => method === "session.options.update") + .map(([, params]) => params as any); expect(createPayload.enableCitations).toBe(true); expect(createPayload.enableFileChangeTracking).toBe(true); expect(createPayload.excludedBuiltinAgents).toEqual(["explore"]); expect(createPayload.sessionLimits).toEqual({ maxAiCredits: 30 }); + expect(createPayload.sandboxConfig).toEqual({ + enabled: true, + userPolicy: { network: { allowOutbound: false } }, + }); expect(resumePayload.enableCitations).toBe(false); expect(resumePayload.enableFileChangeTracking).toBe(false); expect(resumePayload.excludedBuiltinAgents).toEqual(["task"]); expect(resumePayload.sessionLimits).toEqual({ maxAiCredits: 15 }); + expect(resumePayload.sandboxConfig).toEqual({ enabled: false }); + expect(updatePayloads.map(({ sandboxConfig }) => sandboxConfig)).toEqual([ + { + enabled: true, + userPolicy: { network: { allowOutbound: false } }, + }, + { enabled: false }, + ]); }); it("opts into GitHub telemetry forwarding when onGitHubTelemetry is provided", async () => { diff --git a/nodejs/test/e2e/session_config.e2e.test.ts b/nodejs/test/e2e/session_config.e2e.test.ts index 85137e0ff9..8f76ed85e0 100644 --- a/nodejs/test/e2e/session_config.e2e.test.ts +++ b/nodejs/test/e2e/session_config.e2e.test.ts @@ -1,12 +1,13 @@ -import { describe, expect, it } from "vitest"; -import { writeFile, mkdir } from "fs/promises"; +import { mkdir, rm, writeFile } from "fs/promises"; import { join } from "path"; +import { describe, expect, it } from "vitest"; import { approveAll, CopilotClient, CopilotRequestHandler, RuntimeConnection, type CopilotRequestContext, + type CopilotSession, } from "../../src/index.js"; import { createSdkTestContext, DEFAULT_GITHUB_TOKEN } from "./harness/sdkTestContext.js"; import { retry } from "./harness/sdkTestHelper.js"; @@ -26,6 +27,19 @@ describe("Session Configuration", async () => { return openAiEndpoint.getExchanges(); } + async function expectNextShellExecutionResult( + session: CopilotSession, + prompt: string, + expected: string + ) { + const eventCount = (await session.getEvents()).length; + await session.sendAndWait({ prompt }); + const completion = (await session.getEvents()) + .slice(eventCount) + .find((event) => event.type === "tool.execution_complete"); + expect(completion?.data.result?.content).toContain(expected); + } + it("should use workingDirectory for tool execution", async () => { const subDir = join(workDir, "subproject"); await mkdir(subDir, { recursive: true }); @@ -866,6 +880,67 @@ describe("Session Configuration", async () => { } }); + it.skipIf(process.platform === "win32")( + "should apply sandbox config on create and resume", + async () => { + const enabledProbe = "/var/tmp/sandbox-create-enabled.txt"; + const disabledProbe = "/var/tmp/sandbox-create-disabled.txt"; + const resumeProbe = "/var/tmp/sandbox-resume-enabled.txt"; + const probes = [enabledProbe, disabledProbe, resumeProbe]; + await Promise.all(probes.map((probe) => rm(probe, { force: true }))); + try { + const enabledSession = await client.createSession({ + onPermissionRequest: approveAll, + workingDirectory: workDir, + sandboxConfig: { + enabled: true, + userPolicy: { filesystem: { deniedPaths: [enabledProbe] } }, + }, + }); + await expectNextShellExecutionResult( + enabledSession, + "Check sandbox access for sandbox-create-enabled.txt.", + "sandbox-blocked" + ); + + const disabledSession = await client.createSession({ + onPermissionRequest: approveAll, + workingDirectory: workDir, + sandboxConfig: { + enabled: false, + userPolicy: { filesystem: { deniedPaths: [disabledProbe] } }, + }, + }); + await expectNextShellExecutionResult( + disabledSession, + "Check sandbox access for sandbox-create-disabled.txt.", + "sandbox-accessible" + ); + const resumedSession = await client.resumeSession(disabledSession.sessionId, { + onPermissionRequest: approveAll, + workingDirectory: workDir, + sandboxConfig: { + enabled: true, + userPolicy: { filesystem: { deniedPaths: [resumeProbe] } }, + }, + }); + await expectNextShellExecutionResult( + resumedSession, + "Check sandbox access for sandbox-resume-enabled.txt.", + "sandbox-blocked" + ); + + expect(resumedSession.sessionId).toBe(disabledSession.sessionId); + + await resumedSession.disconnect(); + await disabledSession.disconnect(); + await enabledSession.disconnect(); + } finally { + await Promise.all(probes.map((probe) => rm(probe, { force: true }))); + } + } + ); + it("should apply GitHub MCP tool config on create", async () => { const session = await client.createSession({ onPermissionRequest: approveAll, diff --git a/python/copilot/__init__.py b/python/copilot/__init__.py index 608dacf253..8a31d785a2 100644 --- a/python/copilot/__init__.py +++ b/python/copilot/__init__.py @@ -101,6 +101,15 @@ PermissionDecisionSource, PermissionDecisionSurface, PermissionResponseCapability, + SandboxConfig, + SandboxConfigAuth, + SandboxConfigUserPolicy, + SandboxConfigUserPolicyExperimental, + SandboxConfigUserPolicyExperimentalSeatbelt, + SandboxConfigUserPolicyFilesystem, + SandboxConfigUserPolicyNetwork, + SandboxConfigUserPolicyNetworkProxy, + SandboxConfigUserPolicySeatbelt, ) from .generated.session_events import ( PermissionRequest, @@ -346,6 +355,15 @@ "RemoteSessionMode", "RuntimeConnection", "rpc", + "SandboxConfig", + "SandboxConfigAuth", + "SandboxConfigUserPolicy", + "SandboxConfigUserPolicyExperimental", + "SandboxConfigUserPolicyExperimentalSeatbelt", + "SandboxConfigUserPolicyFilesystem", + "SandboxConfigUserPolicyNetwork", + "SandboxConfigUserPolicyNetworkProxy", + "SandboxConfigUserPolicySeatbelt", "session_events", "SessionBackgroundEvent", "SessionCapabilities", diff --git a/python/copilot/client.py b/python/copilot/client.py index 271fad626c..782cb413a4 100644 --- a/python/copilot/client.py +++ b/python/copilot/client.py @@ -76,6 +76,7 @@ ModelBillingTokenPricesLongContext, # noqa: F401 OpenCanvasInstance, RemoteSessionMode, + SandboxConfig, ServerRpc, _ConnectResult, _HookInvokeRequest, @@ -2221,6 +2222,7 @@ async def create_session( enable_file_change_tracking: bool | None = None, excluded_builtin_agents: list[str] | None = None, session_limits: SessionLimitsConfig | None = None, + sandbox_config: SandboxConfig | None = None, skip_custom_instructions: bool | None = None, custom_agents_local_only: bool | None = None, coauthor_enabled: bool | None = None, @@ -2345,6 +2347,8 @@ async def create_session( name is configured. session_limits: **Experimental.** Limits applied to this session's current accounting window. + sandbox_config: **Experimental.** Resolved sandbox configuration applied + when the session is created. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -2642,6 +2646,8 @@ async def create_session( payload["excludedBuiltinAgents"] = excluded_builtin_agents if session_limits is not None: payload["sessionLimits"] = _session_limits_to_wire(session_limits) + if sandbox_config is not None: + payload["sandboxConfig"] = sandbox_config.to_dict() # Add model capabilities override if provided if model_capabilities: @@ -2938,7 +2944,8 @@ def _register_inline(raw_response: Any) -> None: custom_agents_local_only, coauthor_enabled, manage_schedule_enabled, - included_builtin_skills, + included_builtin_skills=included_builtin_skills, + sandbox_config=sandbox_config, ) self._commit_github_token_provider( session.session_id, github_token_provider_registration_id @@ -2982,6 +2989,7 @@ async def resume_session( enable_file_change_tracking: bool | None = None, excluded_builtin_agents: list[str] | None = None, session_limits: SessionLimitsConfig | None = None, + sandbox_config: SandboxConfig | None = None, skip_custom_instructions: bool | None = None, custom_agents_local_only: bool | None = None, coauthor_enabled: bool | None = None, @@ -3108,6 +3116,8 @@ async def resume_session( same name is configured. session_limits: **Experimental.** Limits applied to this session's current accounting window. + sandbox_config: **Experimental.** Resolved sandbox configuration applied + when the session is resumed. model_capabilities: Override individual model capabilities resolved by the runtime. streaming: Whether to enable streaming responses. include_sub_agent_streaming_events: Whether to include sub-agent streaming @@ -3319,6 +3329,8 @@ async def resume_session( payload["excludedBuiltinAgents"] = excluded_builtin_agents if session_limits is not None: payload["sessionLimits"] = _session_limits_to_wire(session_limits) + if sandbox_config is not None: + payload["sandboxConfig"] = sandbox_config.to_dict() if model_capabilities: payload["modelCapabilities"] = _capabilities_to_dict(model_capabilities) if streaming is not None: @@ -3603,7 +3615,8 @@ async def resume_session( custom_agents_local_only, coauthor_enabled, manage_schedule_enabled, - included_builtin_skills, + included_builtin_skills=included_builtin_skills, + sandbox_config=sandbox_config, ) self._commit_github_token_provider(session_id, github_token_provider_registration_id) @@ -4662,6 +4675,7 @@ async def _apply_post_create_options_patch( coauthor_enabled: bool | None, manage_schedule_enabled: bool | None, included_builtin_skills: list[str] | None = None, + sandbox_config: SandboxConfig | None = None, ) -> None: """Apply empty-mode safe defaults (or caller-supplied overrides in copilot-cli mode) via ``session.options.update`` after create/resume. @@ -4679,8 +4693,9 @@ async def _apply_post_create_options_patch( manage_schedule_enabled, included_builtin_skills, ) - if patch is None: + if patch is None and sandbox_config is None: return + patch = patch or {} params = SessionUpdateOptionsParams() if "skipCustomInstructions" in patch: @@ -4696,6 +4711,7 @@ async def _apply_post_create_options_patch( SessionInstalledPlugin.from_dict(p) if isinstance(p, dict) else p for p in patch["installedPlugins"] ] + params.sandbox_config = sandbox_config if "includedBuiltinSkills" in patch: skills = patch["includedBuiltinSkills"] params.included_builtin_skills = list(skills) if skills is not None else None diff --git a/python/e2e/test_session_config_e2e.py b/python/e2e/test_session_config_e2e.py index 62dc671893..39fe1c28a4 100644 --- a/python/e2e/test_session_config_e2e.py +++ b/python/e2e/test_session_config_e2e.py @@ -3,6 +3,7 @@ import base64 import json import os +import sys import uuid import httpx @@ -14,9 +15,13 @@ ModelCapabilitiesOverride, ModelSupportsOverride, RuntimeConnection, + SandboxConfig, + SandboxConfigUserPolicy, + SandboxConfigUserPolicyFilesystem, ) from copilot.copilot_request_handler import CopilotRequestContext from copilot.session import PermissionHandler +from copilot.session_events import ToolExecutionCompleteData from ._copilot_request_helpers import ( build_inference_response, @@ -100,6 +105,19 @@ def _get_tool_names(exchange: dict) -> list[str]: return names +async def _assert_next_shell_execution_result(session, prompt: str, expected: str) -> None: + event_count = len(await session.get_events()) + await session.send_and_wait(prompt) + completions = [ + event.data + for event in (await session.get_events())[event_count:] + if isinstance(event.data, ToolExecutionCompleteData) + ] + assert completions, "Expected tool.execution_complete after sandbox shell prompt" + assert completions[0].result is not None + assert expected in completions[0].result.content + + async def _send_and_get_next_exchange(session, ctx: E2ETestContext, prompt: str) -> dict: existing_count = len(await ctx.get_exchanges()) await session.send_and_wait(prompt) @@ -417,6 +435,76 @@ async def test_should_apply_session_limits_on_resume(self, ctx: E2ETestContext): await session2.disconnect() await session1.disconnect() + @pytest.mark.skipif( + sys.platform == "win32", reason="process sandboxing is not supported on Windows" + ) + async def test_should_apply_sandbox_config_on_create_and_resume(self, ctx: E2ETestContext): + enabled_probe = "/var/tmp/sandbox-create-enabled.txt" + disabled_probe = "/var/tmp/sandbox-create-disabled.txt" + resume_probe = "/var/tmp/sandbox-resume-enabled.txt" + probes = [enabled_probe, disabled_probe, resume_probe] + for probe in probes: + if os.path.exists(probe): + os.remove(probe) + try: + enabled_session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + working_directory=ctx.work_dir, + sandbox_config=SandboxConfig( + enabled=True, + user_policy=SandboxConfigUserPolicy( + filesystem=SandboxConfigUserPolicyFilesystem(denied_paths=[enabled_probe]) + ), + ), + ) + await _assert_next_shell_execution_result( + enabled_session, + "Check sandbox access for sandbox-create-enabled.txt.", + "sandbox-blocked", + ) + + disabled_session = await ctx.client.create_session( + on_permission_request=PermissionHandler.approve_all, + working_directory=ctx.work_dir, + sandbox_config=SandboxConfig( + enabled=False, + user_policy=SandboxConfigUserPolicy( + filesystem=SandboxConfigUserPolicyFilesystem(denied_paths=[disabled_probe]) + ), + ), + ) + await _assert_next_shell_execution_result( + disabled_session, + "Check sandbox access for sandbox-create-disabled.txt.", + "sandbox-accessible", + ) + resumed_session = await ctx.client.resume_session( + disabled_session.session_id, + on_permission_request=PermissionHandler.approve_all, + working_directory=ctx.work_dir, + sandbox_config=SandboxConfig( + enabled=True, + user_policy=SandboxConfigUserPolicy( + filesystem=SandboxConfigUserPolicyFilesystem(denied_paths=[resume_probe]) + ), + ), + ) + await _assert_next_shell_execution_result( + resumed_session, + "Check sandbox access for sandbox-resume-enabled.txt.", + "sandbox-blocked", + ) + + assert resumed_session.session_id == disabled_session.session_id + + await resumed_session.disconnect() + await disabled_session.disconnect() + await enabled_session.disconnect() + finally: + for probe in probes: + if os.path.exists(probe): + os.remove(probe) + async def test_should_apply_excluded_built_in_agents_on_create(self, ctx: E2ETestContext): excluded_agent = "explore" prompt = "What is 1+1?" diff --git a/python/test_client.py b/python/test_client.py index a33f0ecd60..1c6c826234 100644 --- a/python/test_client.py +++ b/python/test_client.py @@ -22,6 +22,7 @@ ModelBillingTokenPrices, ModelBillingTokenPricesLongContext, RuntimeConnection, + SandboxConfig, StdioRuntimeConnection, define_tool, ) @@ -1031,6 +1032,7 @@ async def test_create_and_resume_session_forward_new_session_options(self): await client.start() try: captured = {} + options_updates = [] async def mock_request(method, params, **kwargs): captured[method] = params @@ -1040,6 +1042,9 @@ async def mock_request(method, params, **kwargs): if callback is not None: callback(result) return result + if method == "session.options.update": + options_updates.append(params) + return {"success": True} return {} client._client.request = mock_request @@ -1049,6 +1054,10 @@ async def mock_request(method, params, **kwargs): enable_file_change_tracking=True, excluded_builtin_agents=["explore"], session_limits={"max_ai_credits": 30}, + sandbox_config=SandboxConfig( + enabled=True, + add_current_working_directory=False, + ), ) await client.resume_session( session.session_id, @@ -1057,16 +1066,26 @@ async def mock_request(method, params, **kwargs): enable_file_change_tracking=False, excluded_builtin_agents=["task"], session_limits={"max_ai_credits": 15}, + sandbox_config=SandboxConfig(enabled=False), ) assert captured["session.create"]["enableCitations"] is True assert captured["session.create"]["enableFileChangeTracking"] is True assert captured["session.create"]["excludedBuiltinAgents"] == ["explore"] assert captured["session.create"]["sessionLimits"] == {"maxAiCredits": 30} + assert captured["session.create"]["sandboxConfig"] == { + "enabled": True, + "addCurrentWorkingDirectory": False, + } assert captured["session.resume"]["enableCitations"] is False assert captured["session.resume"]["enableFileChangeTracking"] is False assert captured["session.resume"]["excludedBuiltinAgents"] == ["task"] assert captured["session.resume"]["sessionLimits"] == {"maxAiCredits": 15} + assert captured["session.resume"]["sandboxConfig"] == {"enabled": False} + assert [update["sandboxConfig"] for update in options_updates] == [ + {"enabled": True, "addCurrentWorkingDirectory": False}, + {"enabled": False}, + ] finally: await client.force_stop() diff --git a/rust/src/session.rs b/rust/src/session.rs index b9d2173055..680b978c3e 100644 --- a/rust/src/session.rs +++ b/rust/src/session.rs @@ -903,11 +903,14 @@ impl Client { } config.custom_agents_local_only = crate::mode::resolve_custom_agents_local_only(mode, config.custom_agents_local_only); - let opt_skip_custom_instructions = config.skip_custom_instructions; - let opt_custom_agents_local_only = config.custom_agents_local_only; - let opt_coauthor_enabled = config.coauthor_enabled; - let opt_manage_schedule_enabled = config.manage_schedule_enabled; - let opt_included_builtin_skills = config.included_builtin_skills.take(); + let post_create_options = ModePostCreateOptions { + skip_custom_instructions: config.skip_custom_instructions, + custom_agents_local_only: config.custom_agents_local_only, + coauthor_enabled: config.coauthor_enabled, + manage_schedule_enabled: config.manage_schedule_enabled, + sandbox_config: config.sandbox_config.clone(), + included_builtin_skills: config.included_builtin_skills.take(), + }; let (mut wire, mut runtime) = config.into_wire(local_session_id.clone())?; wire.enable_github_telemetry_forwarding = self.inner.on_github_telemetry.is_some().then_some(true); @@ -1104,16 +1107,7 @@ impl Client { event_tx, github_token_registration: ParkingLotMutex::new(github_token_registration), }; - apply_mode_post_create_patch( - &session, - mode, - opt_skip_custom_instructions, - opt_custom_agents_local_only, - opt_coauthor_enabled, - opt_manage_schedule_enabled, - opt_included_builtin_skills, - ) - .await?; + apply_mode_post_create_patch(&session, mode, post_create_options).await?; if let Some(registration) = session.github_token_registration.lock().as_ref() { registration.claim(session.id.clone()); } else { @@ -1191,11 +1185,14 @@ impl Client { } config.custom_agents_local_only = crate::mode::resolve_custom_agents_local_only(mode, config.custom_agents_local_only); - let opt_skip_custom_instructions = config.skip_custom_instructions; - let opt_custom_agents_local_only = config.custom_agents_local_only; - let opt_coauthor_enabled = config.coauthor_enabled; - let opt_manage_schedule_enabled = config.manage_schedule_enabled; - let opt_included_builtin_skills = config.included_builtin_skills.take(); + let post_create_options = ModePostCreateOptions { + skip_custom_instructions: config.skip_custom_instructions, + custom_agents_local_only: config.custom_agents_local_only, + coauthor_enabled: config.coauthor_enabled, + manage_schedule_enabled: config.manage_schedule_enabled, + sandbox_config: config.sandbox_config.clone(), + included_builtin_skills: config.included_builtin_skills.take(), + }; let (mut wire, mut runtime) = config.into_wire()?; wire.enable_github_telemetry_forwarding = self.inner.on_github_telemetry.is_some().then_some(true); @@ -1379,16 +1376,7 @@ impl Client { event_tx, github_token_registration: ParkingLotMutex::new(github_token_registration), }; - apply_mode_post_create_patch( - &session, - mode, - opt_skip_custom_instructions, - opt_custom_agents_local_only, - opt_coauthor_enabled, - opt_manage_schedule_enabled, - opt_included_builtin_skills, - ) - .await?; + apply_mode_post_create_patch(&session, mode, post_create_options).await?; if let Some(registration) = session.github_token_registration.lock().as_ref() { registration.claim(session.id.clone()); } else { @@ -1400,23 +1388,22 @@ impl Client { type CommandHandlerMap = HashMap>; +#[derive(Default)] +struct ModePostCreateOptions { + skip_custom_instructions: Option, + custom_agents_local_only: Option, + coauthor_enabled: Option, + manage_schedule_enabled: Option, + sandbox_config: Option, + included_builtin_skills: Option>, +} + async fn apply_mode_post_create_patch( session: &Session, mode: crate::ClientMode, - opt_skip_custom_instructions: Option, - opt_custom_agents_local_only: Option, - opt_coauthor_enabled: Option, - opt_manage_schedule_enabled: Option, - opt_included_builtin_skills: Option>, + options: ModePostCreateOptions, ) -> Result<(), Error> { - let Some(patch) = build_mode_post_create_patch( - mode, - opt_skip_custom_instructions, - opt_custom_agents_local_only, - opt_coauthor_enabled, - opt_manage_schedule_enabled, - opt_included_builtin_skills, - ) else { + let Some(patch) = build_mode_post_create_patch(mode, options) else { return Ok(()); }; if let Err(error) = session.rpc().options().update(patch).await { @@ -1437,41 +1424,48 @@ async fn apply_mode_post_create_patch( /// forwarded. fn build_mode_post_create_patch( mode: crate::ClientMode, - opt_skip_custom_instructions: Option, - opt_custom_agents_local_only: Option, - opt_coauthor_enabled: Option, - opt_manage_schedule_enabled: Option, - opt_included_builtin_skills: Option>, + options: ModePostCreateOptions, ) -> Option { use crate::generated::api_types::SessionUpdateOptionsParams; - let mut patch = SessionUpdateOptionsParams::default(); + let ModePostCreateOptions { + skip_custom_instructions, + custom_agents_local_only, + coauthor_enabled, + manage_schedule_enabled, + sandbox_config, + included_builtin_skills, + } = options; + let mut patch = SessionUpdateOptionsParams { + sandbox_config, + ..SessionUpdateOptionsParams::default() + }; let should_send = if mode == crate::ClientMode::Empty { - patch.skip_custom_instructions = Some(opt_skip_custom_instructions.unwrap_or(true)); - patch.custom_agents_local_only = Some(opt_custom_agents_local_only.unwrap_or(true)); - patch.coauthor_enabled = Some(opt_coauthor_enabled.unwrap_or(false)); - patch.manage_schedule_enabled = Some(opt_manage_schedule_enabled.unwrap_or(false)); + patch.skip_custom_instructions = Some(skip_custom_instructions.unwrap_or(true)); + patch.custom_agents_local_only = Some(custom_agents_local_only.unwrap_or(true)); + patch.coauthor_enabled = Some(coauthor_enabled.unwrap_or(false)); + patch.manage_schedule_enabled = Some(manage_schedule_enabled.unwrap_or(false)); patch.installed_plugins = Some(Vec::new()); - patch.included_builtin_skills = Some(opt_included_builtin_skills.unwrap_or_default()); + patch.included_builtin_skills = Some(included_builtin_skills.unwrap_or_default()); true } else { - let mut any = false; - if let Some(v) = opt_skip_custom_instructions { + let mut any = patch.sandbox_config.is_some(); + if let Some(v) = skip_custom_instructions { patch.skip_custom_instructions = Some(v); any = true; } - if let Some(v) = opt_custom_agents_local_only { + if let Some(v) = custom_agents_local_only { patch.custom_agents_local_only = Some(v); any = true; } - if let Some(v) = opt_coauthor_enabled { + if let Some(v) = coauthor_enabled { patch.coauthor_enabled = Some(v); any = true; } - if let Some(v) = opt_manage_schedule_enabled { + if let Some(v) = manage_schedule_enabled { patch.manage_schedule_enabled = Some(v); any = true; } - if let Some(v) = opt_included_builtin_skills { + if let Some(v) = included_builtin_skills { patch.included_builtin_skills = Some(v); any = true; } @@ -2659,8 +2653,8 @@ mod tests { use serde_json::json; use super::{ - build_mode_post_create_patch, has_managed_settings, is_autopilot_continuation_idle, - permission_request_data, permission_response_params, + ModePostCreateOptions, build_mode_post_create_patch, has_managed_settings, + is_autopilot_continuation_idle, permission_request_data, permission_response_params, }; use crate::handler::PermissionResult; use crate::types::{ @@ -2693,9 +2687,11 @@ mod tests { #[test] fn empty_mode_post_patch_sets_empty_included_builtin_skills() { - let patch = - build_mode_post_create_patch(crate::ClientMode::Empty, None, None, None, None, None) - .expect("empty mode always sends a patch"); + let patch = build_mode_post_create_patch( + crate::ClientMode::Empty, + ModePostCreateOptions::default(), + ) + .expect("empty mode always sends a patch"); assert_eq!( patch.included_builtin_skills, Some(Vec::new()), @@ -2711,11 +2707,14 @@ mod tests { fn empty_mode_post_patch_preserves_explicit_builtin_skill_allowlist() { let patch = build_mode_post_create_patch( crate::ClientMode::Empty, - Some(false), - Some(false), - Some(true), - Some(true), - Some(vec!["code-review".to_string()]), + ModePostCreateOptions { + skip_custom_instructions: Some(false), + custom_agents_local_only: Some(false), + coauthor_enabled: Some(true), + manage_schedule_enabled: Some(true), + included_builtin_skills: Some(vec!["code-review".to_string()]), + ..Default::default() + }, ) .expect("empty mode always sends a patch"); assert_eq!( @@ -2730,22 +2729,17 @@ mod tests { assert!( build_mode_post_create_patch( crate::ClientMode::CopilotCli, - None, - None, - None, - None, - None + ModePostCreateOptions::default(), ) .is_none() ); // A field set -> patch sent, but skills field stays absent. let patch = build_mode_post_create_patch( crate::ClientMode::CopilotCli, - Some(true), - None, - None, - None, - None, + ModePostCreateOptions { + skip_custom_instructions: Some(true), + ..Default::default() + }, ) .expect("a set field triggers a patch"); assert_eq!(patch.included_builtin_skills, None); @@ -2755,11 +2749,10 @@ mod tests { let patch = build_mode_post_create_patch( crate::ClientMode::CopilotCli, - None, - None, - None, - None, - Some(vec!["code-review".to_string()]), + ModePostCreateOptions { + included_builtin_skills: Some(vec!["code-review".to_string()]), + ..Default::default() + }, ) .expect("an explicit allowlist triggers a patch"); assert_eq!( diff --git a/rust/src/types.rs b/rust/src/types.rs index 6e451eb452..046d8cdc89 100644 --- a/rust/src/types.rs +++ b/rust/src/types.rs @@ -2097,6 +2097,8 @@ pub struct SessionConfig { pub enable_file_change_tracking: Option, /// **Experimental.** Limits applied to this session's current accounting window. pub session_limits: Option, + /// **Experimental.** Resolved sandbox configuration applied when the session is created. + pub sandbox_config: Option, /// Per-property overrides for model capabilities, deep-merged over /// runtime defaults. pub model_capabilities: Option, @@ -2298,6 +2300,10 @@ impl std::fmt::Debug for SessionConfig { &self.enable_file_change_tracking, ) .field("session_limits", &self.session_limits) + .field( + "sandbox_config", + &self.sandbox_config.as_ref().map(|_| ""), + ) .field("model_capabilities", &self.model_capabilities) .field("memory", &self.memory) .field("config_directory", &self.config_directory) @@ -2423,6 +2429,7 @@ impl Default for SessionConfig { enable_citations: None, enable_file_change_tracking: None, session_limits: None, + sandbox_config: None, model_capabilities: None, memory: None, config_directory: None, @@ -2596,6 +2603,7 @@ impl SessionConfig { enable_citations: self.enable_citations, enable_file_change_tracking: self.enable_file_change_tracking, session_limits: self.session_limits, + sandbox_config: self.sandbox_config, model_capabilities: self.model_capabilities, memory: self.memory, config_dir: self.config_directory, @@ -3132,6 +3140,12 @@ impl SessionConfig { self } + /// **Experimental.** Set the sandbox configuration for this session. + pub fn with_sandbox_config(mut self, config: SandboxConfig) -> Self { + self.sandbox_config = Some(config); + self + } + /// Set per-property overrides for model capabilities. pub fn with_model_capabilities( mut self, @@ -3438,6 +3452,8 @@ pub struct ResumeSessionConfig { pub enable_file_change_tracking: Option, /// **Experimental.** Limits applied to this session's current accounting window. pub session_limits: Option, + /// **Experimental.** Resolved sandbox configuration applied when the session is resumed. + pub sandbox_config: Option, /// Per-property model capability overrides on resume. pub model_capabilities: Option, /// Per-session configuration for the runtime memory feature on resume. @@ -3608,6 +3624,10 @@ impl std::fmt::Debug for ResumeSessionConfig { &self.enable_file_change_tracking, ) .field("session_limits", &self.session_limits) + .field( + "sandbox_config", + &self.sandbox_config.as_ref().map(|_| ""), + ) .field("model_capabilities", &self.model_capabilities) .field("memory", &self.memory) .field("config_directory", &self.config_directory) @@ -3782,6 +3802,7 @@ impl ResumeSessionConfig { enable_citations: self.enable_citations, enable_file_change_tracking: self.enable_file_change_tracking, session_limits: self.session_limits, + sandbox_config: self.sandbox_config, model_capabilities: self.model_capabilities, memory: self.memory, config_dir: self.config_directory, @@ -3882,6 +3903,7 @@ impl ResumeSessionConfig { enable_citations: None, enable_file_change_tracking: None, session_limits: None, + sandbox_config: None, model_capabilities: None, memory: None, config_directory: None, @@ -4394,6 +4416,12 @@ impl ResumeSessionConfig { self } + /// **Experimental.** Set the sandbox configuration for this resumed session. + pub fn with_sandbox_config(mut self, config: SandboxConfig) -> Self { + self.sandbox_config = Some(config); + self + } + /// Set per-property model capability overrides on resume. pub fn with_model_capabilities( mut self, @@ -5860,7 +5888,11 @@ pub use crate::generated::api_types::{ ModelCapabilitiesSupports, ModelList, ModelPolicy, PermissionDecision, PermissionDecisionApproveOnce, PermissionDecisionContext, PermissionDecisionOutcome, PermissionDecisionReject, PermissionDecisionSource, PermissionDecisionSurface, - PermissionDecisionUserNotAvailable, PermissionResponseCapability, + PermissionDecisionUserNotAvailable, PermissionResponseCapability, SandboxConfig, + SandboxConfigAuth, SandboxConfigUserPolicy, SandboxConfigUserPolicyExperimental, + SandboxConfigUserPolicyExperimentalSeatbelt, SandboxConfigUserPolicyFilesystem, + SandboxConfigUserPolicyNetwork, SandboxConfigUserPolicyNetworkProxy, + SandboxConfigUserPolicySeatbelt, }; /// Permission categories the CLI may request approval for. @@ -5971,8 +6003,10 @@ mod tests { ExpFlagValue, ExtensionInfo, GitHubMcpToolConfig, GitHubReferenceType, InfiniteSessionConfig, LargeToolOutputConfig, McpServerConfig, McpStdioServerConfig, MemoryConfiguration, NamedProviderConfig, PermissionResponseCapability, ProviderConfig, - ProviderModelConfig, ReasoningSummary, ResumeSessionConfig, SessionConfig, SessionEvent, - SessionId, SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, + ProviderModelConfig, ReasoningSummary, ResumeSessionConfig, SandboxConfig, + SandboxConfigUserPolicy, SandboxConfigUserPolicyNetwork, + SandboxConfigUserPolicyNetworkProxy, SessionConfig, SessionEvent, SessionId, + SystemMessageConfig, Tool, ToolBinaryResult, ToolResult, ToolResultExpanded, ToolResultResponse, ensure_attachment_display_names, }; use crate::generated::session_events::TypedSessionEvent; @@ -6286,6 +6320,54 @@ mod tests { assert!(unset_resume_json.get("customAgentsLocalOnly").is_none()); } + #[test] + fn sandbox_config_serializes_on_create_and_resume() { + let sandbox_config = SandboxConfig { + enabled: true, + user_policy: Some(SandboxConfigUserPolicy { + network: Some(SandboxConfigUserPolicyNetwork { + allow_outbound: Some(false), + allow_local_network: Some(false), + proxy: Some(SandboxConfigUserPolicyNetworkProxy { + url: "http://127.0.0.1:4321".to_string(), + username: None, + password: None, + }), + }), + ..Default::default() + }), + ..Default::default() + }; + + let create_config = SessionConfig::default().with_sandbox_config(sandbox_config.clone()); + let (create_wire, _) = create_config + .into_wire(Some(SessionId::from("create-sandbox"))) + .expect("create config has no duplicate handlers"); + let create_json = serde_json::to_value(&create_wire).unwrap(); + assert_eq!( + create_json["sandboxConfig"]["userPolicy"]["network"]["proxy"]["url"], + "http://127.0.0.1:4321" + ); + + let resume_config = ResumeSessionConfig::new(SessionId::from("resume-sandbox")) + .with_sandbox_config(sandbox_config); + let (resume_wire, _) = resume_config + .into_wire() + .expect("resume config has no duplicate handlers"); + let resume_json = serde_json::to_value(&resume_wire).unwrap(); + assert_eq!(resume_json["sandboxConfig"]["enabled"], true); + + let (unset_wire, _) = SessionConfig::default() + .into_wire(Some(SessionId::from("sandbox-unset"))) + .expect("unset config has no duplicate handlers"); + assert!( + serde_json::to_value(&unset_wire) + .unwrap() + .get("sandboxConfig") + .is_none() + ); + } + #[test] fn session_config_enable_mcp_apps_sets_wire_flag_and_serializes() { let cfg = SessionConfig::default().with_enable_mcp_apps(true); diff --git a/rust/src/wire.rs b/rust/src/wire.rs index f7de33839c..1c3c0829d6 100644 --- a/rust/src/wire.rs +++ b/rust/src/wire.rs @@ -20,7 +20,7 @@ use serde::Serialize; use crate::canvas::CanvasDeclaration; use crate::generated::api_types::{ - ModelCapabilitiesOverride, OpenCanvasInstance, RemoteSessionMode, + ModelCapabilitiesOverride, OpenCanvasInstance, RemoteSessionMode, SandboxConfig, }; use crate::generated::session_events::ReasoningSummary; use crate::types::{ @@ -159,6 +159,8 @@ pub(crate) struct SessionCreateWire { #[serde(skip_serializing_if = "Option::is_none")] pub session_limits: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, #[serde(skip_serializing_if = "Option::is_none")] pub memory: Option, @@ -314,6 +316,8 @@ pub(crate) struct SessionResumeWire { #[serde(skip_serializing_if = "Option::is_none")] pub session_limits: Option, #[serde(skip_serializing_if = "Option::is_none")] + pub sandbox_config: Option, + #[serde(skip_serializing_if = "Option::is_none")] pub model_capabilities: Option, #[serde(skip_serializing_if = "Option::is_none")] pub memory: Option, diff --git a/rust/tests/e2e/session_config.rs b/rust/tests/e2e/session_config.rs index c3f6b57aea..26931daec7 100644 --- a/rust/tests/e2e/session_config.rs +++ b/rust/tests/e2e/session_config.rs @@ -6,19 +6,23 @@ use async_trait::async_trait; use base64::Engine; use bytes::Bytes; use github_copilot_sdk::handler::ApproveAllHandler; +use github_copilot_sdk::session_events::{SessionEventType, ToolExecutionCompleteData}; use github_copilot_sdk::{ Attachment, Client, CopilotHttpRequest, CopilotHttpResponse, CopilotRequestContext, CopilotRequestError, CopilotRequestHandler, MessageOptions, ProviderConfig, - ResumeSessionConfig, SessionConfig, SessionLimitsConfig, Transport, + ResumeSessionConfig, SandboxConfig, SandboxConfigUserPolicy, SandboxConfigUserPolicyFilesystem, + SessionConfig, SessionLimitsConfig, Transport, }; use http::{HeaderMap, HeaderValue}; use parking_lot::Mutex; use serde_json::{Value, json}; -use super::support::{DEFAULT_TEST_TOKEN, E2eContext, with_e2e_context_no_snapshot}; +use super::support::{ + DEFAULT_TEST_TOKEN, E2eContext, collect_until_idle, with_e2e_context_no_snapshot, +}; static E2E: super::support::SharedE2eGroup = - super::support::SharedE2eGroup::standard("session_config", 4); + super::support::SharedE2eGroup::standard("session_config", if cfg!(windows) { 4 } else { 5 }); const SYNTHETIC_TEXT: &str = "OK from the synthetic stream."; const CITATION_PROMPT: &str = "Summarize the attached PDF with citations enabled."; @@ -89,6 +93,141 @@ fn task_agent_types(exchange: &Value) -> Vec { panic!("expected task tool in request"); } +fn sandbox_config(enabled: bool, denied_path: &str) -> SandboxConfig { + SandboxConfig { + enabled, + user_policy: Some(SandboxConfigUserPolicy { + filesystem: Some(SandboxConfigUserPolicyFilesystem { + denied_paths: Some(vec![denied_path.to_string()]), + ..Default::default() + }), + ..Default::default() + }), + ..Default::default() + } +} + +async fn assert_next_shell_execution_result( + session: &github_copilot_sdk::session::Session, + prompt: &str, + expected: &str, +) { + let events = session.subscribe(); + session + .send_and_wait(MessageOptions::new(prompt).with_wait_timeout(Duration::from_secs(120))) + .await + .expect("send_and_wait"); + + let observed = collect_until_idle(events).await; + let completion = observed + .iter() + .find(|event| event.parsed_type() == SessionEventType::ToolExecutionComplete) + .and_then(|event| event.typed_data::()) + .expect("tool.execution_complete after sandbox shell prompt"); + let content = &completion + .result + .as_ref() + .expect("sandbox shell result") + .content; + assert!( + content.contains(expected), + "expected sandbox shell result to contain {expected:?}, got {content:?}" + ); +} + +#[tokio::test] +async fn should_apply_sandbox_config_on_create_and_resume() { + if cfg!(windows) { + return; + } + + super::support::with_shared_e2e_context( + &E2E, + "session_config", + "should_apply_sandbox_config_on_create_and_resume", + |ctx| { + Box::pin(async move { + const ENABLED_PROBE: &str = "/var/tmp/sandbox-create-enabled.txt"; + const DISABLED_PROBE: &str = "/var/tmp/sandbox-create-disabled.txt"; + const RESUME_PROBE: &str = "/var/tmp/sandbox-resume-enabled.txt"; + const PROBES: [&str; 3] = [ENABLED_PROBE, DISABLED_PROBE, RESUME_PROBE]; + + for probe in PROBES { + let _ = std::fs::remove_file(probe); + } + + ctx.set_default_copilot_user(); + let client = ctx.start_client().await; + let mut enabled_config = ctx + .approve_all_session_config() + .with_working_directory(ctx.work_dir()); + enabled_config.sandbox_config = Some(sandbox_config(true, ENABLED_PROBE)); + let enabled_session = client + .create_session(enabled_config) + .await + .expect("create sandbox-enabled session"); + assert_next_shell_execution_result( + &enabled_session, + "Check sandbox access for sandbox-create-enabled.txt.", + "sandbox-blocked", + ) + .await; + + let mut disabled_config = ctx + .approve_all_session_config() + .with_working_directory(ctx.work_dir()); + disabled_config.sandbox_config = Some(sandbox_config(false, DISABLED_PROBE)); + let disabled_session = client + .create_session(disabled_config) + .await + .expect("create sandbox-disabled session"); + assert_next_shell_execution_result( + &disabled_session, + "Check sandbox access for sandbox-create-disabled.txt.", + "sandbox-accessible", + ) + .await; + + let mut resume_config = ResumeSessionConfig::new(disabled_session.id().clone()) + .with_permission_handler(Arc::new(ApproveAllHandler)) + .with_github_token(DEFAULT_TEST_TOKEN) + .with_working_directory(ctx.work_dir()); + resume_config.sandbox_config = Some(sandbox_config(true, RESUME_PROBE)); + let resumed_session = client + .resume_session(resume_config) + .await + .expect("resume sandbox-enabled session"); + assert_next_shell_execution_result( + &resumed_session, + "Check sandbox access for sandbox-resume-enabled.txt.", + "sandbox-blocked", + ) + .await; + assert_eq!(resumed_session.id(), disabled_session.id()); + + resumed_session + .disconnect() + .await + .expect("disconnect resumed session"); + disabled_session + .disconnect() + .await + .expect("disconnect disabled session"); + enabled_session + .disconnect() + .await + .expect("disconnect enabled session"); + client.stop().await.expect("stop client"); + + for probe in PROBES { + let _ = std::fs::remove_file(probe); + } + }) + }, + ) + .await; +} + #[tokio::test] async fn should_apply_session_limits_on_create() { super::support::with_shared_e2e_context( diff --git a/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml b/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml new file mode 100644 index 0000000000..0a0e5d5c63 --- /dev/null +++ b/test/snapshots/session_config/should_apply_sandbox_config_on_create_and_resume.yaml @@ -0,0 +1,128 @@ +models: + - claude-sonnet-4.5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Check sandbox access for sandbox-create-enabled.txt. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-enabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check enabled sandbox policy"}' + - messages: + - role: system + content: ${system} + - role: user + content: Check sandbox access for sandbox-create-enabled.txt. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-enabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check enabled sandbox policy"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + sandbox-blocked + + - role: assistant + content: sandbox-blocked + - messages: + - role: system + content: ${system} + - role: user + content: Check sandbox access for sandbox-create-disabled.txt. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-disabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' + - messages: + - role: system + content: ${system} + - role: user + content: Check sandbox access for sandbox-create-disabled.txt. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-disabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + sandbox-accessible + + - role: assistant + content: sandbox-accessible + - messages: + - role: system + content: ${system} + - role: user + content: Check sandbox access for sandbox-create-disabled.txt. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-disabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + sandbox-accessible + + - role: assistant + content: sandbox-accessible + - role: user + content: Check sandbox access for sandbox-resume-enabled.txt. + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"(printf probe > /var/tmp/sandbox-resume-enabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check resumed sandbox policy"}' + - messages: + - role: system + content: ${system} + - role: user + content: Check sandbox access for sandbox-create-disabled.txt. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: ${shell} + arguments: '{"command":"(printf probe > /var/tmp/sandbox-create-disabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check disabled sandbox policy"}' + - role: tool + tool_call_id: toolcall_0 + content: |- + sandbox-accessible + + - role: assistant + content: sandbox-accessible + - role: user + content: Check sandbox access for sandbox-resume-enabled.txt. + - role: assistant + tool_calls: + - id: toolcall_1 + type: function + function: + name: ${shell} + arguments: '{"command":"(printf probe > /var/tmp/sandbox-resume-enabled.txt) 2>/dev/null && echo sandbox-accessible || echo sandbox-blocked","description":"Check resumed sandbox policy"}' + - role: tool + tool_call_id: toolcall_1 + content: |- + sandbox-blocked + + - role: assistant + content: sandbox-blocked