Skip to content

Commit 604c716

Browse files
ellismgCopilot
andauthored
Expose sandbox bypass across SDKs (#2372)
* Expose sandbox bypass in Rust API Teach the Rust code generator about the runtime's allowBypass host capability until the pinned CLI schema includes it, then verify camelCase serialization and omission when unset. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Add sandbox bypass parity across SDKs Generate SandboxConfig.allowBypass for Node, .NET, Python, Go, Java, and Rust from one temporary schema augmentation. Preserve Python and Java constructor compatibility and cover camelCase round-tripping plus omission in each binding. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Use authoritative sandbox bypass schema Remove the temporary schema augmentation now that CLI 1.0.83-4 ships allowBypass, update serialization coverage for the current generated shapes, and add a real-runtime bypass E2E test. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Initialize sandbox E2E harness during collection Register replay snapshot and in-process environment hooks before the test runs while avoiding unsupported Windows sandbox setup. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Run sandbox bypass E2E where supported Use the macOS sandbox backend available in CI and prove the approved bypass by observing the denied-path result in the successful tool completion. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> * Correlate sandbox tool completion by call ID Track the grep execution start so the E2E can verify its successful denied-path result even when completion events omit the tool name. Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com> --------- Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
1 parent fcffcdf commit 604c716

8 files changed

Lines changed: 265 additions & 1 deletion

File tree

‎dotnet/test/Unit/SerializationTests.cs‎

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,25 @@ namespace GitHub.Copilot.Test.Unit;
1717
/// </summary>
1818
public class SerializationTests
1919
{
20+
[Fact]
21+
public void SandboxConfig_RoundtripsAllowBypass_AndOmitsWhenAbsent()
22+
{
23+
var options = GetSerializerOptions();
24+
var configured = new SandboxConfig { Enabled = true, AllowBypass = true };
25+
26+
var json = JsonSerializer.Serialize(configured, options);
27+
using var document = JsonDocument.Parse(json);
28+
Assert.True(document.RootElement.GetProperty("allowBypass").GetBoolean());
29+
30+
var roundTripped = JsonSerializer.Deserialize<SandboxConfig>(json, options);
31+
Assert.NotNull(roundTripped);
32+
Assert.True(roundTripped.AllowBypass);
33+
34+
var omitted = JsonSerializer.Serialize(new SandboxConfig { Enabled = true }, options);
35+
using var omittedDocument = JsonDocument.Parse(omitted);
36+
Assert.False(omittedDocument.RootElement.TryGetProperty("allowBypass", out _));
37+
}
38+
2039
[Fact]
2140
public void ProviderConfig_CanSerializeHeaders_WithSdkOptions()
2241
{

‎go/rpc/sandbox_config_test.go‎

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
package rpc
2+
3+
import (
4+
"encoding/json"
5+
"testing"
6+
)
7+
8+
func TestSandboxConfigAllowBypassJSON(t *testing.T) {
9+
allowBypass := true
10+
configured := SandboxConfig{Enabled: true, AllowBypass: &allowBypass}
11+
12+
data, err := json.Marshal(configured)
13+
if err != nil {
14+
t.Fatalf("marshal configured sandbox: %v", err)
15+
}
16+
var wire map[string]any
17+
if err := json.Unmarshal(data, &wire); err != nil {
18+
t.Fatalf("unmarshal configured sandbox: %v", err)
19+
}
20+
if got := wire["allowBypass"]; got != true {
21+
t.Fatalf("allowBypass = %v, want true", got)
22+
}
23+
24+
var roundTripped SandboxConfig
25+
if err := json.Unmarshal(data, &roundTripped); err != nil {
26+
t.Fatalf("round-trip configured sandbox: %v", err)
27+
}
28+
if roundTripped.AllowBypass == nil || !*roundTripped.AllowBypass {
29+
t.Fatal("round-tripped allowBypass = nil or false, want true")
30+
}
31+
32+
data, err = json.Marshal(SandboxConfig{Enabled: true})
33+
if err != nil {
34+
t.Fatalf("marshal sandbox without bypass: %v", err)
35+
}
36+
wire = make(map[string]any)
37+
if err := json.Unmarshal(data, &wire); err != nil {
38+
t.Fatalf("unmarshal sandbox without bypass: %v", err)
39+
}
40+
if _, ok := wire["allowBypass"]; ok {
41+
t.Fatal("allowBypass was serialized when absent")
42+
}
43+
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
package com.github.copilot.generated.rpc;
6+
7+
import static org.junit.jupiter.api.Assertions.*;
8+
9+
import org.junit.jupiter.api.Test;
10+
11+
import com.fasterxml.jackson.databind.ObjectMapper;
12+
13+
class SandboxConfigSerializationTest {
14+
15+
private static final ObjectMapper MAPPER = new ObjectMapper();
16+
17+
@Test
18+
void allowBypassRoundTripsAndIsOmittedWhenAbsent() throws Exception {
19+
var configured = MAPPER.readValue("""
20+
{"enabled":true,"allowBypass":true}
21+
""", SandboxConfig.class);
22+
23+
assertEquals(Boolean.TRUE, configured.allowBypass());
24+
var configuredJson = MAPPER.readTree(MAPPER.writeValueAsString(configured));
25+
assertTrue(configuredJson.path("allowBypass").asBoolean());
26+
27+
var omitted = MAPPER.readValue("""
28+
{"enabled":true}
29+
""", SandboxConfig.class);
30+
var omittedJson = MAPPER.readTree(MAPPER.writeValueAsString(omitted));
31+
assertTrue(omittedJson.path("allowBypass").isMissingNode());
32+
}
33+
}
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
/*---------------------------------------------------------------------------------------------
2+
* Copyright (c) Microsoft Corporation. All rights reserved.
3+
*--------------------------------------------------------------------------------------------*/
4+
5+
import { mkdir, writeFile } from "fs/promises";
6+
import { join } from "path";
7+
import { describe, expect, it } from "vitest";
8+
import type { PermissionRequest } from "../../src/index.js";
9+
import { createSdkTestContext } from "./harness/sdkTestContext.js";
10+
11+
const SEND_TIMEOUT_MS = 120_000;
12+
const TEST_TIMEOUT_MS = 180_000;
13+
const TEST_NAME = "approves a blocked search and executes it outside the sandbox";
14+
15+
describe("Sandbox bypass", async () => {
16+
if (process.platform !== "darwin") {
17+
// SDK runners provide a sandbox backend only on macOS (no bwrap/BaseContainer elsewhere).
18+
it.skip(TEST_NAME, () => undefined);
19+
return;
20+
}
21+
22+
const { copilotClient: client, workDir } = await createSdkTestContext({
23+
copilotClientOptions: {
24+
env: { COPILOT_CLI_ENABLED_FEATURE_FLAGS: "SANDBOX" },
25+
},
26+
});
27+
28+
it(
29+
TEST_NAME,
30+
async () => {
31+
const vaultDir = join(workDir, "vault");
32+
await mkdir(vaultDir, { recursive: true });
33+
await writeFile(join(vaultDir, "notes.txt"), "OUTSIDE_MATCH_LINE bypass-approved\n");
34+
35+
const permissionRequests: PermissionRequest[] = [];
36+
let bypassedSearchCompleted = false;
37+
const session = await client.createSession({
38+
onPermissionRequest: (request) => {
39+
permissionRequests.push(request);
40+
return { kind: "approve-once" };
41+
},
42+
});
43+
const update = await session.rpc.options.update({
44+
sandboxConfig: {
45+
enabled: true,
46+
allowBypass: true,
47+
addCurrentWorkingDirectory: true,
48+
userPolicy: { filesystem: { deniedPaths: [vaultDir] } },
49+
},
50+
});
51+
expect(update.success).toBe(true);
52+
let grepToolCallId: string | undefined;
53+
session.on((event) => {
54+
if (event.type === "tool.execution_start" && event.data.toolName === "grep") {
55+
grepToolCallId = event.data.toolCallId;
56+
} else if (
57+
event.type === "tool.execution_complete" &&
58+
event.data.toolCallId === grepToolCallId &&
59+
event.data.success &&
60+
event.data.result?.content.includes("OUTSIDE_MATCH_LINE bypass-approved")
61+
) {
62+
bypassedSearchCompleted = true;
63+
}
64+
});
65+
66+
const message = await session.sendAndWait(
67+
{
68+
prompt:
69+
"Search for OUTSIDE_MATCH_LINE in the vault directory. " +
70+
"After the search succeeds, reply with exactly SANDBOX_BYPASS_APPROVED.",
71+
},
72+
SEND_TIMEOUT_MS
73+
);
74+
75+
expect(message?.data.content).toContain("SANDBOX_BYPASS_APPROVED");
76+
expect(
77+
permissionRequests.some(
78+
(request) =>
79+
"requestSandboxBypass" in request && request.requestSandboxBypass === true
80+
)
81+
).toBe(true);
82+
expect(bypassedSearchCompleted).toBe(true);
83+
84+
await session.disconnect();
85+
},
86+
TEST_TIMEOUT_MS
87+
);
88+
});

‎nodejs/test/sandbox-config.test.ts‎

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,16 @@
1+
import { describe, expect, it } from "vitest";
2+
3+
import type { SandboxConfig } from "../src/generated/rpc.js";
4+
5+
describe("SandboxConfig", () => {
6+
it("round-trips allowBypass and omits it when absent", () => {
7+
const enabled: SandboxConfig = { enabled: true, allowBypass: true };
8+
const roundTripped = JSON.parse(JSON.stringify(enabled)) as SandboxConfig;
9+
10+
expect(roundTripped.allowBypass).toBe(true);
11+
expect(roundTripped).toEqual({ enabled: true, allowBypass: true });
12+
13+
const omitted: SandboxConfig = { enabled: true };
14+
expect(JSON.parse(JSON.stringify(omitted))).toEqual({ enabled: true });
15+
});
16+
});

‎python/test_rpc_generated.py‎

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,13 +16,22 @@
1616
RemoteControlStatusOff,
1717
RemoteControlStatusResult,
1818
RemoteSessionMetadataValue,
19+
SandboxConfig,
1920
SessionList,
2021
SlashCommandTextResult,
2122
TaskAgentInfo,
2223
UIElicitationSchemaType,
2324
)
2425

2526

27+
def test_sandbox_config_round_trips_allow_bypass_and_omits_when_absent():
28+
configured = SandboxConfig(enabled=True, allow_bypass=True)
29+
30+
assert configured.to_dict() == {"enabled": True, "allowBypass": True}
31+
assert SandboxConfig.from_dict(configured.to_dict()).allow_bypass is True
32+
assert SandboxConfig(enabled=True).to_dict() == {"enabled": True}
33+
34+
2635
@pytest.mark.asyncio
2736
async def test_commands_invoke_deserializes_slash_command_result():
2837
client = AsyncMock()

‎rust/tests/api_types_test.rs‎

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ use github_copilot_sdk::rpc::{
77
Extension, ExtensionList, ExtensionSource, ExtensionStatus, ExtensionsDisableRequest,
88
ExtensionsEnableRequest, FleetStartRequest, FleetStartResult, ModelSwitchAutoTierRequest,
99
ModelSwitchAutoTierResult, ModelSwitchAutoTierStatus, QueuePendingItems, QueuePendingItemsKind,
10-
SendAgentMode, TasksStartAgentRequest,
10+
SandboxConfig, SendAgentMode, TasksStartAgentRequest,
1111
};
1212
use github_copilot_sdk::session_events::{
1313
PermissionRequest, PermissionRequestedData, SessionEventData, TypedSessionEvent,
@@ -185,6 +185,30 @@ fn queue_pending_message_id_is_optional_for_older_hosts() {
185185
);
186186
}
187187

188+
#[test]
189+
fn sandbox_allow_bypass_round_trips_as_optional_camel_case() {
190+
let mut enabled = SandboxConfig::default();
191+
enabled.enabled = true;
192+
enabled.allow_bypass = Some(true);
193+
let value = serde_json::to_value(enabled).unwrap();
194+
assert_eq!(
195+
value,
196+
serde_json::json!({
197+
"allowBypass": true,
198+
"enabled": true,
199+
})
200+
);
201+
let round_tripped: SandboxConfig = serde_json::from_value(value).unwrap();
202+
assert_eq!(round_tripped.allow_bypass, Some(true));
203+
204+
let mut omitted = SandboxConfig::default();
205+
omitted.enabled = true;
206+
assert_eq!(
207+
serde_json::to_value(omitted).unwrap(),
208+
serde_json::json!({ "enabled": true })
209+
);
210+
}
211+
188212
fn running_extension(id: &str, name: &str) -> Extension {
189213
Extension {
190214
id: id.to_string(),
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
models:
2+
- claude-sonnet-5
3+
conversations:
4+
- messages:
5+
- role: system
6+
content: ${system}
7+
- role: user
8+
content: Search for OUTSIDE_MATCH_LINE in the vault directory. After the search succeeds, reply with exactly SANDBOX_BYPASS_APPROVED.
9+
- role: assistant
10+
tool_calls:
11+
- id: toolcall_0
12+
type: function
13+
function:
14+
name: grep
15+
arguments: '{"pattern":"OUTSIDE_MATCH_LINE","path":"${workdir}/vault","output_mode":"content","-n":true}'
16+
- messages:
17+
- role: system
18+
content: ${system}
19+
- role: user
20+
content: Search for OUTSIDE_MATCH_LINE in the vault directory. After the search succeeds, reply with exactly SANDBOX_BYPASS_APPROVED.
21+
- role: assistant
22+
tool_calls:
23+
- id: toolcall_0
24+
type: function
25+
function:
26+
name: grep
27+
arguments: '{"pattern":"OUTSIDE_MATCH_LINE","path":"${workdir}/vault","output_mode":"content","-n":true}'
28+
- role: tool
29+
tool_call_id: toolcall_0
30+
content: '${workdir}/vault/notes.txt:1:OUTSIDE_MATCH_LINE bypass-approved'
31+
- role: assistant
32+
content: SANDBOX_BYPASS_APPROVED

0 commit comments

Comments
 (0)