Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions dotnet/test/Unit/SerializationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,25 @@ namespace GitHub.Copilot.Test.Unit;
/// </summary>
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<SandboxConfig>(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()
{
Expand Down
43 changes: 43 additions & 0 deletions go/rpc/sandbox_config_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Original file line number Diff line number Diff line change
@@ -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());
}
}
88 changes: 88 additions & 0 deletions nodejs/test/e2e/sandbox_bypass.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -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
);
});
16 changes: 16 additions & 0 deletions nodejs/test/sandbox-config.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
});
});
9 changes: 9 additions & 0 deletions python/test_rpc_generated.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,13 +16,22 @@
RemoteControlStatusOff,
RemoteControlStatusResult,
RemoteSessionMetadataValue,
SandboxConfig,
SessionList,
SlashCommandTextResult,
TaskAgentInfo,
UIElicitationSchemaType,
)


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()
Expand Down
26 changes: 25 additions & 1 deletion rust/tests/api_types_test.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(),
Expand Down
Original file line number Diff line number Diff line change
@@ -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
Loading