feat(mxc): adopt stable MXC 1.0.0 contract

Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
Drew Newberry
2026-10-08 23:31:20 -07:00
parent 7e0c8a67f6
commit 2f9a05dc11
18 changed files with 915 additions and 654 deletions
@@ -314,6 +314,29 @@ in the gateway build graph, but their Unix-socket standalone binaries do not.
## Unsupported Driver Contract
MXC requests and mapper artifacts use the stable 1.0.0 schema. Upgrade the
native host executor to the signed MXC 1.0.0 release before running E2E; preserve
the old executable for rollback and verify the release checksum, architecture,
signature, and `--probe` output. Remove `default_configuration_id` from gateway
configurations: it is retired and rejected. Do not restore alpha-version or
wildcard compatibility knobs. Standalone network mapping emits numeric CIDRs
and TCP ports and reports unrepresentable DNS/L7 semantics as losses; full
network governance remains in the host supervisor. IsolationSession remains
unsupported by this runtime pair, and its filesystem grants are not enforceable.
MXC does not support external-resource label admission. Its factory explicitly
acknowledges admission as disabled; do not add `resource_admission` to MXC TOML
or weaken admission defaults for other drivers. Caller driver JSON still
requires `allow_driver_config = true`. This does not waive native isolation,
workload policy, authenticated transport, or outer-fence confirmation.
UI controls remain in `SandboxPolicy`. Advertise `openshell.policy.ui.v1` using
existing extension metadata, not a new per-driver boolean. The shared gateway
checks effective policy before driver validation and provisioning; drivers
without the capability reject explicit UI without implementing UI-specific
handling. Keep native UI validation and mapping in MXC. Omitted UI settings deny
graphical UI, clipboard access, and input injection. UI changes require
recreating the sandbox.
Windows must continue to reject unsupported compute drivers clearly.
The gateway's `compute-driver-mxc` feature independently links and registers
+6 -3
View File
@@ -98,9 +98,12 @@ etw_audit = false
Only `process_container` supports this architecture. `isolation_session` is
rejected during sandbox validation.
Legacy configurations may retain `default_configuration_id`; it remains unused
by ProcessContainer and does not enable IsolationSession. New configurations
should omit that field.
MXC 1.0.0 is required. Pre-1.0 executors and the retired
`default_configuration_id` setting are not supported; remove that setting
before upgrading. Both live launches and standalone mapper output use the
stable directional `network.egress` / `network.ingress` schema. The standalone
mapper emits numeric CIDRs and TCP ports, and reports DNS and L7 semantics as
mapping losses; the host supervisor continues to enforce the full network policy.
Supply the workload command and working directory per sandbox:
@@ -65,15 +65,23 @@ function Invoke-WxcDryRun([string] $wxc, [hashtable] $config) {
return Invoke-Native @($wxc, "--config-base64", $b64, "--dry-run")
}
function Invoke-WxcPhase([string] $wxc, [hashtable] $config, [switch] $Experimental) {
function Invoke-WxcPhase(
[string] $wxc,
[hashtable] $config,
[string] $Operation,
[string] $ContainerId
) {
$json = $config | ConvertTo-Json -Depth 20 -Compress
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$b64 = [Convert]::ToBase64String($bytes)
if ($Experimental) {
return Invoke-Native @($wxc, "--config-base64", $b64, "--experimental")
} else {
return Invoke-Native @($wxc, "--config-base64", $b64)
$arguments = @($wxc, "--config-base64", $b64)
if (-not [string]::IsNullOrWhiteSpace($Operation)) {
$arguments += @("--operation", $Operation)
}
if (-not [string]::IsNullOrWhiteSpace($ContainerId)) {
$arguments += @("--container-id", $ContainerId)
}
return Invoke-Native $arguments
}
function Invoke-WxcProbe([string] $wxc) {
@@ -90,8 +98,8 @@ try {
if ($null -ne $ubr) { $osRevision = $ubr }
} catch {}
$osBuildFull = "$osBuild.$osRevision"
$isoSessionMinBuild = 26300
$isoSessionMinRevision = 8553
$isoSessionMinBuild = 26340
$isoSessionMinRevision = 9212
# ── wxc-exec info ─────────────────────────────────────────────────────────────
@@ -112,6 +120,7 @@ if (Test-Path $WxcExecPath) {
# ── Probe section ─────────────────────────────────────────────────────────────
$probeOutput = $null
$probeData = $null
$dryRunExitCode = $null
$dryRunOutput = $null
$pcTrialResult = "absent"
@@ -127,10 +136,13 @@ if ($wxcInfo.exists) {
# --probe
$probeResult = Invoke-WxcProbe -wxc $WxcExecPath
$probeOutput = $probeResult.Output
try {
$probeData = $probeOutput | ConvertFrom-Json
} catch {}
# dry-run trial (minimal processcontainer config)
$dryConfig = @{
version = "0.6.0-alpha"
version = "1.0.0"
containerId = "probe-dryrun"
containment = "processcontainer"
process = @{
@@ -148,7 +160,7 @@ if ($wxcInfo.exists) {
# processcontainer one-shot trial
$pcConfig = @{
version = "0.6.0-alpha"
version = "1.0.0"
containerId = "probe-pc-oneshot"
containment = "processcontainer"
process = @{
@@ -198,27 +210,20 @@ if ($wxcInfo.exists) {
# isolation_session provision trial
$isoConfig = @{
version = "0.6.0-alpha"
phase = "provision"
version = "1.0.0"
containment = "isolation_session"
filesystem = @{
readwritePaths = @()
readonlyPaths = @()
}
experimental = @{
isolation_session = @{
configurationId = "composable"
provision = @{}
}
network = @{
egress = @{ default = "allow" }
ingress = @{ default = "allow"; hostLoopback = "allow" }
}
}
$isoResult = Invoke-WxcPhase -wxc $WxcExecPath -config $isoConfig -Experimental
$isoResult = Invoke-WxcPhase -wxc $WxcExecPath -config $isoConfig -Operation "provision"
$isoOutput = $isoResult.Output
$isoOutputLower = $isoOutput.ToLower()
if ($isoOutputLower -match "backend_unavailable" -or $isoOutputLower -match "0x80040154") {
$isoTrialResult = "unavailable"
$isoTrialMessage = "backend_unavailable: IsoSessionApp.dll absent or OS build < 26300.8553"
$isoTrialMessage = "backend_unavailable: IsoSessionApp.dll absent or OS build < 26340.9212"
} elseif ($isoResult.ExitCode -eq 0) {
# Provision succeeded — deprovision immediately to avoid orphaning.
$isoTrialResult = "live"
@@ -232,32 +237,10 @@ if ($wxcInfo.exists) {
} catch {}
if ($null -ne $sandboxId) {
# Stop first (a provisioned-but-unstarted session may still accept it;
# ignore failures), then deprovision. Surface the deprovision error
# text — an orphaned session blocks the single-session backend.
$stopConfig = @{
version = "0.6.0-alpha"
phase = "stop"
sandboxId = $sandboxId
experimental = @{
isolation_session = @{
# Unit variant: serialize as null, not {} (malformed_request otherwise).
stop = $null
}
}
}
Invoke-WxcPhase -wxc $WxcExecPath -config $stopConfig -Experimental | Out-Null
$deprovConfig = @{
version = "0.6.0-alpha"
phase = "deprovision"
sandboxId = $sandboxId
experimental = @{
isolation_session = @{
deprovision = $null
}
}
}
$deprovResult = Invoke-WxcPhase -wxc $WxcExecPath -config $deprovConfig -Experimental
# Deprovision immediately. Surface the error text because an
# orphaned session blocks the single-session backend.
$deprovConfig = @{ version = "1.0.0" }
$deprovResult = Invoke-WxcPhase -wxc $WxcExecPath -config $deprovConfig -Operation "deprovision" -ContainerId $sandboxId
if ($deprovResult.ExitCode -eq 0) {
$isoTrialMessage = "isolation_session live (provisioned $sandboxId, deprovisioned cleanly)"
} else {
@@ -300,6 +283,22 @@ if ($null -eq $dryRunExitCode) {
$dryRunVerdict = "failed: exit $dryRunExitCode"
}
$isolationTier = "unknown"
$hostLoopbackSupported = $null
$governedEgressVerdict = "unavailable: wxc-exec probe data missing"
if ($null -ne $probeData) {
if ($null -ne $probeData.tier) { $isolationTier = [string]$probeData.tier }
if ($null -ne $probeData.probes -and
$probeData.probes.PSObject.Properties.Name -contains "baseContainerSupportsIngressHostLoopbackAllow") {
$hostLoopbackSupported = [bool]$probeData.probes.baseContainerSupportsIngressHostLoopbackAllow
if ($hostLoopbackSupported) {
$governedEgressVerdict = "supported"
} else {
$governedEgressVerdict = "unavailable: isolation tier $isolationTier does not support ingress.hostLoopback=allow"
}
}
}
# ── Assemble report ───────────────────────────────────────────────────────────
$report = [ordered]@{
@@ -322,12 +321,17 @@ $report = [ordered]@{
result = $pcTrialResult
message = $pcTrialMessage
}
processcontainerCapabilities = [ordered]@{
isolationTier = $isolationTier
ingressHostLoopbackAllow = $hostLoopbackSupported
}
isolationSessionTrial = [ordered]@{
result = $isoTrialResult
message = $isoTrialMessage
}
verdicts = [ordered]@{
processcontainer = $pcVerdict
governedEgress = $governedEgressVerdict
isolation_session = $isoVerdict
dryRun = $dryRunVerdict
}
@@ -344,6 +348,7 @@ if ($Full) {
Write-Host "wxc-exec: $WxcExecPath (exists=$($wxcInfo.exists))"
Write-Host "verdicts:"
Write-Host " processcontainer : $pcVerdict"
Write-Host " governed egress : $governedEgressVerdict"
Write-Host " isolation_session : $isoVerdict"
Write-Host " dry-run : $dryRunVerdict"
Write-Host "(re-run with -Full for the complete JSON report, or -OutFile caps.json to save it)"
@@ -436,7 +436,7 @@ function Probe-Backend([string] $backendName, [string] $wxc) {
$probeDir = Join-Path $env:TEMP "mxc-e2e-probe"
New-Item -ItemType Directory -Force $probeDir | Out-Null
$config = @{
version = "0.6.0-alpha"
version = "1.0.0"
containerId = "e2e-probe-pc"
containment = "processcontainer"
process = @{ commandLine = "C:\Windows\System32\cmd.exe /c exit 0"; cwd = $probeDir; timeout = 30000 } # ms (MXC process.timeout is milliseconds)
@@ -457,21 +457,17 @@ function Probe-Backend([string] $backendName, [string] $wxc) {
if ($backendName -eq "isolation_session") {
$config = @{
version = "0.6.0-alpha"
phase = "provision"
version = "1.0.0"
containment = "isolation_session"
filesystem = @{ readwritePaths = @(); readonlyPaths = @() }
experimental = @{
isolation_session = @{
configurationId = "composable"
provision = @{}
}
network = @{
egress = @{ default = "allow" }
ingress = @{ default = "allow"; hostLoopback = "allow" }
}
}
$json = $config | ConvertTo-Json -Depth 20 -Compress
$bytes = [System.Text.Encoding]::UTF8.GetBytes($json)
$b64 = [Convert]::ToBase64String($bytes)
$outObj = & $wxc --config-base64 $b64 --experimental 2>&1
$outObj = & $wxc --config-base64 $b64 --operation provision 2>&1
$exitCode = $LASTEXITCODE
$output = ($outObj -join "`n").ToLower()
if ($output -match "backend_unavailable" -or $output -match "0x80040154") {
@@ -488,19 +484,11 @@ function Probe-Backend([string] $backendName, [string] $wxc) {
$sandboxId = $parsed.result.sandboxId
} catch {}
if ($null -ne $sandboxId) {
$deprovConfig = @{
version = "0.6.0-alpha"
phase = "deprovision"
sandboxId = $sandboxId
experimental = @{
# Unit variant: null, not @{} (malformed_request otherwise).
isolation_session = @{ deprovision = $null }
}
}
$deprovConfig = @{ version = "1.0.0" }
$deprovJson = $deprovConfig | ConvertTo-Json -Depth 20 -Compress
$deprovBytes = [System.Text.Encoding]::UTF8.GetBytes($deprovJson)
$deprovB64 = [Convert]::ToBase64String($deprovBytes)
& $wxc --config-base64 $deprovB64 --experimental 2>&1 | Out-Null
& $wxc --config-base64 $deprovB64 --operation deprovision --container-id $sandboxId 2>&1 | Out-Null
}
return @{ Live = $true; Reason = "isolation_session probe: provisioned and deprovisioned" }
}
+5 -30
View File
@@ -78,10 +78,6 @@ pub struct MxcComputeConfig {
pub state_dir: PathBuf,
pub grpc_endpoint: String,
pub backend: MxcBackend,
/// Legacy isolation-session setting, accepted for existing
/// `ProcessContainer` configurations but unused by that backend.
#[serde(skip_serializing)]
pub default_configuration_id: Option<String>,
pub pc_least_privilege: bool,
pub pc_capabilities: Vec<String>,
pub pc_allow_local_network: bool,
@@ -115,7 +111,6 @@ impl Default for MxcComputeConfig {
state_dir,
grpc_endpoint: String::new(),
backend: MxcBackend::ProcessContainer,
default_configuration_id: None,
pc_least_privilege: false,
pc_capabilities: Vec::new(),
pc_allow_local_network: true,
@@ -757,7 +752,7 @@ async fn run_lifecycle_inner(
timeout: 0,
};
let network = MxcNetwork {
default_policy: "block".to_string(),
egress_default: "deny".to_string(),
proxy: Some(proxy_addr),
allow_local_network: context.config.pc_allow_local_network,
};
@@ -1454,33 +1449,13 @@ mod tests {
}
#[test]
fn legacy_isolation_setting_does_not_change_processcontainer_contract() {
let config: MxcComputeConfig = serde_json::from_value(serde_json::json!({
fn gateway_config_rejects_pre_1_0_configuration_id() {
let error = serde_json::from_value::<MxcComputeConfig>(serde_json::json!({
"backend": "process_container",
"default_configuration_id": "composable",
}))
.expect("legacy ProcessContainer config");
assert_eq!(config.backend, MxcBackend::ProcessContainer);
assert_eq!(
config.default_configuration_id.as_deref(),
Some("composable")
);
assert!(
serde_json::to_value(&config)
.expect("config")
.get("default_configuration_id")
.is_none()
);
let (mut backend, sandbox, _dir) = preflight_fixture();
backend.config.backend = MxcBackend::IsolationSession;
backend.config.default_configuration_id = config.default_configuration_id;
assert!(
backend
.validate_sandbox_create(&sandbox)
.expect_err("IsolationSession still unsupported")
.message()
.contains("requires process_container")
);
.expect_err("MXC pre-1.0 configurationId must not be accepted");
assert!(error.to_string().contains("default_configuration_id"));
}
#[test]
@@ -1731,11 +1731,10 @@ fn map_config_state(ctx: &EventContext, ev: &DecodedEtwEvent) -> OcsfEvent {
"EnforceOsPolicy" => "MXC sandbox OS policy enforced".to_string(),
"SandboxConsoleReferencePlumbed" => "MXC sandbox console reference plumbed".to_string(),
// The one network-plane event the provider emits. Empirically the OS
// Sandboxing provider fires this event *only* when an egress proxy is
// configured for the sandbox, but it does **not** surface the port for
// MXC's URL-based proxy — `proxyPort` is always 0 (MXC redirects egress via
// a `network.proxy.localhost` policy URL, not the OS built-in proxy-port
// mechanism this field reflects). The real per-sandbox listening port is
// Sandboxing provider fires this event when governed egress is
// configured for the sandbox, but `proxyPort` is always 0 because the
// OpenShell listener is supplied through process proxy environment and
// MXC receives only loopback-only directional policy. The real per-sandbox listening port is
// recorded on the host proxy's own Network Activity [4001] "Listen" event.
// So the presence of this event means a proxy WAS configured; only append a
// port on the off chance a future provider/build populates it.
+7 -15
View File
@@ -11,9 +11,8 @@ use thiserror::Error;
use tokio::process::Command;
use tracing::{debug, info};
/// MXC config schema version. The mapper and one-shot launcher share the
/// MXC 0.8 directional network schema.
pub const MXC_SCHEMA_VERSION: &str = "0.8.0-alpha";
/// Stable MXC schema version shared by the mapper and live launcher.
pub const MXC_SCHEMA_VERSION: &str = "1.0.0";
/// Environment flag selecting the in-process mock `wxc-exec` shim. When set to
/// `"1"`, the invoker does not spawn `wxc-exec.exe`. This wiring shim has no
@@ -46,9 +45,9 @@ pub struct MxcFilesystem {
/// Network redirect fragment emitted when governed egress is enabled.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct MxcNetwork {
pub default_policy: String,
pub egress_default: String,
pub proxy: Option<SocketAddr>,
/// When true, includes `"allowLocalNetwork": true` in the network JSON.
/// When true, emits permissive MXC 1.0 directional ingress and host loopback.
/// Required for node.js to initialize inside a processcontainer — without
/// it, node.exe DLL initialization fails with `STATUS_DLL_INIT_FAILED`.
pub allow_local_network: bool,
@@ -137,13 +136,6 @@ fn redact_env_for_debug(config: &serde_json::Value) -> serde_json::Value {
}
fn network_json(network: &MxcNetwork) -> serde_json::Value {
// MXC 0.8.0-alpha schema uses a directional egress/ingress format.
// "block" default_policy maps to egress.default "deny"; "allow" maps to "allow".
let egress_default = if network.default_policy == "block" {
"deny"
} else {
"allow"
};
let mut value = if network.proxy.is_some() {
// Use direct loopback egress rather than runtimeConfig.networkProxy proxy
// mode. Proxy mode routes all outbound TCP through processmodel.dll's WFP
@@ -165,7 +157,7 @@ fn network_json(network: &MxcNetwork) -> serde_json::Value {
"ingress": { "default": "allow", "hostLoopback": "allow" },
})
} else {
serde_json::json!({ "egress": { "default": egress_default } })
serde_json::json!({ "egress": { "default": network.egress_default } })
};
if network.proxy.is_none() && network.allow_local_network {
value["ingress"] = serde_json::json!({ "default": "allow", "hostLoopback": "allow" });
@@ -469,10 +461,10 @@ mod tests {
#[test]
fn network_json_emits_directional_format() {
// MXC 0.8.0-alpha: egress/ingress replaces the legacy
// MXC 1.0.0: egress/ingress replaces the legacy
// defaultPolicy / allowedHosts / proxy.localhost shape.
let network = MxcNetwork {
default_policy: "block".into(),
egress_default: "deny".into(),
proxy: Some("127.0.0.1:18080".parse().unwrap()),
allow_local_network: false,
};
+32 -1
View File
@@ -157,6 +157,20 @@ impl PolicyMapper for EmbeddedPolicyMapper {
)
})?;
if ctx.containment == "isolation_session"
&& policy.filesystem.as_ref().is_some_and(|filesystem| {
filesystem.include_workdir
|| !filesystem.read_only.is_empty()
|| !filesystem.read_write.is_empty()
})
{
return Err(MapError::Unsupported(vec![LossItem {
rule_kind: "filesystem_policy".into(),
detail: "MXC 1.0 isolation_session cannot enforce OpenShell filesystem grants"
.into(),
}]));
}
let (config, loss, trimmed_policy, proxy_addr) = if let Some(addr) = ctx.egress {
// Pattern C: MXC handles filesystem + a proxy redirect, while the
// host CONNECT proxy receives the network-only trimmed policy.
@@ -265,7 +279,7 @@ mod tests {
fn embedded_maps_policy_read_write_to_share() {
let mapper = EmbeddedPolicyMapper;
let policy = fs_policy(&["C:/work/openshell-mxc-demo"], &["C:/tools"]);
let ctx = demo_ctx();
let ctx = processcontainer_ctx();
let config = mapper.map(Some(&policy), &ctx).unwrap();
// Forward slashes normalized to Windows backslashes by the bridge.
assert!(
@@ -305,6 +319,23 @@ mod tests {
assert!(matches!(err, MapError::Unsupported(_)));
}
#[test]
fn embedded_rejects_filesystem_policy_on_isolation_session() {
let mapper = EmbeddedPolicyMapper;
let policy = fs_policy(&["C:/work/demo"], &[]);
let err = mapper.map(Some(&policy), &demo_ctx()).unwrap_err();
match err {
MapError::Unsupported(items) => {
assert_eq!(items.len(), 1);
assert_eq!(items[0].rule_kind, "filesystem_policy");
assert!(items[0].detail.contains("cannot enforce"));
}
MapError::Internal(message) => {
panic!("expected unsupported filesystem policy, got internal error: {message}")
}
}
}
#[test]
fn embedded_rejects_explicit_ui_on_isolation_session() {
use openshell_core::proto::UiPolicy;
@@ -7,34 +7,18 @@ use openshell_core::proto::SandboxPolicy;
use serde_json::{Value, json};
use super::loss::{LossItem, add_loss};
use crate::mxc::MXC_SCHEMA_VERSION;
/// Placeholder command written into `process.commandLine` when the caller does
/// not supply a real workload command.
pub const DEFAULT_COMMAND: &str = "sh -lc \"echo OpenShell policy mapped to MXC; replace process.commandLine before running a real workload\"";
/// Default MXC schema version emitted in `version`.
pub const DEFAULT_MXC_VERSION: &str = "0.7.0-alpha";
/// Stable MXC schema used by every mapper output.
pub const DEFAULT_MXC_VERSION: &str = MXC_SCHEMA_VERSION;
/// Default MXC containment backend for the coarse mapping.
pub const DEFAULT_CONTAINMENT: &str = "bubblewrap";
/// Select a default `network.enforcementMode` for the backend, or `None` to
/// omit the field (backends that derive enforcement from host lists / proxy).
pub fn default_enforcement_mode(
containment: &str,
allowed_hosts: &[String],
) -> Option<&'static str> {
if allowed_hosts.is_empty() {
return None;
}
match containment {
"processcontainer" | "process" => Some("both"),
"wslc" | "seatbelt" | "microvm" | "vm" | "windows_sandbox" => None,
// lxc, bubblewrap, hyperlight, and anything else default to firewall.
_ => Some("firewall"),
}
}
/// Backend-specific advisory about how filesystem default-deny differs from
/// `OpenShell` Landlock.
pub fn filesystem_default_deny_message(containment: &str) -> String {
@@ -61,42 +45,45 @@ pub fn filesystem_default_deny_message(containment: &str) -> String {
pub fn add_backend_specific_config(
config: &mut Value,
containment: &str,
allowed_hosts: &[String],
has_direct_egress: bool,
items: &mut Vec<LossItem>,
) {
match containment {
"processcontainer" | "process" if !allowed_hosts.is_empty() => {
"processcontainer" | "process" if has_direct_egress => {
config["processContainer"] = json!({ "capabilities": ["internetClient"] });
}
"lxc" => {
config["lxc"] = json!({ "distribution": "alpine", "release": "3.20" });
}
backend @ ("windows_sandbox" | "isolation_session" | "vm") if !allowed_hosts.is_empty() => {
backend @ ("windows_sandbox" | "isolation_session" | "vm") if has_direct_egress => {
add_loss(
items,
"containment",
"error",
&format!("{backend} is not a v0 target for OpenShell network policy mapping."),
&format!(
"MXC 1.0 `{backend}` cannot enforce this direct directional egress policy."
),
"OpenShell network policy",
"MXC network behavior is unsupported or unknown for this backend.",
"The mapping must be rejected instead of sending an unenforceable network grant.",
);
}
"microvm" if !allowed_hosts.is_empty() => {
"microvm" | "hyperlight" if has_direct_egress => {
add_loss(
items,
"containment",
"error",
"microvm network policy enforcement is not defined for this mapper.",
"This experimental containment backend is outside the stable MXC 1.0 contract.",
"OpenShell network policy",
"MXC network behavior is unsupported or unknown for microvm.",
"The stable-schema mapping must be rejected.",
);
}
_ => {}
}
}
/// Add a backend-specific advisory about host-allowlist fidelity. Only fires
/// when the source policy declares network rules.
/// Reject direct network mapping on stable-schema backends that cannot enforce
/// the generated directional policy. Only fires when the source policy declares
/// network rules.
pub fn add_backend_network_loss(
policy: &SandboxPolicy,
containment: &str,
@@ -106,38 +93,40 @@ pub fn add_backend_network_loss(
return;
}
match containment {
"seatbelt" => add_loss(
"isolation_session" | "vm" | "windows_sandbox" | "microvm" | "hyperlight" => add_loss(
items,
"network_policies",
"error",
"MXC Seatbelt cannot faithfully enforce arbitrary allowedHosts.",
"host allowlist",
"Seatbelt allowlists can broaden to allow-all outbound.",
),
"processcontainer" | "process" => add_loss(
items,
"network_policies",
"warning",
"Windows ProcessContainer host allowlists are possible but fragile.",
"host allowlist",
"Review firewall/capability behavior before treating this as parity.",
),
"wslc" => add_loss(
items,
"network_policies",
"warning",
"WSLC host filtering relies on bridged networking plus in-container iptables.",
"host allowlist",
"Backend privileges and runner behavior determine parity.",
),
"vm" | "windows_sandbox" => add_loss(
items,
"network_policies",
"error",
"MXC Windows Sandbox / vm cannot faithfully enforce arbitrary allowedHosts.",
"host allowlist",
"Network policy enforcement is unsupported or unknown for this backend.",
"The selected backend cannot enforce stable MXC 1.0 directional egress rules for this mapping.",
"directional network policy",
"The caller must reject the mapping or use governed ProcessContainer egress.",
),
_ => {}
}
}
/// Reject filesystem grants on stable-schema backends that cannot enforce
/// them. Empty filesystem lists remain valid because they do not claim a grant.
pub fn add_backend_filesystem_loss(
policy: &SandboxPolicy,
containment: &str,
items: &mut Vec<LossItem>,
) {
let Some(filesystem) = &policy.filesystem else {
return;
};
if containment == "isolation_session"
&& (filesystem.include_workdir
|| !filesystem.read_only.is_empty()
|| !filesystem.read_write.is_empty())
{
add_loss(
items,
"filesystem_policy.grants",
"error",
"MXC 1.0 isolation_session cannot enforce OpenShell filesystem grants.",
"filesystem path grants",
"The caller must reject the mapping instead of silently dropping the grants.",
);
}
}
@@ -26,14 +26,14 @@ pub struct LossItem {
/// loss report so reviewers understand the mapping is not symmetric.
pub const OPEN_SHELL_SUPERSET_GAPS: &[&str] = &[
"MXC processContainer UI refinements have no portable OpenShell policy equivalent: isolation, desktopSystemControl, systemSettings, and ime.",
"MXC lifecycle fields have no OpenShell policy equivalent: destroyOnExit, preservePolicy, phase, and sandboxId.",
"MXC lifecycle fields destroyOnExit and preservePolicy have no OpenShell policy equivalent.",
"MXC backend selection and backend-specific blocks are outside OpenShell policy YAML.",
"MXC process command, cwd, env, and timeout are runtime config fields, not OpenShell policy fields.",
"MXC explicit deniedPaths are not expressible in current OpenShell policy YAML, which relies on default-deny filesystem behavior instead.",
"MXC fallback.allowDaclMutation (host DACL mutation consent) has no OpenShell policy equivalent.",
"MXC network.allowLocalNetwork (inbound bind/listen permission) has no OpenShell policy equivalent.",
"MXC network.proxy configuration has no OpenShell policy equivalent.",
"MXC experimental backend blocks (windows_sandbox, wslc, seatbelt, isolation_session) are outside OpenShell policy YAML.",
"MXC directional ingress and host-loopback actions have no direct OpenShell policy equivalent.",
"MXC runtimeConfig.networkProxy is runtime configuration rather than an OpenShell policy field.",
"MXC backend-specific configuration blocks are outside OpenShell policy YAML.",
];
pub fn add_loss(
+235 -84
View File
@@ -4,12 +4,12 @@
//! Coarse OpenShell-policy → MXC `ContainerConfig` mapping.
//!
//! Operates on the typed [`SandboxPolicy`] (parse it with
//! `openshell_policy::parse_sandbox_policy`). Network policy is flattened into
//! an MXC host allowlist; everything MXC cannot express is recorded as a loss
//! item. The top-level `network_policies` map is iterated in sorted key order
//! so the output is deterministic (the proto map is unordered).
//! `openshell_policy::parse_sandbox_policy`). Numeric destinations and ports
//! map to MXC 1.0 directional rules; everything MXC cannot express is recorded
//! as a loss item. The top-level `network_policies` map is iterated in sorted
//! key order so the output is deterministic (the proto map is unordered).
use std::net::SocketAddr;
use std::net::{IpAddr, SocketAddr};
use openshell_core::proto::{
NetworkEndpoint, NetworkPolicyRule, SandboxPolicy, UiClipboardAccess, UiPolicy,
@@ -17,19 +17,15 @@ use openshell_core::proto::{
use serde_json::{Value, json};
use super::config::{
DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, add_backend_network_loss,
add_backend_specific_config, default_enforcement_mode, filesystem_default_deny_message,
DEFAULT_COMMAND, DEFAULT_CONTAINMENT, DEFAULT_MXC_VERSION, add_backend_filesystem_loss,
add_backend_network_loss, add_backend_specific_config, filesystem_default_deny_message,
};
use super::loss::{LossItem, add_loss};
use crate::mxc::MXC_SCHEMA_VERSION;
/// Options controlling the generated MXC config. Fields not relevant to the
/// coarse map (e.g. `proxy_redirect`) are reserved for the governed-egress split.
#[derive(Clone, Debug)]
pub struct MxcMappingOptions {
/// MXC schema version written into coarse-map output. The governed-egress
/// split uses the driver's MXC 0.8 schema.
pub mxc_version: String,
/// MXC containment backend.
pub containment: String,
/// `process.commandLine` value.
@@ -42,8 +38,6 @@ pub struct MxcMappingOptions {
pub env: Vec<String>,
/// `process.timeout`.
pub timeout_ms: u64,
/// Emit `OpenShell` wildcard hosts into `allowedHosts` despite lossiness.
pub allow_wildcards: bool,
/// Governed-egress redirect address (used by the governed-egress split, not the
/// coarse map).
pub proxy_redirect: Option<SocketAddr>,
@@ -52,14 +46,12 @@ pub struct MxcMappingOptions {
impl Default for MxcMappingOptions {
fn default() -> Self {
Self {
mxc_version: DEFAULT_MXC_VERSION.to_owned(),
containment: DEFAULT_CONTAINMENT.to_owned(),
command: DEFAULT_COMMAND.to_owned(),
container_id: "openshell-policy".to_owned(),
cwd: None,
env: Vec::new(),
timeout_ms: 0,
allow_wildcards: false,
proxy_redirect: None,
}
}
@@ -146,6 +138,7 @@ fn build_split_mxc_config(
}
let filesystem = map_filesystem(policy, opts, items);
add_backend_filesystem_loss(policy, &opts.containment, items);
let proxy_supported = matches!(opts.containment.as_str(), "processcontainer" | "process");
if !proxy_supported {
@@ -176,7 +169,7 @@ fn build_split_mxc_config(
}
// Direct Internet egress is denied. Proxy-aware clients can reach only the
// OpenShell proxy (and other host loopback listeners) through 127.0.0.1.
if proxy_supported && proxy_addr.ip() != std::net::IpAddr::from([127, 0, 0, 1]) {
if proxy_supported && proxy_addr.ip() != IpAddr::from([127, 0, 0, 1]) {
add_loss(
items,
"proxy_redirect",
@@ -201,7 +194,7 @@ fn build_split_mxc_config(
});
let mut config = json!({
"version": MXC_SCHEMA_VERSION,
"version": DEFAULT_MXC_VERSION,
"containerId": opts.container_id,
"containment": opts.containment,
"lifecycle": {
@@ -218,7 +211,7 @@ fn build_split_mxc_config(
// No network hosts, so backend-specific network blocks (processContainer
// internetClient, etc.) are not added — correct for the proxy path.
add_backend_specific_config(&mut config, &opts.containment, &[], items);
add_backend_specific_config(&mut config, &opts.containment, false, items);
add_static_policy_loss(policy, opts, items);
config
}
@@ -240,19 +233,23 @@ fn build_mxc_config(
}
let filesystem = map_filesystem(policy, opts, items);
let allowed_hosts = map_network(policy, opts, items);
let mut network = json!({
"defaultPolicy": "block",
"allowedHosts": allowed_hosts,
"blockedHosts": [],
});
if let Some(mode) = default_enforcement_mode(&opts.containment, &allowed_hosts) {
network["enforcementMode"] = json!(mode);
add_backend_filesystem_loss(policy, &opts.containment, items);
let allow_rules = map_network(policy, opts, items);
let has_direct_egress = !allow_rules.is_empty();
let mut egress = json!({ "default": "deny" });
if has_direct_egress {
egress["allow"] = json!(allow_rules);
}
let network = json!({
"egress": egress,
"ingress": {
"default": "deny",
"hostLoopback": "deny",
},
});
let mut config = json!({
"version": opts.mxc_version,
"version": DEFAULT_MXC_VERSION,
"containerId": opts.container_id,
"containment": opts.containment,
"lifecycle": {
@@ -267,7 +264,7 @@ fn build_mxc_config(
config["ui"] = ui;
}
add_backend_specific_config(&mut config, &opts.containment, &allowed_hosts, items);
add_backend_specific_config(&mut config, &opts.containment, has_direct_egress, items);
add_static_policy_loss(policy, opts, items);
config
}
@@ -423,7 +420,7 @@ fn map_network(
policy: &SandboxPolicy,
opts: &MxcMappingOptions,
items: &mut Vec<LossItem>,
) -> Vec<String> {
) -> Vec<Value> {
if !policy.network_middlewares.is_empty() {
add_loss(
items,
@@ -447,7 +444,7 @@ fn map_network(
let mut rules: Vec<(&String, &NetworkPolicyRule)> = policy.network_policies.iter().collect();
rules.sort_by(|a, b| a.0.cmp(b.0));
let mut allowed_hosts: Vec<String> = Vec::new();
let mut allow_rules: Vec<Value> = Vec::new();
for (key, rule) in rules {
let rule_path = format!("network_policies.{key}");
@@ -459,12 +456,12 @@ fn map_network(
"error",
"OpenShell policy entry has no endpoints.",
"network endpoints",
"No MXC host allowlist entries were produced for this policy.",
"No MXC directional allow rules were produced for this policy.",
);
}
for (index, endpoint) in rule.endpoints.iter().enumerate() {
let endpoint_path = format!("{rule_path}.endpoints[{index}]");
map_endpoint(endpoint, &endpoint_path, &mut allowed_hosts, opts, items);
map_endpoint(endpoint, &endpoint_path, &mut allow_rules, items);
}
if rule.binaries.is_empty() {
@@ -494,43 +491,51 @@ fn map_network(
}
add_backend_network_loss(policy, &opts.containment, items);
allowed_hosts
allow_rules
}
fn map_endpoint(
endpoint: &NetworkEndpoint,
path: &str,
allowed_hosts: &mut Vec<String>,
opts: &MxcMappingOptions,
allow_rules: &mut Vec<Value>,
items: &mut Vec<LossItem>,
) {
// host
if endpoint.host.is_empty() {
add_loss(
items,
&format!("{path}.host"),
"error",
"Endpoint has no host.",
"network endpoint host",
"Endpoint was not added to MXC allowedHosts.",
);
} else if contains_wildcard(&endpoint.host) {
let (message, impact) = if opts.allow_wildcards {
append_unique(allowed_hosts, endpoint.host.clone());
let mut cidrs = Vec::new();
let numeric_host = if endpoint.host.is_empty() {
None
} else {
normalize_cidr(&endpoint.host)
};
if let Some(cidr) = &numeric_host {
append_unique(&mut cidrs, cidr.clone());
} else if endpoint.host.is_empty() {
if endpoint.allowed_ips.is_empty() {
add_loss(
items,
&format!("{path}.host"),
"error",
"Endpoint has neither a numeric host nor allowed_ips.",
"network endpoint destination",
"No MXC 1.0 directional allow rule is emitted.",
);
}
} else {
let (feature, message) = if contains_wildcard(&endpoint.host) {
(
"OpenShell wildcard host matching",
format!(
"Wildcard host emitted despite non-portable MXC semantics: {}.",
"Wildcard host '{}' cannot be represented by MXC 1.0 directional CIDR rules.",
endpoint.host
),
"Backend behavior is not portable and may fail or broaden access.",
)
} else {
(
"OpenShell DNS host matching",
format!(
"Wildcard host omitted because MXC has no portable syntax: {}.",
"DNS host '{}' cannot be represented by MXC 1.0 directional CIDR rules.",
endpoint.host
),
"Generated MXC config is more restrictive for this endpoint.",
)
};
add_loss(
@@ -538,51 +543,96 @@ fn map_endpoint(
&format!("{path}.host"),
"error",
&message,
"OpenShell wildcard host matching",
impact,
feature,
"The hostname is omitted; supply allowed_ips or use governed egress for DNS policy.",
);
} else {
append_unique(allowed_hosts, endpoint.host.clone());
}
// port / ports (the proto normalizes a single port into `ports`)
if !endpoint.ports.is_empty() {
let (field, repr) = if endpoint.ports.len() == 1 {
("port", endpoint.ports[0].to_string())
for (index, raw) in endpoint.allowed_ips.iter().enumerate() {
if let Some(cidr) = normalize_cidr(raw) {
append_unique(&mut cidrs, cidr);
} else {
("ports", format!("{:?}", endpoint.ports))
add_loss(
items,
&format!("{path}.allowed_ips[{index}]"),
"error",
&format!("'{raw}' is not a valid IP address or CIDR for MXC 1.0."),
"DNS result pinning / SSRF override",
"The invalid destination is omitted from the directional allow rule.",
);
}
}
if !endpoint.allowed_ips.is_empty() && numeric_host.is_none() {
let reason = if endpoint.host.is_empty() {
"OpenShell permits DNS names whose resolved address is in allowed_ips, while MXC also permits direct connections to those CIDRs."
} else {
"MXC can carry the CIDRs but cannot bind them to the requested DNS hostname."
};
add_loss(
items,
&format!("{path}.{field}"),
"error",
&format!("MXC allowedHosts cannot encode port constraint {repr}."),
"port-scoped outbound policy",
"MXC allows or blocks the host as a whole.",
);
}
// allowed_ips
for ip in &endpoint.allowed_ips {
append_unique(allowed_hosts, ip.clone());
add_loss(
items,
&format!("{path}.allowed_ips"),
"warning",
&format!(
"MXC can carry CIDR/IP '{ip}', but cannot bind it to DNS for '{}'.",
endpoint.host
),
"error",
reason,
"DNS result pinning / SSRF override",
"The CIDR/IP becomes a standalone allowed destination.",
"The valid CIDRs are emitted, but the caller must reject the coarse mapping or accept the broader direct-IP scope.",
);
}
report_endpoint_l7_losses(endpoint, path, items);
if cidrs.is_empty() {
return;
}
let selected_ports: Vec<u32> = if !endpoint.ports.is_empty() {
endpoint.ports.clone()
} else if endpoint.port > 0 {
vec![endpoint.port]
} else {
Vec::new()
};
let had_explicit_ports = !selected_ports.is_empty();
let mut valid_ports = Vec::new();
for (index, port) in selected_ports.into_iter().enumerate() {
if (1..=u32::from(u16::MAX)).contains(&port) {
if !valid_ports.contains(&port) {
valid_ports.push(port);
}
} else {
add_loss(
items,
&format!("{path}.ports[{index}]"),
"error",
&format!("Port {port} is outside the MXC 1.0 range 1..=65535."),
"port-scoped outbound policy",
"The invalid port is omitted from the directional allow rule.",
);
}
}
if had_explicit_ports && valid_ports.is_empty() {
return;
}
let port_rules: Vec<Value> = if valid_ports.is_empty() {
vec![json!({ "protocol": "tcp" })]
} else {
valid_ports
.into_iter()
.map(|port| json!({ "protocol": "tcp", "port": port }))
.collect()
};
let rule = json!({
"to": cidrs
.into_iter()
.map(|cidr| json!({ "cidr": cidr }))
.collect::<Vec<_>>(),
"ports": port_rules,
});
append_unique_value(allow_rules, rule);
}
fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut Vec<LossItem>) {
if !endpoint.protocol.is_empty() {
if !endpoint.protocol.is_empty() && endpoint.protocol != "tcp" {
add_loss(
items,
&format!("{path}.protocol"),
@@ -625,9 +675,9 @@ fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut
items,
&format!("{path}.enforcement"),
"warning",
"MXC enforcementMode is backend-wide, not per endpoint.",
"MXC 1.0 directional rules are enforce-only and have no per-endpoint enforcement selector.",
"per-endpoint enforcement",
"The mapper chooses a backend-level enforcement mode.",
"The generated directional rule is enforced.",
);
}
}
@@ -707,6 +757,73 @@ fn report_endpoint_l7_losses(endpoint: &NetworkEndpoint, path: &str, items: &mut
if endpoint.graphql_max_body_bytes > 0 {
add_graphql_loss(items, path, "graphql_max_body_bytes");
}
let string_losses: &[(&str, &str, &str)] = &[
(
endpoint.path.as_str(),
"path",
"path-scoped endpoint routing",
),
(
endpoint.credential_signing.as_str(),
"credential_signing",
"proxy-side credential signing",
),
(
endpoint.signing_service.as_str(),
"signing_service",
"credential-signing service selection",
),
(
endpoint.signing_region.as_str(),
"signing_region",
"credential-signing region selection",
),
];
for (value, field, feature) in string_losses {
if !value.is_empty() {
add_proxy_only_loss(items, path, field, feature);
}
}
if endpoint.advisor_proposed {
add_proxy_only_loss(items, path, "advisor_proposed", "policy-advisor provenance");
}
if endpoint.json_rpc_max_body_bytes > 0 {
add_proxy_only_loss(
items,
path,
"json_rpc_max_body_bytes",
"JSON-RPC request-size policy",
);
}
if endpoint.mcp.is_some() {
add_proxy_only_loss(items, path, "mcp", "MCP protocol policy");
}
if endpoint.credential_binding.is_some() {
add_proxy_only_loss(
items,
path,
"credential_binding",
"provider credential binding",
);
}
if endpoint.allow_uninspected_credentials {
add_proxy_only_loss(
items,
path,
"allow_uninspected_credentials",
"uninspected credential traffic approval",
);
}
if endpoint.provider_credentialed {
add_proxy_only_loss(
items,
path,
"provider_credentialed",
"provider credential provenance",
);
}
}
fn add_graphql_loss(items: &mut Vec<LossItem>, path: &str, field: &str) {
@@ -720,6 +837,17 @@ fn add_graphql_loss(items: &mut Vec<LossItem>, path: &str, field: &str) {
);
}
fn add_proxy_only_loss(items: &mut Vec<LossItem>, path: &str, field: &str, feature: &str) {
add_loss(
items,
&format!("{path}.{field}"),
"error",
&format!("MXC 1.0 has no equivalent for {feature}."),
feature,
"The coarse config preserves only its directional CIDR/port restriction; governed egress is required for this behavior.",
);
}
fn add_static_policy_loss(
policy: &SandboxPolicy,
opts: &MxcMappingOptions,
@@ -783,6 +911,29 @@ fn append_unique(list: &mut Vec<String>, value: String) {
}
}
fn append_unique_value(list: &mut Vec<Value>, value: Value) {
if !list.contains(&value) {
list.push(value);
}
}
fn normalize_cidr(value: &str) -> Option<String> {
let (address, prefix) = match value.split_once('/') {
Some((address, prefix)) => (address, Some(prefix.parse::<u8>().ok()?)),
None => (value, None),
};
let address = address.parse::<IpAddr>().ok()?;
let max_prefix: u8 = match address {
IpAddr::V4(_) => 32,
IpAddr::V6(_) => 128,
};
let prefix = prefix.unwrap_or(max_prefix);
if prefix > max_prefix {
return None;
}
Some(format!("{address}/{prefix}"))
}
fn contains_wildcard(host: &str) -> bool {
host.contains('*')
}
@@ -13,16 +13,19 @@
//!
//! Two mapping shapes are intended:
//!
//! - [`map_to_mxc`] — the *coarse / standalone* mapping. `OpenShell` network
//! policy is flattened into an MXC host allowlist (`network.allowedHosts`),
//! and anything MXC cannot express (ports, protocol, L7 rules, binary scope)
//! is recorded in the loss report. Use this when MXC enforces network on its
//! own, with no `OpenShell` proxy in the loop.
//! - [`map_to_mxc`] — the *coarse / standalone* mapping. Numeric destinations
//! and ports become MXC 1.0 directional CIDR rules; DNS, L7, and binary scope
//! that MXC cannot express are recorded in the loss report. Use this when MXC
//! enforces network on its own, with no `OpenShell` proxy in the loop.
//! - [`split_policy`] — the *lossless* split for the Windows MXC compute
//! driver: MXC handles filesystem + containment + loopback-only egress,
//! while the full `OpenShell` network policy is preserved in a trimmed policy
//! enforced by the host CONNECT proxy.
//!
//! Every generated MXC artifact uses the same stable 1.0 schema as the live
//! driver. The embedded driver may use a coarse mapping as an intermediate
//! policy translation but does not send that JSON to MXC.
//!
//! The report/loss-report helpers are only exercised by the example and the
//! integration tests, so the Windows lib build would otherwise warn on them;
//! `#![allow(dead_code)]` keeps the module quiet without per-item churn.
@@ -5,15 +5,18 @@
use serde_json::{Value, json};
use super::config::DEFAULT_MXC_VERSION;
use super::loss::{LossItem, OPEN_SHELL_SUPERSET_GAPS, summarize_missing_mxc};
/// Build the structured `loss-report.json` value.
/// Build the structured `loss-report.json` value for a generated mapper artifact.
///
/// `target.schemaVersion` is the same stable schema used by the live MXC
/// driver. The mapper does not expose a pre-1.0 compatibility mode.
pub fn build_loss_report(
source_policy: &str,
generated_config: &str,
items: &[LossItem],
schema_errors: &[LossItem],
mxc_version: &str,
containment: &str,
schema: Option<&str>,
) -> Value {
@@ -23,7 +26,7 @@ pub fn build_loss_report(
"sourcePolicy": source_policy,
"generatedConfig": generated_config,
"target": {
"schemaVersion": mxc_version,
"schemaVersion": DEFAULT_MXC_VERSION,
"containment": containment,
},
"schemaValidation": {
@@ -62,7 +65,19 @@ pub fn render_readme(source_policy: &str, report: &Value, config: &Value) -> Str
}
};
let allowed = join_strs(&config["network"]["allowedHosts"]);
let allowed = config["network"]["egress"]["allow"]
.as_array()
.into_iter()
.flatten()
.filter_map(|rule| rule["to"].as_array())
.flatten()
.filter_map(|peer| peer["cidr"].as_str())
.collect::<Vec<_>>();
let allowed = if allowed.is_empty() {
"(none)".to_owned()
} else {
allowed.join(", ")
};
let rw = join_strs(&config["filesystem"]["readwritePaths"]);
let ro = join_strs(&config["filesystem"]["readonlyPaths"]);
@@ -84,7 +99,7 @@ pub fn render_readme(source_policy: &str, report: &Value, config: &Value) -> Str
String::new(),
format!("- Containment: `{containment}`"),
format!("- Schema validation: `{schema_valid}`"),
format!("- Allowed hosts: `{allowed}`"),
format!("- Allowed CIDRs: `{allowed}`"),
format!("- Read-write paths: `{rw}`"),
format!("- Read-only paths: `{ro}`"),
String::new(),
@@ -131,7 +146,8 @@ pub fn render_readme(source_policy: &str, report: &Value, config: &Value) -> Str
"## Notes".to_owned(),
String::new(),
"- OpenShell network policies are binary-, port-, protocol-, and often L7-scoped.".to_owned(),
"- This coarse mapper emits only MXC host/IP/CIDR allowlists plus filesystem lists.".to_owned(),
"- This coarse mapper emits MXC 1.0 directional CIDR/port rules plus filesystem lists.".to_owned(),
"- DNS, binary scope, and L7 policy require governed egress and are reported as mapping errors.".to_owned(),
"- Treat any `error` item in the loss report as a semantic broadening or unsupported parity gap.".to_owned(),
String::new(),
]);
@@ -11,8 +11,8 @@
//! Byte-for-byte parity with the previous raw-YAML mapper is intentionally
//! *not* asserted: routing through the canonical typed `SandboxPolicy`
//! normalizes ports and uses an unordered proto map (we sort keys). Instead we
//! assert the substantive invariants — filesystem fidelity, the host allowlist,
//! deny-by-default, and that broadening features are flagged as losses.
//! assert the substantive invariants — filesystem fidelity, MXC 1.0 directional
//! networking, deny-by-default, and explicit losses for proxy-only behavior.
#![cfg(target_os = "windows")]
@@ -56,6 +56,17 @@ fn str_list(value: &Value) -> Vec<String> {
.unwrap_or_default()
}
fn allowed_cidrs(config: &Value) -> Vec<String> {
config["network"]["egress"]["allow"]
.as_array()
.into_iter()
.flatten()
.filter_map(|rule| rule["to"].as_array())
.flatten()
.filter_map(|peer| peer["cidr"].as_str().map(str::to_owned))
.collect()
}
fn proxy_addr() -> std::net::SocketAddr {
"127.0.0.1:18080".parse().unwrap()
}
@@ -80,13 +91,14 @@ fn all_example_policies_map_with_invariants() {
let result = map_to_mxc(&policy, &MxcMappingOptions::default());
let cfg = &result.config;
// Deny-by-default network posture is always emitted.
assert_eq!(
cfg["network"]["defaultPolicy"],
"block",
"{} must emit defaultPolicy=block",
path.display()
);
// Stable MXC 1.0 directional deny-by-default posture is always emitted.
assert_eq!(cfg["version"], "1.0.0", "{} schema", path.display());
assert_eq!(cfg["network"]["egress"]["default"], "deny");
assert_eq!(cfg["network"]["ingress"]["default"], "deny");
assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "deny");
assert!(cfg["network"].get("defaultPolicy").is_none());
assert!(cfg["network"].get("allowedHosts").is_none());
assert!(cfg["network"].get("enforcementMode").is_none());
// Filesystem fidelity: read_write / read_only copied exactly.
if let Some(fs) = &policy.filesystem {
@@ -104,14 +116,19 @@ fn all_example_policies_map_with_invariants() {
);
}
// Every non-wildcard endpoint host appears in allowedHosts.
let allowed = str_list(&cfg["network"]["allowedHosts"]);
// Hostname policies stay fail-closed and require governed egress; the
// coarse artifact contains only numeric CIDRs.
let allowed = allowed_cidrs(cfg);
for rule in policy.network_policies.values() {
for ep in &rule.endpoints {
if !ep.host.is_empty() && !ep.host.contains('*') {
if !ep.host.is_empty() && ep.host.parse::<std::net::IpAddr>().is_err() {
assert!(
allowed.contains(&ep.host),
"{} missing host {} in allowedHosts",
result.loss.iter().any(|item| {
item.severity == "error"
&& item.path.contains(".host")
&& item.message.contains(&ep.host)
}),
"{} missing DNS/glob loss for {}",
path.display(),
ep.host
);
@@ -119,14 +136,14 @@ fn all_example_policies_map_with_invariants() {
}
}
// allowedHosts is deduplicated.
// Directional destination CIDRs are deduplicated.
let mut sorted = allowed.clone();
sorted.sort();
sorted.dedup();
assert_eq!(
sorted.len(),
allowed.len(),
"duplicate hosts for {}",
"duplicate CIDRs for {}",
path.display()
);
@@ -210,7 +227,7 @@ fn all_example_policies_split_with_expected_invariants() {
assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty());
}
assert_eq!(cfg["version"], "0.8.0-alpha");
assert_eq!(cfg["version"], "1.0.0");
assert_eq!(cfg["network"]["egress"]["default"], "deny");
assert_eq!(
cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"],
@@ -221,16 +238,10 @@ fn all_example_policies_split_with_expected_invariants() {
assert!(cfg["network"].get("proxy").is_none());
assert!(
!result.loss.iter().any(|item| item.severity == "error"),
"processcontainer split must not emit error losses for {}: {:?}",
"processcontainer split must delegate middleware to the supervisor for {}: {:?}",
path.display(),
result.loss
);
assert_eq!(
result.proxy_policy.network_middlewares,
policy.network_middlewares,
"supervisor handoff must preserve middleware for {}",
path.display()
);
}
}
@@ -242,24 +253,22 @@ fn quickstart_coarse_mapping() {
let result = map_to_mxc(&policy, &MxcMappingOptions::default());
let cfg = &result.config;
assert_eq!(
str_list(&cfg["network"]["allowedHosts"]),
vec!["api.github.com".to_owned()]
);
assert!(allowed_cidrs(cfg).is_empty());
assert_eq!(
str_list(&cfg["filesystem"]["readwritePaths"]),
vec!["/sandbox", "/tmp", "/dev/null"]
);
assert_eq!(cfg["containment"], "bubblewrap");
// The github_api endpoint loses port, protocol, access, and binary scope.
// DNS, protocol, access, and binary scope require governed egress. The port
// itself is representable but no directional rule is emitted without a CIDR.
let has = |severity: &str, needle: &str| {
result
.loss
.iter()
.any(|i| i.severity == severity && i.path.contains(needle))
};
assert!(has("error", "endpoints[0].port"), "expected port loss");
assert!(has("error", "endpoints[0].host"), "expected DNS host loss");
assert!(
has("error", "endpoints[0].protocol"),
"expected protocol loss"
@@ -385,11 +394,19 @@ fn split_policy_rejects_proxy_redirect_on_isolation_session() {
.collect();
assert_eq!(
errors.len(),
1,
"expected one containment error: {errors:?}"
2,
"expected filesystem and containment errors: {errors:?}"
);
assert_eq!(errors[0].path, "containment");
assert!(errors[0].message.contains("processcontainer"));
let filesystem = errors
.iter()
.find(|item| item.path == "filesystem_policy.grants")
.expect("isolation_session filesystem error");
assert!(filesystem.message.contains("cannot enforce"));
let containment = errors
.iter()
.find(|item| item.path == "containment")
.expect("isolation_session proxy-containment error");
assert!(containment.message.contains("processcontainer"));
assert!(result.mxc_config["network"].get("proxy").is_none());
}
@@ -404,16 +421,18 @@ fn network_only_policy_has_empty_filesystem() {
assert!(str_list(&cfg["filesystem"]["readwritePaths"]).is_empty());
assert!(str_list(&cfg["filesystem"]["readonlyPaths"]).is_empty());
assert_eq!(
str_list(&cfg["network"]["allowedHosts"]),
vec!["api.anthropic.com".to_owned()]
);
assert!(allowed_cidrs(cfg).is_empty());
assert!(result.loss.iter().any(|item| {
item.severity == "error"
&& item.path.contains(".host")
&& item.message.contains("api.anthropic.com")
}));
}
// ── New tests: proxy JSON shape and non-127.0.0.1 guard ──────────────────────
#[test]
fn split_with_loopback_addr_emits_loopback_only_08_shape() {
fn split_with_loopback_addr_emits_loopback_only_1_0_shape() {
let path = examples_root().join("sandbox-policy-quickstart/policy.yaml");
let yaml = std::fs::read_to_string(&path).expect("read quickstart");
let policy = parse_sandbox_policy(&yaml).expect("parse quickstart");
@@ -426,7 +445,7 @@ fn split_with_loopback_addr_emits_loopback_only_08_shape() {
let result = split_policy(&policy, &opts).expect("split returns Some");
let cfg = &result.mxc_config;
assert_eq!(cfg["version"], "0.8.0-alpha");
assert_eq!(cfg["version"], "1.0.0");
assert_eq!(cfg["network"]["egress"]["default"], "deny");
assert_eq!(
cfg["network"]["egress"]["allow"][0]["to"][0]["cidr"],
@@ -22,13 +22,14 @@
)]
use openshell_core::proto::{
FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy,
MiddlewareEndpointSelector, NetworkBinary, NetworkEndpoint, NetworkMiddlewareConfig,
NetworkPolicyRule, ProcessPolicy, SandboxPolicy, UiClipboardAccess, UiPolicy,
FilesystemPolicy, GraphqlOperation, L7Allow, L7DenyRule, L7Rule, LandlockPolicy, McpOptions,
MiddlewareEndpointSelector, NetworkAccessPreset, NetworkBinary, NetworkCredentialBinding,
NetworkEndpoint, NetworkEnforcementMode, NetworkMiddlewareConfig, NetworkPolicyRule,
NetworkTlsMode, ProcessPolicy, SandboxPolicy, UiClipboardAccess, UiPolicy,
};
use openshell_driver_mxc::{
EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper, map_to_mxc,
split_policy,
DEFAULT_MXC_VERSION, EmbeddedPolicyMapper, MapCtx, MapError, MxcMappingOptions, PolicyMapper,
map_to_mxc, split_policy,
};
use openshell_policy::{serialize_sandbox_policy, validate_sandbox_policy};
use serde_json::Value;
@@ -59,6 +60,17 @@ fn str_list(v: &Value) -> Vec<String> {
.unwrap_or_default()
}
fn allowed_cidrs(config: &Value) -> Vec<String> {
config["network"]["egress"]["allow"]
.as_array()
.into_iter()
.flatten()
.filter_map(|rule| rule["to"].as_array())
.flatten()
.filter_map(|peer| peer["cidr"].as_str().map(str::to_owned))
.collect()
}
fn default_opts() -> MxcMappingOptions {
MxcMappingOptions::default() // bubblewrap containment
}
@@ -132,6 +144,27 @@ fn assert_single_loss(
// ─── QUADRANT A: mappable fields, assert exact MXC output ───────────────────
/// The standalone mapper and governed-egress path both emit the stable MXC 1.0
/// directional network schema.
#[test]
fn a_schema_versions_use_the_same_stable_network_shape() {
let policy = SandboxPolicy::default();
let coarse = map_to_mxc(&policy, &default_opts()).config;
assert_eq!(DEFAULT_MXC_VERSION, "1.0.0");
assert_eq!(coarse["version"], DEFAULT_MXC_VERSION);
assert!(coarse["network"].get("egress").is_some());
assert!(coarse["network"].get("ingress").is_some());
assert!(coarse["network"].get("allowedHosts").is_none());
assert!(coarse["network"].get("defaultPolicy").is_none());
let governed = split_policy(&policy, &pc_split_opts())
.expect("governed split must exist when a proxy redirect is configured")
.mxc_config;
assert_eq!(governed["version"], DEFAULT_MXC_VERSION);
assert!(governed["network"].get("allowedHosts").is_none());
assert!(governed["network"].get("egress").is_some());
}
/// filesystem.read_write → readwritePaths verbatim, order preserved.
#[test]
fn a_rw_paths_verbatim() {
@@ -239,27 +272,28 @@ fn a_include_workdir_no_cwd_emits_info_loss() {
assert_eq!(rw, vec!["/work"]);
}
/// Plain endpoint host → appears in allowedHosts.
/// Numeric endpoint host → normalized into a directional CIDR rule.
#[test]
fn a_plain_host_in_allowed_hosts() {
fn a_numeric_host_in_directional_allow_rule() {
let policy = net_policy(
"api",
NetworkEndpoint {
host: "api.example.com".into(),
host: "198.51.100.7".into(),
..Default::default()
},
);
let r = map_to_mxc(&policy, &bubblewrap_opts());
let hosts = str_list(&r.config["network"]["allowedHosts"]);
assert!(
hosts.contains(&"api.example.com".to_owned()),
"host must appear in allowedHosts; got: {hosts:?}"
assert_eq!(allowed_cidrs(&r.config), vec!["198.51.100.7/32"]);
assert_eq!(
r.config["network"]["egress"]["allow"][0]["ports"][0]["protocol"],
"tcp"
);
}
/// endpoint.allowed_ips → each IP appended to allowedHosts + a "warning" loss.
/// endpoint.allowed_ips → valid destinations become CIDRs, while the lost DNS
/// binding remains a fail-closed mapping error.
#[test]
fn a_allowed_ips_appended_with_warning() {
fn a_allowed_ips_map_to_cidrs_with_dns_binding_error() {
let policy = net_policy(
"api",
NetworkEndpoint {
@@ -269,37 +303,21 @@ fn a_allowed_ips_appended_with_warning() {
},
);
let r = map_to_mxc(&policy, &bubblewrap_opts());
let hosts = str_list(&r.config["network"]["allowedHosts"]);
assert!(
hosts.contains(&"10.0.0.1".to_owned()),
"IP 10.0.0.1 must be in allowedHosts"
);
assert!(
hosts.contains(&"10.0.0.2".to_owned()),
"IP 10.0.0.2 must be in allowedHosts"
);
let warnings: Vec<_> = r
.loss
.iter()
.filter(|i| i.severity == "warning" && i.path.contains("allowed_ips"))
.collect();
assert!(
!warnings.is_empty(),
"expected warning loss for allowed_ips; got: {:?}",
r.loss
);
assert_eq!(allowed_cidrs(&r.config), vec!["10.0.0.1/32", "10.0.0.2/32"]);
assert_single_loss(&r.loss, ".host", "error", "DNS host binding");
assert_single_loss(&r.loss, ".allowed_ips", "error", "allowed_ips scope");
}
/// Duplicate hosts across rules → deduplicated in allowedHosts.
/// Duplicate directional rules are deduplicated.
#[test]
fn a_duplicate_hosts_deduplicated() {
fn a_duplicate_directional_rules_deduplicated() {
let mut policy = SandboxPolicy::default();
let ep_a = NetworkEndpoint {
host: "shared.example.com".into(),
host: "203.0.113.10".into(),
..Default::default()
};
let ep_b = NetworkEndpoint {
host: "shared.example.com".into(),
host: "203.0.113.10".into(),
..Default::default()
};
policy.network_policies.insert(
@@ -319,14 +337,13 @@ fn a_duplicate_hosts_deduplicated() {
},
);
let r = map_to_mxc(&policy, &bubblewrap_opts());
let hosts = str_list(&r.config["network"]["allowedHosts"]);
let count = hosts
.iter()
.filter(|h| h.as_str() == "shared.example.com")
.count();
assert_eq!(allowed_cidrs(&r.config), vec!["203.0.113.10/32"]);
assert_eq!(
count, 1,
"duplicate hosts must be deduplicated; got: {hosts:?}"
r.config["network"]["egress"]["allow"]
.as_array()
.unwrap()
.len(),
1
);
}
@@ -390,13 +407,13 @@ fn a_split_network_verbatim_and_version_preserved() {
);
}
/// Split path emits MXC 0.8 loopback-only governed-egress fields.
/// Split path emits MXC 1.0 loopback-only governed-egress fields.
#[test]
fn a_split_proxy_uses_loopback_only_08_fields() {
fn a_split_proxy_uses_loopback_only_1_0_fields() {
let policy = SandboxPolicy::default();
let result = split_policy(&policy, &pc_split_opts()).expect("split must return Some");
assert!(result.mxc_config.get("runtimeConfig").is_none());
assert_eq!(result.mxc_config["version"], "0.8.0-alpha");
assert_eq!(result.mxc_config["version"], "1.0.0");
assert_eq!(result.mxc_config["network"]["egress"]["default"], "deny");
assert_eq!(
result.mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"],
@@ -411,19 +428,48 @@ fn a_split_proxy_uses_loopback_only_08_fields() {
// containment, assert exactly one loss item with the documented path fragment
// and severity.
/// endpoint.ports → "error" (path contains ".port").
/// endpoint.ports → exact MXC 1.0 TCP destination-port selectors.
#[test]
fn b_ports_error() {
fn a_ports_map_to_directional_rules() {
let policy = net_policy(
"r",
NetworkEndpoint {
host: "api.example.com".into(),
ports: vec![443],
host: "198.51.100.7".into(),
ports: vec![80, 443],
..Default::default()
},
);
let r = map_to_mxc(&policy, &bubblewrap_opts());
assert_single_loss(&r.loss, ".port", "error", "endpoint.ports");
assert_eq!(
r.config["network"]["egress"]["allow"][0]["ports"],
serde_json::json!([
{ "protocol": "tcp", "port": 80 },
{ "protocol": "tcp", "port": 443 }
])
);
assert!(r.loss.iter().all(|item| !item.path.contains(".port")));
}
#[test]
fn b_invalid_ports_are_rejected_without_broadening() {
let policy = net_policy(
"r",
NetworkEndpoint {
host: "198.51.100.7".into(),
ports: vec![0, 65_536],
..Default::default()
},
);
let result = map_to_mxc(&policy, &bubblewrap_opts());
assert!(allowed_cidrs(&result.config).is_empty());
assert_eq!(
result
.loss
.iter()
.filter(|item| item.path.contains(".ports[") && item.severity == "error")
.count(),
2
);
}
/// endpoint.protocol → "error".
@@ -448,7 +494,7 @@ fn b_tls_skip_warning() {
"r",
NetworkEndpoint {
host: "api.example.com".into(),
tls: openshell_core::proto::NetworkTlsMode::Skip as i32,
tls: NetworkTlsMode::Skip as i32,
..Default::default()
},
);
@@ -458,13 +504,13 @@ fn b_tls_skip_warning() {
/// endpoint.tls = "full" (any non-skip) → "error".
#[test]
#[allow(deprecated)]
#[allow(deprecated)] // Exercise rejection of an authored pre-1.0 TLS mode.
fn b_tls_non_skip_error() {
let policy = net_policy(
"r",
NetworkEndpoint {
host: "api.example.com".into(),
tls: openshell_core::proto::NetworkTlsMode::Terminate as i32,
tls: NetworkTlsMode::Terminate as i32,
..Default::default()
},
);
@@ -479,7 +525,7 @@ fn b_enforcement_audit_error() {
"r",
NetworkEndpoint {
host: "api.example.com".into(),
enforcement: openshell_core::proto::NetworkEnforcementMode::Audit as i32,
enforcement: NetworkEnforcementMode::Audit as i32,
..Default::default()
},
);
@@ -499,7 +545,7 @@ fn b_enforcement_non_audit_warning() {
"r",
NetworkEndpoint {
host: "api.example.com".into(),
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
enforcement: NetworkEnforcementMode::Enforce as i32,
..Default::default()
},
);
@@ -519,7 +565,7 @@ fn b_access_error() {
"r",
NetworkEndpoint {
host: "api.example.com".into(),
access: openshell_core::proto::NetworkAccessPreset::ReadOnly as i32,
access: NetworkAccessPreset::ReadOnly as i32,
..Default::default()
},
);
@@ -686,9 +732,9 @@ fn b_graphql_max_body_bytes_error() {
);
}
/// Wildcard host ("*.example.com") with allow_wildcards=false → "error", host NOT in allowedHosts.
/// Wildcard hosts are not representable in MXC 1.0 directional CIDR rules.
#[test]
fn b_wildcard_host_deny_wildcards_error_host_absent() {
fn b_wildcard_host_error_and_no_allow_rule() {
let policy = net_policy(
"r",
NetworkEndpoint {
@@ -696,13 +742,7 @@ fn b_wildcard_host_deny_wildcards_error_host_absent() {
..Default::default()
},
);
let opts = MxcMappingOptions {
allow_wildcards: false,
containment: "bubblewrap".to_owned(),
..Default::default()
};
let r = map_to_mxc(&policy, &opts);
// Must have an "error" loss for the wildcard host.
let r = map_to_mxc(&policy, &bubblewrap_opts());
let err_loss: Vec<_> = r
.loss
.iter()
@@ -713,47 +753,52 @@ fn b_wildcard_host_deny_wildcards_error_host_absent() {
"expected error loss for wildcard host; got: {:?}",
r.loss
);
// Host must NOT be in allowedHosts when allow_wildcards=false.
let hosts = str_list(&r.config["network"]["allowedHosts"]);
assert!(
!hosts.contains(&"*.example.com".to_owned()),
"wildcard host must be absent from allowedHosts when allow_wildcards=false; got: {hosts:?}"
allowed_cidrs(&r.config).is_empty(),
"wildcard host must not create an MXC directional allow rule"
);
}
/// Wildcard host with allow_wildcards=true → "error" (semantics warning), host IS in allowedHosts.
#[test]
fn b_wildcard_host_allow_wildcards_error_host_present() {
fn b_proxy_only_endpoint_fields_each_emit_error() {
let policy = net_policy(
"r",
NetworkEndpoint {
host: "*.example.com".into(),
host: "198.51.100.7".into(),
path: "/v1/**".into(),
advisor_proposed: true,
credential_signing: "sigv4".into(),
signing_service: "bedrock".into(),
signing_region: "us-west-2".into(),
json_rpc_max_body_bytes: 65_536,
mcp: Some(McpOptions {
strict_tool_names: Some(true),
allow_all_known_mcp_methods: Some(false),
versions: vec!["2025-11-25".into()],
}),
credential_binding: Some(NetworkCredentialBinding {
provider: "aws".into(),
}),
allow_uninspected_credentials: true,
provider_credentialed: true,
..Default::default()
},
);
let opts = MxcMappingOptions {
allow_wildcards: true,
containment: "bubblewrap".to_owned(),
..Default::default()
};
let r = map_to_mxc(&policy, &opts);
// Still an "error" loss (MXC semantics warning), even though we emitted the host.
let err_loss: Vec<_> = r
.loss
.iter()
.filter(|i| i.severity == "error" && i.path.contains(".host"))
.collect();
assert!(
!err_loss.is_empty(),
"expected error loss for wildcard host even with allow_wildcards=true; got: {:?}",
r.loss
);
// Host IS in allowedHosts when allow_wildcards=true.
let hosts = str_list(&r.config["network"]["allowedHosts"]);
assert!(
hosts.contains(&"*.example.com".to_owned()),
"wildcard host must appear in allowedHosts when allow_wildcards=true; got: {hosts:?}"
);
let result = map_to_mxc(&policy, &bubblewrap_opts());
for field in [
"path",
"advisor_proposed",
"credential_signing",
"signing_service",
"signing_region",
"json_rpc_max_body_bytes",
"mcp",
"credential_binding",
"allow_uninspected_credentials",
"provider_credentialed",
] {
assert_single_loss(&result.loss, field, "error", field);
}
}
/// rule.binaries non-empty → "error" per binary.
@@ -909,12 +954,12 @@ fn b_run_as_group_warning() {
assert_single_loss(&r.loss, "run_as_group", "warning", "process.run_as_group");
}
/// Seam-level: EmbeddedPolicyMapper.map over a policy with one error-class field
/// (a port) via MapCtx{egress: None} returns Err(MapError::Unsupported(_)).
/// Seam-level: EmbeddedPolicyMapper.map over a policy with unsupported fields
/// via MapCtx{egress: None} returns Err(MapError::Unsupported(_)).
#[test]
fn b_seam_returns_unsupported_on_error_field() {
let mapper = EmbeddedPolicyMapper;
// isolation_session containment + network policy → error loss from add_backend_specific_config.
// isolation_session containment cannot enforce filesystem or network policy.
let mut policy = SandboxPolicy {
filesystem: Some(FilesystemPolicy {
read_write: vec!["C:/work".into()],
@@ -946,6 +991,30 @@ fn b_seam_returns_unsupported_on_error_field() {
);
}
#[test]
fn b_isolation_session_reports_filesystem_grants_as_error() {
let policy = SandboxPolicy {
filesystem: Some(FilesystemPolicy {
read_write: vec!["C:/work".into()],
..Default::default()
}),
..Default::default()
};
let result = map_to_mxc(
&policy,
&MxcMappingOptions {
containment: "isolation_session".into(),
..Default::default()
},
);
assert_single_loss(
&result.loss,
"filesystem_policy.grants",
"error",
"isolation_session filesystem grants",
);
}
// ─── QUADRANT C: restrictive defaults ("default deny posture") ───────────────
/// Empty SandboxPolicy (all None/empty) with default options produces the most
@@ -956,19 +1025,15 @@ fn c_empty_policy_default_deny_posture() {
let r = map_to_mxc(&policy, &bubblewrap_opts());
let cfg = &r.config;
// Network: default deny, no allowed/blocked hosts.
assert_eq!(
cfg["network"]["defaultPolicy"], "block",
"defaultPolicy must be 'block' on empty policy"
);
assert!(
str_list(&cfg["network"]["allowedHosts"]).is_empty(),
"allowedHosts must be [] on empty policy"
);
assert!(
str_list(&cfg["network"]["blockedHosts"]).is_empty(),
"blockedHosts must be [] on empty policy"
);
// Network: stable 1.0 directional default deny, with no allow/deny rules.
assert_eq!(cfg["network"]["egress"]["default"], "deny");
assert!(cfg["network"]["egress"].get("allow").is_none());
assert!(cfg["network"]["egress"].get("deny").is_none());
assert_eq!(cfg["network"]["ingress"]["default"], "deny");
assert_eq!(cfg["network"]["ingress"]["hostLoopback"], "deny");
assert!(cfg["network"].get("defaultPolicy").is_none());
assert!(cfg["network"].get("allowedHosts").is_none());
assert!(cfg["network"].get("blockedHosts").is_none());
// UI: fully locked down.
assert_eq!(cfg["ui"]["disable"], true, "ui.disable must be true");
@@ -1008,11 +1073,7 @@ fn c_empty_policy_default_deny_posture() {
"processContainer must be absent when no hosts are mapped"
);
// No enforcementMode key when allowedHosts is empty.
assert!(
cfg["network"].get("enforcementMode").is_none(),
"enforcementMode must be absent when allowedHosts is empty"
);
assert!(cfg["network"].get("enforcementMode").is_none());
}
/// Split path with network rules present: unsupported host lists stay absent.
@@ -1199,15 +1260,14 @@ const HANDLED_RULE_KEYS: &[&str] = &["name", "endpoints", "binaries"];
/// Per-endpoint keys that the mapper accounts for (mapping or loss item).
///
/// "host" — mapped to allowedHosts (or loss if wildcard/empty)
/// "port" — normalized to ports at parse; covered by ports loss
/// "ports" — error loss
/// "host" — numeric values map to directional CIDRs; DNS/globs are errors
/// "port" / "ports" — mapped to directional TCP port selectors
/// "protocol" — error loss
/// "tls" — warning (skip) or error loss
/// "enforcement" — error (audit) or warning (other) loss
/// "access" — error loss
/// "rules" — error loss
/// "allowed_ips" — appended to allowedHosts + warning loss
/// "allowed_ips" — mapped to directional CIDRs plus binding/scope error
/// "deny_rules" — error loss
/// "allow_encoded_slash" — error loss
/// "websocket_credential_rewrite" — error loss
@@ -1215,11 +1275,7 @@ const HANDLED_RULE_KEYS: &[&str] = &["name", "endpoints", "binaries"];
/// "persisted_queries" — error loss
/// "graphql_persisted_queries" — error loss
/// "graphql_max_body_bytes" — error loss
/// "path" — not currently read by the mapper (no loss emitted);
/// included here so the drift guard does not trip on
/// existing schema fields the mapper silently ignores.
/// If the mapper needs to enforce path-scoped routing,
/// remove this entry and add an explicit loss item.
/// "path" and credential/L7 config — explicit governed-egress-only errors
const HANDLED_ENDPOINT_KEYS: &[&str] = &[
"host",
"port",
@@ -1238,6 +1294,13 @@ const HANDLED_ENDPOINT_KEYS: &[&str] = &[
"graphql_persisted_queries",
"graphql_max_body_bytes",
"path",
"allow_uninspected_credentials",
"credential_signing",
"signing_service",
"signing_region",
"credential_binding",
"json_rpc",
"mcp",
];
#[test]
@@ -1333,15 +1396,22 @@ fn handled_fields_inventory() {
// Two ports → serializes as `ports: [80, 443]` (array form).
ports: vec![80, 443],
protocol: "graphql".into(),
tls: openshell_core::proto::NetworkTlsMode::Skip as i32,
enforcement: openshell_core::proto::NetworkEnforcementMode::Enforce as i32,
access: openshell_core::proto::NetworkAccessPreset::Full as i32,
tls: NetworkTlsMode::Skip as i32,
enforcement: NetworkEnforcementMode::Enforce as i32,
access: NetworkAccessPreset::Full as i32,
allowed_ips: vec!["10.0.0.1".into()],
allow_encoded_slash: true,
websocket_credential_rewrite: true,
request_body_credential_rewrite: true,
allow_uninspected_credentials: true,
persisted_queries: "deny".into(),
graphql_max_body_bytes: 65536,
graphql_max_body_bytes: 65_536,
credential_signing: "sigv4".into(),
signing_service: "bedrock".into(),
signing_region: "us-west-2".into(),
credential_binding: Some(NetworkCredentialBinding {
provider: "aws".into(),
}),
rules: vec![L7Rule {
allow: Some(L7Allow {
method: "GET".into(),
@@ -1369,6 +1439,23 @@ fn handled_fields_inventory() {
ports: vec![443],
..Default::default()
};
let mcp_ep = NetworkEndpoint {
host: "mcp.example.com".into(),
protocol: "mcp".into(),
json_rpc_max_body_bytes: 131_072,
mcp: Some(McpOptions {
strict_tool_names: Some(true),
allow_all_known_mcp_methods: Some(false),
versions: vec!["2025-11-25".into()],
}),
..Default::default()
};
let json_rpc_ep = NetworkEndpoint {
host: "rpc.example.com".into(),
protocol: "json-rpc".into(),
json_rpc_max_body_bytes: 65_536,
..Default::default()
};
let full_rule_policy = SandboxPolicy {
version: 1,
@@ -1378,7 +1465,7 @@ fn handled_fields_inventory() {
"rule".to_owned(),
NetworkPolicyRule {
name: "rule".to_owned(),
endpoints: vec![full_ep, single_port_ep],
endpoints: vec![full_ep, single_port_ep, mcp_ep, json_rpc_ep],
binaries: vec![NetworkBinary {
path: "/usr/bin/curl".into(),
}],
+177 -203
View File
@@ -15,9 +15,9 @@
//! Two families:
//!
//! **(a) Dry-run contract tests** — exercise `--dry-run` only; pass/fail on
//! schema acceptance. These pass on this box even though no enforcement
//! backend is live (dry-run validates the JSON schema without spinning up the
//! `AppContainer` or isolation session).
//! schema acceptance. Most require no live enforcement backend. Tests that
//! request a host capability such as bidirectional host loopback probe that
//! capability first because MXC validates platform support during dry-run.
//!
//! **(b) Enforcement tests** — probe-gated; print a human-readable SKIP reason
//! and return early when the backend is not live. The probe distinguishes
@@ -135,8 +135,10 @@ fn wxc_version(wxc: &Path) -> Option<(u64, u64, u64, String)> {
// ── (a) Dry-run contract tests ────────────────────────────────────────────────
//
// These PASS on any box that has the wxc-exec binary — no enforcement backend
// is required because --dry-run only validates the JSON schema.
// Most PASS on any box that has the wxc-exec binary — no enforcement backend is
// required. MXC also validates requested platform capabilities during dry-run,
// so capability-specific configs probe those capabilities before asserting
// schema acceptance.
/// Minimal processcontainer one-shot config accepted by `--dry-run`.
#[test]
@@ -149,7 +151,7 @@ fn dryrun_accepts_minimal_processcontainer_config() {
let (_tempdir, temp_path) = temp_fixture();
let config = serde_json::json!({
"version": "0.6.0-alpha",
"version": "1.0.0",
"containerId": "test-minimal",
"containment": "processcontainer",
"process": {
@@ -183,7 +185,7 @@ fn dryrun_accepts_processcontainer_ui_policy_matrix() {
let temp_path = tempdir.path().to_string_lossy().into_owned();
for clipboard in ["none", "read", "write", "all"] {
let config = serde_json::json!({
"version": "0.7.0-alpha",
"version": "1.0.0",
"containerId": format!("test-ui-{clipboard}"),
"containment": "processcontainer",
"process": {
@@ -209,10 +211,9 @@ fn dryrun_accepts_processcontainer_ui_policy_matrix() {
}
}
/// MXC 0.8 and the 0.9 development schema reject the shared top-level
/// UI object on `isolation_session`, while omission remains accepted. Older
/// 0.7 builds accepted and ignored the object, so `OpenShell`'s gateway-level
/// capability check is the stable enforcement boundary across versions.
/// MXC 1.0 rejects the shared top-level UI object on `isolation_session`, while
/// omission remains accepted. `OpenShell`'s gateway-level capability check is
/// the stable enforcement boundary.
#[test]
#[ignore = "requires real wxc-exec"]
fn dryrun_current_schema_rejects_isolation_session_ui() {
@@ -224,31 +225,20 @@ fn dryrun_current_schema_rejects_isolation_session_ui() {
eprintln!("SKIP: could not determine wxc-exec version");
return;
};
if (major, minor) < (0, 8) {
if (major, minor) < (1, 0) {
eprintln!("SKIP: {raw_version} predates the isolation_session UI rejection contract");
return;
}
let mut base = serde_json::json!({
"version": "0.8.0-alpha",
"phase": "provision",
let base = serde_json::json!({
"version": "1.0.0",
"containment": "isolation_session",
"network": {
"defaultPolicy": "allow",
"allowLocalNetwork": true,
},
});
let args: &[&str] = if (major, minor) >= (0, 9) {
base.as_object_mut().unwrap().remove("phase");
base["version"] = serde_json::json!("0.9.0-alpha");
base["network"] = serde_json::json!({
"egress": { "default": "allow" },
"ingress": { "default": "allow", "hostLoopback": "allow" },
});
&["--experimental", "--operation", "provision"]
} else {
&["--experimental"]
};
},
});
let args: &[&str] = &["--operation", "provision"];
let (code, stdout, stderr) = dry_run_with_args(&wxc, &base, args);
let output = format!("{stdout} {stderr}").to_ascii_lowercase();
if code != 0
@@ -278,7 +268,7 @@ fn dryrun_current_schema_rejects_isolation_session_ui() {
);
}
/// Network block without proxy (defaultPolicy block, empty host lists) accepted.
/// Stable directional network block without proxy is accepted.
#[test]
#[ignore = "requires real wxc-exec"]
fn dryrun_accepts_network_block_without_proxy() {
@@ -289,7 +279,7 @@ fn dryrun_accepts_network_block_without_proxy() {
let (_tempdir, temp_path) = temp_fixture();
let config = serde_json::json!({
"version": "0.6.0-alpha",
"version": "1.0.0",
"containerId": "test-net-block",
"containment": "processcontainer",
"process": {
@@ -301,7 +291,7 @@ fn dryrun_accepts_network_block_without_proxy() {
"readwritePaths": [temp_path],
},
"network": {
"defaultPolicy": "block",
"egress": { "default": "deny" },
},
});
@@ -312,82 +302,6 @@ fn dryrun_accepts_network_block_without_proxy() {
);
}
/// The ONLY accepted proxy shape in MXC 0.6.0-alpha: `{"localhost": <port>}`.
/// Verified empirically against the real binary — any other shape is rejected
/// with "Request error".
#[test]
#[ignore = "requires real wxc-exec"]
fn dryrun_accepts_localhost_proxy_shape() {
let Some(wxc) = wxc_path() else {
eprintln!("SKIP: wxc-exec not found");
return;
};
let (_tempdir, temp_path) = temp_fixture();
let config = serde_json::json!({
"version": "0.6.0-alpha",
"containerId": "test-proxy-localhost",
"containment": "processcontainer",
"process": {
"commandLine": "cmd /c exit 0",
"cwd": temp_path,
"timeout": 0,
},
"filesystem": {
"readwritePaths": [temp_path],
},
"network": {
"defaultPolicy": "block",
"proxy": { "localhost": 18080 },
},
});
let (code, stdout, stderr) = dry_run(&wxc, &config);
assert_eq!(
code, 0,
"{{\"localhost\": N}} proxy shape rejected by --dry-run\nstdout={stdout}\nstderr={stderr}"
);
}
/// The `{"host": ..., "port": ...}` proxy shape is REJECTED by MXC 0.6.0-alpha.
/// This test guards the schema contract discovered via dry-run bisection.
/// See docs4gtb/mxc-box-capabilities.md §"Schema contract findings".
#[test]
#[ignore = "requires real wxc-exec"]
fn dryrun_rejects_host_port_proxy_shape() {
let Some(wxc) = wxc_path() else {
eprintln!("SKIP: wxc-exec not found");
return;
};
let (_tempdir, temp_path) = temp_fixture();
let config = serde_json::json!({
"version": "0.6.0-alpha",
"containerId": "test-proxy-hostport",
"containment": "processcontainer",
"process": {
"commandLine": "cmd /c exit 0",
"cwd": temp_path,
"timeout": 0,
},
"filesystem": {
"readwritePaths": [temp_path],
},
"network": {
"defaultPolicy": "block",
// MXC 0.6.0-alpha rejects {"host","port"} — verified empirically.
"proxy": { "host": "127.0.0.1", "port": 18080 },
},
});
let (code, _stdout, _stderr) = dry_run(&wxc, &config);
assert_ne!(
code, 0,
"{{\"host\",\"port\"}} proxy shape was unexpectedly ACCEPTED — \
schema may have widened in a newer wxc-exec build"
);
}
/// Unknown containment value is rejected.
#[test]
#[ignore = "requires real wxc-exec"]
@@ -399,7 +313,7 @@ fn dryrun_rejects_unknown_containment() {
let (_tempdir, temp_path) = temp_fixture();
let config = serde_json::json!({
"version": "0.6.0-alpha",
"version": "1.0.0",
"containerId": "test-bad-containment",
"containment": "nonsense",
"process": {
@@ -417,7 +331,7 @@ fn dryrun_rejects_unknown_containment() {
}
/// The most important dry-run test: build a typed Windows policy, run
/// `split_policy` (MXC 0.8 loopback-only proxy access at 127.0.0.1:18080,
/// `split_policy` (MXC 1.0 loopback-only proxy access at 127.0.0.1:18080,
/// containment
/// "processcontainer"), and verify the resulting config with `--dry-run`.
///
@@ -430,6 +344,10 @@ fn dryrun_accepts_split_policy_output() {
eprintln!("SKIP: wxc-exec not found");
return;
};
if let Err(reason) = probe_processcontainer_host_loopback(&wxc) {
eprintln!("SKIP: processcontainer governed egress unavailable: {reason}");
return;
}
let (_tempdir, temp_path) = temp_fixture();
let policy = SandboxPolicy {
@@ -467,7 +385,7 @@ fn dryrun_accepts_split_policy_output() {
}
let mxc_config = result.mxc_config;
assert_eq!(mxc_config["version"], "0.8.0-alpha");
assert_eq!(mxc_config["version"], "1.0.0");
assert_eq!(mxc_config["network"]["egress"]["default"], "deny");
assert_eq!(
mxc_config["network"]["egress"]["allow"][0]["to"][0]["cidr"],
@@ -509,6 +427,89 @@ fn dryrun_accepts_split_policy_output() {
);
}
/// The standalone mapper must emit the same stable MXC 1.0 schema as the live
/// governed-egress path. Exercise a numeric destination and ports so this test
/// would fail if the mapper regressed to the retired host-list shape. Some MXC
/// builds select a backend that recognizes the directional schema but cannot
/// enforce egress rules; that explicit capability error still proves the 1.0
/// fields were parsed rather than rejected as unknown schema.
#[test]
#[ignore = "requires real wxc-exec"]
fn dryrun_accepts_standalone_mapper_output() {
let Some(wxc) = wxc_path() else {
eprintln!("SKIP: wxc-exec not found");
return;
};
let (_tempdir, temp_path) = temp_fixture();
let mut policy = SandboxPolicy {
filesystem: Some(FilesystemPolicy {
include_workdir: false,
read_only: Vec::new(),
read_write: vec![temp_path.clone()],
}),
..Default::default()
};
policy.network_policies.insert(
"numeric-egress".into(),
NetworkPolicyRule {
name: "numeric-egress".into(),
endpoints: vec![NetworkEndpoint {
host: "198.51.100.7".into(),
ports: vec![80, 443],
..Default::default()
}],
binaries: Vec::new(),
},
);
let options = openshell_driver_mxc::MxcMappingOptions {
containment: "processcontainer".into(),
command: "cmd /c exit 0".into(),
container_id: "standalone-mapper-dryrun".into(),
cwd: Some(temp_path),
..Default::default()
};
let result = openshell_driver_mxc::map_to_mxc(&policy, &options);
assert!(
result
.loss
.iter()
.all(|item| item.path != "network_policies.numeric-egress.endpoints[0].host")
);
let config = result.config;
assert_eq!(config["version"], "1.0.0");
assert_eq!(config["network"]["egress"]["default"], "deny");
assert_eq!(
config["network"]["egress"]["allow"][0]["to"][0]["cidr"],
"198.51.100.7/32"
);
assert_eq!(
config["network"]["egress"]["allow"][0]["ports"],
serde_json::json!([
{ "protocol": "tcp", "port": 80 },
{ "protocol": "tcp", "port": 443 }
])
);
let (code, stdout, stderr) = dry_run(&wxc, &config);
let output = format!("{stdout} {stderr}").to_ascii_lowercase();
if code != 0
&& output
.contains("network.egress allow/deny rules are not supported by the selected backend")
{
eprintln!(
"PASS: MXC parsed the 1.0 directional network schema; selected backend cannot enforce egress rules"
);
return;
}
assert_eq!(
code,
0,
"standalone mapper output rejected by MXC 1.0 --dry-run\nconfig={}\nstdout={stdout}\nstderr={stderr}",
serde_json::to_string_pretty(&config).unwrap_or_default()
);
}
// ── (b) Enforcement tests — probe-gated ───────────────────────────────────────
//
// These skip on this box (processcontainer velocity keys not enabled;
@@ -531,7 +532,7 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> {
let (_tempdir, temp_path) = temp_fixture();
let config = serde_json::json!({
"version": "0.6.0-alpha",
"version": "1.0.0",
"containerId": "probe-pc",
"containment": "processcontainer",
"process": {
@@ -594,55 +595,46 @@ fn probe_processcontainer(wxc: &PathBuf) -> Result<(), String> {
Ok(())
}
/// Probe the released binary's live `network.proxy` support separately from
/// ordinary `ProcessContainer` support. Some builds accept the proxy JSON during
/// `--dry-run` but return `ERROR_INVALID_PARAMETER` from the live launcher.
fn probe_processcontainer_proxy(wxc: &PathBuf) -> Result<(), String> {
let (_tempdir, temp_path) = temp_fixture();
let proxy_listener = std::net::TcpListener::bind("127.0.0.1:0")
.map_err(|error| format!("failed to reserve proxy probe port: {error}"))?;
let proxy_port = proxy_listener
.local_addr()
.map_err(|error| format!("failed to read proxy probe port: {error}"))?
.port();
let config = serde_json::json!({
"version": "0.6.0-alpha",
"containerId": "probe-pc-proxy",
"containment": "processcontainer",
"process": {
"commandLine": "C:\\Windows\\System32\\cmd.exe /c exit 0",
"cwd": temp_path,
"timeout": 30_000,
},
"filesystem": {
"readwritePaths": [temp_path],
},
"processContainer": {
"leastPrivilege": false,
},
"network": {
"defaultPolicy": "block",
"proxy": { "localhost": proxy_port },
},
});
/// Probe the released binary's native host-loopback capability separately from
/// ordinary `ProcessContainer` support. Schema validation also checks requested
/// platform capabilities, so loopback-dependent tests require a positive probe.
fn probe_processcontainer_host_loopback(wxc: &Path) -> Result<(), String> {
if std::env::var("OPENSHELL_MXC_MOCK_WXC").is_ok_and(|v| v == "1") {
return Err(
"OPENSHELL_MXC_MOCK_WXC=1 is set — unset it before running real enforcement tests"
.to_string(),
);
}
let json = serde_json::to_string(&config).unwrap();
let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
let output = Command::new(wxc)
.arg("--config-base64")
.arg(&b64)
let out = Command::new(wxc)
.arg("--probe")
.output()
.map_err(|error| format!("wxc-exec proxy probe failed to spawn: {error}"))?;
.map_err(|e| format!("wxc-exec --probe spawn failed: {e}"))?;
if !out.status.success() {
return Err(format!(
"wxc-exec --probe returned exit {}: stdout={} stderr={}",
out.status.code().unwrap_or(-1),
String::from_utf8_lossy(&out.stdout),
String::from_utf8_lossy(&out.stderr),
));
}
if output.status.success() {
let probe: serde_json::Value = serde_json::from_slice(&out.stdout)
.map_err(|e| format!("could not parse wxc-exec --probe JSON: {e}"))?;
let supported = probe
.pointer("/probes/baseContainerSupportsIngressHostLoopbackAllow")
.and_then(serde_json::Value::as_bool)
.ok_or_else(|| {
"wxc-exec --probe omitted baseContainerSupportsIngressHostLoopbackAllow".to_string()
})?;
if supported {
return Ok(());
}
let tier = probe["tier"].as_str().unwrap_or("unknown");
Err(format!(
"live network.proxy probe returned exit {}: stdout={} stderr={}",
output.status.code().unwrap_or(-1),
String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr),
"wxc-exec --probe reports baseContainerSupportsIngressHostLoopbackAllow=false \
for isolation tier {tier}"
))
}
@@ -659,18 +651,11 @@ fn probe_isolation_session(wxc: &PathBuf) -> Result<String, String> {
}
let config = serde_json::json!({
"version": "0.6.0-alpha",
"phase": "provision",
"version": "1.0.0",
"containment": "isolation_session",
"filesystem": {
"readwritePaths": [],
"readonlyPaths": [],
},
"experimental": {
"isolation_session": {
"configurationId": "composable",
"provision": {}
}
"network": {
"egress": { "default": "allow" },
"ingress": { "default": "allow", "hostLoopback": "allow" },
}
});
@@ -680,7 +665,8 @@ fn probe_isolation_session(wxc: &PathBuf) -> Result<String, String> {
let out = Command::new(wxc)
.arg("--config-base64")
.arg(&b64)
.arg("--experimental")
.arg("--operation")
.arg("provision")
.output()
.map_err(|e| format!("wxc-exec spawn failed: {e}"))?;
@@ -691,7 +677,7 @@ fn probe_isolation_session(wxc: &PathBuf) -> Result<String, String> {
if combined.contains("backend_unavailable") || combined.contains("0x80040154") {
return Err(
"backend_unavailable: IsoSessionApp.dll absent or OS build < 26300.8553".to_string(),
"backend_unavailable: IsoSessionApp.dll absent or OS build < 26340.9212".to_string(),
);
}
@@ -742,15 +728,7 @@ impl<'a> DeprovisionGuard<'a> {
fn run_deprovision(wxc: &PathBuf, sandbox_id: &str) {
let config = serde_json::json!({
"version": "0.6.0-alpha",
"phase": "deprovision",
"sandboxId": sandbox_id,
"experimental": {
"isolation_session": {
// Unit variant: must be null, not {} (malformed_request otherwise).
"deprovision": null
}
}
"version": "1.0.0",
});
let json = serde_json::to_string(&config).unwrap_or_default();
let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
@@ -758,7 +736,10 @@ impl<'a> DeprovisionGuard<'a> {
let _ = Command::new(wxc)
.arg("--config-base64")
.arg(&b64)
.arg("--experimental")
.arg("--operation")
.arg("deprovision")
.arg("--container-id")
.arg(sandbox_id)
.output();
}
}
@@ -794,7 +775,7 @@ fn pc_oneshot_in_policy_write_succeeds() {
let tmpdir_str = tmpdir.path().to_string_lossy().into_owned();
let config = serde_json::json!({
"version": "0.6.0-alpha",
"version": "1.0.0",
"containerId": "pc-in-policy-write",
"containment": "processcontainer",
"process": {
@@ -853,8 +834,8 @@ async fn pc_https_egress_reads_injected_ca_bundle() {
eprintln!("SKIP: processcontainer not live: {reason}");
return;
}
if let Err(reason) = probe_processcontainer_proxy(&wxc) {
eprintln!("SKIP: processcontainer network.proxy not live: {reason}");
if let Err(reason) = probe_processcontainer_host_loopback(&wxc) {
eprintln!("SKIP: processcontainer host loopback not live: {reason}");
return;
}
@@ -1008,7 +989,7 @@ fn pc_oneshot_out_of_policy_write_denied() {
let granted_str = granted_dir.path().to_string_lossy().into_owned();
let config = serde_json::json!({
"version": "0.6.0-alpha",
"version": "1.0.0",
"containerId": "pc-out-of-policy-write",
"containment": "processcontainer",
"process": {
@@ -1080,21 +1061,17 @@ fn iso_lifecycle_round_trip() {
// start
let start_config = serde_json::json!({
"version": "0.6.0-alpha",
"phase": "start",
"sandboxId": sandbox_id,
"experimental": {
"isolation_session": {
"start": {}
}
}
"version": "1.0.0",
});
let json = serde_json::to_string(&start_config).unwrap();
let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
let out = Command::new(&wxc)
.arg("--config-base64")
.arg(&b64)
.arg("--experimental")
.arg("--operation")
.arg("start")
.arg("--container-id")
.arg(&sandbox_id)
.output()
.expect("start");
assert!(
@@ -1109,13 +1086,12 @@ fn iso_lifecycle_round_trip() {
// (HRESULT 0x80070057). 0 is the documented no-timeout value and matches
// what the driver's exec path sends by default (MxcProcess.timeout = 0).
let exec_config = serde_json::json!({
"version": "0.6.0-alpha",
"phase": "exec",
"sandboxId": sandbox_id,
"version": "1.0.0",
"process": {
"commandLine": "cmd /c exit 0",
"cwd": "C:\\Windows\\Temp",
"env": [],
"inheritDefaultEnv": true,
"timeout": 0,
}
});
@@ -1124,7 +1100,10 @@ fn iso_lifecycle_round_trip() {
let out = Command::new(&wxc)
.arg("--config-base64")
.arg(&b64)
.arg("--experimental")
.arg("--operation")
.arg("exec")
.arg("--container-id")
.arg(&sandbox_id)
.output()
.expect("exec");
assert_eq!(
@@ -1136,22 +1115,17 @@ fn iso_lifecycle_round_trip() {
// stop
let stop_config = serde_json::json!({
"version": "0.6.0-alpha",
"phase": "stop",
"sandboxId": sandbox_id,
"experimental": {
"isolation_session": {
// Unit variant: must be null, not {} (malformed_request otherwise).
"stop": null
}
}
"version": "1.0.0",
});
let json = serde_json::to_string(&stop_config).unwrap();
let b64 = base64::engine::general_purpose::STANDARD.encode(json.as_bytes());
let out = Command::new(&wxc)
.arg("--config-base64")
.arg(&b64)
.arg("--experimental")
.arg("--operation")
.arg("stop")
.arg("--container-id")
.arg(&sandbox_id)
.output()
.expect("stop");
assert!(
+1 -1
View File
@@ -1219,7 +1219,7 @@ etw_audit = false
The packaged supervisor and sandbox binaries default to siblings of `openshell-gateway.exe`; explicit paths are useful for development layouts. The driver protects host supervisor tokens and descriptors with an owner-only Windows DACL.
Existing ProcessContainer configurations may retain `default_configuration_id`. This legacy IsolationSession-only field is accepted for configuration compatibility but has no effect on ProcessContainer. It does not enable the unsupported `isolation_session` backend; omit it in new configurations.
The MXC driver requires MXC 1.0.0 and emits its stable directional network schema. Remove the retired `default_configuration_id` setting before upgrading; the gateway rejects it. The `isolation_session` backend remains unsupported.
Supply the workload command and working directory through `sandbox create --driver-config-json`, for example `{"mxc":{"command":["C:\\Windows\\System32\\cmd.exe","/d","/c","echo hello"],"cwd":"C:\\work"}}`. Both are required. Supply workload environment through `sandbox create --env` or `--env-from`; it is not part of gateway configuration.
+6
View File
@@ -22,6 +22,12 @@ The authenticated Sandbox Protocol is the only runtime control and forwarding
transport. MXC supplies the Windows outer fence; the existing supervisor owns
policy evaluation, credentials, network proxying, and the gateway session.
The runtime requires the signed MXC 1.0.0 executor. Live requests and standalone
mapper artifacts share its stable directional network schema. Retired alpha
compatibility settings, including `default_configuration_id`, are rejected;
numeric CIDR/port rules belong to MXC, while DNS and L7 policy remain governed
by the host supervisor. IsolationSession is still outside this runtime's scope.
## Motivation
Docker Desktop and WSL2 add a Linux VM to Windows workflows. MXC provides a