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) 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: diff --git a/sdk/src/sandbox.ts b/sdk/src/sandbox.ts index e941e2783..edec2e403 100644 --- a/sdk/src/sandbox.ts +++ b/sdk/src/sandbox.ts @@ -85,6 +85,27 @@ function buildWslcContainerConfig( return config; } +/** + * Applies iptables-based network enforcement to `config.network`: + * when host filtering is requested, force `enforcementMode = 'firewall'` + * 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'` / `'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 applyIptablesNetworkEnforcement(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 +116,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'; - } - } - + applyIptablesNetworkEnforcement(config); return config; } @@ -119,7 +133,7 @@ function buildLinuxProcessConfig( release: '3.23', destroyOnExit: true, }; - + applyIptablesNetworkEnforcement(config); return config; } @@ -297,10 +311,18 @@ 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 // maps to AppContainer capabilities. - if (containment !== 'wslc' && containment !== 'seatbelt' && containment !== 'bubblewrap') { + const resolvesToHostFilteringBackend = + 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) { throw new Error('allowedHosts/blockedHosts require allowOutbound to be true'); } @@ -328,11 +350,26 @@ 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}`); - 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'`. + // + // Network enforcement still needs the same iptables firewall mode + // as explicit `'bubblewrap'` when host filtering is in play. + applyIptablesNetworkEnforcement(config); + 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/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. diff --git a/sdk/tests/integration/linux-bubblewrap.test.ts b/sdk/tests/integration/linux-bubblewrap.test.ts new file mode 100644 index 000000000..868c8fcc0 --- /dev/null +++ b/sdk/tests/integration/linux-bubblewrap.test.ts @@ -0,0 +1,76 @@ +// 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'; + +// 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 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..277e4f57a 100644 --- a/sdk/tests/integration/test-helpers.ts +++ b/sdk/tests/integration/test-helpers.ts @@ -251,6 +251,39 @@ export function createTempDir(prefix: string = 'mxc-test'): string { return dir; } +// 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 = {}, + 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..20aaa1457 100644 --- a/sdk/tests/unit/sandbox.test.ts +++ b/sdk/tests/unit/sandbox.test.ts @@ -217,12 +217,14 @@ describe('buildSandboxPayload', () => { } }; - 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,48 @@ 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(); + } + }); + + 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(); } @@ -557,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(); + } }); }); @@ -671,6 +736,65 @@ 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 applyIptablesNetworkEnforcement, 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'); + }); + + 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'); + }); + }); }); describe('Schema 0.6.0 vocabulary', () => { @@ -755,4 +879,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..53a966df8 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 @@ -1713,6 +1790,7 @@ mod tests { fn proxy_localhost_port() { let json = r#"{ "process": {"commandLine": "echo test"}, + "containment": "processcontainer", "network": { "proxy": { "localhost": 8080 } } @@ -1732,6 +1810,7 @@ mod tests { fn proxy_url_parsed() { let json = r#"{ "process": {"commandLine": "echo test"}, + "containment": "processcontainer", "network": { "proxy": { "url": "http://localhost:3128" } } @@ -1750,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" } } @@ -1778,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" } } @@ -1795,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"], @@ -1856,6 +1938,7 @@ mod tests { fn proxy_builtin_test_server() { let json = r#"{ "process": {"commandLine": "echo test"}, + "containment": "processcontainer", "network": { "proxy": { "builtinTestServer": true } } 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."