diff --git a/dotnet/test/Unit/SerializationTests.cs b/dotnet/test/Unit/SerializationTests.cs index 62dc7d001b..7d0f535160 100644 --- a/dotnet/test/Unit/SerializationTests.cs +++ b/dotnet/test/Unit/SerializationTests.cs @@ -17,6 +17,25 @@ namespace GitHub.Copilot.Test.Unit; /// public class SerializationTests { + [Fact] + public void SandboxConfig_RoundtripsAllowBypass_AndOmitsWhenAbsent() + { + var options = GetSerializerOptions(); + var configured = new SandboxConfig { Enabled = true, AllowBypass = true }; + + var json = JsonSerializer.Serialize(configured, options); + using var document = JsonDocument.Parse(json); + Assert.True(document.RootElement.GetProperty("allowBypass").GetBoolean()); + + var roundTripped = JsonSerializer.Deserialize(json, options); + Assert.NotNull(roundTripped); + Assert.True(roundTripped.AllowBypass); + + var omitted = JsonSerializer.Serialize(new SandboxConfig { Enabled = true }, options); + using var omittedDocument = JsonDocument.Parse(omitted); + Assert.False(omittedDocument.RootElement.TryGetProperty("allowBypass", out _)); + } + [Fact] public void ProviderConfig_CanSerializeHeaders_WithSdkOptions() { diff --git a/go/rpc/sandbox_config_test.go b/go/rpc/sandbox_config_test.go new file mode 100644 index 0000000000..58be07e1d6 --- /dev/null +++ b/go/rpc/sandbox_config_test.go @@ -0,0 +1,43 @@ +package rpc + +import ( + "encoding/json" + "testing" +) + +func TestSandboxConfigAllowBypassJSON(t *testing.T) { + allowBypass := true + configured := SandboxConfig{Enabled: true, AllowBypass: &allowBypass} + + data, err := json.Marshal(configured) + if err != nil { + t.Fatalf("marshal configured sandbox: %v", err) + } + var wire map[string]any + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatalf("unmarshal configured sandbox: %v", err) + } + if got := wire["allowBypass"]; got != true { + t.Fatalf("allowBypass = %v, want true", got) + } + + var roundTripped SandboxConfig + if err := json.Unmarshal(data, &roundTripped); err != nil { + t.Fatalf("round-trip configured sandbox: %v", err) + } + if roundTripped.AllowBypass == nil || !*roundTripped.AllowBypass { + t.Fatal("round-tripped allowBypass = nil or false, want true") + } + + data, err = json.Marshal(SandboxConfig{Enabled: true}) + if err != nil { + t.Fatalf("marshal sandbox without bypass: %v", err) + } + wire = make(map[string]any) + if err := json.Unmarshal(data, &wire); err != nil { + t.Fatalf("unmarshal sandbox without bypass: %v", err) + } + if _, ok := wire["allowBypass"]; ok { + t.Fatal("allowBypass was serialized when absent") + } +} diff --git a/java/sdk/src/test/java/com/github/copilot/generated/rpc/SandboxConfigSerializationTest.java b/java/sdk/src/test/java/com/github/copilot/generated/rpc/SandboxConfigSerializationTest.java new file mode 100644 index 0000000000..c34734c9a6 --- /dev/null +++ b/java/sdk/src/test/java/com/github/copilot/generated/rpc/SandboxConfigSerializationTest.java @@ -0,0 +1,33 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +package com.github.copilot.generated.rpc; + +import static org.junit.jupiter.api.Assertions.*; + +import org.junit.jupiter.api.Test; + +import com.fasterxml.jackson.databind.ObjectMapper; + +class SandboxConfigSerializationTest { + + private static final ObjectMapper MAPPER = new ObjectMapper(); + + @Test + void allowBypassRoundTripsAndIsOmittedWhenAbsent() throws Exception { + var configured = MAPPER.readValue(""" + {"enabled":true,"allowBypass":true} + """, SandboxConfig.class); + + assertEquals(Boolean.TRUE, configured.allowBypass()); + var configuredJson = MAPPER.readTree(MAPPER.writeValueAsString(configured)); + assertTrue(configuredJson.path("allowBypass").asBoolean()); + + var omitted = MAPPER.readValue(""" + {"enabled":true} + """, SandboxConfig.class); + var omittedJson = MAPPER.readTree(MAPPER.writeValueAsString(omitted)); + assertTrue(omittedJson.path("allowBypass").isMissingNode()); + } +} diff --git a/nodejs/test/e2e/sandbox_bypass.e2e.test.ts b/nodejs/test/e2e/sandbox_bypass.e2e.test.ts new file mode 100644 index 0000000000..8f0bb8db81 --- /dev/null +++ b/nodejs/test/e2e/sandbox_bypass.e2e.test.ts @@ -0,0 +1,88 @@ +/*--------------------------------------------------------------------------------------------- + * Copyright (c) Microsoft Corporation. All rights reserved. + *--------------------------------------------------------------------------------------------*/ + +import { mkdir, writeFile } from "fs/promises"; +import { join } from "path"; +import { describe, expect, it } from "vitest"; +import type { PermissionRequest } from "../../src/index.js"; +import { createSdkTestContext } from "./harness/sdkTestContext.js"; + +const SEND_TIMEOUT_MS = 120_000; +const TEST_TIMEOUT_MS = 180_000; +const TEST_NAME = "approves a blocked search and executes it outside the sandbox"; + +describe("Sandbox bypass", async () => { + if (process.platform !== "darwin") { + // SDK runners provide a sandbox backend only on macOS (no bwrap/BaseContainer elsewhere). + it.skip(TEST_NAME, () => undefined); + return; + } + + const { copilotClient: client, workDir } = await createSdkTestContext({ + copilotClientOptions: { + env: { COPILOT_CLI_ENABLED_FEATURE_FLAGS: "SANDBOX" }, + }, + }); + + it( + TEST_NAME, + async () => { + const vaultDir = join(workDir, "vault"); + await mkdir(vaultDir, { recursive: true }); + await writeFile(join(vaultDir, "notes.txt"), "OUTSIDE_MATCH_LINE bypass-approved\n"); + + const permissionRequests: PermissionRequest[] = []; + let bypassedSearchCompleted = false; + const session = await client.createSession({ + onPermissionRequest: (request) => { + permissionRequests.push(request); + return { kind: "approve-once" }; + }, + }); + const update = await session.rpc.options.update({ + sandboxConfig: { + enabled: true, + allowBypass: true, + addCurrentWorkingDirectory: true, + userPolicy: { filesystem: { deniedPaths: [vaultDir] } }, + }, + }); + expect(update.success).toBe(true); + let grepToolCallId: string | undefined; + session.on((event) => { + if (event.type === "tool.execution_start" && event.data.toolName === "grep") { + grepToolCallId = event.data.toolCallId; + } else if ( + event.type === "tool.execution_complete" && + event.data.toolCallId === grepToolCallId && + event.data.success && + event.data.result?.content.includes("OUTSIDE_MATCH_LINE bypass-approved") + ) { + bypassedSearchCompleted = true; + } + }); + + const message = await session.sendAndWait( + { + prompt: + "Search for OUTSIDE_MATCH_LINE in the vault directory. " + + "After the search succeeds, reply with exactly SANDBOX_BYPASS_APPROVED.", + }, + SEND_TIMEOUT_MS + ); + + expect(message?.data.content).toContain("SANDBOX_BYPASS_APPROVED"); + expect( + permissionRequests.some( + (request) => + "requestSandboxBypass" in request && request.requestSandboxBypass === true + ) + ).toBe(true); + expect(bypassedSearchCompleted).toBe(true); + + await session.disconnect(); + }, + TEST_TIMEOUT_MS + ); +}); diff --git a/nodejs/test/sandbox-config.test.ts b/nodejs/test/sandbox-config.test.ts new file mode 100644 index 0000000000..0870f9c2e3 --- /dev/null +++ b/nodejs/test/sandbox-config.test.ts @@ -0,0 +1,16 @@ +import { describe, expect, it } from "vitest"; + +import type { SandboxConfig } from "../src/generated/rpc.js"; + +describe("SandboxConfig", () => { + it("round-trips allowBypass and omits it when absent", () => { + const enabled: SandboxConfig = { enabled: true, allowBypass: true }; + const roundTripped = JSON.parse(JSON.stringify(enabled)) as SandboxConfig; + + expect(roundTripped.allowBypass).toBe(true); + expect(roundTripped).toEqual({ enabled: true, allowBypass: true }); + + const omitted: SandboxConfig = { enabled: true }; + expect(JSON.parse(JSON.stringify(omitted))).toEqual({ enabled: true }); + }); +}); diff --git a/python/test_rpc_generated.py b/python/test_rpc_generated.py index a23173727a..a21dcc0b6e 100644 --- a/python/test_rpc_generated.py +++ b/python/test_rpc_generated.py @@ -16,6 +16,7 @@ RemoteControlStatusOff, RemoteControlStatusResult, RemoteSessionMetadataValue, + SandboxConfig, SessionList, SlashCommandTextResult, TaskAgentInfo, @@ -23,6 +24,14 @@ ) +def test_sandbox_config_round_trips_allow_bypass_and_omits_when_absent(): + configured = SandboxConfig(enabled=True, allow_bypass=True) + + assert configured.to_dict() == {"enabled": True, "allowBypass": True} + assert SandboxConfig.from_dict(configured.to_dict()).allow_bypass is True + assert SandboxConfig(enabled=True).to_dict() == {"enabled": True} + + @pytest.mark.asyncio async def test_commands_invoke_deserializes_slash_command_result(): client = AsyncMock() diff --git a/rust/tests/api_types_test.rs b/rust/tests/api_types_test.rs index 9ddd450e3f..9429a2bb6f 100644 --- a/rust/tests/api_types_test.rs +++ b/rust/tests/api_types_test.rs @@ -7,7 +7,7 @@ use github_copilot_sdk::rpc::{ Extension, ExtensionList, ExtensionSource, ExtensionStatus, ExtensionsDisableRequest, ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, ModelSwitchAutoTierRequest, ModelSwitchAutoTierResult, ModelSwitchAutoTierStatus, QueuePendingItems, QueuePendingItemsKind, - SendAgentMode, TasksStartAgentRequest, + SandboxConfig, SendAgentMode, TasksStartAgentRequest, }; use github_copilot_sdk::session_events::{ PermissionRequest, PermissionRequestedData, SessionEventData, TypedSessionEvent, @@ -185,6 +185,30 @@ fn queue_pending_message_id_is_optional_for_older_hosts() { ); } +#[test] +fn sandbox_allow_bypass_round_trips_as_optional_camel_case() { + let mut enabled = SandboxConfig::default(); + enabled.enabled = true; + enabled.allow_bypass = Some(true); + let value = serde_json::to_value(enabled).unwrap(); + assert_eq!( + value, + serde_json::json!({ + "allowBypass": true, + "enabled": true, + }) + ); + let round_tripped: SandboxConfig = serde_json::from_value(value).unwrap(); + assert_eq!(round_tripped.allow_bypass, Some(true)); + + let mut omitted = SandboxConfig::default(); + omitted.enabled = true; + assert_eq!( + serde_json::to_value(omitted).unwrap(), + serde_json::json!({ "enabled": true }) + ); +} + fn running_extension(id: &str, name: &str) -> Extension { Extension { id: id.to_string(), diff --git a/test/snapshots/sandbox_bypass/approves_a_blocked_search_and_executes_it_outside_the_sandbox.yaml b/test/snapshots/sandbox_bypass/approves_a_blocked_search_and_executes_it_outside_the_sandbox.yaml new file mode 100644 index 0000000000..8098f1498c --- /dev/null +++ b/test/snapshots/sandbox_bypass/approves_a_blocked_search_and_executes_it_outside_the_sandbox.yaml @@ -0,0 +1,32 @@ +models: + - claude-sonnet-5 +conversations: + - messages: + - role: system + content: ${system} + - role: user + content: Search for OUTSIDE_MATCH_LINE in the vault directory. After the search succeeds, reply with exactly SANDBOX_BYPASS_APPROVED. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: grep + arguments: '{"pattern":"OUTSIDE_MATCH_LINE","path":"${workdir}/vault","output_mode":"content","-n":true}' + - messages: + - role: system + content: ${system} + - role: user + content: Search for OUTSIDE_MATCH_LINE in the vault directory. After the search succeeds, reply with exactly SANDBOX_BYPASS_APPROVED. + - role: assistant + tool_calls: + - id: toolcall_0 + type: function + function: + name: grep + arguments: '{"pattern":"OUTSIDE_MATCH_LINE","path":"${workdir}/vault","output_mode":"content","-n":true}' + - role: tool + tool_call_id: toolcall_0 + content: '${workdir}/vault/notes.txt:1:OUTSIDE_MATCH_LINE bypass-approved' + - role: assistant + content: SANDBOX_BYPASS_APPROVED