From ef031a35ce2d5521359fae686542d6e1454fd721 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Wed, 20 May 2026 20:45:11 -0700 Subject: [PATCH 1/9] Make Bubblewrap the silent default Linux process-sandbox backen --- sdk/src/sandbox.ts | 8 +- .../integration/linux-bubblewrap.test.ts | 74 +++++++++++++ .../linux-process-container.test.ts | 77 ++++++++----- sdk/tests/integration/test-helpers.ts | 24 ++++ sdk/tests/unit/sandbox.test.ts | 45 ++++++++ src/lxc/src/main.rs | 10 +- src/wxc_common/src/config_parser.rs | 103 +++++++++++++++--- test_configs/linux_process_abstract.json | 9 ++ test_configs/linux_process_default.json | 8 ++ test_scripts/run_bwrap_all_tests.sh | 5 +- .../run_linux_process_default_test.sh | 31 ++++++ 11 files changed, 347 insertions(+), 47 deletions(-) create mode 100644 sdk/tests/integration/linux-bubblewrap.test.ts create mode 100644 test_configs/linux_process_abstract.json create mode 100644 test_configs/linux_process_default.json create mode 100644 test_scripts/run_linux_process_default_test.sh diff --git a/sdk/src/sandbox.ts b/sdk/src/sandbox.ts index e941e2783..0da06e36c 100644 --- a/sdk/src/sandbox.ts +++ b/sdk/src/sandbox.ts @@ -328,10 +328,16 @@ export function createConfigFromPolicy( return buildBubblewrapConfig(config); } + if (containment === 'lxc') { + diagLog(`createConfigFromPolicy: containment=lxc, id=${containerId}`); + config.containment = 'lxc'; + return buildLinuxProcessConfig(config, containerId); + } + if (containment === 'process') { config.containment = 'process'; if (platform === 'linux') { - diagLog(`createConfigFromPolicy: containment=lxc, id=${containerId}`); + diagLog(`createConfigFromPolicy: containment=process (linux), id=${containerId}`); return buildLinuxProcessConfig(config, containerId); } if (platform === 'darwin') { diff --git a/sdk/tests/integration/linux-bubblewrap.test.ts b/sdk/tests/integration/linux-bubblewrap.test.ts new file mode 100644 index 000000000..4b5b397ea --- /dev/null +++ b/sdk/tests/integration/linux-bubblewrap.test.ts @@ -0,0 +1,74 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it } from 'node:test'; +import assert from 'node:assert'; +import { + sdk, + supportedVersions, + isLinuxRoot, + debugSpawnOptions, + spawnFromConfigAsync, +} from './test-helpers.js'; + +// Probe printed inside the sandbox. Bwrap always creates a fresh mount +// namespace with a minimal mount table (typically <20 entries from +// --ro-bind, --dev, --proc, --tmpfs); the host has 30-100+. This signal is +// robust across root vs non-root contexts (PID/user namespace mechanics +// differ when running bwrap as root in WSL, but the mount namespace setup +// is always applied). +const BWRAP_PROBE = + "PID1=$(cat /proc/1/comm 2>/dev/null || echo unknown); " + + "MOUNTS=$(wc -l { + it('should default to Bubblewrap when containment is omitted (silent default)', async () => { + // spawnSandboxAsync routes through abstract `containment: 'process'`, + // which on Linux resolves to Bubblewrap in the binary. No --experimental + // flag is required for this path. + const result = await sdk.spawnSandboxAsync( + BWRAP_PROBE, + { version: schemaVersion.raw }, + debugSpawnOptions, + undefined, + `bwrap-default-${schemaVersion}`, + ); + assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] silent-default Bubblewrap probe failed: ${result.stdout}`); + assert.ok(result.stdout.includes('OK: under bubblewrap'), `[${schemaVersion}] ${result.stdout}`); + }); + + it('should select Bubblewrap for abstract containment="process"', async () => { + const config = sdk.createConfigFromPolicy( + { version: schemaVersion.raw }, + 'process', + `bwrap-process-${schemaVersion}`, + ); + config.process!.commandLine = BWRAP_PROBE; + assert.strictEqual(config.containment, 'process', 'wire-format containment should be "process"'); + const result = await spawnFromConfigAsync(config, debugSpawnOptions); + assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] containment=process Bubblewrap probe failed: ${result.stdout}`); + assert.ok(result.stdout.includes('OK: under bubblewrap'), `[${schemaVersion}] ${result.stdout}`); + }); + + it('should select Bubblewrap for explicit containment="bubblewrap" with experimental flag', async () => { + // Explicit "bubblewrap" still requires `experimental: true` per the SDK + // gate in helper.ts (`ExperimentalBackends`). + const config = sdk.createConfigFromPolicy( + { version: schemaVersion.raw }, + 'bubblewrap', + `bwrap-explicit-${schemaVersion}`, + ); + config.process!.commandLine = BWRAP_PROBE; + assert.strictEqual(config.containment, 'bubblewrap', 'wire-format containment should be "bubblewrap"'); + const result = await spawnFromConfigAsync(config, { ...debugSpawnOptions, experimental: true }); + assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] explicit Bubblewrap probe failed: ${result.stdout}`); + assert.ok(result.stdout.includes('OK: under bubblewrap'), `[${schemaVersion}] ${result.stdout}`); + }); +}); +} diff --git a/sdk/tests/integration/linux-process-container.test.ts b/sdk/tests/integration/linux-process-container.test.ts index 87b257233..f91519b71 100644 --- a/sdk/tests/integration/linux-process-container.test.ts +++ b/sdk/tests/integration/linux-process-container.test.ts @@ -6,6 +6,7 @@ import assert from 'node:assert'; import fs from 'fs'; import os from 'os'; import path from 'path'; +import type { SandboxPolicy } from '@microsoft/mxc-sdk'; import { sdk, supportedVersions, @@ -14,11 +15,26 @@ import { NETWORK_TEST_URL, lxcNetworkSkipReason, debugSpawnOptions, + spawnFromConfigAsync, } from './test-helpers.js'; +// Route through explicit `containment: 'lxc'` so these tests genuinely exercise +// the LXC backend. spawnSandboxAsync internally routes through abstract +// `containment: 'process'`, which on Linux resolves to a different backend +// (Bubblewrap). The LXC backend is covered by an explicit opt-in only. +async function runLxc( + script: string, + policy: SandboxPolicy, + containerId: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + const config = sdk.createConfigFromPolicy(policy, 'lxc', containerId); + config.process!.commandLine = script; + return spawnFromConfigAsync(config, debugSpawnOptions); +} + for (const schemaVersion of supportedVersions) { -describe(`Linux Process Container (schema ${schemaVersion})`, { - skip: !isLinuxRoot ? 'Linux Process Container tests require Linux with root privileges (sudo npm test)' : undefined, +describe(`Linux LXC Container (schema ${schemaVersion})`, { + skip: !isLinuxRoot ? 'Linux LXC Container tests require Linux with root privileges (sudo npm test)' : undefined, }, () => { let tempDir = ''; @@ -30,11 +46,9 @@ describe(`Linux Process Container (schema ${schemaVersion})`, { }); it('should execute hello world in LXC container', async () => { - const result = await sdk.spawnSandboxAsync( + const result = await runLxc( "echo 'Hello from LXC via CLI'", { version: schemaVersion.raw }, - debugSpawnOptions, - undefined, `lxc-hello-${schemaVersion}`, ); assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`); @@ -42,11 +56,9 @@ describe(`Linux Process Container (schema ${schemaVersion})`, { }); it('should propagate exit code', async () => { - const result = await sdk.spawnSandboxAsync( + const result = await runLxc( "echo 'about to exit' && exit 0", { version: schemaVersion.raw }, - debugSpawnOptions, - undefined, `lxc-exit-${schemaVersion}`, ); assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`); @@ -54,24 +66,39 @@ describe(`Linux Process Container (schema ${schemaVersion})`, { }); it('should report system info', async () => { - const result = await sdk.spawnSandboxAsync( + const result = await runLxc( "uname -a && echo 'System info test passed'", { version: schemaVersion.raw }, - debugSpawnOptions, - undefined, `lxc-sysinfo-${schemaVersion}`, ); assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`); assert.ok(result.stdout.includes('System info test passed')); }); + it('should select LXC backend (not Bubblewrap) on explicit containment="lxc"', async () => { + // Probe PID 1 to distinguish backends: + // bwrap -> PID 1 comm == 'bwrap' (bwrap re-execs itself as init in the new pid namespace) + // LXC -> PID 1 comm is 'init' / 'systemd' (the container's init) + // We assert NOT bwrap by checking PID 1 is not 'bwrap'. + const probe = + "PID1=$(cat /proc/1/comm); " + + "echo \"pid1=$PID1\"; " + + "[ \"$PID1\" != bwrap ] || { echo 'FAIL: PID 1 is bwrap, looks like Bubblewrap, not LXC'; exit 1; }; " + + "echo 'OK: not under Bubblewrap'"; + const result = await runLxc( + probe, + { version: schemaVersion.raw }, + `lxc-probe-${schemaVersion}`, + ); + assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] LXC backend probe failed: ${result.stdout}`); + assert.ok(result.stdout.includes('OK: not under Bubblewrap'), `[${schemaVersion}] ${result.stdout}`); + }); + it('should allow outbound network access', { skip: lxcNetworkSkipReason }, async () => { const policy = { version: schemaVersion.raw, network: { allowOutbound: true } }; - const result = await sdk.spawnSandboxAsync( + const result = await runLxc( `wget -q -T 10 -O /dev/null '${NETWORK_TEST_URL}' && echo 'Network accessible'`, policy, - debugSpawnOptions, - undefined, `lxc-net-${schemaVersion}`, ); assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`); @@ -83,9 +110,7 @@ describe(`Linux Process Container (schema ${schemaVersion})`, { fs.writeFileSync(path.join(tempDir, 'test.txt'), 'original'); const policy = { version: schemaVersion.raw, filesystem: { readwritePaths: [tempDir] } }; const script = `cat ${tempDir}/test.txt && echo 'overwritten' > ${tempDir}/test.txt && cat ${tempDir}/test.txt`; - const result = await sdk.spawnSandboxAsync( - script, policy, debugSpawnOptions, undefined, `lxc-rw-${schemaVersion}`, - ); + const result = await runLxc(script, policy, `lxc-rw-${schemaVersion}`); assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`); assert.ok(result.stdout.includes('overwritten')); }); @@ -94,11 +119,9 @@ describe(`Linux Process Container (schema ${schemaVersion})`, { tempDir = createTempDir('mxc-lxc-test'); fs.writeFileSync(path.join(tempDir, 'data.txt'), 'readonly content'); const policy = { version: schemaVersion.raw, filesystem: { readonlyPaths: [tempDir] } }; - const result = await sdk.spawnSandboxAsync( + const result = await runLxc( `cat ${tempDir}/data.txt && echo 'Read succeeded'`, policy, - debugSpawnOptions, - undefined, `lxc-ro-${schemaVersion}`, ); assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`); @@ -115,20 +138,16 @@ describe(`Linux Process Container (schema ${schemaVersion})`, { const script = `wget -q -T 10 -O ${tempDir}/download.json '${NETWORK_TEST_URL}'` + ` && test -s ${tempDir}/download.json && echo 'Combined test passed'`; - const result = await sdk.spawnSandboxAsync( - script, policy, debugSpawnOptions, undefined, `lxc-combined-${schemaVersion}`, - ); + const result = await runLxc(script, policy, `lxc-combined-${schemaVersion}`); assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`); assert.ok(result.stdout.includes('Combined test passed')); }); it('should access HTTPS endpoint', { skip: lxcNetworkSkipReason }, async () => { const policy = { version: schemaVersion.raw, network: { allowOutbound: true } }; - const result = await sdk.spawnSandboxAsync( + const result = await runLxc( `wget -q -T 10 -O /dev/null '${NETWORK_TEST_URL}' && echo 'HTTPS endpoint accessible'`, policy, - debugSpawnOptions, - undefined, `lxc-https-${schemaVersion}`, ); assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`); @@ -137,8 +156,10 @@ describe(`Linux Process Container (schema ${schemaVersion})`, { it('should run multi-command pipeline', async () => { const script = "echo 'step 1' && ls / && echo 'step 2' && whoami && echo 'Multi-command passed'"; - const result = await sdk.spawnSandboxAsync( - script, { version: schemaVersion.raw }, debugSpawnOptions, undefined, `lxc-pipeline-${schemaVersion}`, + const result = await runLxc( + script, + { version: schemaVersion.raw }, + `lxc-pipeline-${schemaVersion}`, ); assert.strictEqual(result.exitCode, 0, `[${schemaVersion}] Expected exit 0: ${result.stderr}`); assert.ok(result.stdout.includes('Multi-command passed')); diff --git a/sdk/tests/integration/test-helpers.ts b/sdk/tests/integration/test-helpers.ts index 67a764e63..fe215d817 100644 --- a/sdk/tests/integration/test-helpers.ts +++ b/sdk/tests/integration/test-helpers.ts @@ -251,6 +251,30 @@ export function createTempDir(prefix: string = 'mxc-test'): string { return dir; } +// Async spawn from a pre-built ContainerConfig (no per-call containment arg on +// spawnSandboxAsync, so tests that need a specific backend build the config +// directly and run it through this helper). +export function spawnFromConfigAsync( + config: sdkNamespace.ContainerConfig, + options: sdkNamespace.SandboxSpawnOptions = {}, + workingDirectory?: string, +): Promise<{ stdout: string; stderr: string; exitCode: number }> { + return new Promise((resolve, reject) => { + try { + const ptyProcess = sdkNamespace.spawnSandboxFromConfig(config, options, workingDirectory); + let output = ''; + ptyProcess.onData((data: string) => { + output += data; + }); + ptyProcess.onExit((event: { exitCode: number; signal?: number }) => { + resolve({ stdout: output, stderr: '', exitCode: event.exitCode }); + }); + } catch (err) { + reject(err); + } + }); +} + // Python helpers /** Detect a usable Python command. Returns undefined if not installed. */ diff --git a/sdk/tests/unit/sandbox.test.ts b/sdk/tests/unit/sandbox.test.ts index b15437b18..8dac93798 100644 --- a/sdk/tests/unit/sandbox.test.ts +++ b/sdk/tests/unit/sandbox.test.ts @@ -671,6 +671,41 @@ describe('createConfigFromPolicy', () => { ); }); }); + + describe('Bubblewrap', () => { + it('should set containment to bubblewrap', () => { + const config = createConfigFromPolicy({ version: '0.5.0-alpha' }, 'bubblewrap'); + assert.strictEqual(config.containment, 'bubblewrap'); + }); + + it('should map filesystem and network policy fields through to ContainerConfig', () => { + const config = createConfigFromPolicy({ + version: '0.5.0-alpha', + filesystem: { + readwritePaths: ['/workspace'], + readonlyPaths: ['/data'], + deniedPaths: ['/secrets'], + }, + network: { allowOutbound: true, allowedHosts: ['example.com'] }, + }, 'bubblewrap'); + assert.deepStrictEqual(config.filesystem!.readwritePaths, ['/workspace']); + assert.deepStrictEqual(config.filesystem!.readonlyPaths, ['/data']); + assert.deepStrictEqual(config.filesystem!.deniedPaths, ['/secrets']); + // Per buildBubblewrapConfig, host filtering forces firewall mode. + assert.strictEqual(config.network!.enforcementMode, 'firewall'); + }); + }); + + describe('Lxc (explicit opt-in)', () => { + it('should set containment to lxc and populate the lxc backend block', () => { + // Regression guard: making bubblewrap the Linux default for the + // abstract `"process"` intent must not break the explicit LXC path. + const config = createConfigFromPolicy({ version: '0.5.0-alpha' }, 'lxc'); + assert.strictEqual(config.containment, 'lxc'); + assert.ok(config.lxc, 'lxc backend block should be populated'); + assert.strictEqual(config.lxc!.distribution, 'alpine'); + }); + }); }); describe('Schema 0.6.0 vocabulary', () => { @@ -755,4 +790,14 @@ describe('resolveExecutableAndArgs (containment validation)', { skip: platformSk { message: /experimental mode/ }, ); }); + + it('should NOT require experimental mode for explicit lxc containment', function (this: { skip: (reason?: string) => void }) { + if (process.platform !== 'linux') { + this.skip('lxc is Linux-only'); + return; + } + assert.doesNotThrow(() => + resolveExecutableAndArgs(makeConfig('lxc'), { executablePath: fakeExe }), + ); + }); }); diff --git a/src/lxc/src/main.rs b/src/lxc/src/main.rs index 0bfc80d58..ed9cc2d72 100644 --- a/src/lxc/src/main.rs +++ b/src/lxc/src/main.rs @@ -206,9 +206,13 @@ fn main() { log_request(&request, &mut logger); - // Dispatch by containment backend. LXC is the default on Linux; - // Hyperlight is the embedded Hyperlight+Unikraft micro-VM; - // Bubblewrap is the unprivileged namespace sandbox (experimental). + // Dispatch by containment backend. On Linux, Bubblewrap is now the + // default for abstract intents (omitted `containment` and + // `containment: "process"` both resolve to Bubblewrap in + // `wxc_common::config_parser`). LXC is still available via explicit + // `containment: "lxc"`, and `containment: "processcontainer"` falls + // through to LXC via the catch-all below. Hyperlight is the embedded + // Hyperlight+Unikraft micro-VM (experimental, x86_64-only). let mut runner: Box = match request.containment { ContainmentBackend::Hyperlight => { #[cfg(all(feature = "hyperlight", target_arch = "x86_64"))] diff --git a/src/wxc_common/src/config_parser.rs b/src/wxc_common/src/config_parser.rs index 09e5c65d6..9f6239c4f 100644 --- a/src/wxc_common/src/config_parser.rs +++ b/src/wxc_common/src/config_parser.rs @@ -599,8 +599,31 @@ fn convert_raw_config_inner( // target_os. Any future concrete-only backend just needs an arm; any // future abstract intent should resolve here (and likewise in // `parse_containment_str` below for the state-aware path). + // + // Default resolution (omitted `containment`) and the abstract intent + // `"process"` map to the OS-native process sandbox on each platform: + // * Windows -> ProcessContainer (AppContainer) + // * macOS -> Seatbelt + // * Linux -> Bubblewrap (lightweight, unprivileged process sandbox) + // LXC is treated as a full Linux container and is only selected when + // explicitly requested via `"lxc"`; `"processcontainer"` continues to + // route to ProcessContainer (which `lxc-exec` falls back to LXC for). let containment = match raw.containment.as_deref() { - None | Some("processcontainer") => ContainmentBackend::ProcessContainer, + None => { + #[cfg(target_os = "linux")] + { + ContainmentBackend::Bubblewrap + } + #[cfg(target_os = "macos")] + { + ContainmentBackend::Seatbelt + } + #[cfg(not(any(target_os = "linux", target_os = "macos")))] + { + ContainmentBackend::ProcessContainer + } + } + Some("processcontainer") => ContainmentBackend::ProcessContainer, Some("appcontainer") => { logger.log_line( "[deprecated] containment value 'appcontainer' is a legacy alias for 'processcontainer'; \ @@ -609,14 +632,13 @@ fn convert_raw_config_inner( ContainmentBackend::ProcessContainer } Some("process") => { - // Abstract intent: the caller wants process-level containment but - // does not care which concrete backend implements it. Today this - // resolves trivially per OS (ProcessContainer on Windows, LXC on - // Linux, Seatbelt on macOS). The lxc-exec binary additionally - // overrides this to LXC unconditionally on Linux. + // Abstract intent: the caller wants the OS-native process + // sandbox. Resolves to ProcessContainer on Windows, Bubblewrap + // on Linux, and Seatbelt on macOS. Callers who want LXC (a + // full container) must request it explicitly via "lxc". #[cfg(target_os = "linux")] { - ContainmentBackend::Lxc + ContainmentBackend::Bubblewrap } #[cfg(target_os = "macos")] { @@ -999,9 +1021,13 @@ fn parse_containment_str(s: &str, logger: &mut Logger) -> Result { + // Abstract intent: the caller wants the OS-native process + // sandbox. Resolves to ProcessContainer on Windows, Bubblewrap + // on Linux, and Seatbelt on macOS. Callers who want LXC (a + // full container) must request it explicitly via "lxc". #[cfg(target_os = "linux")] { - Ok(ContainmentBackend::Lxc) + Ok(ContainmentBackend::Bubblewrap) } #[cfg(target_os = "macos")] { @@ -1028,6 +1054,7 @@ fn parse_containment_str(s: &str, logger: &mut Logger) -> Result Ok(ContainmentBackend::MicroVm), "isolation_session" => Ok(ContainmentBackend::IsolationSession), "seatbelt" => Ok(ContainmentBackend::Seatbelt), + "hyperlight" => Ok(ContainmentBackend::Hyperlight), "bubblewrap" => Ok(ContainmentBackend::Bubblewrap), "macos_sandbox" => { logger.log_line( @@ -1038,7 +1065,7 @@ fn parse_containment_str(s: &str, logger: &mut Logger) -> Result { let msg = format!( - "Invalid containment value '{}' (must be 'process', 'processcontainer', 'windows_sandbox', 'isolation_session', 'wslc', 'lxc', 'vm', 'microvm', 'seatbelt', or 'bubblewrap')", + "Invalid containment value '{}' (must be 'process', 'processcontainer', 'windows_sandbox', 'isolation_session', 'wslc', 'lxc', 'vm', 'microvm', 'seatbelt', 'hyperlight', or 'bubblewrap')", other ); logger.log_line(&msg); @@ -1587,12 +1614,20 @@ mod tests { // ====== Containment backend selection tests ====== #[test] - fn default_containment_is_processcontainer() { + fn default_containment_resolves_per_target() { + // Omitted `containment` resolves to the OS-native process sandbox: + // ProcessContainer on Windows, Bubblewrap on Linux, Seatbelt on macOS. let json = r#"{"process": {"commandLine": "echo hello"}}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); let req = load_request(&encoded, &mut logger, true).unwrap(); + + #[cfg(target_os = "linux")] + assert_eq!(req.containment, ContainmentBackend::Bubblewrap); + #[cfg(target_os = "macos")] + assert_eq!(req.containment, ContainmentBackend::Seatbelt); + #[cfg(not(any(target_os = "linux", target_os = "macos")))] assert_eq!(req.containment, ContainmentBackend::ProcessContainer); } @@ -1609,8 +1644,10 @@ mod tests { #[test] fn process_containment_resolves_per_target() { - // Abstract intent "process" resolves to the per-OS default backend: - // ProcessContainer on Windows, LXC on Linux, Seatbelt on macOS. + // Abstract intent "process" resolves to the OS-native process sandbox: + // ProcessContainer on Windows, Bubblewrap on Linux, Seatbelt on macOS. + // Callers who want LXC (a full container) must request it explicitly + // via `"containment": "lxc"`. let json = r#"{"process": {"commandLine": "echo hello"}, "containment": "process"}"#; let encoded = base64_encode(json.as_bytes()); let mut logger = test_logger(); @@ -1618,13 +1655,53 @@ mod tests { let req = load_request(&encoded, &mut logger, true).unwrap(); #[cfg(target_os = "linux")] - assert_eq!(req.containment, ContainmentBackend::Lxc); + assert_eq!(req.containment, ContainmentBackend::Bubblewrap); #[cfg(target_os = "macos")] assert_eq!(req.containment, ContainmentBackend::Seatbelt); #[cfg(not(any(target_os = "linux", target_os = "macos")))] assert_eq!(req.containment, ContainmentBackend::ProcessContainer); } + #[test] + fn explicit_lxc_containment_unaffected_by_default_shift() { + // Regression guard: making bubblewrap the Linux default for the + // abstract `"process"` intent must NOT change how explicit `"lxc"` + // resolves. LXC remains available to any caller that asks for it. + let json = r#"{"process": {"commandLine": "echo hello"}, "containment": "lxc"}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert_eq!(req.containment, ContainmentBackend::Lxc); + } + + #[test] + fn explicit_bubblewrap_containment_parses_cleanly() { + // Bubblewrap no longer requires gating in the parser/SDK; explicit + // `"bubblewrap"` should parse to the concrete backend on every + // target without error. (Host availability is checked at runtime by + // the runner, not here.) + let json = r#"{"process": {"commandLine": "echo hello"}, "containment": "bubblewrap"}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert_eq!(req.containment, ContainmentBackend::Bubblewrap); + } + + #[test] + fn hyperlight_containment_value_parses() { + // Lock in that `"hyperlight"` is accepted by the parser (mirrors + // the `convert_raw_config_inner` arm and keeps `parse_containment_str` + // in sync for the state-aware path). + let json = r#"{"process": {"commandLine": "echo hello"}, "containment": "hyperlight"}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert_eq!(req.containment, ContainmentBackend::Hyperlight); + } + #[test] fn vm_containment_resolves_per_target() { // Abstract intent "vm" resolves to Windows Sandbox on Windows. On diff --git a/test_configs/linux_process_abstract.json b/test_configs/linux_process_abstract.json new file mode 100644 index 000000000..1e69b0f25 --- /dev/null +++ b/test_configs/linux_process_abstract.json @@ -0,0 +1,9 @@ +{ + "version": "0.6.0-alpha", + "containerId": "CLI-Linux-Process-Abstract", + "containment": "process", + "platform": "linux", + "process": { + "commandLine": "PID1=$(cat /proc/1/comm 2>/dev/null || echo unknown); MOUNTS=$(wc -l /dev/null || echo unknown); MOUNTS=$(wc -l /dev/null 2>&1; then + if grep -rPl '\r$' "$SCRIPT_DIR"/run_bwrap_*.sh "$SCRIPT_DIR"/run_linux_process_default_test.sh >/dev/null 2>&1; then echo "ERROR: Shell scripts have Windows line endings (CRLF)." - echo "Fix with: sed -i 's/\r\$//' $SCRIPT_DIR/run_bwrap_*.sh" + echo "Fix with: sed -i 's/\r\$//' $SCRIPT_DIR/run_bwrap_*.sh $SCRIPT_DIR/run_linux_process_default_test.sh" exit 1 fi } @@ -36,6 +36,7 @@ run_test() { run_test "Basic Bubblewrap" "$SCRIPT_DIR/run_bwrap_basic_test.sh" run_test "Bubblewrap Filesystem" "$SCRIPT_DIR/run_bwrap_filesystem_test.sh" run_test "Bubblewrap Network Block" "$SCRIPT_DIR/run_bwrap_network_test.sh" +run_test "Linux Process Default" "$SCRIPT_DIR/run_linux_process_default_test.sh" echo "================================" echo "Results: $PASSED passed, $FAILED failed" diff --git a/test_scripts/run_linux_process_default_test.sh b/test_scripts/run_linux_process_default_test.sh new file mode 100644 index 000000000..9b003f80d --- /dev/null +++ b/test_scripts/run_linux_process_default_test.sh @@ -0,0 +1,31 @@ +#!/bin/bash +# Default Linux process sandbox tests. +# +# These configs intentionally do NOT pass --experimental and do NOT set +# containment: "bubblewrap". They exercise the default Linux process-sandbox +# resolution path: +# - containment omitted -> default process sandbox +# - containment: "process" -> default process sandbox +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +REPO_DIR="$(dirname "$SCRIPT_DIR")" +LXC_EXEC="$REPO_DIR/src/target/release/lxc-exec" + +if [ ! -f "$LXC_EXEC" ]; then + LXC_EXEC="$REPO_DIR/src/target/debug/lxc-exec" +fi + +if [ ! -f "$LXC_EXEC" ]; then + echo "Error: lxc-exec not found. Run build.sh first." + exit 1 +fi + +echo "Running default Linux process test (containment omitted)..." +"$LXC_EXEC" "$REPO_DIR/test_configs/linux_process_default.json" +echo "Default Linux process test complete." +echo "" + +echo "Running abstract process containment test (containment: \"process\")..." +"$LXC_EXEC" "$REPO_DIR/test_configs/linux_process_abstract.json" +echo "Abstract process containment test complete." From 25bb4dde05576ab302a022d65ce3214978dcd339 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Wed, 20 May 2026 21:29:33 -0700 Subject: [PATCH 2/9] Addressed PR comments --- sdk/src/sandbox.ts | 9 +++++++-- sdk/tests/integration/linux-bubblewrap.test.ts | 14 ++++++++------ sdk/tests/unit/sandbox.test.ts | 13 ++++++++----- 3 files changed, 23 insertions(+), 13 deletions(-) diff --git a/sdk/src/sandbox.ts b/sdk/src/sandbox.ts index 0da06e36c..caf202e90 100644 --- a/sdk/src/sandbox.ts +++ b/sdk/src/sandbox.ts @@ -337,8 +337,13 @@ export function createConfigFromPolicy( if (containment === 'process') { config.containment = 'process'; if (platform === 'linux') { - diagLog(`createConfigFromPolicy: containment=process (linux), id=${containerId}`); - return buildLinuxProcessConfig(config, containerId); + // Abstract `'process'` on Linux is resolved to Bubblewrap by the + // native binary (see `wxc_common::config_parser`). The wire-format + // payload intentionally omits any backend-specific block so the + // config reflects the abstract intent. Callers who explicitly want + // LXC must pass `containment: 'lxc'`. + diagLog(`createConfigFromPolicy: containment=process (linux, resolves to bubblewrap), id=${containerId}`); + return config; } if (platform === 'darwin') { // The seatbelt backend has no container abstraction diff --git a/sdk/tests/integration/linux-bubblewrap.test.ts b/sdk/tests/integration/linux-bubblewrap.test.ts index 4b5b397ea..868c8fcc0 100644 --- a/sdk/tests/integration/linux-bubblewrap.test.ts +++ b/sdk/tests/integration/linux-bubblewrap.test.ts @@ -11,12 +11,14 @@ import { spawnFromConfigAsync, } from './test-helpers.js'; -// Probe printed inside the sandbox. Bwrap always creates a fresh mount -// namespace with a minimal mount table (typically <20 entries from -// --ro-bind, --dev, --proc, --tmpfs); the host has 30-100+. This signal is -// robust across root vs non-root contexts (PID/user namespace mechanics -// differ when running bwrap as root in WSL, but the mount namespace setup -// is always applied). +// Bwrap fingerprint: when invoked with `--unshare-pid`, bubblewrap creates a +// new PID namespace and stays as PID 1 in that namespace, acting as init +// (reaping orphans, forwarding signals). It does NOT exec the child shell +// directly — the script runs as PID 2. So /proc/1/comm always reads "bwrap" +// from inside the sandbox, regardless of how bwrap is invoked or which user +// runs it. This is documented bubblewrap behavior (see bwrap(1)) and the +// most reliable cross-context signal — mount-count heuristics break under +// WSL2 where bind-mount propagation can produce 40+ entries. const BWRAP_PROBE = "PID1=$(cat /proc/1/comm 2>/dev/null || echo unknown); " + "MOUNTS=$(wc -l { } }; - it('should default to process containment on Linux (resolved by binary to lxc)', () => { + it('should default to process containment on Linux (resolved by binary to bubblewrap)', () => { mockLinux(); try { const payload = buildSandboxPayload('echo hi', defaultPolicy); assert.strictEqual(payload.containment, 'process'); - assert.strictEqual(payload.lxc!.destroyOnExit, true); + // Abstract 'process' on Linux resolves to Bubblewrap at runtime; + // the wire-format payload must NOT carry an LXC-specific block. + assert.strictEqual(payload.lxc, undefined); } finally { restore(); } @@ -528,13 +530,14 @@ describe('createConfigFromPolicy', () => { } }; - it('should default to process containment (resolved by binary to lxc on Linux)', () => { + it('should default to process containment (resolved by binary to bubblewrap on Linux)', () => { mockLinux(); try { const config = createConfigFromPolicy(defaultPolicy); assert.strictEqual(config.containment, 'process'); - assert.strictEqual(config.lxc!.distribution, 'alpine'); - assert.strictEqual(config.lxc!.destroyOnExit, true); + // Abstract 'process' on Linux resolves to Bubblewrap at runtime; + // the wire-format config must NOT carry an LXC-specific block. + assert.strictEqual(config.lxc, undefined); } finally { restore(); } From c824a3637bb663e082d39e6ef1379342f3517127 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 21 May 2026 09:50:00 -0700 Subject: [PATCH 3/9] Update ContainmentType JSDoc to reflect bubblewrap as Linux default --- sdk/src/types.ts | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/sdk/src/types.ts b/sdk/src/types.ts index c021ea591..5f1f4cb78 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -31,12 +31,6 @@ export interface LifecycleConfig { preservePolicy?: boolean; } -/** - * Abstract containment intent. Names the *kind* of isolation the caller - * wants. The native binary (or the SDK as a fallback) resolves this to a - * concrete {@link ContainmentBackend} at run time based on what the host - * supports. - * /** * Abstract containment intent. Names the *kind* of isolation the caller * wants; the native binary resolves it to a concrete @@ -44,7 +38,10 @@ export interface LifecycleConfig { * * Today's intents: * - "process": OS-native process-level isolation. Resolves to - * `processcontainer` (Windows), `lxc` (Linux), or `seatbelt` (macOS). + * `processcontainer` (Windows), `bubblewrap` (Linux), or `seatbelt` + * (macOS). On Linux, `lxc` remains available as an explicit concrete + * backend but is no longer the default for the abstract `"process"` + * intent. * - "vm": full hardware-virtualised VM isolation. Resolves to * `windows_sandbox` on Windows; no concrete VM backend exists on other * platforms today. From 09017a3a1f37db862045cc85c461c2e506851d6b Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 21 May 2026 12:06:20 -0700 Subject: [PATCH 4/9] Make proxy tests explicit about containment to fix macOS CI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Six proxy tests omitted containment and relied on the previous unconditional ProcessContainer default to satisfy the proxy guard (proxy is only valid with processcontainer). After this PR the default resolves to Seatbelt on macOS, causing those six tests to fail in the arm64 MAC CI job. Make each test explicit by setting containment: "processcontainer" in the JSON — they are testing proxy parsing behavior, not the OS-default resolution. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- src/wxc_common/src/config_parser.rs | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/wxc_common/src/config_parser.rs b/src/wxc_common/src/config_parser.rs index 9f6239c4f..53a966df8 100644 --- a/src/wxc_common/src/config_parser.rs +++ b/src/wxc_common/src/config_parser.rs @@ -1790,6 +1790,7 @@ mod tests { fn proxy_localhost_port() { let json = r#"{ "process": {"commandLine": "echo test"}, + "containment": "processcontainer", "network": { "proxy": { "localhost": 8080 } } @@ -1809,6 +1810,7 @@ mod tests { fn proxy_url_parsed() { let json = r#"{ "process": {"commandLine": "echo test"}, + "containment": "processcontainer", "network": { "proxy": { "url": "http://localhost:3128" } } @@ -1827,6 +1829,7 @@ mod tests { fn proxy_url_non_localhost() { let json = r#"{ "process": {"commandLine": "echo test"}, + "containment": "processcontainer", "network": { "proxy": { "url": "http://proxy.example.com:8080" } } @@ -1855,6 +1858,7 @@ mod tests { fn proxy_url_ipv6_loopback() { let json = r#"{ "process": {"commandLine": "echo test"}, + "containment": "processcontainer", "network": { "proxy": { "url": "http://[::1]:8080" } } @@ -1872,6 +1876,7 @@ mod tests { fn proxy_with_firewall_fields() { let json = r#"{ "process": {"commandLine": "echo test"}, + "containment": "processcontainer", "network": { "defaultPolicy": "block", "allowedHosts": ["api.github.com"], @@ -1933,6 +1938,7 @@ mod tests { fn proxy_builtin_test_server() { let json = r#"{ "process": {"commandLine": "echo test"}, + "containment": "processcontainer", "network": { "proxy": { "builtinTestServer": true } } From 13b6f46e9f1cc169fd2e7be5428e16b694b42556 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Thu, 21 May 2026 19:33:07 +0000 Subject: [PATCH 5/9] Fix MicroVM workflow cache permissions Agent-Logs-Url: https://github.com/microsoft/mxc/sessions/81dc5e19-9e89-4a1a-a005-6633f4ffbfe8 Co-authored-by: huzaifa-d <16077119+huzaifa-d@users.noreply.github.com> --- .github/workflows/microvm-e2e.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/microvm-e2e.yml b/.github/workflows/microvm-e2e.yml index 914ea458e..756779176 100644 --- a/.github/workflows/microvm-e2e.yml +++ b/.github/workflows/microvm-e2e.yml @@ -8,6 +8,7 @@ on: workflow_dispatch: permissions: + actions: write contents: read jobs: From a4fa3ca3d9334a5ac65351a2051a14e8bcca63e9 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 21 May 2026 13:10:56 -0700 Subject: [PATCH 6/9] Install bubblewrap in Linux SDK integration CI job PR 368 introduces SDK integration tests that exercise the Bubblewrap backend (the new Linux default for `containment: "process"` / omitted). Those tests skip unless `bwrap` is installed on the runner; the Linux integration job previously only installed `lxc`, so the new bwrap tests failed under sudo. Add `bubblewrap` to the apt-get install line alongside `lxc lxc-utils dnsmasq-base iptables` so both backends are present and both test suites can run. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .azure-pipelines/templates/SDK.Integration.Test.Job.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.azure-pipelines/templates/SDK.Integration.Test.Job.yml b/.azure-pipelines/templates/SDK.Integration.Test.Job.yml index 7bde10cad..0801826d2 100644 --- a/.azure-pipelines/templates/SDK.Integration.Test.Job.yml +++ b/.azure-pipelines/templates/SDK.Integration.Test.Job.yml @@ -85,8 +85,8 @@ jobs: - ${{ if eq(target.os, 'linux') }}: - script: | sudo apt-get update -qq - sudo apt-get install -y -qq lxc lxc-utils dnsmasq-base iptables - displayName: Install LXC + sudo apt-get install -y -qq lxc lxc-utils dnsmasq-base iptables bubblewrap + displayName: Install LXC and Bubblewrap - script: sudo MXC_SKIP_LXC_NETWORK_TESTS=1 MXC_DEBUG=${{ parameters.debug }} npm test workingDirectory: $(integrationDirectory) From be214173a9296b72ac0f960b58bfb94b7927df88 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 21 May 2026 18:15:13 -0700 Subject: [PATCH 7/9] Addressed PR comments --- sdk/src/sandbox.ts | 40 +++++++++++---- sdk/tests/unit/sandbox.test.ts | 90 ++++++++++++++++++++++++++++------ 2 files changed, 107 insertions(+), 23 deletions(-) diff --git a/sdk/src/sandbox.ts b/sdk/src/sandbox.ts index caf202e90..f5a00d986 100644 --- a/sdk/src/sandbox.ts +++ b/sdk/src/sandbox.ts @@ -85,6 +85,24 @@ function buildWslcContainerConfig( return config; } +/** + * Applies Bubblewrap-style network enforcement to `config.network`: + * when host filtering is requested, force `enforcementMode = 'firewall'` + * (bwrap relies on host iptables, same approach as LXC). + * + * Shared between the explicit `'bubblewrap'` builder and the abstract + * `'process'` branch on Linux (which resolves to Bubblewrap server-side) + * so the wire-format `network` block is identical regardless of which + * intent the caller used. + */ +function applyBubblewrapNetworkEnforcement(config: ContainerConfig): void { + if (config.network) { + if (config.network.allowedHosts?.length || config.network.blockedHosts?.length) { + config.network.enforcementMode = 'firewall'; + } + } +} + /** * Builds the Bubblewrap (bwrap) portion of a ContainerConfig. * Bubblewrap is Linux-only and uses shared cross-backend fields only — @@ -95,14 +113,7 @@ function buildBubblewrapConfig( config: ContainerConfig, ): ContainerConfig { config.containment = 'bubblewrap'; - - // Network enforcement: use firewall when host filtering is needed - if (config.network) { - if (config.network.allowedHosts?.length || config.network.blockedHosts?.length) { - config.network.enforcementMode = 'firewall'; - } - } - + applyBubblewrapNetworkEnforcement(config); return config; } @@ -298,9 +309,16 @@ export function createConfigFromPolicy( // WSLC supports block + allowedHosts via iptables (Bridged networking // with per-host filtering). macOS sandbox supports it natively via // per-host Seatbelt rules. Bubblewrap supports it via iptables. + // Abstract `'process'` on Linux resolves to Bubblewrap server-side, so + // treat it the same as explicit `'bubblewrap'` here. // Other backends require allowOutbound for host filtering since it // maps to AppContainer capabilities. - if (containment !== 'wslc' && containment !== 'seatbelt' && containment !== 'bubblewrap') { + const resolvesToHostFilteringBackend = + containment === 'wslc' || + containment === 'seatbelt' || + containment === 'bubblewrap' || + (containment === 'process' && platform === 'linux'); + if (!resolvesToHostFilteringBackend) { if ((policy.network.allowedHosts?.length || policy.network.blockedHosts?.length) && !policy.network.allowOutbound) { throw new Error('allowedHosts/blockedHosts require allowOutbound to be true'); } @@ -342,6 +360,10 @@ export function createConfigFromPolicy( // payload intentionally omits any backend-specific block so the // config reflects the abstract intent. Callers who explicitly want // LXC must pass `containment: 'lxc'`. + // + // Network enforcement still needs the same iptables firewall mode + // as explicit `'bubblewrap'` when host filtering is in play. + applyBubblewrapNetworkEnforcement(config); diagLog(`createConfigFromPolicy: containment=process (linux, resolves to bubblewrap), id=${containerId}`); return config; } diff --git a/sdk/tests/unit/sandbox.test.ts b/sdk/tests/unit/sandbox.test.ts index d149732e3..aa5ee42bb 100644 --- a/sdk/tests/unit/sandbox.test.ts +++ b/sdk/tests/unit/sandbox.test.ts @@ -543,6 +543,40 @@ describe('createConfigFromPolicy', () => { } }); + it('should force enforcementMode=firewall when host filtering is requested (process resolves to bubblewrap on Linux)', () => { + mockLinux(); + try { + const config = createConfigFromPolicy({ + version: '0.5.0-alpha', + network: { allowOutbound: true, allowedHosts: ['example.com'] }, + }); + assert.strictEqual(config.containment, 'process'); + assert.strictEqual(config.lxc, undefined); + // Abstract 'process' on Linux must apply the same iptables firewall + // enforcement as explicit 'bubblewrap', because the native binary + // resolves the abstract intent to Bubblewrap server-side. + assert.strictEqual(config.network!.enforcementMode, 'firewall'); + } finally { + restore(); + } + }); + + it('should allow allowedHosts without allowOutbound on Linux (bubblewrap supports per-host filtering)', () => { + mockLinux(); + try { + const config = createConfigFromPolicy({ + version: '0.5.0-alpha', + network: { allowedHosts: ['example.com'] }, + }); + assert.strictEqual(config.containment, 'process'); + assert.deepStrictEqual(config.network!.allowedHosts, ['example.com']); + assert.strictEqual(config.network!.defaultPolicy, 'block'); + assert.strictEqual(config.network!.enforcementMode, 'firewall'); + } finally { + restore(); + } + }); + it('should reject proxy on Linux', () => { mockLinux(); try { @@ -560,24 +594,52 @@ describe('createConfigFromPolicy', () => { }); describe('network validation', () => { + // These tests assert the "allowOutbound required for host filtering" + // gate. The gate applies to backends that map host filtering to + // capabilities/ACLs (Windows process container path). It is intentionally + // waived for backends that do per-host iptables/Seatbelt filtering + // (wslc, seatbelt, bubblewrap, and Linux abstract 'process' which + // resolves to bubblewrap). Mock platform to win32 so the test asserts + // the gate independent of the CI runner's OS. + let originalPlatform: PropertyDescriptor | undefined; + const mockWindows = () => { + originalPlatform = Object.getOwnPropertyDescriptor(process, 'platform'); + Object.defineProperty(process, 'platform', { value: 'win32' }); + }; + const restore = () => { + if (originalPlatform) { + Object.defineProperty(process, 'platform', originalPlatform); + } + }; + it('should reject allowedHosts without allowOutbound', () => { - assert.throws( - () => createConfigFromPolicy({ - version: '0.4.0-alpha', - network: { allowedHosts: ['example.com'] }, - }), - { message: /allowedHosts\/blockedHosts require allowOutbound/ }, - ); + mockWindows(); + try { + assert.throws( + () => createConfigFromPolicy({ + version: '0.4.0-alpha', + network: { allowedHosts: ['example.com'] }, + }), + { message: /allowedHosts\/blockedHosts require allowOutbound/ }, + ); + } finally { + restore(); + } }); it('should reject blockedHosts without allowOutbound', () => { - assert.throws( - () => createConfigFromPolicy({ - version: '0.4.0-alpha', - network: { blockedHosts: ['evil.com'] }, - }), - { message: /allowedHosts\/blockedHosts require allowOutbound/ }, - ); + mockWindows(); + try { + assert.throws( + () => createConfigFromPolicy({ + version: '0.4.0-alpha', + network: { blockedHosts: ['evil.com'] }, + }), + { message: /allowedHosts\/blockedHosts require allowOutbound/ }, + ); + } finally { + restore(); + } }); }); From a830520bee764e466e02ec146b6dca40d111e86f Mon Sep 17 00:00:00 2001 From: Soham Das Date: Thu, 21 May 2026 18:22:40 -0700 Subject: [PATCH 8/9] More fixes to LXC --- sdk/src/sandbox.ts | 26 +++++++++++++++----------- sdk/tests/unit/sandbox.test.ts | 26 +++++++++++++++++++++++++- 2 files changed, 40 insertions(+), 12 deletions(-) diff --git a/sdk/src/sandbox.ts b/sdk/src/sandbox.ts index f5a00d986..edec2e403 100644 --- a/sdk/src/sandbox.ts +++ b/sdk/src/sandbox.ts @@ -86,16 +86,19 @@ function buildWslcContainerConfig( } /** - * Applies Bubblewrap-style network enforcement to `config.network`: + * Applies iptables-based network enforcement to `config.network`: * when host filtering is requested, force `enforcementMode = 'firewall'` - * (bwrap relies on host iptables, same approach as LXC). + * so the Linux runner actually applies iptables rules. Without this, + * `network_enforcement_mode` falls back to the parser default of + * `Capabilities` and the runner silently skips iptables, dropping + * `allowedHosts` / `blockedHosts` on the floor. * - * Shared between the explicit `'bubblewrap'` builder and the abstract - * `'process'` branch on Linux (which resolves to Bubblewrap server-side) - * so the wire-format `network` block is identical regardless of which - * intent the caller used. + * Shared between the explicit `'bubblewrap'` / `'lxc'` builders and the + * abstract `'process'` branch on Linux (which resolves to Bubblewrap + * server-side) so the wire-format `network` block is identical regardless + * of which intent the caller used. */ -function applyBubblewrapNetworkEnforcement(config: ContainerConfig): void { +function applyIptablesNetworkEnforcement(config: ContainerConfig): void { if (config.network) { if (config.network.allowedHosts?.length || config.network.blockedHosts?.length) { config.network.enforcementMode = 'firewall'; @@ -113,7 +116,7 @@ function buildBubblewrapConfig( config: ContainerConfig, ): ContainerConfig { config.containment = 'bubblewrap'; - applyBubblewrapNetworkEnforcement(config); + applyIptablesNetworkEnforcement(config); return config; } @@ -130,7 +133,7 @@ function buildLinuxProcessConfig( release: '3.23', destroyOnExit: true, }; - + applyIptablesNetworkEnforcement(config); return config; } @@ -308,7 +311,7 @@ export function createConfigFromPolicy( // WSLC supports block + allowedHosts via iptables (Bridged networking // with per-host filtering). macOS sandbox supports it natively via - // per-host Seatbelt rules. Bubblewrap supports it via iptables. + // per-host Seatbelt rules. Bubblewrap and LXC support it via iptables. // Abstract `'process'` on Linux resolves to Bubblewrap server-side, so // treat it the same as explicit `'bubblewrap'` here. // Other backends require allowOutbound for host filtering since it @@ -317,6 +320,7 @@ export function createConfigFromPolicy( containment === 'wslc' || containment === 'seatbelt' || containment === 'bubblewrap' || + containment === 'lxc' || (containment === 'process' && platform === 'linux'); if (!resolvesToHostFilteringBackend) { if ((policy.network.allowedHosts?.length || policy.network.blockedHosts?.length) && !policy.network.allowOutbound) { @@ -363,7 +367,7 @@ export function createConfigFromPolicy( // // Network enforcement still needs the same iptables firewall mode // as explicit `'bubblewrap'` when host filtering is in play. - applyBubblewrapNetworkEnforcement(config); + applyIptablesNetworkEnforcement(config); diagLog(`createConfigFromPolicy: containment=process (linux, resolves to bubblewrap), id=${containerId}`); return config; } diff --git a/sdk/tests/unit/sandbox.test.ts b/sdk/tests/unit/sandbox.test.ts index aa5ee42bb..20aaa1457 100644 --- a/sdk/tests/unit/sandbox.test.ts +++ b/sdk/tests/unit/sandbox.test.ts @@ -756,7 +756,7 @@ describe('createConfigFromPolicy', () => { assert.deepStrictEqual(config.filesystem!.readwritePaths, ['/workspace']); assert.deepStrictEqual(config.filesystem!.readonlyPaths, ['/data']); assert.deepStrictEqual(config.filesystem!.deniedPaths, ['/secrets']); - // Per buildBubblewrapConfig, host filtering forces firewall mode. + // Per applyIptablesNetworkEnforcement, host filtering forces firewall mode. assert.strictEqual(config.network!.enforcementMode, 'firewall'); }); }); @@ -770,6 +770,30 @@ describe('createConfigFromPolicy', () => { assert.ok(config.lxc, 'lxc backend block should be populated'); assert.strictEqual(config.lxc!.distribution, 'alpine'); }); + + it('should force enforcementMode=firewall when host filtering is requested', () => { + // The LXC runner only invokes iptables when network_enforcement_mode is + // Firewall|Both (see lxc_common::network_iptables). Without this stamp, + // the parser would default to Capabilities and allowedHosts/blockedHosts + // would be silently dropped on the floor. + const config = createConfigFromPolicy({ + version: '0.5.0-alpha', + network: { allowOutbound: true, allowedHosts: ['example.com'] }, + }, 'lxc'); + assert.strictEqual(config.containment, 'lxc'); + assert.strictEqual(config.network!.enforcementMode, 'firewall'); + }); + + it('should allow allowedHosts without allowOutbound (LXC supports per-host iptables filtering)', () => { + const config = createConfigFromPolicy({ + version: '0.5.0-alpha', + network: { allowedHosts: ['example.com'] }, + }, 'lxc'); + assert.strictEqual(config.containment, 'lxc'); + assert.deepStrictEqual(config.network!.allowedHosts, ['example.com']); + assert.strictEqual(config.network!.defaultPolicy, 'block'); + assert.strictEqual(config.network!.enforcementMode, 'firewall'); + }); }); }); From 59e4c6d07af0607d8210f14e51c5974ccbf244c8 Mon Sep 17 00:00:00 2001 From: Soham Das Date: Fri, 22 May 2026 09:59:54 -0700 Subject: [PATCH 9/9] Comment clarification --- sdk/tests/integration/test-helpers.ts | 15 ++++++++++++--- 1 file changed, 12 insertions(+), 3 deletions(-) diff --git a/sdk/tests/integration/test-helpers.ts b/sdk/tests/integration/test-helpers.ts index fe215d817..277e4f57a 100644 --- a/sdk/tests/integration/test-helpers.ts +++ b/sdk/tests/integration/test-helpers.ts @@ -251,9 +251,18 @@ export function createTempDir(prefix: string = 'mxc-test'): string { return dir; } -// Async spawn from a pre-built ContainerConfig (no per-call containment arg on -// spawnSandboxAsync, so tests that need a specific backend build the config -// directly and run it through this helper). +// Async spawn from a pre-built ContainerConfig. Mirrors the SDK's own +// spawnSandboxAsync (sandbox.ts) -- it exists because the SDK doesn't expose +// an async wrapper around spawnSandboxFromConfig, and tests that need a +// specific backend build the config directly. +// +// Notes (kept in lockstep with spawnSandboxAsync): +// - stdout/stderr are merged: wxc-exec runs under node-pty (a single PTY), +// so the OS combines both streams. stderr: '' is structural padding. +// - No per-call timeout: node:test enforces test-level timeouts and the +// config's process.timeout is enforced by the native runner. +// - IPty has no onError event. Synchronous spawn failures are caught below; +// post-spawn failures surface as a non-zero exitCode via onExit. export function spawnFromConfigAsync( config: sdkNamespace.ContainerConfig, options: sdkNamespace.SandboxSpawnOptions = {},