mirror of
https://github.com/NVIDIA/OpenShell.git
synced 2026-11-06 08:44:05 +08:00
feat(mxc): adopt stable MXC 1.0.0 contract
Signed-off-by: Drew Newberry <anewberry@nvidia.com>
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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" }
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(),
|
||||
}],
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user