diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..2ed378e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +third_party/mxc/patches/*.patch text eol=lf -whitespace diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 33ab566..5517b0b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -379,6 +379,19 @@ jobs: - name: Build run: cargo build --locked --release + - name: Build pinned MXC Windows executor + shell: pwsh + run: | + $mxcDir = Join-Path $env:RUNNER_TEMP "mxc" + git clone --filter=blob:none $env:MXC_REPOSITORY $mxcDir + git -C $mxcDir checkout $env:MXC_REF + Get-ChildItem (Join-Path $env:GITHUB_WORKSPACE "third_party\mxc\patches\*.patch") | + Sort-Object Name | ForEach-Object { git -C $mxcDir apply --whitespace=error $_.FullName } + Push-Location (Join-Path $mxcDir "src") + cargo build --release -p wxc --no-default-features --locked + Pop-Location + Copy-Item (Join-Path $mxcDir "src\target\release\wxc-exec.exe") target\release\wxc-exec.exe + - name: Unit tests run: cargo test --locked @@ -449,6 +462,14 @@ jobs: run: powershell -ExecutionPolicy Bypass -File e2e\agents\test_agents.ps1 -AxisBin .\target\release\axis.exe shell: pwsh + - name: Windows MXC ProcessContainer smoke and security tests + shell: pwsh + env: + AXIS_RUN_MXC_BASECONTAINER_E2E: "1" + AXIS_SKIP_UNAVAILABLE_MXC_BASECONTAINER_E2E: "1" + AXIS_TEST_MXC_EXECUTOR: ${{ github.workspace }}\target\release\wxc-exec.exe + run: pwsh -NoProfile -File e2e/windows/test_mxc_processcontainer.ps1 -AxisBin ./target/release/axis.exe + - name: Test agent install (PowerShell) run: | # Test axis install --list diff --git a/.github/workflows/nightly.yml b/.github/workflows/nightly.yml index 1428f7e..0b6b190 100644 --- a/.github/workflows/nightly.yml +++ b/.github/workflows/nightly.yml @@ -192,6 +192,20 @@ jobs: cmp "$RUNNER_TEMP/axis-first-build/$binary" "target/$TARGET/release/$binary" done + - name: Build MXC Windows executor + if: runner.os == 'Windows' + shell: pwsh + run: | + $mxcDir = Join-Path $env:RUNNER_TEMP "mxc" + git clone --filter=blob:none $env:MXC_REPOSITORY $mxcDir + git -C $mxcDir checkout $env:MXC_REF + Get-ChildItem (Join-Path $env:GITHUB_WORKSPACE "third_party\mxc\patches\*.patch") | + Sort-Object Name | ForEach-Object { git -C $mxcDir apply --whitespace=error $_.FullName } + Push-Location (Join-Path $mxcDir "src") + cargo build --release -p wxc --no-default-features --locked --target ${{ matrix.target }} + Pop-Location + Copy-Item (Join-Path $mxcDir "src\target\${{ matrix.target }}\release\wxc-exec.exe") target\${{ matrix.target }}\release\wxc-exec.exe + - name: Build MXC Linux executor if: matrix.platform == 'linux-x86_64' env: @@ -285,6 +299,10 @@ jobs: New-Item -ItemType Directory -Path "$packageDir" -Force Copy-Item -LiteralPath (Join-Path "$targetDir" "axis.exe") -Destination "$packageDir" Copy-Item -LiteralPath (Join-Path "$targetDir" "axisd.exe") -Destination "$packageDir" + Copy-Item -LiteralPath (Join-Path "$targetDir" "wxc-exec.exe") -Destination "$packageDir" + Copy-Item -LiteralPath (Join-Path "$targetDir" "axis-wfp-broker.exe") -Destination "$packageDir" + Copy-Item -LiteralPath (Join-Path "$targetDir" "axis-ssh-proxy.exe") -Destination "$packageDir" + Copy-Item -LiteralPath "scripts/install_windows_wfp_broker.ps1" -Destination "$packageDir" Copy-Item -Path "policies\*.yaml" -Destination "$packageDir" Copy-Item -LiteralPath "LICENSE" -Destination "$packageDir" Copy-Item -LiteralPath "install.ps1" -Destination "$packageDir" @@ -321,6 +339,10 @@ jobs: "axis-$env:PLATFORM" --require axis.exe --require axisd.exe + --require wxc-exec.exe + --require axis-wfp-broker.exe + --require axis-ssh-proxy.exe + --require install_windows_wfp_broker.ps1 --require REPRODUCIBILITY.json - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index dd610d7..4056505 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -187,6 +187,20 @@ jobs: cmp "$RUNNER_TEMP/axis-first-build/$binary" "target/$TARGET/release/$binary" done + - name: Build MXC Windows executor + if: runner.os == 'Windows' + shell: pwsh + run: | + $mxcDir = Join-Path $env:RUNNER_TEMP "mxc" + git clone --filter=blob:none $env:MXC_REPOSITORY $mxcDir + git -C $mxcDir checkout $env:MXC_REF + Get-ChildItem (Join-Path $env:GITHUB_WORKSPACE "third_party\mxc\patches\*.patch") | + Sort-Object Name | ForEach-Object { git -C $mxcDir apply --whitespace=error $_.FullName } + Push-Location (Join-Path $mxcDir "src") + cargo build --release -p wxc --no-default-features --locked --target ${{ matrix.target }} + Pop-Location + Copy-Item (Join-Path $mxcDir "src\target\${{ matrix.target }}\release\wxc-exec.exe") target\${{ matrix.target }}\release\wxc-exec.exe + - name: Build MXC Linux executor if: matrix.platform == 'linux-x86_64' env: @@ -277,6 +291,10 @@ jobs: New-Item -ItemType Directory -Path "$packageDir" -Force Copy-Item -LiteralPath (Join-Path "$targetDir" "axis.exe") -Destination "$packageDir" Copy-Item -LiteralPath (Join-Path "$targetDir" "axisd.exe") -Destination "$packageDir" + Copy-Item -LiteralPath (Join-Path "$targetDir" "wxc-exec.exe") -Destination "$packageDir" + Copy-Item -LiteralPath (Join-Path "$targetDir" "axis-wfp-broker.exe") -Destination "$packageDir" + Copy-Item -LiteralPath (Join-Path "$targetDir" "axis-ssh-proxy.exe") -Destination "$packageDir" + Copy-Item -LiteralPath "scripts/install_windows_wfp_broker.ps1" -Destination "$packageDir" Copy-Item -Path "policies\*.yaml" -Destination "$packageDir" Copy-Item -LiteralPath "README.md" -Destination "$packageDir" Copy-Item -LiteralPath "LICENSE" -Destination "$packageDir" @@ -314,6 +332,10 @@ jobs: "axis-$env:PLATFORM" --require axis.exe --require axisd.exe + --require wxc-exec.exe + --require axis-wfp-broker.exe + --require axis-ssh-proxy.exe + --require install_windows_wfp_broker.ps1 --require REPRODUCIBILITY.json - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 diff --git a/Cargo.lock b/Cargo.lock index e6e0b90..5b52a09 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -230,6 +230,7 @@ dependencies = [ "hyper", "hyper-util", "libc", + "serde_json", "serde_yaml", "sha2", "tempfile", diff --git a/README.md b/README.md index 13a2623..325bba4 100644 --- a/README.md +++ b/README.md @@ -13,11 +13,11 @@ platform backend is available: | Layer | Linux | Windows | macOS | |---|---|---|---| -| Process | MXC Bubblewrap process backend with AXIS seccomp; native Landlock/seccomp retained | Launch blocked; Restricted Token + Job Object are containment targets | Seatbelt (sandbox-exec) | -| Filesystem | MXC Bubblewrap mounts or native Landlock LSM | Launch blocked; NTFS ACLs + Low Integrity are containment targets | Seatbelt profile (subpath rules) | -| Network | block mode or strict netns proxy | Launch blocked; AppContainer + loopback proxy are containment targets | Seatbelt network deny + proxy | -| GPU | Optional HIP Remote artifacts | Unavailable while native launch is blocked | Optional HIP Remote artifacts | -| Inference | Local LLM via llama.cpp or vLLM | Unavailable while native launch is blocked | Same | +| Process | MXC Bubblewrap process backend with AXIS seccomp; native Landlock/seccomp retained | MXC ProcessContainer with AXIS-owned lifecycle cleanup | Seatbelt (sandbox-exec) | +| Filesystem | MXC Bubblewrap mounts or native Landlock LSM | MXC ProcessContainer path allowlists | Seatbelt profile (subpath rules) | +| Network | block mode, MXC cooperative proxy, or strict native netns proxy | MXC allow/block plus BaseContainer strict proxy through the installed AXIS WFP broker | Seatbelt network deny + proxy | +| GPU | Optional HIP Remote artifacts | Optional HIP Remote artifacts | Optional HIP Remote artifacts | +| Inference | Local LLM via llama.cpp or vLLM | Same | Same | In proxy mode, allowed network requests go through a policy-evaluated proxy. HIP Remote policies route GPU API calls to a worker process when the optional @@ -71,6 +71,11 @@ Linux users may choose `--with-cap-net-admin --prefix /usr/local/bin` instead, but the helper is the narrower privileged path. +Windows release archives include the pinned MXC `wxc-exec.exe` beside +`axis.exe`. Windows `runtime.provider: auto` and `mxc` use ProcessContainer; +the incomplete legacy `axis_native` host-spawn path is disabled and never used +as a fallback. + The Linux default MXC process backend also needs the host `bubblewrap` runtime and unprivileged user namespaces enabled. Those are host runtime prerequisites, not AXIS privileged install steps. @@ -148,14 +153,38 @@ fails closed when the selected provider cannot enforce the policy: 5. bubblewrap fallback — block-mode fallback when Landlock is unavailable and a safe system `bwrap` can preserve the requested semantics -### Windows (Launch Blocked) - -The native Windows backend fails closed before creating a user process. Its -current code does not apply a Job Object, AppContainer, restricted token, NTFS -ACL boundary, proxy boundary, or isolated environment to the initial process. -Those controls remain implementation targets, and no Windows native containment -or bypass-detection claim should be treated as proven until the complete launch -path and negative tests land. +### Windows (MXC ProcessContainer) + +Windows process policies default to the packaged MXC `wxc-exec.exe` backend. +AXIS translates the shared policy into MXC JSON, clears the executor environment, +rejects secrets and unsupported surfaces before launch, and places the executor +in a kill-on-close Job Object for timeout and teardown cleanup. AXIS requires +MXC BaseContainer by default, enables its least-privilege token mode, and fails +closed rather than silently selecting another ProcessContainer tier. + +The supported slice now includes filesystem read-only/read-write allowlists, +BaseContainer-default-deny normalization for non-overlapping deny rules, a +physical managed Windows profile, child-tree process/aggregate-memory/CPU limits, +default allow/block networking, and BaseContainer strict proxy routing through +the installed AXIS WFP broker. The broker installs an exact proxy permit plus +IPv4/IPv6 default-deny filters against the SID read from the suspended child; +direct TCP, UDP, DNS, and QUIC bypasses remain blocked. Nested deny rules and +GPU/AMD policies still reject instead of weakening the policy. Scoped SSH is +supported through BaseContainer strict proxy mode when every projected key has +the same literal host set and that set exactly matches port-22 network rules. +Managed inference, streaming, host-side provider credential injection, and +conservative token-budget reservation are supported through BaseContainer strict +proxy mode; token-budget exhaustion currently supports the exact `reject` action +only. + +Interactive ConPTY is unsupported through BaseContainer on Windows build 26300 +because its creation API rejects pseudoconsole startup handles with +`ERROR_INVALID_HANDLE`. AXIS does not force the older AppContainer/DACL tier to +obtain terminal support. AppContainer/DACL fallback remains disabled in the MXC +configuration, so an unavailable BaseContainer is a launch failure. +The pinned MXC project is an early preview and does not claim its profiles are +security boundaries; AXIS therefore treats unsupported or unavailable behavior +as a launch failure, not as permission to execute on the host. ## GPU Sandbox diff --git a/crates/axis-cli/src/main.rs b/crates/axis-cli/src/main.rs index a1119d4..583736c 100644 --- a/crates/axis-cli/src/main.rs +++ b/crates/axis-cli/src/main.rs @@ -425,6 +425,7 @@ async fn main() -> Result<()> { #[cfg(windows)] { + let _ = use_system; let install_script = include_str!("../../../e2e/agents/install_agents.ps1"); let script_path = std::env::temp_dir().join("axis-install-agents.ps1"); std::fs::write(&script_path, install_script)?; @@ -785,11 +786,13 @@ async fn main() -> Result<()> { } else { None }; + let inference_endpoint = configured_standalone_inference_endpoint()?; // Start an inline proxy if policy uses proxy mode. let proxy_addr = match standalone_proxy_config_for_sandbox( sandbox_id, &policy, + inference_endpoint, connect_attribution.clone(), ) { Some(proxy_config) => { @@ -1222,6 +1225,7 @@ fn proxy_bind_addr_for_sandbox( fn standalone_proxy_config_for_sandbox( id: axis_core::types::SandboxId, policy: &axis_core::policy::Policy, + inference_endpoint: Option, connect_attribution: Option, ) -> Option { if !matches!(policy.network.mode, axis_core::policy::NetworkMode::Proxy) { @@ -1233,13 +1237,26 @@ fn standalone_proxy_config_for_sandbox( bind_addr: proxy_bind_addr_for_sandbox(id, 0, policy), policy: policy.clone(), enable_leak_detection: true, - inference_endpoint: None, + inference_endpoint, connect_attribution, enable_identity_diagnostics: false, timing_tx: None, }) } +fn configured_standalone_inference_endpoint() -> anyhow::Result> { + let Some(value) = std::env::var_os("AXIS_INFERENCE_ENDPOINT") else { + return Ok(None); + }; + let value = value + .into_string() + .map_err(|_| anyhow::anyhow!("AXIS_INFERENCE_ENDPOINT must be valid Unicode"))?; + value + .parse() + .map(Some) + .map_err(|error| anyhow::anyhow!("invalid AXIS_INFERENCE_ENDPOINT '{value}': {error}")) +} + fn collect_standalone_sandbox_env() -> Vec<(String, String)> { collect_standalone_sandbox_env_from(std::env::vars()) } @@ -1355,7 +1372,7 @@ mod tests { for mode in [NetworkMode::Block, NetworkMode::Allow, NetworkMode::Proxy] { let policy = test_policy(provider, mode.clone()); let bind_addr = proxy_bind_addr_for_sandbox(id, 0, &policy); - let proxy_config = standalone_proxy_config_for_sandbox(id, &policy, None); + let proxy_config = standalone_proxy_config_for_sandbox(id, &policy, None, None); if matches!(mode, NetworkMode::Proxy) { #[cfg(target_os = "linux")] @@ -1396,6 +1413,19 @@ mod tests { } } + #[test] + fn standalone_proxy_keeps_host_inference_endpoint_out_of_policy_data() { + let id = SandboxId::new(); + let policy = test_policy(RuntimeProvider::Mxc, NetworkMode::Proxy); + let endpoint = "127.0.0.1:8080".parse().unwrap(); + + let config = + standalone_proxy_config_for_sandbox(id, &policy, Some(endpoint), None).unwrap(); + + assert_eq!(config.inference_endpoint, Some(endpoint)); + assert!(config.policy.inference.routes.is_empty()); + } + #[test] fn standalone_env_collection_omits_provider_secrets_and_proxy_vars() { let env = collect_standalone_sandbox_env_from(vec![ @@ -1403,6 +1433,7 @@ mod tests { ("ANTHROPIC_API_KEY".into(), "secret".into()), ("OPENAI_API_KEY".into(), "secret".into()), ("ANTHROPIC_BASE_URL".into(), "https://api.example".into()), + ("AXIS_INFERENCE_ENDPOINT".into(), "127.0.0.1:8080".into()), ("All_Proxy".into(), "http://proxy-with-creds".into()), ("UNRELATED".into(), "value".into()), ]); diff --git a/crates/axis-core/src/audit.rs b/crates/axis-core/src/audit.rs index 6e2b9b8..f974d75 100644 --- a/crates/axis-core/src/audit.rs +++ b/crates/axis-core/src/audit.rs @@ -178,6 +178,15 @@ impl AuditLog { format!("credential leak detected: {pattern}"), )); } + + pub fn inference_budget_denied(&self, sandbox_id: SandboxId, reason: &str) { + self.emit(&AuditEvent::new( + EventCategory::InferenceActivity, + Severity::Medium, + Some(sandbox_id), + format!("inference token budget denied request: {reason}"), + )); + } } impl Default for AuditLog { diff --git a/crates/axis-core/src/backend_defaults.rs b/crates/axis-core/src/backend_defaults.rs index dc7ef0e..077ba71 100644 --- a/crates/axis-core/src/backend_defaults.rs +++ b/crates/axis-core/src/backend_defaults.rs @@ -209,8 +209,8 @@ pub const BACKEND_DEFAULT_RECORDS: &[BackendDefaultRecord] = &[ id: BackendCapabilityMapId::MxcWindowsProcessContainer, platform: BackendPlatform::Windows, execution_class: BackendExecutionClass::Process, - status: BackendDefaultStatus::Candidate, - rationale: "MXC ProcessContainer is a Windows process candidate and must prove resource, lifecycle, startup, and policy behavior before becoming a current default.", + status: BackendDefaultStatus::CurrentDefault, + rationale: "MXC ProcessContainer is the Windows process default. AXIS rejects unsupported policy surfaces, sanitizes the executor boundary, and owns timeout and cleanup around the packaged MXC runtime.", required_benchmark_metrics: PROCESS_METRICS, required_security_evidence: SECURITY_EVIDENCE, benchmark_gate: Some("AXIS_BENCH_MXC_WINDOWS_PROCESSCONTAINER=1"), @@ -311,6 +311,10 @@ mod tests { BackendPlatform::Macos, BackendCapabilityMapId::AxisNativeMacosSeatbelt, ), + ( + BackendPlatform::Windows, + BackendCapabilityMapId::MxcWindowsProcessContainer, + ), ] { let defaults = backend_default_records() .iter() @@ -331,15 +335,6 @@ mod tests { "{platform:?} process default must match the declared backend decision" ); } - - assert!( - backend_default_records().iter().all(|record| { - record.platform != BackendPlatform::Windows - || record.execution_class != BackendExecutionClass::Process - || record.status != BackendDefaultStatus::CurrentDefault - }), - "Windows must not advertise a current process default before confinement is implemented" - ); } #[test] diff --git a/crates/axis-core/src/backend_evidence.rs b/crates/axis-core/src/backend_evidence.rs index 1783846..9598856 100644 --- a/crates/axis-core/src/backend_evidence.rs +++ b/crates/axis-core/src/backend_evidence.rs @@ -88,6 +88,24 @@ network: access: read-write binaries: - path: "*/python*" +"#, + }, + PolicyScenario { + name: "portable_isolated_no_children", + description: "portable isolated identity with an atomic one-process tree limit", + yaml: r#" +version: 1 +name: evidence-portable-isolated-no-children +filesystem: + read_write: + - "{workspace}" +process: + identity: isolated + child_processes: deny + max_memory_mb: 0 + cpu_rate_percent: 0 +network: + mode: block "#, }, PolicyScenario { @@ -331,7 +349,7 @@ mod tests { assert_eq!(reports.len(), backend_default_records().len()); for report in reports { - assert_eq!(report.scenario_count, 4); + assert_eq!(report.scenario_count, POLICY_SCENARIOS.len()); assert_eq!(report.scenarios.len(), report.scenario_count); assert!( report.benchmark_gate.is_some(), diff --git a/crates/axis-core/src/capability.rs b/crates/axis-core/src/capability.rs index 3342fa3..ccd3c75 100644 --- a/crates/axis-core/src/capability.rs +++ b/crates/axis-core/src/capability.rs @@ -93,6 +93,7 @@ pub struct ProcessCapabilities { pub environment: CapabilitySupport, pub stdio: CapabilitySupport, pub user_identity: CapabilitySupport, + pub isolated_identity: CapabilitySupport, pub syscall_filtering: CapabilitySupport, pub pty: CapabilitySupport, pub timeout: CapabilitySupport, @@ -561,6 +562,17 @@ fn requirements_for_policy( &backend.process.user_identity, ); } + if matches!( + policy.process.identity, + crate::policy::ProcessIdentity::Isolated + ) { + push( + &mut requirements, + PolicySurface::Process, + "process.isolated_identity", + &backend.process.isolated_identity, + ); + } if !policy.process.blocked_syscalls.is_empty() { push( &mut requirements, @@ -639,7 +651,7 @@ fn requirements_for_policy( ); } - if policy.process.max_processes > 0 { + if policy.process.effective_max_processes() > 0 { push( &mut requirements, PolicySurface::Resources, @@ -828,7 +840,7 @@ fn push( } fn resources_requested(policy: &Policy) -> bool { - policy.process.max_processes > 0 + policy.process.effective_max_processes() > 0 || policy.process.max_memory_mb > 0 || policy.process.cpu_rate_percent > 0 } @@ -882,6 +894,7 @@ mod tests { environment: CapabilitySupport::exact(), stdio: CapabilitySupport::exact(), user_identity: CapabilitySupport::exact(), + isolated_identity: CapabilitySupport::exact(), syscall_filtering: CapabilitySupport::exact(), pty: CapabilitySupport::unsupported("pty not implemented"), timeout: CapabilitySupport::exact(), @@ -948,6 +961,8 @@ mod tests { cpu_rate_percent: 0, run_as_user: None, blocked_syscalls: Vec::new(), + identity: Default::default(), + child_processes: Default::default(), timeout_sec: None, }, network: NetworkPolicy { diff --git a/crates/axis-core/src/capability_map.rs b/crates/axis-core/src/capability_map.rs index 8b65985..4336e93 100644 --- a/crates/axis-core/src/capability_map.rs +++ b/crates/axis-core/src/capability_map.rs @@ -41,6 +41,7 @@ pub mod host_dependency { pub const WINDOWS_JOBOBJECT: &str = "windows.job_object"; pub const WINDOWS_LOW_INTEGRITY: &str = "windows.low_integrity"; pub const WINDOWS_PROCESS_CONTAINER: &str = "windows.processcontainer"; + pub const WINDOWS_WFP_BROKER: &str = "windows.axis_wfp_broker"; pub const WINDOWS_SANDBOX: &str = "windows.windows_sandbox"; pub const WINDOWS_WHP: &str = "windows.whp"; pub const WINDOWS_WSL2: &str = "windows.wsl2"; @@ -247,6 +248,12 @@ pub fn validate_backend_capability_map(backend: &BackendCapabilities) -> Result< &declared, &mut problems, ); + validate_support( + &backend.process.isolated_identity, + "process.isolated_identity", + &declared, + &mut problems, + ); validate_support( &backend.process.syscall_filtering, "process.syscall_filtering", @@ -512,6 +519,9 @@ fn axis_native_linux() -> BackendCapabilities { user_identity: CapabilitySupport::unsupported( "run_as_user needs a platform identity adapter before it can be planned generically", ), + isolated_identity: CapabilitySupport::unsupported( + "the native Linux process backend does not create a distinct identity by default", + ), syscall_filtering: seccomp.clone(), pty: CapabilitySupport::unsupported( "PTY attachment is not part of the current backend contract", @@ -586,6 +596,9 @@ fn mxc_linux_bubblewrap() -> BackendCapabilities { environment: CapabilitySupport::AxisOwned, stdio: CapabilitySupport::AxisOwned, user_identity: CapabilitySupport::AxisOwned, + isolated_identity: CapabilitySupport::unsupported( + "MXC bubblewrap isolated identity semantics are not yet proven", + ), syscall_filtering: dep_support(host_dependency::AXIS_SECCOMP_LAUNCHER), pty: CapabilitySupport::unsupported("MXC bubblewrap PTY support is not mapped by AXIS"), timeout: CapabilitySupport::AxisOwned, @@ -651,6 +664,7 @@ fn mxc_linux_lxc() -> BackendCapabilities { environment: CapabilitySupport::AxisOwned, stdio: CapabilitySupport::AxisOwned, user_identity: mxc_lxc.clone(), + isolated_identity: mxc_lxc.clone(), syscall_filtering: CapabilitySupport::weaker( "LXC profile support is host configuration dependent and not yet proven against the AXIS syscall matrix", ), @@ -748,6 +762,9 @@ fn axis_native_macos_seatbelt() -> BackendCapabilities { user_identity: CapabilitySupport::unsupported( "macOS run_as_user parity is not part of the current AXIS contract", ), + isolated_identity: CapabilitySupport::unsupported( + "Seatbelt does not create a distinct process identity", + ), syscall_filtering: CapabilitySupport::unsupported( "Seatbelt profiles do not expose seccomp-style syscall filtering", ), @@ -831,6 +848,9 @@ fn mxc_macos_seatbelt() -> BackendCapabilities { user_identity: CapabilitySupport::unsupported( "MXC Seatbelt run_as_user parity is not mapped by AXIS", ), + isolated_identity: CapabilitySupport::unsupported( + "MXC Seatbelt isolated identity semantics are not mapped", + ), syscall_filtering: CapabilitySupport::unsupported( "Seatbelt does not provide AXIS seccomp-style syscall filtering", ), @@ -907,6 +927,7 @@ fn axis_native_windows() -> BackendCapabilities { environment: unavailable.clone(), stdio: unavailable.clone(), user_identity: unavailable.clone(), + isolated_identity: unavailable.clone(), syscall_filtering: unavailable.clone(), pty: unavailable.clone(), timeout: unavailable.clone(), @@ -973,11 +994,14 @@ fn mxc_windows_processcontainer() -> BackendCapabilities { host_dependency::MXC_EXECUTOR, host_dependency::WINDOWS_PROCESS_CONTAINER, host_dependency::WINDOWS_JOBOBJECT, + host_dependency::WINDOWS_WFP_BROKER, ]), filesystem: FilesystemCapabilities { read_only: processcontainer.clone(), read_write: processcontainer.clone(), - deny: processcontainer.clone(), + deny: CapabilitySupport::unsupported( + "MXC ProcessContainer deniedPaths support depends on the selected Windows isolation tier and is not uniformly enforceable", + ), workspace: processcontainer.clone(), }, process: ProcessCapabilities { @@ -986,26 +1010,31 @@ fn mxc_windows_processcontainer() -> BackendCapabilities { environment: CapabilitySupport::AxisOwned, stdio: CapabilitySupport::AxisOwned, user_identity: CapabilitySupport::unsupported( - "MXC ProcessContainer run_as_user parity is not mapped", + "MXC ProcessContainer cannot map a caller-named host account", ), + isolated_identity: processcontainer.clone(), syscall_filtering: CapabilitySupport::unsupported( "ProcessContainer does not provide AXIS seccomp-style syscall filtering", ), - pty: CapabilitySupport::unsupported("MXC ProcessContainer PTY support is not mapped"), + pty: CapabilitySupport::unsupported( + "the BaseContainer launch API rejects pseudoconsole handles, and AXIS does not force the older AppContainer/DACL tier to obtain ConPTY support", + ), timeout: CapabilitySupport::AxisOwned, }, network: NetworkCapabilities { allow: processcontainer.clone(), block: processcontainer.clone(), - strict_proxy: CapabilitySupport::weaker( - "MXC ProcessContainer strict proxy requires AXIS WFP/AppContainer routing that is not mapped yet", - ), + strict_proxy: deps_support([ + host_dependency::MXC_EXECUTOR, + host_dependency::WINDOWS_PROCESS_CONTAINER, + host_dependency::WINDOWS_WFP_BROKER, + ]), cooperative_proxy: CapabilitySupport::weaker( "cooperative proxy environment variables cannot prevent direct socket bypass", ), endpoint_policy: CapabilitySupport::AxisOwned, binary_attribution: CapabilitySupport::unsupported( - "connect-time executable attribution is not implemented for MXC ProcessContainer", + "SID-scoped WFP filters prove the sandbox boundary but not the initiating image; user-mode PID/tuple lookup is raceable through PID reuse and short-lived connects, so per-binary rules remain fail-closed without ALE process metadata or a callout", ), l7_policy: unsupported_l7_policy(), }, @@ -1027,9 +1056,7 @@ fn mxc_windows_processcontainer() -> BackendCapabilities { audit: AuditCapabilities { denials: CapabilitySupport::AxisOwned, dependency_reasons: CapabilitySupport::AxisOwned, - bypass_evidence: CapabilitySupport::unsupported( - "Windows bypass evidence collection is not mapped", - ), + bypass_evidence: dep_support(host_dependency::WINDOWS_WFP_BROKER), }, } } @@ -1106,6 +1133,9 @@ fn mxc_windows_wslc() -> BackendCapabilities { user_identity: CapabilitySupport::unsupported( "WSL identity mapping is not planned as AXIS run_as_user parity", ), + isolated_identity: CapabilitySupport::unsupported( + "WSLC isolated identity semantics are not yet mapped", + ), syscall_filtering: CapabilitySupport::unsupported( "MXC WSLC syscall behavior is not mapped to the AXIS seccomp contract", ), @@ -1214,6 +1244,9 @@ fn vm_backend( user_identity: CapabilitySupport::unsupported( "VM identity mapping is not planned as AXIS run_as_user parity", ), + isolated_identity: CapabilitySupport::unsupported( + "VM guest identity is not yet mapped to the portable process identity contract", + ), syscall_filtering: CapabilitySupport::unsupported( "VM backends isolate the guest but do not expose the AXIS per-process syscall contract", ), @@ -1313,6 +1346,9 @@ fn windows_vm_like_backend( user_identity: CapabilitySupport::unsupported( "Windows VM-style identity mapping is not planned as AXIS run_as_user parity", ), + isolated_identity: CapabilitySupport::unsupported( + "Windows VM-style guest identity is not mapped to the portable process identity contract", + ), syscall_filtering: CapabilitySupport::unsupported( "Windows VM-style backends do not expose AXIS seccomp-style syscall filtering", ), @@ -1453,6 +1489,9 @@ fn host_dependency_for(name: &'static str) -> HostDependency { host_dependency::WINDOWS_JOBOBJECT => "Windows Job Object resource enforcement", host_dependency::WINDOWS_LOW_INTEGRITY => "Windows low-integrity token support", host_dependency::WINDOWS_PROCESS_CONTAINER => "Windows ProcessContainer support", + host_dependency::WINDOWS_WFP_BROKER => { + "installed AXIS WFP broker service with a reachable lease pipe" + } host_dependency::WINDOWS_SANDBOX => "Windows Sandbox optional feature", host_dependency::WINDOWS_WHP => "Windows Hypervisor Platform feature", host_dependency::WINDOWS_WSL2 => "Windows Subsystem for Linux 2 feature", @@ -1821,6 +1860,8 @@ mod tests { cpu_rate_percent: 0, run_as_user: None, blocked_syscalls: Vec::new(), + identity: Default::default(), + child_processes: Default::default(), timeout_sec: None, }, network: NetworkPolicy { diff --git a/crates/axis-core/src/container_backend.rs b/crates/axis-core/src/container_backend.rs index a9f48a3..764704e 100644 --- a/crates/axis-core/src/container_backend.rs +++ b/crates/axis-core/src/container_backend.rs @@ -267,7 +267,7 @@ pub fn build_container_backend_execution_spec( filesystem: container_filesystem_spec(policy), network: container_network_spec(policy), resources: ContainerBackendResourceSpec { - max_processes: policy.process.max_processes, + max_processes: policy.process.effective_max_processes(), max_memory_mb: policy.process.max_memory_mb, cpu_rate_percent: policy.process.cpu_rate_percent, }, diff --git a/crates/axis-core/src/mxc_config.rs b/crates/axis-core/src/mxc_config.rs index 4d5fd02..859b341 100644 --- a/crates/axis-core/src/mxc_config.rs +++ b/crates/axis-core/src/mxc_config.rs @@ -132,6 +132,8 @@ pub struct MxcProcessConfigOptions { pub strict_proxy_enforced_by_axis: bool, pub cooperative_proxy_configured_by_mxc: bool, pub resource_limits_enforced_by_axis: bool, + pub proxy_url: Option, + pub axis_wfp: Option, } #[derive(Debug, Clone, Default, PartialEq, Eq)] @@ -242,6 +244,13 @@ pub struct MxcFilesystemConfig { pub struct MxcNetworkConfig { #[serde(rename = "defaultPolicy")] pub default_policy: MxcNetworkDefaultPolicy, + #[serde(skip_serializing_if = "Option::is_none")] + pub proxy: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MxcNetworkProxy { + pub url: String, } #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] @@ -265,6 +274,27 @@ pub struct MxcProcessContainerConfig { pub least_privilege: bool, #[serde(default, skip_serializing_if = "Vec::is_empty")] pub capabilities: Vec, + pub resources: MxcProcessResourceConfig, + #[serde(rename = "axisWfp", skip_serializing_if = "Option::is_none")] + pub axis_wfp: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct MxcAxisWfpConfig { + #[serde(rename = "pipeName")] + pub pipe_name: String, + #[serde(rename = "leaseId")] + pub lease_id: String, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)] +pub struct MxcProcessResourceConfig { + #[serde(rename = "maxProcesses")] + pub max_processes: u32, + #[serde(rename = "maxMemoryMb")] + pub max_memory_mb: u64, + #[serde(rename = "cpuRatePercent")] + pub cpu_rate_percent: u32, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -394,6 +424,7 @@ pub fn build_mxc_container_config( }; let network = MxcNetworkConfig { default_policy: mxc_network_policy(spec, &options)?, + proxy: None, }; let lifecycle = MxcLifecycleConfig { destroy_on_exit: spec.destroy_on_exit, @@ -456,6 +487,7 @@ pub fn build_mxc_process_config( }; let network = MxcNetworkConfig { default_policy: mxc_process_network_policy(spec, &options)?, + proxy: options.proxy_url.clone().map(|url| MxcNetworkProxy { url }), }; let lifecycle = MxcLifecycleConfig { destroy_on_exit: true, @@ -485,7 +517,16 @@ pub fn build_mxc_process_config( filesystem, network, lifecycle, - process_container: Some(MxcProcessContainerConfig::default()), + process_container: Some(MxcProcessContainerConfig { + least_privilege: true, + capabilities: Vec::new(), + resources: MxcProcessResourceConfig { + max_processes: spec.resources.max_processes, + max_memory_mb: spec.resources.max_memory_mb, + cpu_rate_percent: spec.resources.cpu_rate_percent, + }, + axis_wfp: options.axis_wfp, + }), fallback: Some(MxcFallbackConfig { allow_dacl_mutation: false, }), @@ -532,6 +573,7 @@ pub fn build_mxc_vm_config( }; let network = MxcNetworkConfig { default_policy: mxc_vm_network_policy(spec, &options)?, + proxy: None, }; let lifecycle = MxcLifecycleConfig { destroy_on_exit: spec.destroy_on_exit, @@ -655,7 +697,14 @@ fn validate_process_resources( spec: &ProcessBackendExecutionSpec, options: &MxcProcessConfigOptions, ) -> Result<(), MxcConfigError> { - if process_resources_requested(spec) && !options.resource_limits_enforced_by_axis { + let emitted_to_windows_processcontainer = matches!( + spec.config_format, + ProcessBackendConfigFormat::MxcWindowsProcessContainer + ); + if process_resources_requested(spec) + && !emitted_to_windows_processcontainer + && !options.resource_limits_enforced_by_axis + { return Err(MxcConfigError::ProcessResourceLimitsRequireAxisLayer); } Ok(()) @@ -1107,12 +1156,55 @@ mod tests { assert_eq!(json["containment"], "processcontainer"); assert_eq!(json["platform"], "windows"); assert_eq!(json["network"]["defaultPolicy"], "allow"); - assert_eq!(json["processContainer"]["leastPrivilege"], false); + assert_eq!(json["processContainer"]["leastPrivilege"], true); assert!(json["processContainer"].get("capabilities").is_none()); + assert_eq!( + json["processContainer"]["resources"], + serde_json::json!({ + "maxProcesses": 0, + "maxMemoryMb": 0, + "cpuRatePercent": 0 + }) + ); assert_eq!(json["fallback"]["allowDaclMutation"], false); assert!(json.get("experimental").is_none()); } + #[test] + fn windows_strict_proxy_serializes_axis_wfp_pre_resume_contract() { + let spec = process_execution_spec( + BackendCapabilityMapId::MxcWindowsProcessContainer, + NetworkMode::Proxy, + ) + .unwrap(); + let config = build_mxc_process_config( + "agent --version", + &spec, + MxcProcessConfigOptions { + container_id: Some("axis-windows-proxy".into()), + strict_proxy_enforced_by_axis: true, + proxy_url: Some("http://127.0.0.1:31280".into()), + axis_wfp: Some(MxcAxisWfpConfig { + pipe_name: r"\\.\pipe\axis-wfp-broker-v1".into(), + lease_id: "d5406689-f871-42e0-ae5b-eb4b5720ad2a".into(), + }), + ..Default::default() + }, + ) + .unwrap(); + let json = serde_json::to_value(config).unwrap(); + + assert_eq!(json["network"]["defaultPolicy"], "allow"); + assert_eq!(json["network"]["proxy"]["url"], "http://127.0.0.1:31280"); + assert_eq!( + json["processContainer"]["axisWfp"], + serde_json::json!({ + "pipeName": r"\\.\pipe\axis-wfp-broker-v1", + "leaseId": "d5406689-f871-42e0-ae5b-eb4b5720ad2a" + }) + ); + } + #[test] fn macos_seatbelt_config_serializes_experimental_mxc_wire_shape() { let spec = @@ -1458,7 +1550,7 @@ mod tests { } #[test] - fn process_resource_limits_require_axis_owned_layer_before_mxc_config() { + fn windows_process_resource_limits_are_emitted_for_mxc_inner_job() { let mut spec = process_execution_spec( BackendCapabilityMapId::MxcWindowsProcessContainer, NetworkMode::Allow, @@ -1468,30 +1560,20 @@ mod tests { spec.resources.max_memory_mb = 1024; spec.resources.cpu_rate_percent = 50; - let err = build_mxc_process_config( - "agent --version", - &spec, - MxcProcessConfigOptions { - container_id: Some("axis-windows".into()), - resource_limits_enforced_by_axis: false, - ..Default::default() - }, - ) - .unwrap_err(); - - assert_eq!(err, MxcConfigError::ProcessResourceLimitsRequireAxisLayer); - let config = build_mxc_process_config( "agent --version", &spec, MxcProcessConfigOptions { container_id: Some("axis-windows".into()), - resource_limits_enforced_by_axis: true, + resource_limits_enforced_by_axis: false, ..Default::default() }, ) .unwrap(); - assert_eq!(config.containment, MxcContainment::ProcessContainer); + let resources = &config.process_container.unwrap().resources; + assert_eq!(resources.max_processes, 32); + assert_eq!(resources.max_memory_mb, 1024); + assert_eq!(resources.cpu_rate_percent, 50); } #[test] @@ -2126,8 +2208,12 @@ mod tests { id: BackendCapabilityMapId, mode: NetworkMode, ) -> Result { + let mut policy = process_policy(mode); + if id == BackendCapabilityMapId::MxcWindowsProcessContainer { + policy.filesystem.deny.clear(); + } build_process_backend_execution_spec( - &process_policy(mode), + &policy, id, process_launch(), &present_runtime_for_backend(id), diff --git a/crates/axis-core/src/policy.rs b/crates/axis-core/src/policy.rs index 190eee1..7c220c6 100644 --- a/crates/axis-core/src/policy.rs +++ b/crates/axis-core/src/policy.rs @@ -519,6 +519,16 @@ pub struct ProcessPolicy { #[serde(default)] pub blocked_syscalls: Vec, + /// Portable identity intent. Unlike `run_as_user`, this does not name a + /// host account and can map to a BaseContainer/AppContainer identity. + #[serde(default)] + pub identity: ProcessIdentity, + + /// Portable descendant-process intent. `deny` maps to a child-tree process + /// limit of one rather than to platform-specific syscall names. + #[serde(default)] + pub child_processes: ChildProcessPolicy, + /// Maximum wall-clock time in seconds before auto-destroy. None = no timeout. #[serde(default)] pub timeout_sec: Option, @@ -532,6 +542,8 @@ impl Default for ProcessPolicy { cpu_rate_percent: default_cpu_rate(), run_as_user: None, blocked_syscalls: Vec::new(), + identity: ProcessIdentity::default(), + child_processes: ChildProcessPolicy::default(), timeout_sec: None, } } @@ -547,6 +559,30 @@ impl ProcessPolicy { } Ok(()) } + + pub fn effective_max_processes(&self) -> u32 { + if matches!(self.child_processes, ChildProcessPolicy::Deny) { + 1 + } else { + self.max_processes + } + } +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ProcessIdentity { + #[default] + BackendDefault, + Isolated, +} + +#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum ChildProcessPolicy { + #[default] + Allow, + Deny, } fn default_max_processes() -> u32 { @@ -1187,6 +1223,17 @@ runtime: assert_eq!(policy.process.cpu_rate_percent, 0); } + #[test] + fn portable_process_intents_parse_and_derive_effective_limit() { + let policy = Policy::from_yaml( + "version: 1\nname: portable-process\nprocess:\n identity: isolated\n child_processes: deny\n", + ) + .unwrap(); + assert_eq!(policy.process.identity, ProcessIdentity::Isolated); + assert_eq!(policy.process.child_processes, ChildProcessPolicy::Deny); + assert_eq!(policy.process.effective_max_processes(), 1); + } + #[test] fn reject_invalid_cpu_rate() { let yaml = "version: 1\nname: test\nprocess:\n cpu_rate_percent: 101\n"; diff --git a/crates/axis-core/src/process_backend.rs b/crates/axis-core/src/process_backend.rs index 5a5d9c7..f01b266 100644 --- a/crates/axis-core/src/process_backend.rs +++ b/crates/axis-core/src/process_backend.rs @@ -337,7 +337,7 @@ pub fn build_process_backend_execution_spec( filesystem: process_filesystem_spec(policy), network: process_network_spec(policy), resources: ProcessBackendResourceSpec { - max_processes: policy.process.max_processes, + max_processes: policy.process.effective_max_processes(), max_memory_mb: policy.process.max_memory_mb, cpu_rate_percent: policy.process.cpu_rate_percent, }, @@ -600,7 +600,10 @@ mod tests { #[test] fn process_execution_spec_covers_all_process_backend_formats() { for descriptor in process_backend_descriptors() { - let policy = process_policy(NetworkMode::Allow); + let mut policy = process_policy(NetworkMode::Allow); + if descriptor.id == BackendCapabilityMapId::MxcWindowsProcessContainer { + policy.filesystem.deny.clear(); + } let runtime = present_runtime_for_backend(descriptor.id); let result = build_process_backend_execution_spec( &policy, @@ -696,24 +699,24 @@ mod tests { } #[test] - fn process_execution_spec_for_windows_mxc_rejects_proxy_before_config() { + fn process_execution_spec_for_windows_mxc_accepts_proxy_with_wfp_dependencies() { let runtime = present_runtime_for_backend(BackendCapabilityMapId::MxcWindowsProcessContainer); + let mut policy = process_policy(NetworkMode::Proxy); + // BaseContainer deny normalization is owned by the Windows adapter and + // runs before this platform-neutral execution-spec builder. + policy.filesystem.deny.clear(); - let err = build_process_backend_execution_spec( - &process_policy(NetworkMode::Proxy), + let spec = build_process_backend_execution_spec( + &policy, BackendCapabilityMapId::MxcWindowsProcessContainer, launch_options(), &runtime, &PlannerOptions::new(), ) - .unwrap_err(); + .unwrap(); - assert!(matches!( - err, - ProcessBackendSpecError::RejectedBeforeConfig(_) - )); - assert!(err.to_string().contains("audit.bypass_evidence")); + assert_eq!(spec.network.mode, ProcessBackendNetworkMode::StrictProxy); } #[test] @@ -750,8 +753,13 @@ mod tests { for descriptor in process_backend_descriptors() { let runtime = present_runtime_for_backend(descriptor.id); + let mut backend_policy = policy.clone(); + if descriptor.id == BackendCapabilityMapId::MxcWindowsProcessContainer { + // BaseContainer deny normalization precedes the portable planner. + backend_policy.filesystem.deny.clear(); + } let plan = plan_process_backend_policy( - &policy, + &backend_policy, descriptor.id, &runtime, &PlannerOptions::new(), @@ -810,8 +818,9 @@ mod tests { } #[test] - fn windows_process_resources_require_job_object_dependency() { + fn mxc_windows_resources_require_job_object_dependency() { let mut policy = process_policy(NetworkMode::Allow); + policy.filesystem.deny.clear(); policy.process.max_processes = 8; policy.process.max_memory_mb = 256; policy.process.cpu_rate_percent = 50; @@ -839,6 +848,46 @@ mod tests { ); } + #[test] + fn mxc_windows_resources_use_inner_job_accounting() { + let mut policy = process_policy(NetworkMode::Allow); + policy.filesystem.deny.clear(); + policy.process.max_processes = 8; + policy.process.max_memory_mb = 256; + policy.process.cpu_rate_percent = 50; + let runtime = + present_runtime_for_backend(BackendCapabilityMapId::MxcWindowsProcessContainer); + let plan = plan_process_backend_policy( + &policy, + BackendCapabilityMapId::MxcWindowsProcessContainer, + &runtime, + &PlannerOptions::new(), + ) + .unwrap(); + + assert!(plan.spawn_allowed(), "{:?}", plan.pre_spawn_error()); + for requirement in [ + "resources.process_count", + "resources.memory", + "resources.cpu", + "cleanup.resources", + ] { + let decision = plan + .policy_plan + .decisions + .iter() + .find(|decision| decision.requirement == requirement) + .unwrap(); + assert!( + !matches!( + decision.support, + CapabilitySupport::Unsupported { .. } | CapabilitySupport::WeakerOnly { .. } + ), + "{decision:?}" + ); + } + } + #[test] fn planner_records_process_surfaces_for_mxc_linux() { let runtime = RuntimeProbeSnapshot::new() @@ -1003,7 +1052,7 @@ mod tests { } #[test] - fn windows_processcontainer_rejects_proxy_when_bypass_evidence_is_unmapped() { + fn windows_processcontainer_requires_wfp_for_proxy_and_bypass_evidence() { let runtime = RuntimeProbeSnapshot::new() .with_dependency(host_dependency::MXC_EXECUTOR, DependencyState::Present) .with_dependency( @@ -1011,7 +1060,9 @@ mod tests { DependencyState::Present, ) .with_dependency(host_dependency::WINDOWS_JOBOBJECT, DependencyState::Present); - let policy = process_policy(NetworkMode::Proxy); + let mut policy = process_policy(NetworkMode::Proxy); + // BaseContainer deny normalization precedes the portable planner. + policy.filesystem.deny.clear(); let plan = plan_process_backend_policy( &policy, @@ -1023,11 +1074,11 @@ mod tests { assert!(matches!( plan.policy_plan.outcome, - BackendPlanOutcome::Unsupported { .. } + BackendPlanOutcome::ExactWithHostDependency { .. } )); assert!(!plan.spawn_allowed()); let error = plan.pre_spawn_error().unwrap(); - assert!(error.contains("audit.bypass_evidence")); + assert!(error.contains(host_dependency::WINDOWS_WFP_BROKER)); let strict_proxy = plan .policy_plan @@ -1037,8 +1088,43 @@ mod tests { .unwrap(); assert!(matches!( strict_proxy.support, - CapabilitySupport::WeakerOnly { .. } + CapabilitySupport::ExactWithHostDependency { .. } )); + + let runtime = runtime.with_dependency( + host_dependency::WINDOWS_WFP_BROKER, + DependencyState::Present, + ); + let plan = plan_process_backend_policy( + &policy, + BackendCapabilityMapId::MxcWindowsProcessContainer, + &runtime, + &PlannerOptions::new(), + ) + .unwrap(); + assert!(plan.spawn_allowed(), "{:?}", plan.pre_spawn_error()); + } + + #[test] + fn windows_binary_attribution_remains_fail_closed_despite_wfp() { + let runtime = + present_runtime_for_backend(BackendCapabilityMapId::MxcWindowsProcessContainer); + let mut policy = process_policy(NetworkMode::Proxy); + policy.filesystem.deny.clear(); + policy.network.policies.push(endpoint_policy_with_binary()); + + let plan = plan_process_backend_policy( + &policy, + BackendCapabilityMapId::MxcWindowsProcessContainer, + &runtime, + &PlannerOptions::new(), + ) + .unwrap(); + + assert!(!plan.spawn_allowed()); + let error = plan.pre_spawn_error().unwrap(); + assert!(error.contains("network.binary_attribution")); + assert!(error.contains("PID reuse")); } #[test] @@ -1160,6 +1246,8 @@ mod tests { cpu_rate_percent: 0, run_as_user: None, blocked_syscalls: Vec::new(), + identity: Default::default(), + child_processes: Default::default(), timeout_sec: None, }, network: NetworkPolicy { diff --git a/crates/axis-core/src/vm_backend.rs b/crates/axis-core/src/vm_backend.rs index 3d93a6e..5503355 100644 --- a/crates/axis-core/src/vm_backend.rs +++ b/crates/axis-core/src/vm_backend.rs @@ -1434,6 +1434,8 @@ mod tests { cpu_rate_percent: 0, run_as_user: None, blocked_syscalls: Vec::new(), + identity: Default::default(), + child_processes: Default::default(), timeout_sec: None, }, network: NetworkPolicy { diff --git a/crates/axis-daemon/src/sandbox_mgr.rs b/crates/axis-daemon/src/sandbox_mgr.rs index 91f2f5c..8141fe6 100644 --- a/crates/axis-daemon/src/sandbox_mgr.rs +++ b/crates/axis-daemon/src/sandbox_mgr.rs @@ -1047,6 +1047,8 @@ fn proxy_bind_addr_for_sandbox(id: SandboxId, proxy_port: u16, policy: &Policy) } let _ = id; + #[cfg(not(target_os = "linux"))] + let _ = policy; format!("127.0.0.1:{proxy_port}").parse().unwrap() } diff --git a/crates/axis-proxy/Cargo.toml b/crates/axis-proxy/Cargo.toml index 6b9da86..95dc72f 100644 --- a/crates/axis-proxy/Cargo.toml +++ b/crates/axis-proxy/Cargo.toml @@ -18,6 +18,7 @@ thiserror = { workspace = true } anyhow = { workspace = true } uuid = { workspace = true } url = { workspace = true } +serde_json = { workspace = true } [dev-dependencies] tempfile = { workspace = true } diff --git a/crates/axis-proxy/src/proxy.rs b/crates/axis-proxy/src/proxy.rs index 7a247e3..b5b43a5 100644 --- a/crates/axis-proxy/src/proxy.rs +++ b/crates/axis-proxy/src/proxy.rs @@ -16,9 +16,10 @@ use axis_core::connect_attribution::{ policy_requires_connect_attribution, }; use axis_core::opa::PolicyEngine; -use axis_core::policy::Policy; +use axis_core::policy::{ExhaustAction, Policy, TokenBudget}; use axis_core::types::{NetworkAction, SandboxId}; use axis_safety::leak_detect::LeakDetector; +use std::collections::HashSet; use std::net::{IpAddr, Ipv4Addr, Ipv6Addr, SocketAddr}; #[cfg(target_os = "linux")] use std::os::fd::AsRawFd; @@ -148,6 +149,67 @@ struct ProxyState { credential_injector: CredentialInjector, connect_attribution: Option, identity_mode: ProxyIdentityMode, + inference_budget: Option, +} + +struct InferenceBudget { + config: TokenBudget, + reserved_tokens: u64, + window_start: Instant, + hosts: HashSet, +} + +impl InferenceBudget { + fn new(config: TokenBudget, hosts: HashSet) -> Result { + if !matches!(config.action_on_exhaust, ExhaustAction::Reject) { + return Err(ProxyError::BindFailed( + "inference token budgets currently support only action_on_exhaust: reject; queue and fallback require a trusted request scheduler" + .into(), + )); + } + if config.max_tokens_per_hour == 0 || config.max_tokens_per_request == 0 { + return Err(ProxyError::BindFailed( + "inference token budget limits must be greater than zero".into(), + )); + } + Ok(Self { + config, + reserved_tokens: 0, + window_start: Instant::now(), + hosts, + }) + } + + fn applies_to(&self, hostname: &str) -> bool { + self.hosts.contains(&hostname.to_ascii_lowercase()) + } + + fn reserve(&mut self, request_body: &[u8]) -> Result { + if self.window_start.elapsed() >= Duration::from_secs(3600) { + self.reserved_tokens = 0; + self.window_start = Instant::now(); + } + let input_upper_bound = request_body.len() as u64; + let declared_output = declared_output_tokens(request_body)?.ok_or_else(|| { + "token-budgeted requests must declare max_tokens, max_completion_tokens, or max_output_tokens" + .to_string() + })?; + let requested = input_upper_bound.saturating_add(declared_output); + if requested > self.config.max_tokens_per_request { + return Err(format!( + "request reserves {requested} tokens, exceeding per-request limit {}", + self.config.max_tokens_per_request + )); + } + if self.reserved_tokens.saturating_add(requested) > self.config.max_tokens_per_hour { + return Err(format!( + "hourly token budget exhausted: {} reserved, {requested} requested, {} allowed", + self.reserved_tokens, self.config.max_tokens_per_hour + )); + } + self.reserved_tokens = self.reserved_tokens.saturating_add(requested); + Ok(requested) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -198,6 +260,14 @@ impl AxisProxy { let credential_injector = CredentialInjector::from_policy(&config.policy) .map_err(|e| ProxyError::BindFailed(format!("credential injection: {e}")))?; let identity_mode = proxy_identity_mode(&config.policy, config.enable_identity_diagnostics); + let inference_hosts = inference_route_hosts(&config.policy); + let inference_budget = config + .policy + .inference + .token_budget + .clone() + .map(|budget| InferenceBudget::new(budget, inference_hosts)) + .transpose()?; let state = Arc::new(Mutex::new(ProxyState { policy_engine, @@ -207,6 +277,7 @@ impl AxisProxy { credential_injector, connect_attribution: config.connect_attribution.clone(), identity_mode, + inference_budget, })); Ok(Self { @@ -602,12 +673,12 @@ async fn handle_connection( st.leak_detector.is_some() }; - let credential_injection_enabled = { + let request_policy_enabled = { let st = state.lock().unwrap(); - st.credential_injector.has_rules() + st.credential_injector.has_rules() || st.inference_budget.is_some() }; - if leak_enabled || credential_injection_enabled { + if leak_enabled || request_policy_enabled { relay_with_leak_detection(sandbox_id, &host, port, false, reader, upstream, state).await } else { relay_plain(reader, upstream).await @@ -826,12 +897,16 @@ where R: tokio::io::AsyncRead + Unpin, W: tokio::io::AsyncWrite + Unpin, { - let requires_injection = { + let requires_request_policy = { let st = state.lock().unwrap(); st.credential_injector .connection_requires_injection(hostname, port, is_tls) + || st + .inference_budget + .as_ref() + .is_some_and(|budget| budget.applies_to(hostname)) }; - if !requires_injection { + if !requires_request_policy { return relay_scanned_bytes(sandbox_id, client_read, upstream_write, state).await; } @@ -878,10 +953,48 @@ where let rewritten = { let st = state.lock().unwrap(); st.credential_injector - .rewrite_http_request_head_with_body_length(hostname, port, is_tls, &head) + .inspect_http_request_head_with_body_length(hostname, port, is_tls, &head) .map_err(secret_error_to_io)? }; let next_body_len = rewritten.body_length; + let budgeted = { + let st = state.lock().unwrap(); + st.inference_budget + .as_ref() + .is_some_and(|budget| budget.applies_to(hostname)) + && is_token_generating_inference_request(&head, hostname) + }; + if budgeted { + let complete_request_len = + head_end.checked_add(next_body_len).ok_or_else(|| { + std::io::Error::new( + std::io::ErrorKind::InvalidData, + "inference request length overflow", + ) + })?; + if pending.len() < complete_request_len { + break; + } + let body = &pending[head_end..complete_request_len]; + let mut st = state.lock().unwrap(); + let result = st + .inference_budget + .as_mut() + .expect("budget presence checked") + .reserve(body); + match result { + Ok(reserved) => { + tracing::info!("sandbox {sandbox_id}: reserved {reserved} inference tokens") + } + Err(reason) => { + st.audit_log.inference_budget_denied(sandbox_id, &reason); + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + format!("inference token budget denied request: {reason}"), + )); + } + } + } if let Some(rewritten_head) = rewritten.head { tokio::io::AsyncWriteExt::write_all(upstream_write, &rewritten_head).await?; } else { @@ -915,6 +1028,89 @@ where Ok(()) } +fn is_token_generating_inference_request(head: &[u8], hostname: &str) -> bool { + let Ok(text) = std::str::from_utf8(head) else { + return false; + }; + let Some(request_line) = text.lines().next() else { + return false; + }; + let mut parts = request_line.split_whitespace(); + let Some(method) = parts.next() else { + return false; + }; + let Some(path) = parts.next() else { + return false; + }; + let path = path.split('?').next().unwrap_or(path).trim_end_matches('/'); + let known_path = matches!( + path, + "/v1/chat/completions" + | "/chat/completions" + | "/v1/completions" + | "/v1/responses" + | "/v1/messages" + | "/messages" + ); + let _ = hostname; + method.eq_ignore_ascii_case("POST") && known_path +} + +fn declared_output_tokens(body: &[u8]) -> Result, String> { + let value: serde_json::Value = serde_json::from_slice(body) + .map_err(|error| format!("inference request body is not valid JSON: {error}"))?; + for field in ["max_output_tokens", "max_completion_tokens", "max_tokens"] { + if let Some(value) = value.get(field) { + return value + .as_u64() + .map(Some) + .ok_or_else(|| format!("{field} must be a non-negative integer")); + } + } + Ok(None) +} + +fn inference_route_hosts(policy: &Policy) -> HashSet { + let mut hosts = HashSet::from(["inference.local".to_string()]); + for route in &policy.inference.routes { + if let Some(endpoint) = route.endpoint.as_deref() + && let Some(host) = inference_endpoint_host(endpoint) + { + hosts.insert(host); + } + if let Some(provider) = route.provider.as_deref() { + match provider.to_ascii_lowercase().as_str() { + "openai" | "openai-compatible" => { + hosts.insert("api.openai.com".into()); + } + "anthropic" => { + hosts.insert("api.anthropic.com".into()); + } + _ => {} + } + } + } + hosts +} + +fn inference_endpoint_host(endpoint: &str) -> Option { + let authority = endpoint + .strip_prefix("https://") + .or_else(|| endpoint.strip_prefix("http://"))? + .split(['/', '?', '#']) + .next()?; + if let Some(rest) = authority.strip_prefix('[') { + return rest + .split_once(']') + .map(|(host, _)| host.to_ascii_lowercase()); + } + let host = authority + .rsplit_once(':') + .filter(|(_, port)| port.parse::().is_ok()) + .map_or(authority, |(host, _)| host); + (!host.is_empty()).then(|| host.to_ascii_lowercase()) +} + async fn relay_scanned_bytes( sandbox_id: SandboxId, client_read: &mut R, @@ -1525,6 +1721,68 @@ network: proxy_task.abort(); } + #[test] + fn inference_budget_reserves_conservative_input_and_declared_output() { + let mut budget = InferenceBudget::new( + TokenBudget { + max_tokens_per_hour: 1_000, + max_tokens_per_request: 500, + action_on_exhaust: ExhaustAction::Reject, + fallback_route: None, + }, + HashSet::from(["inference.local".into()]), + ) + .unwrap(); + let body = br#"{"model":"test","max_tokens":100}"#; + assert_eq!(budget.reserve(body).unwrap(), body.len() as u64 + 100); + assert_eq!(budget.reserved_tokens, body.len() as u64 + 100); + } + + #[test] + fn inference_budget_fails_closed_for_oversize_and_unsupported_actions() { + let mut budget = InferenceBudget::new( + TokenBudget { + max_tokens_per_hour: 1_000, + max_tokens_per_request: 100, + action_on_exhaust: ExhaustAction::Reject, + fallback_route: None, + }, + HashSet::new(), + ) + .unwrap(); + assert!(budget.reserve(br#"{"max_output_tokens":101}"#).is_err()); + assert!(budget.reserve(br#"{"model":"missing-limit"}"#).is_err()); + + let error = InferenceBudget::new( + TokenBudget { + max_tokens_per_hour: 1_000, + max_tokens_per_request: 100, + action_on_exhaust: ExhaustAction::Queue, + fallback_route: None, + }, + HashSet::new(), + ) + .err() + .unwrap(); + assert!(error.to_string().contains("trusted request scheduler")); + } + + #[test] + fn budget_detection_is_limited_to_token_generating_inference_posts() { + assert!(is_token_generating_inference_request( + b"POST /v1/chat/completions HTTP/1.1\r\n\r\n", + "inference.local" + )); + assert!(!is_token_generating_inference_request( + b"GET /v1/models HTTP/1.1\r\n\r\n", + "inference.local" + )); + assert!(!is_token_generating_inference_request( + b"POST /unrelated HTTP/1.1\r\n\r\n", + "inference.local" + )); + } + #[cfg(target_os = "linux")] #[test] fn linux_freebind_is_used_only_for_nonlocal_ipv4_binds() { diff --git a/crates/axis-proxy/src/secrets.rs b/crates/axis-proxy/src/secrets.rs index ff9b74f..4bc58a7 100644 --- a/crates/axis-proxy/src/secrets.rs +++ b/crates/axis-proxy/src/secrets.rs @@ -208,6 +208,39 @@ impl CredentialInjector { connect_port: u16, is_tls: bool, head: &[u8], + ) -> Result { + self.rewrite_http_request_head_with_body_length_impl( + connect_host, + connect_port, + is_tls, + head, + false, + ) + } + + pub(crate) fn inspect_http_request_head_with_body_length( + &self, + connect_host: &str, + connect_port: u16, + is_tls: bool, + head: &[u8], + ) -> Result { + self.rewrite_http_request_head_with_body_length_impl( + connect_host, + connect_port, + is_tls, + head, + true, + ) + } + + fn rewrite_http_request_head_with_body_length_impl( + &self, + connect_host: &str, + connect_port: u16, + is_tls: bool, + head: &[u8], + inspect_without_rules: bool, ) -> Result { let host = normalize_host(connect_host); let rules: Vec<_> = self @@ -215,13 +248,12 @@ impl CredentialInjector { .iter() .filter(|rule| rule.matches_connection(&host, connect_port, is_tls)) .collect(); - if rules.is_empty() { + if rules.is_empty() && !inspect_without_rules { return Ok(RewrittenHttpRequestHead { head: None, body_length: 0, }); } - let scheme = EndpointScheme::from_tls(is_tls); let parsed = ParsedHttpHead::parse(head, scheme)?; let connect_authority = NormalizedAuthority { @@ -233,6 +265,12 @@ impl CredentialInjector { "Host authority does not match CONNECT destination".into(), )); } + if rules.is_empty() { + return Ok(RewrittenHttpRequestHead { + head: None, + body_length: parsed.body_length, + }); + } let Some(rule) = rules .into_iter() .find(|rule| rule.applies_to(&parsed, &host)) diff --git a/crates/axis-proxy/tests/proxy_opa_integration.rs b/crates/axis-proxy/tests/proxy_opa_integration.rs index 44d5a4a..1926dd4 100644 --- a/crates/axis-proxy/tests/proxy_opa_integration.rs +++ b/crates/axis-proxy/tests/proxy_opa_integration.rs @@ -1329,6 +1329,86 @@ inference: assert!(!request.contains("AXIS_TEST_PROXY_PROVIDER_KEY")); } +#[tokio::test] +async fn inference_token_budget_allows_bounded_request_and_rejects_oversize_before_forwarding() { + let policy = r#" +version: 1 +name: proxy-token-budget-test + +network: + mode: proxy + policies: + - name: inference + endpoints: + - host: "inference.local" + port: 443 + access: read-write + +inference: + routes: + - name: local + endpoint: "http://inference.local:443" + token_budget: + max_tokens_per_hour: 1000 + max_tokens_per_request: 100 + action_on_exhaust: reject +"#; + + let (allowed_upstream, allowed_received) = + start_recording_http_server_until("max_tokens").await; + let (_sandbox_id, allowed_proxy) = + start_proxy_with_policy(policy, Some(allowed_upstream)).await; + let mut allowed = TcpStream::connect(allowed_proxy).await.unwrap(); + allowed + .write_all(b"CONNECT inference.local:443 HTTP/1.1\r\nHost: inference.local\r\n\r\n") + .await + .unwrap(); + let mut reader = BufReader::new(allowed); + let mut response = String::new(); + reader.read_line(&mut response).await.unwrap(); + assert!(response.contains("200")); + let body = br#"{"max_tokens":10}"#; + let request = format!( + "POST /v1/chat/completions HTTP/1.1\r\nHost: inference.local:443\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + String::from_utf8_lossy(body) + ); + reader + .into_inner() + .write_all(request.as_bytes()) + .await + .unwrap(); + assert!(allowed_received.await.unwrap().contains("max_tokens")); + + let (denied_upstream, denied_received) = start_recording_http_server().await; + let (_sandbox_id, denied_proxy) = start_proxy_with_policy(policy, Some(denied_upstream)).await; + let mut denied = TcpStream::connect(denied_proxy).await.unwrap(); + denied + .write_all(b"CONNECT inference.local:443 HTTP/1.1\r\nHost: inference.local\r\n\r\n") + .await + .unwrap(); + let mut reader = BufReader::new(denied); + response.clear(); + reader.read_line(&mut response).await.unwrap(); + assert!(response.contains("200")); + let body = br#"{"max_tokens":101}"#; + let request = format!( + "POST /v1/chat/completions HTTP/1.1\r\nHost: inference.local:443\r\nContent-Length: {}\r\n\r\n{}", + body.len(), + String::from_utf8_lossy(body) + ); + reader + .into_inner() + .write_all(request.as_bytes()) + .await + .unwrap(); + let denied_request = denied_received.await.unwrap(); + assert!( + denied_request.is_empty(), + "oversize inference body reached provider: {denied_request:?}" + ); +} + #[tokio::test] async fn provider_credentials_fail_closed_for_unbound_or_ambiguous_host() { unsafe { diff --git a/crates/axis-pty/src/windows.rs b/crates/axis-pty/src/windows.rs index f5171b8..1b9e42a 100644 --- a/crates/axis-pty/src/windows.rs +++ b/crates/axis-pty/src/windows.rs @@ -18,9 +18,9 @@ pub struct WindowsPtyMaster { /// Handle to the pseudoconsole (HPCON). hpc: isize, /// Read handle — reads child output from the console. - read_handle: std::os::windows::io::OwnedHandle, + _read_handle: std::os::windows::io::OwnedHandle, /// Write handle — writes input to the child via console. - write_handle: std::os::windows::io::OwnedHandle, + _write_handle: std::os::windows::io::OwnedHandle, } /// Slave info for Windows ConPTY — the child process needs the HPCON @@ -136,8 +136,8 @@ pub fn create_pty_windows(size: WinSize) -> Result { let master = WindowsPtyMaster { hpc, - read_handle: unsafe { OwnedHandle::from_raw_handle(pipe_out_read as *mut _) }, - write_handle: unsafe { OwnedHandle::from_raw_handle(pipe_in_write as *mut _) }, + _read_handle: unsafe { OwnedHandle::from_raw_handle(pipe_out_read as *mut _) }, + _write_handle: unsafe { OwnedHandle::from_raw_handle(pipe_in_write as *mut _) }, }; let slave = ConPtySlave { diff --git a/crates/axis-sandbox/Cargo.toml b/crates/axis-sandbox/Cargo.toml index 68c566e..11cbeee 100644 --- a/crates/axis-sandbox/Cargo.toml +++ b/crates/axis-sandbox/Cargo.toml @@ -27,9 +27,16 @@ libc = { workspace = true } [target.'cfg(target_os = "windows")'.dependencies] windows = { version = "0.62", features = [ "Win32_Foundation", + "Win32_Globalization", + "Win32_NetworkManagement_WindowsFilteringPlatform", "Win32_Security", "Win32_Security_Authorization", + "Win32_System_Diagnostics_ToolHelp", + "Win32_System_IO", "Win32_System_JobObjects", + "Win32_System_Pipes", + "Win32_System_Rpc", + "Win32_System_Services", "Win32_System_Threading", "Win32_System_Memory", "Win32_Storage_FileSystem", diff --git a/crates/axis-sandbox/src/bin/axis-ssh-proxy.rs b/crates/axis-sandbox/src/bin/axis-ssh-proxy.rs new file mode 100644 index 0000000..14e112f --- /dev/null +++ b/crates/axis-sandbox/src/bin/axis-ssh-proxy.rs @@ -0,0 +1,67 @@ +// Copyright 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Narrow HTTP CONNECT transport used by generated Windows OpenSSH config. + +use std::io::{Read, Write}; +use std::net::TcpStream; + +fn main() { + if let Err(error) = run() { + eprintln!("axis-ssh-proxy: {error}"); + std::process::exit(1); + } +} + +fn run() -> Result<(), Box> { + let mut arguments = std::env::args().skip(1); + let host = arguments.next().ok_or("missing SSH host")?; + let port = arguments.next().ok_or("missing SSH port")?.parse::()?; + if arguments.next().is_some() + || host.is_empty() + || host.chars().any(|character| { + character.is_control() || character.is_whitespace() || matches!(character, '/' | '\\') + }) + { + return Err("invalid SSH CONNECT target".into()); + } + + let proxy = std::env::var("HTTP_PROXY").or_else(|_| std::env::var("http_proxy"))?; + let proxy = proxy + .strip_prefix("http://") + .ok_or("HTTP_PROXY must use http://")?; + let mut stream = TcpStream::connect(proxy)?; + write!( + stream, + "CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n" + )?; + stream.flush()?; + + let response = read_connect_response(&mut stream)?; + if !response.starts_with("HTTP/1.1 200") { + return Err(format!("proxy rejected {host}:{port}: {}", response.trim()).into()); + } + + let mut upload = stream.try_clone()?; + let input = std::thread::spawn(move || { + let _ = std::io::copy(&mut std::io::stdin().lock(), &mut upload); + }); + std::io::copy(&mut stream, &mut std::io::stdout().lock())?; + let _ = input.join(); + Ok(()) +} + +fn read_connect_response(stream: &mut TcpStream) -> Result> { + let mut bytes = Vec::new(); + let mut byte = [0u8; 1]; + while bytes.len() < 64 * 1024 { + if stream.read(&mut byte)? == 0 { + break; + } + bytes.push(byte[0]); + if bytes.ends_with(b"\r\n\r\n") { + return Ok(String::from_utf8(bytes)?); + } + } + Err("incomplete HTTP CONNECT response".into()) +} diff --git a/crates/axis-sandbox/src/bin/axis-wfp-broker.rs b/crates/axis-sandbox/src/bin/axis-wfp-broker.rs new file mode 100644 index 0000000..4e48390 --- /dev/null +++ b/crates/axis-sandbox/src/bin/axis-wfp-broker.rs @@ -0,0 +1,180 @@ +// Copyright 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: Apache-2.0 + +#[cfg(not(target_os = "windows"))] +fn main() { + eprintln!("axis-wfp-broker is available only on Windows"); + std::process::exit(2); +} + +#[cfg(target_os = "windows")] +fn main() { + if let Err(error) = windows_main() { + eprintln!("axis-wfp-broker: {error}"); + std::process::exit(1); + } +} + +#[cfg(target_os = "windows")] +fn windows_main() -> Result<(), String> { + use axis_sandbox::windows::wfp::{BrokerConfig, serve}; + use std::sync::Arc; + use std::sync::atomic::AtomicBool; + + let mut args = std::env::args_os(); + let _program = args.next(); + let command = args + .next() + .and_then(|value| value.into_string().ok()) + .unwrap_or_else(|| "run".into()); + if args.next().is_some() { + return Err("usage: axis-wfp-broker [run|service|probe]".into()); + } + + match command.as_str() { + "run" => serve(BrokerConfig::default(), Arc::new(AtomicBool::new(false))), + "service" => service::dispatch(), + "probe" => probe(), + _ => Err("usage: axis-wfp-broker [run|service|probe]".into()), + } +} + +#[cfg(target_os = "windows")] +fn probe() -> Result<(), String> { + use windows::Win32::Foundation::HANDLE; + use windows::Win32::NetworkManagement::WindowsFilteringPlatform::{ + FWPM_SESSION_FLAG_DYNAMIC, FWPM_SESSION0, FwpmEngineClose0, FwpmEngineOpen0, + }; + use windows::Win32::System::Rpc::RPC_C_AUTHN_WINNT; + use windows::core::PCWSTR; + + let mut engine = HANDLE::default(); + let session = FWPM_SESSION0 { + flags: FWPM_SESSION_FLAG_DYNAMIC, + txnWaitTimeoutInMSec: 1_000, + ..Default::default() + }; + let status = unsafe { + FwpmEngineOpen0( + PCWSTR::null(), + RPC_C_AUTHN_WINNT, + None, + Some(&session), + &mut engine, + ) + }; + if status != 0 { + return Err(format!( + "Windows Filtering Platform engine unavailable: 0x{status:08X}" + )); + } + unsafe { + let _ = FwpmEngineClose0(engine); + } + println!("AXIS_WFP_BROKER_PROBE_OK"); + Ok(()) +} + +#[cfg(target_os = "windows")] +mod service { + use axis_sandbox::windows::wfp::{BrokerConfig, DEFAULT_PIPE_NAME, serve}; + use std::fs::OpenOptions; + use std::sync::atomic::{AtomicBool, Ordering}; + use std::sync::{Arc, OnceLock}; + use windows::Win32::System::Services::{ + RegisterServiceCtrlHandlerExW, SERVICE_ACCEPT_SHUTDOWN, SERVICE_ACCEPT_STOP, + SERVICE_CONTROL_SHUTDOWN, SERVICE_CONTROL_STOP, SERVICE_RUNNING, SERVICE_START_PENDING, + SERVICE_STATUS, SERVICE_STATUS_HANDLE, SERVICE_STOP_PENDING, SERVICE_STOPPED, + SERVICE_TABLE_ENTRYW, SERVICE_WIN32_OWN_PROCESS, SetServiceStatus, + StartServiceCtrlDispatcherW, + }; + use windows::core::{PWSTR, w}; + + const SERVICE_NAME: windows::core::PCWSTR = w!("AxisWfpBroker"); + static STOP: OnceLock> = OnceLock::new(); + static STATUS: OnceLock = OnceLock::new(); + + pub(super) fn dispatch() -> Result<(), String> { + let mut name = "AxisWfpBroker\0".encode_utf16().collect::>(); + let entries = [ + SERVICE_TABLE_ENTRYW { + lpServiceName: PWSTR(name.as_mut_ptr()), + lpServiceProc: Some(service_main), + }, + SERVICE_TABLE_ENTRYW::default(), + ]; + unsafe { StartServiceCtrlDispatcherW(entries.as_ptr()) } + .map_err(|e| format!("StartServiceCtrlDispatcherW failed: {e}")) + } + + unsafe extern "system" fn service_main(_argc: u32, _argv: *mut PWSTR) { + let handle = match unsafe { + RegisterServiceCtrlHandlerExW(SERVICE_NAME, Some(control_handler), None) + } { + Ok(handle) => handle, + Err(_) => return, + }; + let _ = STATUS.set(handle.0 as usize); + report(SERVICE_START_PENDING, 0, 10_000); + + let stop = Arc::new(AtomicBool::new(false)); + let _ = STOP.set(stop.clone()); + report( + SERVICE_RUNNING, + SERVICE_ACCEPT_STOP | SERVICE_ACCEPT_SHUTDOWN, + 0, + ); + let exit_code = match serve(BrokerConfig::default(), stop) { + Ok(()) => 0, + Err(_) => 1, + }; + report(SERVICE_STOPPED, 0, 0); + if exit_code != 0 { + // SCM observes the nonzero service-specific result in the final + // status reported below on a future extension; for now the broker + // audit log carries the actionable error without exposing secrets. + } + } + + unsafe extern "system" fn control_handler( + control: u32, + _event_type: u32, + _event_data: *mut core::ffi::c_void, + _context: *mut core::ffi::c_void, + ) -> u32 { + if control == SERVICE_CONTROL_STOP || control == SERVICE_CONTROL_SHUTDOWN { + report(SERVICE_STOP_PENDING, 0, 5_000); + if let Some(stop) = STOP.get() { + stop.store(true, Ordering::Release); + } + // Wake a blocking ConnectNamedPipe. The server loop observes STOP + // before creating the next instance; this connection is rejected + // in its worker and disappears with process shutdown. + let _ = OpenOptions::new() + .read(true) + .write(true) + .open(DEFAULT_PIPE_NAME); + } + 0 + } + + fn report( + state: windows::Win32::System::Services::SERVICE_STATUS_CURRENT_STATE, + accepted: u32, + wait_hint: u32, + ) { + let Some(raw) = STATUS.get().copied() else { + return; + }; + let status = SERVICE_STATUS { + dwServiceType: SERVICE_WIN32_OWN_PROCESS, + dwCurrentState: state, + dwControlsAccepted: accepted, + dwWaitHint: wait_hint, + ..Default::default() + }; + unsafe { + let _ = SetServiceStatus(SERVICE_STATUS_HANDLE(raw as *mut _), &status); + } + } +} diff --git a/crates/axis-sandbox/src/linux/mod.rs b/crates/axis-sandbox/src/linux/mod.rs index b17956c..5b71f9a 100644 --- a/crates/axis-sandbox/src/linux/mod.rs +++ b/crates/axis-sandbox/src/linux/mod.rs @@ -1348,7 +1348,7 @@ fn prepare_rlimits_for_plan( }; let max_processes = match process_limit { strategy::ProcessLimitFallback::RlimitNprocWithDedicatedUser => { - Some(rlim_from_u64(u64::from(policy.max_processes))?) + Some(rlim_from_u64(u64::from(policy.effective_max_processes()))?) } strategy::ProcessLimitFallback::NotRequested => None, }; diff --git a/crates/axis-sandbox/src/linux/resources.rs b/crates/axis-sandbox/src/linux/resources.rs index 82e5d7a..8487547 100644 --- a/crates/axis-sandbox/src/linux/resources.rs +++ b/crates/axis-sandbox/src/linux/resources.rs @@ -231,7 +231,7 @@ fn cgroup_limits(policy: &ProcessPolicy) -> Result { } else { None }, - pids_max: (policy.max_processes > 0).then_some(policy.max_processes), + pids_max: (policy.effective_max_processes() > 0).then(|| policy.effective_max_processes()), cpu_max: (policy.cpu_rate_percent > 0).then(|| CpuMax { quota_us: CPU_PERIOD_US * u64::from(policy.cpu_rate_percent) / 100, period_us: CPU_PERIOD_US, @@ -354,6 +354,8 @@ mod tests { cpu_rate_percent: 25, run_as_user: None, blocked_syscalls: Vec::new(), + identity: Default::default(), + child_processes: Default::default(), timeout_sec: None, } } diff --git a/crates/axis-sandbox/src/linux/strategy.rs b/crates/axis-sandbox/src/linux/strategy.rs index 55784b9..d6a7b19 100644 --- a/crates/axis-sandbox/src/linux/strategy.rs +++ b/crates/axis-sandbox/src/linux/strategy.rs @@ -529,7 +529,7 @@ fn plan_resources( fallbacks: &mut Vec, ) -> Result { let memory_requested = policy.max_memory_mb > 0; - let process_requested = policy.max_processes > 0; + let process_requested = policy.effective_max_processes() > 0; let cpu_requested = policy.cpu_rate_percent > 0; if !memory_requested && !process_requested && !cpu_requested { diff --git a/crates/axis-sandbox/src/sandbox.rs b/crates/axis-sandbox/src/sandbox.rs index b8954a6..15d8260 100644 --- a/crates/axis-sandbox/src/sandbox.rs +++ b/crates/axis-sandbox/src/sandbox.rs @@ -250,7 +250,7 @@ fn prepare_managed_agent_workspace( manage_agent_workspace: bool, backend: PlatformBackendSelection, ) -> Result, SandboxError> { - if uses_mxc_managed_home_for_scoped_ssh(config, backend) { + if uses_mxc_managed_home(config, backend) { prepare_mxc_managed_home_workspace(config)?; return Ok(Vec::new()); } @@ -337,10 +337,7 @@ fn cleanup_prepared_agent_workspace_on_setup_failure(agent_symlinks: &[(PathBuf, crate::workspace::cleanup_agent_symlinks(agent_symlinks); } -fn uses_mxc_managed_home_for_scoped_ssh( - config: &SandboxConfig, - backend: PlatformBackendSelection, -) -> bool { +fn uses_mxc_managed_home(config: &SandboxConfig, backend: PlatformBackendSelection) -> bool { #[cfg(target_os = "linux")] { matches!( @@ -351,9 +348,18 @@ fn uses_mxc_managed_home_for_scoped_ssh( #[cfg(not(target_os = "linux"))] { - let _ = backend; - let _ = config; - false + #[cfg(target_os = "windows")] + { + let _ = config; + matches!(backend, PlatformBackendSelection::WindowsMxc) + } + + #[cfg(not(target_os = "windows"))] + { + let _ = backend; + let _ = config; + false + } } } @@ -371,9 +377,11 @@ fn prepare_mxc_managed_home_workspace(config: &mut SandboxConfig) -> Result<(), &mut config.policy.filesystem.read_write, )?; reject_unmanaged_home_grants_for_mxc_managed_home(config)?; - crate::workspace::prepare_ssh_workspace_at(&policy_name, &config.policy.ssh, &ssh_dir) - .map_err(|err| format!("scoped SSH workspace: {err}"))?; - push_unique_policy_path(&mut config.policy.filesystem.read_only, &ssh_dir)?; + if !config.policy.ssh.allowed_keys.is_empty() { + crate::workspace::prepare_ssh_workspace_at(&policy_name, &config.policy.ssh, &ssh_dir) + .map_err(|err| format!("scoped SSH workspace: {err}"))?; + push_unique_policy_path(&mut config.policy.filesystem.read_only, &ssh_dir)?; + } set_managed_home_env(&mut config.env, &managed_home)?; Ok(()) }) @@ -388,10 +396,37 @@ fn set_managed_home_env( let xdg_config_home = policy_path_string(&managed_home.join(".config"))?; let xdg_data_home = policy_path_string(&managed_home.join(".local/share"))?; let xdg_cache_home = policy_path_string(&managed_home.join(".cache"))?; - upsert_env(env, "HOME", home); + upsert_env(env, "HOME", home.clone()); upsert_env(env, "XDG_CONFIG_HOME", xdg_config_home); upsert_env(env, "XDG_DATA_HOME", xdg_data_home); upsert_env(env, "XDG_CACHE_HOME", xdg_cache_home); + #[cfg(target_os = "windows")] + { + let appdata = managed_home.join("AppData").join("Roaming"); + let local_appdata = managed_home.join("AppData").join("Local"); + for directory in [ + managed_home.join(".config"), + managed_home.join(".local").join("share"), + managed_home.join(".cache"), + appdata.clone(), + local_appdata.clone(), + ] { + std::fs::create_dir_all(&directory).map_err(|err| { + format!( + "create managed Windows home directory '{}': {err}", + directory.display() + ) + })?; + } + + upsert_env(env, "USERPROFILE", home.clone()); + upsert_env(env, "APPDATA", policy_path_string(&appdata)?); + upsert_env(env, "LOCALAPPDATA", policy_path_string(&local_appdata)?); + if home.as_bytes().get(1) == Some(&b':') { + upsert_env(env, "HOMEDRIVE", home[..2].to_string()); + upsert_env(env, "HOMEPATH", home[2..].to_string()); + } + } Ok(()) } @@ -489,6 +524,7 @@ fn reject_unmanaged_home_grants_for_mxc_managed_home(config: &SandboxConfig) -> )?; let workspace = normalize_existing_or_absolute_path(&config.workspace_dir)?; let tmpdir = normalize_existing_or_absolute_path(&sandbox_tmpdir_path(&config.workspace_dir))?; + let host_temp = normalize_existing_or_absolute_path(&std::env::temp_dir())?; let guard = ManagedHomeGrantGuard { home: &home, agent_root: &agent_root, @@ -496,6 +532,7 @@ fn reject_unmanaged_home_grants_for_mxc_managed_home(config: &SandboxConfig) -> generated_ssh: &generated_ssh, workspace: &workspace, tmpdir: &tmpdir, + host_temp: &host_temp, raw_workspace: &config.workspace_dir, lexical_home: &lexical_home, }; @@ -510,6 +547,7 @@ struct ManagedHomeGrantGuard<'a> { generated_ssh: &'a Path, workspace: &'a Path, tmpdir: &'a Path, + host_temp: &'a Path, raw_workspace: &'a Path, lexical_home: &'a Path, } @@ -540,6 +578,7 @@ fn reject_unmanaged_home_grants( && !path_contains_or_equal(guard.agent_root, &expanded) && !path_contains_or_equal(guard.workspace, &expanded) && !path_contains_or_equal(guard.tmpdir, &expanded) + && !is_allowed_host_temp_grant(&expanded, guard) { return Err(format!( "MXC managed HOME cannot grant real home {section} path '{}' (expanded '{}')", @@ -551,6 +590,12 @@ fn reject_unmanaged_home_grants( Ok(()) } +fn is_allowed_host_temp_grant(expanded: &Path, guard: &ManagedHomeGrantGuard<'_>) -> bool { + cfg!(target_os = "windows") + && path_contains_or_equal(guard.home, guard.host_temp) + && path_contains_or_equal(guard.host_temp, expanded) +} + fn expand_managed_home_guard_path( path: &str, workspace: &Path, @@ -587,36 +632,26 @@ fn normalize_existing_or_absolute_path(path: &Path) -> Result { return normalize_absolute_path(&canonical); } - let mut existing_prefix = PathBuf::from("/"); - let mut probe = PathBuf::from("/"); + let mut existing_prefix = normalized.clone(); let mut missing_suffix = Vec::new(); - let mut missing = false; - - for component in normalized.components() { - match component { - Component::RootDir => {} - Component::Normal(part) if !missing => { - probe.push(part); - if probe.exists() { - existing_prefix = probe.clone(); - } else { - missing = true; - missing_suffix.push(part.to_os_string()); - } - } - Component::Normal(part) => missing_suffix.push(part.to_os_string()), - Component::CurDir | Component::ParentDir => {} - Component::Prefix(_) => { - return Err(format!( - "linux policy path '{}' must not contain a non-linux prefix", - path.display() - )); - } + while !existing_prefix.exists() { + let part = existing_prefix.file_name().ok_or_else(|| { + format!( + "policy path '{}' has no resolvable ancestor", + path.display() + ) + })?; + missing_suffix.push(part.to_os_string()); + if !existing_prefix.pop() { + return Err(format!( + "policy path '{}' has no resolvable ancestor", + path.display() + )); } } let mut resolved = std::fs::canonicalize(&existing_prefix).unwrap_or(existing_prefix); - for part in missing_suffix { + for part in missing_suffix.into_iter().rev() { resolved.push(part); } normalize_absolute_path(&resolved) @@ -633,23 +668,18 @@ fn normalize_absolute_path(path: &Path) -> Result { let mut normalized = PathBuf::new(); for component in path.components() { match component { - Component::RootDir => normalized.push(Path::new("/")), + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(Path::new(std::path::MAIN_SEPARATOR_STR)), Component::CurDir => {} Component::ParentDir => { normalized.pop(); } Component::Normal(part) => normalized.push(part), - Component::Prefix(_) => { - return Err(format!( - "linux policy path '{}' must not contain a non-linux prefix", - path.display() - )); - } } } if normalized.as_os_str().is_empty() { - Ok(PathBuf::from("/")) + Ok(PathBuf::from(std::path::MAIN_SEPARATOR_STR)) } else { Ok(normalized) } @@ -698,6 +728,8 @@ pub(crate) enum PlatformBackendSelection { LinuxNative, #[cfg(target_os = "linux")] LinuxMxc, + #[cfg(target_os = "windows")] + WindowsMxc, } fn platform_backend_for_policy(policy: &Policy) -> Result { @@ -715,11 +747,18 @@ fn process_backend_for_provider( #[cfg(target_os = "linux")] RuntimeProvider::AxisNative => Ok(PlatformBackendSelection::LinuxNative), - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "windows")] + RuntimeProvider::Auto | RuntimeProvider::Mxc => Ok(PlatformBackendSelection::WindowsMxc), + #[cfg(target_os = "windows")] + RuntimeProvider::AxisNative => Err(SandboxError::Unsupported( + "runtime provider 'axis_native' is disabled on Windows because the legacy native path does not currently enforce AXIS policy; use 'auto' or 'mxc'".into(), + )), + + #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] RuntimeProvider::Auto | RuntimeProvider::AxisNative => { Ok(PlatformBackendSelection::Default) } - #[cfg(not(target_os = "linux"))] + #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] RuntimeProvider::Mxc => Err(SandboxError::Unsupported(format!( "runtime provider 'mxc' is not available on {}", std::env::consts::OS @@ -738,8 +777,12 @@ fn ensure_platform_backend_available( #[cfg(target_os = "windows")] { - let _ = backend; - crate::windows::ensure_containment_available() + match backend { + PlatformBackendSelection::WindowsMxc => Ok(()), + _ => Err(SandboxError::Unsupported( + "native Windows containment is unavailable; select the MXC process backend".into(), + )), + } } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] @@ -814,8 +857,14 @@ fn create_platform_sandbox_with_backend( #[cfg(target_os = "windows")] { - let _ = backend; - Ok(Box::new(crate::windows::WindowsSandbox::new(config)?)) + match backend { + PlatformBackendSelection::WindowsMxc => Ok(Box::new( + crate::windows::mxc::MxcWindowsSandbox::new(config)?, + )), + PlatformBackendSelection::Default => Err(SandboxError::Unsupported( + "legacy native Windows host execution is disabled".into(), + )), + } } #[cfg(not(any(target_os = "linux", target_os = "macos", target_os = "windows")))] @@ -842,9 +891,13 @@ fn effective_linux_backend(backend: PlatformBackendSelection) -> PlatformBackend #[cfg(test)] mod tests { use super::*; + #[cfg(target_os = "linux")] + use axis_core::policy::Compatibility; + #[cfg(unix)] + use axis_core::policy::SshKeySpec; use axis_core::policy::{ - Compatibility, FilesystemPolicy, GpuPolicy, InferencePolicy, NetworkMode, NetworkPolicy, - ProcessPolicy, RuntimeProvider, SshKeySpec, SshPolicy, + FilesystemPolicy, GpuPolicy, InferencePolicy, NetworkMode, NetworkPolicy, ProcessPolicy, + RuntimeProvider, SshPolicy, }; use std::path::Path; @@ -888,7 +941,7 @@ mod tests { PlatformBackendSelection::LinuxNative } - #[cfg(not(target_os = "linux"))] + #[cfg(all(unix, not(target_os = "linux")))] fn native_process_backend_selection() -> PlatformBackendSelection { PlatformBackendSelection::Default } @@ -922,66 +975,74 @@ mod tests { } #[cfg(target_os = "windows")] - fn assert_windows_front_door_rejects_without_mutation( - create: impl FnOnce(SandboxConfig) -> Result, - ) { - let parent = tempfile::tempdir().unwrap(); - let home = parent.path().join("home"); - std::fs::create_dir(&home).unwrap(); - let real_state = home.join(".codex"); - std::fs::create_dir(&real_state).unwrap(); - std::fs::write(real_state.join("state.json"), "original").unwrap(); - - crate::test_support::with_home(&home, || { + #[test] + fn windows_mxc_uses_physical_managed_home_and_profile_directories() { + let home = tempfile::tempdir().unwrap(); + let workspace = tempfile::tempdir().unwrap(); + + with_home(home.path(), || { let mut config = test_config(); - config.policy.name = "windows-front-door".into(); - config.policy.filesystem.read_write = vec!["~/.codex".into()]; - config.workspace_dir = parent.path().join("workspace"); - let trace = StartupTrace::new(); - config.startup_trace = Some(trace.clone()); + config.policy.name = "windows-managed-home-test".into(); + config.workspace_dir = workspace.path().to_path_buf(); + config.policy.filesystem.read_write = vec!["{workspace}".into(), "~/.codex".into()]; - let err = match create(config.clone()) { - Ok(_) => panic!("disabled Windows containment must reject creation"), - Err(err) => err, - }; + let symlinks = prepare_managed_agent_workspace( + &mut config, + true, + PlatformBackendSelection::WindowsMxc, + ) + .unwrap(); + let managed_home = home + .path() + .join(".axis") + .join("agents") + .join("windows-managed-home-test") + .join("home"); + let managed_codex = managed_home.join(".codex"); - assert!(matches!(err, SandboxError::Unsupported(_))); - assert!(err.to_string().contains("containment is unavailable")); - assert!(!config.workspace_dir.exists()); - assert!(!home.join(".axis").exists()); - assert!(!home.join(".codex.axis-backup").exists()); - assert!(real_state.is_dir()); - assert!(!real_state.is_symlink()); + assert!(symlinks.is_empty()); + assert!(managed_codex.is_dir()); + assert!(!managed_codex.is_symlink()); + assert!(managed_home.join("AppData/Roaming").is_dir()); + assert!(managed_home.join("AppData/Local").is_dir()); + assert!( + config + .policy + .filesystem + .read_write + .contains(&managed_home.to_string_lossy().into_owned()), + "read_write={:?}, expected={}", + config.policy.filesystem.read_write, + managed_home.display() + ); + assert!( + config + .policy + .filesystem + .read_write + .contains(&managed_codex.to_string_lossy().into_owned()) + ); + + let env_value = |name: &str| { + config + .env + .iter() + .find(|(key, _)| key == name) + .map(|(_, value)| value.as_str()) + }; + assert_eq!(env_value("HOME"), managed_home.to_str()); + assert_eq!(env_value("USERPROFILE"), managed_home.to_str()); assert_eq!( - std::fs::read_to_string(real_state.join("state.json")).unwrap(), - "original" + env_value("APPDATA"), + managed_home.join("AppData").join("Roaming").to_str() ); assert_eq!( - trace - .phases() - .iter() - .map(|timing| timing.phase) - .collect::>(), - vec![ - "front_door.policy_validation", - "front_door.platform_availability" - ] + env_value("LOCALAPPDATA"), + managed_home.join("AppData").join("Local").to_str() ); }); } - #[cfg(target_os = "windows")] - #[test] - fn public_create_rejects_disabled_windows_backend_without_filesystem_mutation() { - assert_windows_front_door_rejects_without_mutation(Sandbox::create); - } - - #[cfg(target_os = "windows")] - #[test] - fn public_create_for_exec_rejects_disabled_windows_backend_without_filesystem_mutation() { - assert_windows_front_door_rejects_without_mutation(Sandbox::create_for_exec); - } - #[cfg(target_os = "linux")] #[test] fn process_runtime_provider_selects_linux_backend() { @@ -1004,7 +1065,37 @@ mod tests { ); } - #[cfg(not(target_os = "linux"))] + #[cfg(target_os = "windows")] + #[test] + fn process_runtime_provider_selects_windows_backend() { + let mut policy = test_config().policy; + assert_eq!( + platform_backend_for_policy(&policy).unwrap(), + PlatformBackendSelection::WindowsMxc + ); + + policy.runtime.provider = RuntimeProvider::Mxc; + assert_eq!( + platform_backend_for_policy(&policy).unwrap(), + PlatformBackendSelection::WindowsMxc + ); + + policy.runtime.provider = RuntimeProvider::AxisNative; + let error = platform_backend_for_policy(&policy).unwrap_err(); + assert!(error.to_string().contains("disabled on Windows")); + } + + #[cfg(target_os = "windows")] + #[test] + fn legacy_windows_default_cannot_spawn_directly() { + let error = + create_platform_sandbox_with_backend(&test_config(), PlatformBackendSelection::Default) + .err() + .expect("legacy Windows host execution must reject"); + assert!(error.to_string().contains("host execution is disabled")); + } + + #[cfg(all(not(target_os = "linux"), not(target_os = "windows")))] #[test] fn mxc_runtime_provider_is_rejected_without_platform_backend() { let mut policy = test_config().policy; @@ -2323,6 +2414,7 @@ mod tests { assert!(err.to_string().contains("default-deny")); } + #[cfg(target_os = "linux")] fn disable_resource_limits(config: &mut SandboxConfig) { config.policy.process.max_processes = 0; config.policy.process.max_memory_mb = 0; diff --git a/crates/axis-sandbox/src/windows/appcontainer.rs b/crates/axis-sandbox/src/windows/appcontainer.rs index 867b3a5..415b9f9 100644 --- a/crates/axis-sandbox/src/windows/appcontainer.rs +++ b/crates/axis-sandbox/src/windows/appcontainer.rs @@ -54,7 +54,7 @@ fn create_profile_impl(name: &str) -> Result { // This avoids depending on specific windows crate feature sets // that may not be available for cross-compilation. - type PSID = *mut std::ffi::c_void; + type Psid = *mut std::ffi::c_void; #[link(name = "userenv")] unsafe extern "system" { @@ -64,20 +64,18 @@ fn create_profile_impl(name: &str) -> Result { pszDescription: *const u16, pCapabilities: *const std::ffi::c_void, dwCapabilityCount: u32, - ppSidAppContainerSid: *mut PSID, + ppSidAppContainerSid: *mut Psid, ) -> i32; // HRESULT - fn DeleteAppContainerProfile(pszAppContainerName: *const u16) -> i32; - fn DeriveAppContainerSidFromAppContainerName( pszAppContainerName: *const u16, - ppsidAppContainerSid: *mut PSID, + ppsidAppContainerSid: *mut Psid, ) -> i32; } #[link(name = "advapi32")] unsafe extern "system" { - fn ConvertSidToStringSidW(Sid: PSID, StringSid: *mut *mut u16) -> i32; // BOOL + fn ConvertSidToStringSidW(Sid: Psid, StringSid: *mut *mut u16) -> i32; // BOOL } #[link(name = "kernel32")] @@ -92,7 +90,7 @@ fn create_profile_impl(name: &str) -> Result { .collect() } - fn sid_to_string(psid: PSID) -> Result { + fn sid_to_string(psid: Psid) -> Result { let mut string_sid: *mut u16 = std::ptr::null_mut(); let ok = unsafe { ConvertSidToStringSidW(psid, &mut string_sid) }; if ok == 0 { @@ -111,7 +109,7 @@ fn create_profile_impl(name: &str) -> Result { let name_w = to_wide(name); let display_w = to_wide("AXIS Sandbox"); let desc_w = to_wide("Isolated agent execution environment"); - let mut psid: PSID = std::ptr::null_mut(); + let mut psid: Psid = std::ptr::null_mut(); let hr = unsafe { CreateAppContainerProfile( @@ -137,7 +135,7 @@ fn create_profile_impl(name: &str) -> Result { // 0x800705B9 = ERROR_ALREADY_EXISTS if hr as u32 == 0x800705B9u32 { // Profile exists — derive the SID. - let mut psid2: PSID = std::ptr::null_mut(); + let mut psid2: Psid = std::ptr::null_mut(); let hr2 = unsafe { DeriveAppContainerSidFromAppContainerName(name_w.as_ptr(), &mut psid2) }; if hr2 == 0 { let sid_str = sid_to_string(psid2)?; diff --git a/crates/axis-sandbox/src/windows/job_object.rs b/crates/axis-sandbox/src/windows/job_object.rs index 8a86fd0..650a59d 100644 --- a/crates/axis-sandbox/src/windows/job_object.rs +++ b/crates/axis-sandbox/src/windows/job_object.rs @@ -9,8 +9,10 @@ use windows::Win32::Foundation::{CloseHandle, HANDLE}; use windows::Win32::System::JobObjects::*; -use windows::Win32::System::Threading::OpenProcess; -use windows::Win32::System::Threading::PROCESS_ALL_ACCESS; +use windows::Win32::System::Threading::{ + ALL_PROCESSOR_GROUPS, GetActiveProcessorCount, OpenProcess, +}; +use windows::Win32::System::Threading::{PROCESS_SET_QUOTA, PROCESS_TERMINATE}; use windows::core::HSTRING; /// Wrapper around a Win32 Job Object handle. @@ -44,8 +46,11 @@ pub fn create_job_object( .map_err(|e| format!("CreateJobObjectW failed: {e}"))?; // Set extended limit information. + let job = JobHandle { handle }; let mut ext_info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); - ext_info.BasicLimitInformation.ActiveProcessLimit = max_processes; + if max_processes > 0 { + ext_info.BasicLimitInformation.ActiveProcessLimit = max_processes; + } ext_info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_ACTIVE_PROCESS | JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE // NOTE: DIE_ON_UNHANDLED_EXCEPTION removed — V8/Node.js uses SEH for @@ -56,7 +61,7 @@ pub fn create_job_object( unsafe { SetInformationJobObject( - handle, + job.handle, JobObjectExtendedLimitInformation, &ext_info as *const _ as *const std::ffi::c_void, std::mem::size_of::() as u32, @@ -65,16 +70,20 @@ pub fn create_job_object( .map_err(|e| format!("SetInformationJobObject (limits) failed: {e}"))?; // Set CPU rate control. - if cpu_rate_percent > 0 && cpu_rate_percent < 100 { - let mut cpu_info = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION::default(); - cpu_info.ControlFlags = - JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP; + if cpu_rate_percent > 0 { + let mut cpu_info = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION { + ControlFlags: JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, + ..Default::default() + }; // CpuRate is in hundredths of a percent (100 = 1%, 10000 = 100%). - cpu_info.Anonymous.CpuRate = cpu_rate_percent * 100; + let processors = unsafe { GetActiveProcessorCount(ALL_PROCESSOR_GROUPS) }.max(1); + cpu_info.Anonymous.CpuRate = (cpu_rate_percent * 100) + .div_ceil(processors) + .clamp(1, 10_000); unsafe { SetInformationJobObject( - handle, + job.handle, JobObjectCpuRateControlInformation, &cpu_info as *const _ as *const std::ffi::c_void, std::mem::size_of::() as u32, @@ -87,12 +96,38 @@ pub fn create_job_object( "job object '{name}': max_procs={max_processes}, max_mem={max_memory_mb}MB, cpu={cpu_rate_percent}%" ); - Ok(JobHandle { handle }) + Ok(job) +} + +/// Create a Job Object used only as a fail-closed process-tree lifetime guard. +/// Resource limits are added separately once their accounting semantics have +/// been proven for the selected backend. +pub fn create_kill_on_close_job(name: &str) -> Result { + let job_name = HSTRING::from(name); + let handle = unsafe { CreateJobObjectW(None, &job_name) } + .map_err(|e| format!("CreateJobObjectW failed: {e}"))?; + let job = JobHandle { handle }; + let mut ext_info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + // MXC's BaseContainer child may explicitly break away before it is placed + // in MXC's child-only resource Job. Silent breakaway remains disabled, so + // ordinary descendants cannot escape this lifecycle boundary. + ext_info.BasicLimitInformation.LimitFlags = + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | JOB_OBJECT_LIMIT_BREAKAWAY_OK; + unsafe { + SetInformationJobObject( + job.handle, + JobObjectExtendedLimitInformation, + &ext_info as *const _ as *const std::ffi::c_void, + std::mem::size_of::() as u32, + ) + } + .map_err(|e| format!("SetInformationJobObject (lifetime guard) failed: {e}"))?; + Ok(job) } /// Assign a process to a Job Object by PID. pub fn assign_process_to_job(job: &JobHandle, pid: u32) -> Result<(), String> { - let proc_handle = unsafe { OpenProcess(PROCESS_ALL_ACCESS, false, pid) } + let proc_handle = unsafe { OpenProcess(PROCESS_SET_QUOTA | PROCESS_TERMINATE, false, pid) } .map_err(|e| format!("OpenProcess({pid}) failed: {e}"))?; let result = unsafe { AssignProcessToJobObject(job.handle, proc_handle) }; diff --git a/crates/axis-sandbox/src/windows/mod.rs b/crates/axis-sandbox/src/windows/mod.rs index d9441e8..51f9cac 100644 --- a/crates/axis-sandbox/src/windows/mod.rs +++ b/crates/axis-sandbox/src/windows/mod.rs @@ -1,115 +1,16 @@ // Copyright 2026 Advanced Micro Devices, Inc. // SPDX-License-Identifier: Apache-2.0 -//! Windows containment targets and fail-closed native launcher. +//! Windows isolation components. //! -//! The supporting modules contain work toward Job Object, AppContainer, -//! restricted-token, ACL, and ETW integration. The native launcher remains -//! disabled until those controls, proxy routing, and environment isolation can -//! be applied to the initial process before it executes user code. +//! Process-containment launches are intentionally routed through the MXC +//! adapter. The modules below provide supporting Windows primitives and do not +//! form an implicit host-process fallback. pub mod acl; pub mod appcontainer; pub mod etw; pub mod job_object; +pub(crate) mod mxc; pub mod restricted; - -use crate::sandbox::{SandboxConfig, SandboxError, SandboxImpl}; - -const NATIVE_CONTAINMENT_UNAVAILABLE: &str = "native Windows process containment is unavailable: the launcher cannot yet apply Job Object, restricted-token/AppContainer, filesystem ACL, proxy, and environment isolation before process creation"; - -fn containment_unavailable() -> SandboxError { - SandboxError::Unsupported(NATIVE_CONTAINMENT_UNAVAILABLE.into()) -} - -pub(crate) fn ensure_containment_available() -> Result<(), SandboxError> { - Err(containment_unavailable()) -} - -/// Disabled native Windows backend. -/// -/// This type retains the platform trait boundary while ensuring no ordinary -/// process launch can bypass the incomplete containment path. -pub(crate) struct WindowsSandbox; - -impl WindowsSandbox { - pub fn new(_config: &SandboxConfig) -> Result { - ensure_containment_available()?; - Ok(Self) - } -} - -impl SandboxImpl for WindowsSandbox { - fn start(&mut self) -> Result { - Err(containment_unavailable()) - } - - fn wait( - &mut self, - ) -> std::pin::Pin> + Send + '_>> - { - Box::pin(async { Err(containment_unavailable()) }) - } - - fn try_wait(&mut self) -> Result, SandboxError> { - Ok(None) - } - - fn destroy(&mut self) -> Result<(), SandboxError> { - Ok(()) - } -} - -#[cfg(test)] -mod tests { - use super::*; - use axis_core::policy::Policy; - use axis_core::types::SandboxId; - - fn test_config(workspace_dir: std::path::PathBuf) -> SandboxConfig { - SandboxConfig { - id: SandboxId::new(), - policy: Policy::from_yaml("version: 1\nname: windows-fail-closed\n").unwrap(), - command: "cmd.exe".into(), - args: vec!["/c".into(), "exit 0".into()], - working_dir: None, - workspace_dir, - env: Vec::new(), - proxy_port: 0, - proxy_addr: None, - connect_attribution: None, - capture_output: false, - interactive_terminal: false, - pty_bridge_helper: None, - timeout_sec: None, - backend_preflight: Default::default(), - startup_trace: None, - } - } - - #[test] - fn native_launcher_rejects_before_workspace_or_process_setup() { - let parent = tempfile::tempdir().unwrap(); - let workspace = parent.path().join("workspace"); - let config = test_config(workspace.clone()); - - let err = match WindowsSandbox::new(&config) { - Ok(_) => panic!("native Windows containment must remain disabled"), - Err(err) => err, - }; - - assert!(matches!(err, SandboxError::Unsupported(_))); - assert!(err.to_string().contains("before process creation")); - assert!(!workspace.exists()); - } - - #[test] - fn start_is_fail_closed_if_constructor_gate_is_bypassed() { - let mut sandbox = WindowsSandbox; - - let err = sandbox.start().unwrap_err(); - - assert!(matches!(err, SandboxError::Unsupported(_))); - assert!(err.to_string().contains("containment is unavailable")); - } -} +pub mod wfp; diff --git a/crates/axis-sandbox/src/windows/mxc.rs b/crates/axis-sandbox/src/windows/mxc.rs new file mode 100644 index 0000000..d083c8a --- /dev/null +++ b/crates/axis-sandbox/src/windows/mxc.rs @@ -0,0 +1,1402 @@ +// Copyright 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Windows MXC ProcessContainer runtime adapter. +//! +//! AXIS owns policy validation and lifecycle. MXC owns the Windows process +//! isolation primitive. Policy surfaces that have not been proven exact are +//! rejected before the executor is resolved or user code is spawned. + +use crate::sandbox::{BackendPreflight, SandboxConfig, SandboxError, SandboxImpl}; +use axis_core::capability::{DependencyState, PlannerOptions, RuntimeProbeSnapshot}; +use axis_core::capability_map::{BackendCapabilityMapId, host_dependency}; +use axis_core::mxc_config::{self, MxcProcessWireConfig}; +use axis_core::policy::{Compatibility, NetworkMode}; +use axis_core::process_backend::{ + ProcessBackendFilesystemSpec, ProcessLaunchOptions, build_process_backend_execution_spec, +}; +use std::collections::{BTreeMap, BTreeSet, HashSet}; +use std::ffi::{OsStr, OsString}; +use std::fs::{self, File}; +use std::io::{Read, Seek, SeekFrom, Write}; +use std::os::windows::ffi::OsStrExt; +use std::path::{Component, Path, PathBuf}; +use std::process::{Child, Command, ExitStatus, Stdio}; +use std::time::{Duration, Instant}; +use thiserror::Error; + +const MXC_DRY_RUN_SUCCESS: &str = "Dry run completed. Result: validation passed"; +const MAX_DRY_RUN_OUTPUT_BYTES: u64 = 64 * 1024; +const EXECUTOR_NAMES: &[&str] = &["wxc-exec.exe", "wxc.exe", "mxc-exec.exe"]; + +#[derive(Debug, Error, PartialEq, Eq)] +enum MxcWindowsError { + #[error("no packaged Windows MXC executor was found beside AXIS or in a stable install path")] + ExecutorUnavailable, + #[error("unsafe Windows MXC executor candidate '{}': {reason}", path.display())] + UnsafeExecutor { path: PathBuf, reason: String }, + #[error("Windows MXC ProcessContainer policy is unsupported: {0}")] + UnsupportedPolicy(String), + #[error("Windows MXC config generation failed: {0}")] + Config(String), + #[error("Windows MXC executor failed: {0}")] + Executor(String), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct MxcWindowsExecutor { + path: PathBuf, +} + +impl MxcWindowsExecutor { + fn resolve() -> Result { + if let Some(path) = std::env::var_os("AXIS_MXC_EXECUTOR") { + return Self::from_path(PathBuf::from(path)); + } + if std::env::var_os("AXIS_RUN_MXC_PROCESS_E2E").as_deref() == Some(OsStr::new("1")) + && let Some(path) = std::env::var_os("AXIS_TEST_MXC_EXECUTOR") + { + return Self::from_path(PathBuf::from(path)); + } + Self::resolve_from_candidates(production_executor_candidates()) + } + + fn resolve_from_candidates(candidates: I) -> Result + where + I: IntoIterator, + P: Into, + { + for candidate in candidates { + if let Ok(executor) = Self::from_path(candidate) { + return Ok(executor); + } + } + Err(MxcWindowsError::ExecutorUnavailable) + } + + fn from_path>(path: P) -> Result { + let path = path.into(); + validate_executor_path(&path, true)?; + Ok(Self { + path: fs::canonicalize(&path).map_err(|err| MxcWindowsError::UnsafeExecutor { + path: path.clone(), + reason: err.to_string(), + })?, + }) + } + + #[cfg(test)] + fn from_test_path>(path: P) -> Result { + let path = path.into(); + validate_executor_path(&path, false)?; + Ok(Self { + path: fs::canonicalize(&path).map_err(|err| MxcWindowsError::UnsafeExecutor { + path: path.clone(), + reason: err.to_string(), + })?, + }) + } + + fn write_private_config( + &self, + config: &MxcProcessWireConfig, + ) -> Result { + let json = + serde_json::to_vec(config).map_err(|err| MxcWindowsError::Config(err.to_string()))?; + let mut file = tempfile::Builder::new() + .prefix("axis-mxc-windows-") + .suffix(".json") + .tempfile() + .map_err(|err| MxcWindowsError::Config(err.to_string()))?; + file.write_all(&json) + .and_then(|()| file.flush()) + .map_err(|err| MxcWindowsError::Config(err.to_string()))?; + Ok(file) + } + + fn dry_run( + &self, + config: &MxcProcessWireConfig, + timeout: Duration, + ) -> Result<(), MxcWindowsError> { + let config = self.write_private_config(config)?; + let mut stdout_file = + tempfile::tempfile().map_err(|err| MxcWindowsError::Executor(err.to_string()))?; + let mut stderr_file = + tempfile::tempfile().map_err(|err| MxcWindowsError::Executor(err.to_string()))?; + let stdout = stdout_file + .try_clone() + .map_err(|err| MxcWindowsError::Executor(err.to_string()))?; + let stderr = stderr_file + .try_clone() + .map_err(|err| MxcWindowsError::Executor(err.to_string()))?; + let mut command = Command::new(&self.path); + configure_executor_invocation(&mut command, config.path(), true); + command + .stdin(Stdio::null()) + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + configure_executor_environment(&mut command, &self.path); + let child = command + .spawn() + .map_err(|err| MxcWindowsError::Executor(err.to_string()))?; + let status = wait_child_with_timeout(child, timeout)?; + let stdout = read_limited_output(&mut stdout_file)?; + let stderr = read_limited_output(&mut stderr_file)?; + if !status.success() { + return Err(MxcWindowsError::Executor(format!( + "dry-run exited with {:?}: {}", + status.code(), + stderr.trim() + ))); + } + if !stdout.contains(MXC_DRY_RUN_SUCCESS) { + return Err(MxcWindowsError::Executor( + "dry-run did not report validation success".into(), + )); + } + Ok(()) + } +} + +pub(crate) struct MxcWindowsSandbox { + id: axis_core::types::SandboxId, + wire_config: MxcProcessWireConfig, + executor: MxcWindowsExecutor, + child: Option, + job: Option, + config_file: Option, + exit_code: Option, + workspace_dir: PathBuf, + capture_output: bool, + timeout_sec: Option, +} + +enum MxcExecutorChild { + Standard(Child), +} + +impl MxcExecutorChild { + fn id(&self) -> u32 { + match self { + Self::Standard(child) => child.id(), + } + } + + fn try_wait(&mut self) -> std::io::Result> { + match self { + Self::Standard(child) => { + Ok(child.try_wait()?.map(|status| status.code().unwrap_or(-1))) + } + } + } + + fn kill(&mut self) -> std::io::Result<()> { + match self { + Self::Standard(child) => child.kill(), + } + } + + fn wait(&mut self) -> std::io::Result { + match self { + Self::Standard(child) => Ok(child.wait()?.code().unwrap_or(-1)), + } + } +} + +impl MxcWindowsSandbox { + pub(crate) fn new(config: &SandboxConfig) -> Result { + let wire_config = build_windows_processcontainer_config(config) + .map_err(|err| SandboxError::IsolationFailed(err.to_string()))?; + let executor = MxcWindowsExecutor::resolve() + .map_err(|err| SandboxError::IsolationFailed(err.to_string()))?; + if config.backend_preflight == BackendPreflight::DryRun { + executor + .dry_run(&wire_config, Duration::from_secs(5)) + .map_err(|err| SandboxError::IsolationFailed(err.to_string()))?; + } + Ok(Self::from_parts(config, wire_config, executor)) + } + + fn from_parts( + config: &SandboxConfig, + wire_config: MxcProcessWireConfig, + executor: MxcWindowsExecutor, + ) -> Self { + Self { + id: config.id, + wire_config, + executor, + child: None, + job: None, + config_file: None, + exit_code: None, + workspace_dir: config.workspace_dir.clone(), + capture_output: config.capture_output, + timeout_sec: config.timeout_sec.or(config.policy.process.timeout_sec), + } + } + + #[cfg(test)] + fn new_with_executor( + config: &SandboxConfig, + executor: MxcWindowsExecutor, + ) -> Result { + let wire_config = build_windows_processcontainer_config(config) + .map_err(|err| SandboxError::IsolationFailed(err.to_string()))?; + Ok(Self::from_parts(config, wire_config, executor)) + } + + fn cleanup_after_stop(&mut self) { + // Closing the Job Object is the final process-tree kill boundary. + self.job.take(); + self.config_file.take(); + } + + fn terminate_tree(&mut self) -> Result<(), SandboxError> { + // Drop the job first so every process still assigned to it is killed. + self.job.take(); + if let Some(mut child) = self.child.take() { + let _ = child.kill(); + let _ = child.wait()?; + } + self.config_file.take(); + Ok(()) + } +} + +impl SandboxImpl for MxcWindowsSandbox { + fn start(&mut self) -> Result { + if self.child.is_some() || self.exit_code.is_some() { + return Err(SandboxError::SpawnFailed( + "Windows MXC backend is already started".into(), + )); + } + let config = self + .executor + .write_private_config(&self.wire_config) + .map_err(|err| SandboxError::IsolationFailed(err.to_string()))?; + let job = super::job_object::create_kill_on_close_job(&format!("axis-mxc-{}", self.id)) + .map_err(|err| SandboxError::IsolationFailed(format!("MXC Job Object: {err}")))?; + let mut command = Command::new(&self.executor.path); + configure_executor_invocation(&mut command, config.path(), false); + command.current_dir(&self.workspace_dir); + configure_executor_environment(&mut command, &self.executor.path); + if self.capture_output { + command.stdin(Stdio::null()); + let stdout = File::create(self.workspace_dir.join("stdout.log")) + .map_err(|err| SandboxError::SpawnFailed(format!("stdout log: {err}")))?; + let stderr = File::create(self.workspace_dir.join("stderr.log")) + .map_err(|err| SandboxError::SpawnFailed(format!("stderr log: {err}")))?; + command + .stdout(Stdio::from(stdout)) + .stderr(Stdio::from(stderr)); + } + let mut child = + MxcExecutorChild::Standard(command.spawn().map_err(|err| { + SandboxError::SpawnFailed(format!("Windows MXC executor: {err}")) + })?); + let pid = child.id(); + if let Err(err) = super::job_object::assign_process_to_job(&job, pid) { + let _ = child.kill(); + let _ = child.wait(); + return Err(SandboxError::IsolationFailed(format!( + "MXC Job Object assignment: {err}" + ))); + } + self.config_file = Some(config); + self.job = Some(job); + self.child = Some(child); + tracing::info!( + "sandbox {} started via MXC ProcessContainer, pid={pid}", + self.id + ); + Ok(pid) + } + + fn wait( + &mut self, + ) -> std::pin::Pin> + Send + '_>> + { + Box::pin(async move { + if let Some(code) = self.exit_code { + return Ok(code); + } + let deadline = self + .timeout_sec + .map(|seconds| Instant::now() + Duration::from_secs(seconds)); + loop { + let status = self + .child + .as_mut() + .ok_or_else(|| SandboxError::SpawnFailed("no Windows MXC child".into()))? + .try_wait()?; + if let Some(code) = status { + self.child.take(); + self.exit_code = Some(code); + self.cleanup_after_stop(); + return Ok(code); + } + if deadline.is_some_and(|deadline| Instant::now() >= deadline) { + self.terminate_tree()?; + self.exit_code = Some(-1); + return Err(SandboxError::IsolationFailed(format!( + "Windows MXC process timed out after {} seconds", + self.timeout_sec.unwrap_or_default() + ))); + } + tokio::time::sleep(Duration::from_millis(10)).await; + } + }) + } + + fn try_wait(&mut self) -> Result, SandboxError> { + if let Some(code) = self.exit_code { + return Ok(Some(code)); + } + let Some(child) = self.child.as_mut() else { + return Ok(None); + }; + let Some(code) = child.try_wait()? else { + return Ok(None); + }; + self.child.take(); + self.exit_code = Some(code); + self.cleanup_after_stop(); + Ok(Some(code)) + } + + fn destroy(&mut self) -> Result<(), SandboxError> { + self.terminate_tree()?; + tracing::info!("sandbox {} destroyed via MXC ProcessContainer", self.id); + Ok(()) + } +} + +fn build_windows_processcontainer_config( + config: &SandboxConfig, +) -> Result { + validate_unproven_policy_surfaces(config)?; + validate_tier_policy(config.interactive_terminal)?; + let translated_filesystem = translate_filesystem_policy(config)?; + let filesystem = normalize_basecontainer_filesystem(translated_filesystem)?; + let mut effective_policy = config.policy.clone(); + effective_policy.filesystem.read_only = filesystem.read_only.clone(); + effective_policy.filesystem.read_write = filesystem.read_write.clone(); + // BaseContainer keeps its stronger default-deny boundary, so redundant + // denies are removed after overlap validation. + effective_policy.filesystem.deny.clear(); + let environment = sanitized_environment(&config.env)?; + let working_dir = path_string( + config + .working_dir + .as_deref() + .unwrap_or(&config.workspace_dir), + )?; + let strict_proxy = matches!(config.policy.network.mode, NetworkMode::Proxy); + let proxy_addr = if strict_proxy { + Some(config.proxy_addr.ok_or_else(|| { + MxcWindowsError::UnsupportedPolicy( + "strict Windows proxy policy requires an AXIS proxy endpoint before launch".into(), + ) + })?) + } else { + None + }; + let wfp_dependency = + if crate::windows::wfp::broker_available(crate::windows::wfp::DEFAULT_PIPE_NAME) { + DependencyState::Present + } else { + DependencyState::Missing + }; + let runtime = RuntimeProbeSnapshot::new() + .with_dependency(host_dependency::MXC_EXECUTOR, DependencyState::Present) + .with_dependency( + host_dependency::WINDOWS_PROCESS_CONTAINER, + DependencyState::Present, + ) + .with_dependency(host_dependency::WINDOWS_JOBOBJECT, DependencyState::Present) + .with_dependency(host_dependency::WINDOWS_WFP_BROKER, wfp_dependency); + let mut spec = build_process_backend_execution_spec( + &effective_policy, + BackendCapabilityMapId::MxcWindowsProcessContainer, + ProcessLaunchOptions { + command: config.command.clone(), + args: config.args.clone(), + working_dir: Some(working_dir), + environment, + capture_output: config.capture_output, + timeout_sec: config.timeout_sec, + }, + &runtime, + &PlannerOptions::new(), + ) + .map_err(|err| MxcWindowsError::UnsupportedPolicy(err.to_string()))?; + spec.filesystem = filesystem; + let wire = mxc_config::build_mxc_process_config( + windows_command_line(&config.command, &config.args), + &spec, + mxc_config::MxcProcessConfigOptions { + container_id: Some(format!("axis-{}", config.id)), + strict_proxy_enforced_by_axis: strict_proxy, + proxy_url: proxy_addr.map(|addr| format!("http://{addr}")), + axis_wfp: strict_proxy.then(|| mxc_config::MxcAxisWfpConfig { + pipe_name: crate::windows::wfp::DEFAULT_PIPE_NAME.into(), + lease_id: config.id.0.to_string(), + }), + ..Default::default() + }, + ) + .map_err(|err| MxcWindowsError::Config(err.to_string()))?; + Ok(wire) +} + +fn validate_tier_policy(interactive_terminal: bool) -> Result<(), MxcWindowsError> { + if interactive_terminal { + return Err(MxcWindowsError::UnsupportedPolicy( + "interactive ConPTY is unsupported because Experimental_CreateProcessInSandbox rejects PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE with ERROR_INVALID_HANDLE on supported BaseContainer builds; AXIS does not downgrade to AppContainer/DACL to obtain terminal support" + .into(), + )); + } + Ok(()) +} + +fn configure_executor_invocation(command: &mut Command, config_path: &Path, dry_run: bool) { + command.args(executor_arguments(config_path, dry_run)); +} + +fn executor_arguments(config_path: &Path, dry_run: bool) -> Vec { + let mut arguments = Vec::new(); + if dry_run { + arguments.push("--dry-run".into()); + } + arguments.push("--config".into()); + arguments.push(config_path.as_os_str().to_owned()); + arguments +} + +fn validate_unproven_policy_surfaces(config: &SandboxConfig) -> Result<(), MxcWindowsError> { + if config.interactive_terminal && config.capture_output { + return Err(MxcWindowsError::UnsupportedPolicy( + "interactive ConPTY mode requires direct host terminal I/O; daemon-side PTY capture is not yet selected by the public launch API".into(), + )); + } + if config.policy.gpu.enabled || config.policy.amd.is_some() { + return Err(MxcWindowsError::UnsupportedPolicy( + "GPU and AMD extensions are not integrated with MXC ProcessContainer".into(), + )); + } + if config.policy.process.run_as_user.is_some() { + return Err(MxcWindowsError::UnsupportedPolicy( + "process.run_as_user names a host/Unix account and has no sound BaseContainer mapping; use process.identity: isolated for portable identity intent" + .into(), + )); + } + validate_scoped_ssh_policy(config)?; + if inference_requested(config) && !matches!(config.policy.network.mode, NetworkMode::Proxy) { + return Err(MxcWindowsError::UnsupportedPolicy( + "managed inference on Windows requires strict proxy mode so the AXIS broker is the only reachable inference and credential boundary".into(), + )); + } + Ok(()) +} + +fn validate_scoped_ssh_policy(config: &SandboxConfig) -> Result<(), MxcWindowsError> { + if config.policy.ssh.allowed_keys.is_empty() { + return Ok(()); + } + if !matches!(config.policy.network.mode, NetworkMode::Proxy) { + return Err(MxcWindowsError::UnsupportedPolicy( + "scoped SSH on Windows requires strict proxy mode so custom clients cannot bypass destination policy" + .into(), + )); + } + if !config.policy.ssh.generate_config || !config.policy.ssh.generate_known_hosts { + return Err(MxcWindowsError::UnsupportedPolicy( + "scoped SSH on Windows requires generated config and known_hosts".into(), + )); + } + + let mut allowed_hosts: Option> = None; + for key in &config.policy.ssh.allowed_keys { + if key.allowed_hosts.is_empty() { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "scoped SSH key '{}' must declare at least one literal allowed host", + key.name + ))); + } + let hosts = key + .allowed_hosts + .iter() + .map(|host| host.to_ascii_lowercase()) + .collect::>(); + if hosts.iter().any(|host| { + host.chars() + .any(|character| matches!(character, '*' | '?' | '[' | ']')) + }) { + return Err(MxcWindowsError::UnsupportedPolicy( + "Windows scoped SSH requires literal host names; wildcard key scopes are not enforceable at the CONNECT boundary" + .into(), + )); + } + if let Some(existing) = &allowed_hosts { + if existing != &hosts { + return Err(MxcWindowsError::UnsupportedPolicy( + "Windows scoped SSH requires every projected key to share the same allowed-host set; raw private keys cannot enforce different key-to-host mappings" + .into(), + )); + } + } else { + allowed_hosts = Some(hosts); + } + } + + let network_ssh_hosts = config + .policy + .network + .policies + .iter() + .flat_map(|policy| &policy.endpoints) + .filter(|endpoint| endpoint.port == 22) + .map(|endpoint| endpoint.host.to_ascii_lowercase()) + .collect::>(); + if allowed_hosts.as_ref() != Some(&network_ssh_hosts) { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "scoped SSH allowed hosts must exactly match strict network endpoints on port 22 (keys={:?}, network={network_ssh_hosts:?})", + allowed_hosts.unwrap_or_default() + ))); + } + Ok(()) +} + +fn inference_requested(config: &SandboxConfig) -> bool { + config.policy.inference.default_provider.is_some() + || !config.policy.inference.routes.is_empty() + || config.policy.inference.token_budget.is_some() +} + +fn sanitized_environment( + entries: &[(String, String)], +) -> Result, MxcWindowsError> { + let mut seen = HashSet::new(); + let mut environment = BTreeMap::new(); + for (key, value) in entries { + if key.trim().is_empty() || key.contains('=') || key.contains('\0') || value.contains('\0') + { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "invalid environment entry {key:?}" + ))); + } + let normalized = key.to_ascii_uppercase(); + if !seen.insert(normalized) { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "duplicate case-insensitive environment key {key:?}" + ))); + } + if axis_core::sandbox_env::is_secret_env_key(key) + || axis_core::sandbox_env::is_proxy_env_key(key) + { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "filtered environment key {key:?} cannot cross the MXC boundary" + ))); + } + environment.insert(key.clone(), value.clone()); + } + Ok(environment) +} + +fn translate_filesystem_policy( + config: &SandboxConfig, +) -> Result { + Ok(ProcessBackendFilesystemSpec { + read_only: translate_path_list(config, &config.policy.filesystem.read_only)?, + read_write: translate_path_list(config, &config.policy.filesystem.read_write)?, + deny: translate_path_list(config, &config.policy.filesystem.deny)?, + }) +} + +/// BaseContainer denies every path that is not covered by an explicit grant. +/// A deny outside all grants is therefore redundant and is removed before the +/// shared capability planner runs. Any overlap remains unrepresentable by the +/// current BaseContainer API and fails closed. +fn normalize_basecontainer_filesystem( + mut filesystem: ProcessBackendFilesystemSpec, +) -> Result { + if filesystem.deny.is_empty() { + return Ok(filesystem); + } + + let grants = filesystem + .read_only + .iter() + .chain(filesystem.read_write.iter()) + .map(|path| windows_path_key(path)) + .collect::, _>>()?; + + for denied in &filesystem.deny { + let denied_key = windows_path_key(denied)?; + for (granted, granted_key) in filesystem + .read_only + .iter() + .chain(filesystem.read_write.iter()) + .zip(grants.iter()) + { + if component_prefix(granted_key, &denied_key) + || component_prefix(&denied_key, granted_key) + { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "filesystem deny {denied:?} overlaps BaseContainer grant {granted:?}; nested deny semantics are not available" + ))); + } + } + tracing::debug!( + path = denied, + "filesystem deny is redundant under BaseContainer default-deny" + ); + } + + filesystem.deny.clear(); + Ok(filesystem) +} + +fn windows_path_key(path: &str) -> Result, MxcWindowsError> { + reject_ambiguous_windows_path(path)?; + let lexical = normalize_absolute_windows_path(Path::new(path))?; + let canonical = canonicalize_with_nonexistent_suffix(&lexical)?; + Ok(canonical + .components() + .map(|component| component.as_os_str().to_os_string()) + .collect()) +} + +fn reject_ambiguous_windows_path(path: &str) -> Result<(), MxcWindowsError> { + let normalized = path.replace('/', "\\"); + let upper = normalized.to_ascii_uppercase(); + if upper.starts_with("\\\\.\\") + || upper.starts_with("\\\\?\\GLOBALROOT") + || upper.starts_with("\\\\?\\GLOBAL??") + { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "filesystem path {path:?} uses an unsupported Windows device namespace" + ))); + } + + for component in Path::new(path).components() { + if matches!(component, Component::Normal(_)) + && component + .as_os_str() + .encode_wide() + .any(|unit| unit == b':' as u16) + { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "filesystem path {path:?} contains an alternate data stream" + ))); + } + } + Ok(()) +} + +fn normalize_absolute_windows_path(path: &Path) -> Result { + if !path.is_absolute() { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "filesystem path {path:?} is not absolute" + ))); + } + + let mut normalized = PathBuf::new(); + for component in path.components() { + match component { + Component::Prefix(prefix) => normalized.push(prefix.as_os_str()), + Component::RootDir => normalized.push(Path::new("\\")), + Component::CurDir => {} + Component::ParentDir => { + if !normalized.pop() { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "filesystem path {path:?} escapes its Windows root" + ))); + } + } + Component::Normal(part) => normalized.push(part), + } + } + Ok(normalized) +} + +fn canonicalize_with_nonexistent_suffix(path: &Path) -> Result { + let mut ancestor = path.to_path_buf(); + let mut suffix = Vec::new(); + while !ancestor.exists() { + let leaf = ancestor.file_name().ok_or_else(|| { + MxcWindowsError::UnsupportedPolicy(format!( + "filesystem path {:?} has no resolvable Windows ancestor", + path + )) + })?; + suffix.push(leaf.to_os_string()); + if !ancestor.pop() { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "filesystem path {:?} has no resolvable Windows ancestor", + path + ))); + } + } + + let mut canonical = fs::canonicalize(&ancestor).map_err(|err| { + MxcWindowsError::UnsupportedPolicy(format!( + "cannot canonicalize filesystem path ancestor {:?}: {err}", + ancestor + )) + })?; + for component in suffix.into_iter().rev() { + canonical.push(component); + } + Ok(canonical) +} + +fn component_prefix(prefix: &[OsString], path: &[OsString]) -> bool { + prefix.len() <= path.len() + && prefix + .iter() + .zip(path) + .all(|(left, right)| windows_component_eq(left, right)) +} + +fn windows_component_eq(left: &OsStr, right: &OsStr) -> bool { + use windows::Win32::Globalization::{CSTR_EQUAL, CompareStringOrdinal}; + + let left = left.encode_wide().collect::>(); + let right = right.encode_wide().collect::>(); + unsafe { CompareStringOrdinal(&left, &right, true) == CSTR_EQUAL } +} + +fn translate_path_list( + config: &SandboxConfig, + paths: &[String], +) -> Result, MxcWindowsError> { + let mut translated = Vec::new(); + for path in paths { + if path.starts_with('/') { + if matches!( + config.policy.filesystem.compatibility, + Compatibility::BestEffort + ) { + tracing::debug!("skipping Unix-only filesystem policy path on Windows: {path}"); + continue; + } + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "Unix-only filesystem path {path:?} is a hard requirement on Windows" + ))); + } + let expanded = expand_policy_path(config, path)?; + if !Path::new(&expanded).is_absolute() { + return Err(MxcWindowsError::UnsupportedPolicy(format!( + "filesystem path {path:?} did not resolve to an absolute Windows path" + ))); + } + if !translated.iter().any(|existing| existing == &expanded) { + translated.push(expanded); + } + } + Ok(translated) +} + +fn expand_policy_path(config: &SandboxConfig, path: &str) -> Result { + let workspace = path_string(&config.workspace_dir)?; + let tmpdir = path_string(&config.workspace_dir.join(".axis-tmp"))?; + let mut expanded = path + .replace("{workspace}", &workspace) + .replace("{tmpdir}", &tmpdir); + if expanded == "~" || expanded.starts_with("~/") || expanded.starts_with("~\\") { + let home = std::env::var("USERPROFILE").map_err(|_| { + MxcWindowsError::UnsupportedPolicy( + "USERPROFILE is required to expand '~' filesystem policy paths".into(), + ) + })?; + expanded = if expanded == "~" { + home + } else { + PathBuf::from(home) + .join(&expanded[2..]) + .to_string_lossy() + .into_owned() + }; + } + Ok(expanded) +} + +fn windows_command_line(command: &str, args: &[String]) -> String { + std::iter::once(command) + .chain(args.iter().map(String::as_str)) + .map(quote_windows_argument) + .collect::>() + .join(" ") +} + +fn quote_windows_argument(argument: &str) -> String { + if !argument.is_empty() + && !argument + .chars() + .any(|character| character.is_whitespace() || character == '"') + { + return argument.into(); + } + let mut quoted = String::from("\""); + let mut backslashes = 0; + for character in argument.chars() { + if character == '\\' { + backslashes += 1; + } else if character == '"' { + quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); + quoted.push('"'); + backslashes = 0; + } else { + quoted.push_str(&"\\".repeat(backslashes)); + backslashes = 0; + quoted.push(character); + } + } + quoted.push_str(&"\\".repeat(backslashes * 2)); + quoted.push('"'); + quoted +} + +fn configure_executor_environment(command: &mut Command, executor: &Path) { + command.env_clear(); + command.envs(executor_environment(executor)); +} + +fn executor_environment(executor: &Path) -> Vec<(OsString, OsString)> { + let mut environment = Vec::new(); + for key in [ + "SYSTEMROOT", + "WINDIR", + "COMSPEC", + "TEMP", + "TMP", + "LOCALAPPDATA", + "USERPROFILE", + ] { + if let Some(value) = std::env::var_os(key) { + environment.push((key.into(), value)); + } + } + let mut path_entries = Vec::new(); + if let Some(parent) = executor.parent() { + path_entries.push(parent.to_path_buf()); + } + if let Some(system_root) = std::env::var_os("SYSTEMROOT") { + let system_root = PathBuf::from(system_root); + path_entries.push(system_root.join("System32")); + path_entries.push(system_root); + } + if let Ok(path) = std::env::join_paths(path_entries) { + environment.push(("PATH".into(), path)); + } + environment +} + +fn production_executor_candidates() -> Vec { + let mut candidates = Vec::new(); + if let Ok(current_exe) = std::env::current_exe() + && let Some(directory) = current_exe.parent() + { + candidates.extend(EXECUTOR_NAMES.iter().map(|name| directory.join(name))); + } + for variable in ["ProgramFiles", "LOCALAPPDATA"] { + if let Some(root) = std::env::var_os(variable) { + let directory = PathBuf::from(root).join("axis").join("bin"); + candidates.extend(EXECUTOR_NAMES.iter().map(|name| directory.join(name))); + } + } + candidates +} + +fn validate_executor_path(path: &Path, production: bool) -> Result<(), MxcWindowsError> { + if !path.is_absolute() { + return Err(MxcWindowsError::UnsafeExecutor { + path: path.into(), + reason: "path is not absolute".into(), + }); + } + let metadata = fs::symlink_metadata(path).map_err(|err| MxcWindowsError::UnsafeExecutor { + path: path.into(), + reason: err.to_string(), + })?; + if !metadata.is_file() || metadata.file_type().is_symlink() { + return Err(MxcWindowsError::UnsafeExecutor { + path: path.into(), + reason: "candidate is not a regular, non-symlink file".into(), + }); + } + if production + && !path + .extension() + .and_then(OsStr::to_str) + .is_some_and(|extension| extension.eq_ignore_ascii_case("exe")) + { + return Err(MxcWindowsError::UnsafeExecutor { + path: path.into(), + reason: "production executor must be an .exe".into(), + }); + } + Ok(()) +} + +fn path_string(path: &Path) -> Result { + path.to_str() + .map(ToOwned::to_owned) + .ok_or_else(|| MxcWindowsError::UnsupportedPolicy(format!("non-UTF-8 path: {path:?}"))) +} + +fn wait_child_with_timeout( + mut child: Child, + timeout: Duration, +) -> Result { + let deadline = Instant::now() + timeout; + loop { + match child.try_wait() { + Ok(Some(status)) => return Ok(status), + Ok(None) => {} + Err(err) => { + let _ = child.kill(); + let _ = child.wait(); + return Err(MxcWindowsError::Executor(err.to_string())); + } + } + if Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + return Err(MxcWindowsError::Executor(format!( + "timed out after {}ms", + timeout.as_millis() + ))); + } + std::thread::sleep(Duration::from_millis(10)); + } +} + +fn read_limited_output(file: &mut File) -> Result { + file.seek(SeekFrom::Start(0)) + .map_err(|err| MxcWindowsError::Executor(err.to_string()))?; + let mut bytes = Vec::new(); + file.take(MAX_DRY_RUN_OUTPUT_BYTES) + .read_to_end(&mut bytes) + .map_err(|err| MxcWindowsError::Executor(err.to_string()))?; + Ok(String::from_utf8_lossy(&bytes).into_owned()) +} + +#[cfg(test)] +mod tests { + use super::*; + use axis_core::policy::{ + Access, ChildProcessPolicy, Endpoint, EndpointPolicy, FilesystemPolicy, GpuPolicy, + InferencePolicy, InferenceRoute, NetworkMode, NetworkPolicy, Policy, ProcessIdentity, + ProcessPolicy, RuntimePolicy, SshKeySpec, SshPolicy, + }; + + #[test] + fn windows_command_line_quotes_createprocess_arguments() { + assert_eq!(windows_command_line("cmd", &[]), "cmd"); + assert_eq!( + windows_command_line("C:\\Program Files\\tool.exe", &["a b".into(), "".into()]), + "\"C:\\Program Files\\tool.exe\" \"a b\" \"\"" + ); + assert_eq!(quote_windows_argument("a\\\"b"), "\"a\\\\\\\"b\""); + assert_eq!( + quote_windows_argument("C:\\path with space\\"), + "\"C:\\path with space\\\\\"" + ); + } + + #[test] + fn processcontainer_config_is_fail_closed_and_sanitized() { + let root = tempfile::tempdir().unwrap(); + let config = sandbox_config(root.path()); + let wire = build_windows_processcontainer_config(&config).unwrap(); + let json = serde_json::to_value(wire).unwrap(); + assert_eq!(json["containment"], "processcontainer"); + assert_eq!(json["platform"], "windows"); + assert_eq!(json["processContainer"]["leastPrivilege"], true); + assert_eq!(json["fallback"]["allowDaclMutation"], false); + assert_eq!(json["network"]["defaultPolicy"], "block"); + assert_eq!( + json["filesystem"]["readwritePaths"][0], + root.path().to_string_lossy().as_ref() + ); + assert_eq!( + json["process"]["env"], + serde_json::json!(["PATH=safe-path"]) + ); + } + + #[test] + fn processcontainer_serializes_child_only_job_resources() { + let root = tempfile::tempdir().unwrap(); + let mut config = sandbox_config(root.path()); + config.policy.process.max_processes = 8; + config.policy.process.max_memory_mb = 512; + config.policy.process.cpu_rate_percent = 25; + + let wire = build_windows_processcontainer_config(&config).unwrap(); + let json = serde_json::to_value(wire).unwrap(); + + assert_eq!(json["processContainer"]["resources"]["maxProcesses"], 8); + assert_eq!(json["processContainer"]["resources"]["maxMemoryMb"], 512); + assert_eq!(json["processContainer"]["resources"]["cpuRatePercent"], 25); + } + + #[test] + fn portable_identity_and_child_denial_map_without_linux_nouns() { + let root = tempfile::tempdir().unwrap(); + let mut config = sandbox_config(root.path()); + config.policy.process.identity = ProcessIdentity::Isolated; + config.policy.process.child_processes = ChildProcessPolicy::Deny; + config.policy.process.max_processes = 32; + + let wire = build_windows_processcontainer_config(&config).unwrap(); + let json = serde_json::to_value(wire).unwrap(); + assert_eq!(json["processContainer"]["leastPrivilege"], true); + assert_eq!(json["processContainer"]["resources"]["maxProcesses"], 1); + + config.policy.process.run_as_user = Some("sandbox-user".into()); + let error = build_windows_processcontainer_config(&config).unwrap_err(); + assert!(error.to_string().contains("process.identity: isolated")); + } + + #[test] + fn processcontainer_removes_denies_outside_all_grants_as_redundant() { + let root = tempfile::tempdir().unwrap(); + let denied_root = tempfile::tempdir().unwrap(); + let mut config = sandbox_config(root.path()); + config.policy.filesystem.deny = vec![denied_root.path().to_string_lossy().into_owned()]; + + let wire = build_windows_processcontainer_config(&config).unwrap(); + + assert!(wire.filesystem.denied_paths.is_empty()); + } + + #[test] + fn processcontainer_rejects_deny_nested_beneath_grant_case_insensitively() { + let root = tempfile::tempdir().unwrap(); + let nested = root.path().join("Secrets"); + fs::create_dir(&nested).unwrap(); + let mut config = sandbox_config(root.path()); + config.policy.filesystem.deny = vec![nested.to_string_lossy().to_ascii_uppercase()]; + + let error = build_windows_processcontainer_config(&config).unwrap_err(); + + assert!(error.to_string().contains("nested deny semantics")); + } + + #[test] + fn processcontainer_rejects_deny_ancestor_of_grant() { + let root = tempfile::tempdir().unwrap(); + let workspace = root.path().join("workspace"); + fs::create_dir(&workspace).unwrap(); + let mut config = sandbox_config(&workspace); + config.policy.filesystem.deny = vec![root.path().to_string_lossy().into_owned()]; + + let error = build_windows_processcontainer_config(&config).unwrap_err(); + + assert!(error.to_string().contains("nested deny semantics")); + } + + #[test] + fn processcontainer_rejects_nonexistent_deny_reached_through_junction_alias() { + let root = tempfile::tempdir().unwrap(); + let alias_root = tempfile::tempdir().unwrap(); + let junction = alias_root.path().join("workspace-alias"); + let status = Command::new("cmd.exe") + .args(["/d", "/c", "mklink", "/J"]) + .arg(&junction) + .arg(root.path()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .unwrap(); + assert!(status.success(), "test junction creation failed"); + let mut config = sandbox_config(root.path()); + config.policy.filesystem.deny = vec![ + junction + .join("future") + .join("secret.txt") + .to_string_lossy() + .into_owned(), + ]; + + let error = build_windows_processcontainer_config(&config).unwrap_err(); + + assert!(error.to_string().contains("nested deny semantics")); + } + + #[test] + fn processcontainer_rejects_ads_and_device_namespace_deny_paths() { + let root = tempfile::tempdir().unwrap(); + let mut ads = sandbox_config(root.path()); + ads.policy.filesystem.deny = vec![format!("{}:secret", root.path().display())]; + let ads_error = build_windows_processcontainer_config(&ads).unwrap_err(); + assert!(ads_error.to_string().contains("alternate data stream")); + + let mut device = sandbox_config(root.path()); + device.policy.filesystem.deny = vec![r"\\.\C:\axis-secret".into()]; + let device_error = build_windows_processcontainer_config(&device).unwrap_err(); + assert!( + device_error + .to_string() + .contains("unsupported Windows device namespace") + ); + } + + #[test] + fn processcontainer_rejects_secret_environment() { + let root = tempfile::tempdir().unwrap(); + let mut config = sandbox_config(root.path()); + config.env.push(("OPENAI_API_KEY".into(), "secret".into())); + let error = build_windows_processcontainer_config(&config).unwrap_err(); + assert!(error.to_string().contains("filtered environment key")); + } + + #[test] + fn hard_requirement_rejects_unix_paths_on_windows() { + let root = tempfile::tempdir().unwrap(); + let mut config = sandbox_config(root.path()); + config.policy.filesystem.compatibility = Compatibility::HardRequirement; + config.policy.filesystem.read_only.push("/usr".into()); + let error = build_windows_processcontainer_config(&config).unwrap_err(); + assert!(error.to_string().contains("Unix-only filesystem path")); + } + + #[test] + fn interactive_terminal_rejects_instead_of_downgrading() { + let error = validate_tier_policy(true).unwrap_err(); + assert!(error.to_string().contains("ERROR_INVALID_HANDLE")); + assert!(error.to_string().contains("AppContainer/DACL")); + validate_tier_policy(false).unwrap(); + } + + #[test] + fn managed_inference_requires_strict_proxy_but_is_not_windows_rejected() { + let root = tempfile::tempdir().unwrap(); + let mut config = sandbox_config(root.path()); + config.policy.inference.routes.push(InferenceRoute { + name: "provider".into(), + endpoint: Some("https://api.example.test".into()), + provider: Some("openai-compatible".into()), + model: None, + api_key_env: Some("AXIS_TEST_PROVIDER_KEY".into()), + protocols: vec!["openai-chat".into()], + }); + + let error = validate_unproven_policy_surfaces(&config).unwrap_err(); + assert!(error.to_string().contains("requires strict proxy mode")); + + config.policy.network.mode = NetworkMode::Proxy; + validate_unproven_policy_surfaces(&config).unwrap(); + validate_tier_policy(false).unwrap(); + } + + #[test] + fn scoped_ssh_requires_exact_shared_host_and_network_sets() { + let root = tempfile::tempdir().unwrap(); + let mut config = sandbox_config(root.path()); + config.policy.network.mode = NetworkMode::Proxy; + config.policy.network.policies.push(EndpointPolicy { + name: "ssh".into(), + endpoints: vec![Endpoint { + host: "git.example.test".into(), + port: 22, + access: Access::ReadWrite, + protocol: Some("tcp".into()), + rules: Vec::new(), + }], + binaries: Vec::new(), + }); + config.policy.ssh = SshPolicy { + allowed_keys: vec![SshKeySpec { + name: "deploy".into(), + private_key: "C:\\keys\\deploy".into(), + allowed_hosts: vec!["git.example.test".into()], + }], + generate_known_hosts: true, + generate_config: true, + }; + validate_scoped_ssh_policy(&config).unwrap(); + + config.policy.ssh.allowed_keys.push(SshKeySpec { + name: "other".into(), + private_key: "C:\\keys\\other".into(), + allowed_hosts: vec!["other.example.test".into()], + }); + let error = validate_scoped_ssh_policy(&config).unwrap_err(); + assert!(error.to_string().contains("same allowed-host set")); + } + + #[test] + fn executor_invocation_uses_upstream_tier_dispatcher() { + let mut command = Command::new("wxc-exec.exe"); + configure_executor_invocation(&mut command, Path::new("C:\\temp\\policy.json"), true); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + + assert_eq!(args, ["--dry-run", "--config", "C:\\temp\\policy.json"]); + let mut command = Command::new("wxc-exec.exe"); + configure_executor_invocation(&mut command, Path::new("C:\\temp\\policy.json"), false); + let args = command + .get_args() + .map(|arg| arg.to_string_lossy().into_owned()) + .collect::>(); + assert_eq!(args, ["--config", "C:\\temp\\policy.json"]); + } + + #[test] + fn executor_environment_keeps_dacl_state_roots_but_not_credentials() { + let mut command = Command::new("wxc-exec.exe"); + configure_executor_environment(&mut command, Path::new("C:\\axis\\bin\\wxc-exec.exe")); + let keys = command + .get_envs() + .filter_map(|(key, value)| value.map(|_| key.to_string_lossy().to_ascii_uppercase())) + .collect::>(); + + assert!(keys.contains("LOCALAPPDATA")); + assert!(keys.contains("USERPROFILE")); + assert!(!keys.contains("OPENAI_API_KEY")); + } + + #[tokio::test] + async fn fake_executor_receives_private_config_and_cleans_lifecycle_state() { + let root = tempfile::tempdir().unwrap(); + let config_copy = root.path().join("received-config.json"); + let executor_path = root.path().join("fake-mxc.cmd"); + write_fake_executor(&executor_path, &config_copy); + let executor = MxcWindowsExecutor::from_test_path(&executor_path).unwrap(); + let mut config = sandbox_config(root.path()); + config.capture_output = true; + let mut sandbox = MxcWindowsSandbox::new_with_executor(&config, executor).unwrap(); + + let pid = SandboxImpl::start(&mut sandbox).unwrap(); + assert!(pid > 0); + let code = SandboxImpl::wait(&mut sandbox).await.unwrap(); + + assert_eq!(code, 0); + assert!(sandbox.child.is_none()); + assert!(sandbox.job.is_none()); + assert!(sandbox.config_file.is_none()); + assert!( + fs::read_to_string(root.path().join("stdout.log")) + .unwrap() + .contains(MXC_DRY_RUN_SUCCESS) + ); + let json: serde_json::Value = + serde_json::from_slice(&fs::read(config_copy).unwrap()).unwrap(); + assert_eq!(json["containment"], "processcontainer"); + assert_eq!(json["network"]["defaultPolicy"], "block"); + assert!(!serde_json::to_string(&json).unwrap().contains("secret")); + } + + #[test] + fn fake_executor_dry_run_validates_the_same_wire_config() { + let root = tempfile::tempdir().unwrap(); + let config_copy = root.path().join("dry-run-config.json"); + let executor_path = root.path().join("fake-mxc.cmd"); + write_fake_executor(&executor_path, &config_copy); + let executor = MxcWindowsExecutor::from_test_path(&executor_path).unwrap(); + let config = sandbox_config(root.path()); + let wire = build_windows_processcontainer_config(&config).unwrap(); + + executor.dry_run(&wire, Duration::from_secs(2)).unwrap(); + + let received: serde_json::Value = + serde_json::from_slice(&fs::read(config_copy).unwrap()).unwrap(); + assert_eq!(received["containerId"], format!("axis-{}", config.id)); + } + + #[tokio::test] + async fn timeout_kills_fake_executor_and_cleans_private_state() { + let root = tempfile::tempdir().unwrap(); + let executor_path = root.path().join("slow-mxc.cmd"); + fs::write( + &executor_path, + "@echo off\r\nping 127.0.0.1 -n 30 >nul\r\nexit /b 0\r\n", + ) + .unwrap(); + let executor = MxcWindowsExecutor::from_test_path(&executor_path).unwrap(); + let mut config = sandbox_config(root.path()); + config.timeout_sec = Some(1); + let mut sandbox = MxcWindowsSandbox::new_with_executor(&config, executor).unwrap(); + + SandboxImpl::start(&mut sandbox).unwrap(); + let error = SandboxImpl::wait(&mut sandbox).await.unwrap_err(); + + assert!(error.to_string().contains("timed out")); + assert!(sandbox.child.is_none()); + assert!(sandbox.job.is_none()); + assert!(sandbox.config_file.is_none()); + } + + #[test] + fn missing_executor_candidates_fail_without_host_fallback() { + let root = tempfile::tempdir().unwrap(); + let missing = root.path().join("missing-wxc.exe"); + let error = MxcWindowsExecutor::resolve_from_candidates([missing]).unwrap_err(); + assert_eq!(error, MxcWindowsError::ExecutorUnavailable); + } + + fn write_fake_executor(path: &Path, config_copy: &Path) { + let config_copy = config_copy.to_string_lossy().replace('%', "%%"); + let script = format!( + "@echo off\r\nsetlocal\r\nset CONFIG=\r\n:parse\r\nif \"%~1\"==\"\" goto run\r\nif /I \"%~1\"==\"--config\" (\r\n set \"CONFIG=%~2\"\r\n shift\r\n shift\r\n goto parse\r\n)\r\nshift\r\ngoto parse\r\n:run\r\ncopy /Y \"%CONFIG%\" \"{config_copy}\" >nul\r\necho {MXC_DRY_RUN_SUCCESS}\r\nexit /b 0\r\n" + ); + fs::write(path, script).unwrap(); + } + + fn sandbox_config(workspace: &Path) -> SandboxConfig { + SandboxConfig { + id: axis_core::types::SandboxId::new(), + policy: Policy { + version: 1, + name: "windows-mxc-test".into(), + runtime: RuntimePolicy::default(), + filesystem: FilesystemPolicy { + read_only: vec!["/usr".into()], + read_write: vec!["{workspace}".into()], + deny: Vec::new(), + compatibility: Compatibility::BestEffort, + }, + process: ProcessPolicy { + max_processes: 0, + max_memory_mb: 0, + cpu_rate_percent: 0, + run_as_user: None, + blocked_syscalls: Vec::new(), + identity: Default::default(), + child_processes: Default::default(), + timeout_sec: None, + }, + network: NetworkPolicy { + mode: NetworkMode::Block, + policies: Vec::new(), + }, + inference: InferencePolicy::default(), + gpu: GpuPolicy::default(), + ssh: SshPolicy::default(), + amd: None, + }, + command: "cmd.exe".into(), + args: vec!["/c".into(), "echo ok".into()], + working_dir: Some(workspace.into()), + workspace_dir: workspace.into(), + env: vec![("PATH".into(), "safe-path".into())], + proxy_port: 0, + proxy_addr: None, + connect_attribution: None, + capture_output: false, + interactive_terminal: false, + pty_bridge_helper: None, + timeout_sec: None, + backend_preflight: BackendPreflight::InProcess, + startup_trace: None, + } + } +} diff --git a/crates/axis-sandbox/src/windows/wfp.rs b/crates/axis-sandbox/src/windows/wfp.rs new file mode 100644 index 0000000..61ba8dc --- /dev/null +++ b/crates/axis-sandbox/src/windows/wfp.rs @@ -0,0 +1,1428 @@ +// Copyright 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: Apache-2.0 + +//! Privileged Windows Filtering Platform broker for strict proxy leases. +//! +//! The broker deliberately exposes one fixed operation: constrain the actual +//! AppContainer identity of a suspended child to one TCP proxy endpoint. It +//! does not accept caller-provided SIDs, filter keys, layers, actions, or +//! arbitrary conditions. + +use serde::{Deserialize, Serialize}; +use std::ffi::c_void; +use std::fs::{File, OpenOptions}; +use std::io::{Read, Write}; +use std::mem::{MaybeUninit, size_of}; +use std::net::{IpAddr, Ipv4Addr, Ipv6Addr}; +use std::os::windows::io::{FromRawHandle, IntoRawHandle}; +use std::path::{Path, PathBuf}; +use std::ptr::NonNull; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::{Arc, Mutex, OnceLock}; +use std::time::{SystemTime, UNIX_EPOCH}; +use uuid::Uuid; +use windows::Win32::Foundation::{ + CloseHandle, ERROR_ALREADY_EXISTS, ERROR_BROKEN_PIPE, ERROR_PIPE_CONNECTED, FILETIME, + FWP_E_FILTER_NOT_FOUND, FWP_E_SUBLAYER_NOT_FOUND, GetLastError, HANDLE, HLOCAL, + INVALID_HANDLE_VALUE, LocalFree, +}; +use windows::Win32::NetworkManagement::WindowsFilteringPlatform::{ + FWP_ACTION_BLOCK, FWP_ACTION_PERMIT, FWP_CONDITION_VALUE0, FWP_CONDITION_VALUE0_0, + FWP_IP_VERSION_V4, FWP_IP_VERSION_V6, FWP_MATCH_EQUAL, FWP_SID, FWP_UINT8, FWP_UINT16, + FWP_UINT32, FWP_UINT64, FWP_V4_ADDR_AND_MASK, FWP_V4_ADDR_MASK, FWP_V6_ADDR_AND_MASK, + FWP_V6_ADDR_MASK, FWP_VALUE0, FWP_VALUE0_0, FWPM_ACTION0, FWPM_CONDITION_ALE_PACKAGE_ID, + FWPM_CONDITION_IP_PROTOCOL, FWPM_CONDITION_IP_REMOTE_ADDRESS, FWPM_CONDITION_IP_REMOTE_PORT, + FWPM_DISPLAY_DATA0, FWPM_ENGINE_COLLECT_NET_EVENTS, FWPM_FILTER_CONDITION0, + FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT, FWPM_FILTER0, FWPM_LAYER_ALE_AUTH_CONNECT_V4, + FWPM_LAYER_ALE_AUTH_CONNECT_V6, FWPM_NET_EVENT_FLAG_IP_PROTOCOL_SET, + FWPM_NET_EVENT_FLAG_IP_VERSION_SET, FWPM_NET_EVENT_FLAG_REMOTE_ADDR_SET, + FWPM_NET_EVENT_FLAG_REMOTE_PORT_SET, FWPM_NET_EVENT_SUBSCRIPTION0, + FWPM_NET_EVENT_TYPE_CLASSIFY_DROP, FWPM_NET_EVENT5, FWPM_SESSION_FLAG_DYNAMIC, FWPM_SESSION0, + FWPM_SUBLAYER0, FwpmEngineClose0, FwpmEngineGetOption0, FwpmEngineOpen0, FwpmEngineSetOption0, + FwpmFilterAdd0, FwpmFilterDeleteByKey0, FwpmFreeMemory0, FwpmNetEventSubscribe4, + FwpmNetEventUnsubscribe0, FwpmSubLayerAdd0, FwpmSubLayerDeleteByKey0, FwpmTransactionAbort0, + FwpmTransactionBegin0, FwpmTransactionCommit0, +}; +use windows::Win32::Security::Authorization::{ + ConvertSidToStringSidW, ConvertStringSecurityDescriptorToSecurityDescriptorW, SDDL_REVISION_1, +}; +use windows::Win32::Security::{ + GetLengthSid, GetTokenInformation, PSECURITY_DESCRIPTOR, PSID, TOKEN_APPCONTAINER_INFORMATION, + TOKEN_QUERY, TokenAppContainerSid, TokenIsAppContainer, +}; +use windows::Win32::Storage::FileSystem::PIPE_ACCESS_DUPLEX; +use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, PROCESSENTRY32W, Process32FirstW, Process32NextW, TH32CS_SNAPPROCESS, +}; +use windows::Win32::System::Pipes::{ + ConnectNamedPipe, CreateNamedPipeW, DisconnectNamedPipe, GetNamedPipeClientProcessId, + PIPE_READMODE_BYTE, PIPE_REJECT_REMOTE_CLIENTS, PIPE_TYPE_BYTE, PIPE_WAIT, WaitNamedPipeW, +}; +use windows::Win32::System::Rpc::RPC_C_AUTHN_WINNT; +use windows::Win32::System::Threading::{ + CreateMutexW, GetCurrentProcessId, GetProcessTimes, OpenProcess, OpenProcessToken, + PROCESS_ACCESS_RIGHTS, PROCESS_NAME_WIN32, PROCESS_QUERY_LIMITED_INFORMATION, + PROCESS_TERMINATE, QueryFullProcessImageNameW, TerminateProcess, WaitForSingleObject, +}; +use windows::core::{GUID, PCWSTR, PWSTR}; + +pub const PROTOCOL_VERSION: u32 = 1; +pub const DEFAULT_PIPE_NAME: &str = r"\\.\pipe\axis-wfp-broker-v1"; +const MAX_MESSAGE_BYTES: usize = 64 * 1024; +const PIPE_BUFFER_BYTES: u32 = 64 * 1024; +const IPPROTO_TCP: u8 = 6; +static AUDIT_WRITE_LOCK: OnceLock> = OnceLock::new(); + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +pub struct LeaseRequest { + pub version: u32, + pub lease_id: Uuid, + pub child_pid: u32, + pub proxy_address: IpAddr, + pub proxy_port: u16, +} + +impl LeaseRequest { + pub fn validate(&self) -> Result<(), String> { + if self.version != PROTOCOL_VERSION { + return Err(format!( + "unsupported WFP broker protocol version {}", + self.version + )); + } + if self.lease_id.is_nil() { + return Err("leaseId must not be nil".into()); + } + if self.child_pid == 0 { + return Err("childPid must not be zero".into()); + } + if self.proxy_port == 0 { + return Err("proxyPort must not be zero".into()); + } + if self.proxy_address.is_unspecified() || self.proxy_address.is_multicast() { + return Err("proxyAddress must be a concrete unicast address".into()); + } + Ok(()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct LeaseResponse { + pub version: u32, + pub lease_id: Uuid, + pub accepted: bool, + pub app_container_sid: Option, + pub filter_count: u32, + pub error: Option, +} + +impl LeaseResponse { + fn accepted(request: &LeaseRequest, sid: String, filter_count: u32) -> Self { + Self { + version: PROTOCOL_VERSION, + lease_id: request.lease_id, + accepted: true, + app_container_sid: Some(sid), + filter_count, + error: None, + } + } + + fn rejected(lease_id: Uuid, error: impl Into) -> Self { + Self { + version: PROTOCOL_VERSION, + lease_id, + accepted: false, + app_container_sid: None, + filter_count: 0, + error: Some(error.into()), + } + } +} + +#[derive(Debug, Clone)] +pub struct BrokerConfig { + pub pipe_name: String, + pub audit_log: PathBuf, + pub lease_dir: PathBuf, +} + +impl Default for BrokerConfig { + fn default() -> Self { + let root = std::env::var_os("ProgramData") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(r"C:\ProgramData")); + Self { + pipe_name: DEFAULT_PIPE_NAME.into(), + audit_log: root.join("axis").join("logs").join("wfp-broker.jsonl"), + lease_dir: root.join("axis").join("wfp-leases"), + } + } +} + +/// Non-mutating availability probe used by policy planning. It does not open +/// a lease or consume a pipe instance. +pub fn broker_available(pipe_name: &str) -> bool { + let pipe_name = wide(pipe_name); + unsafe { WaitNamedPipeW(PCWSTR(pipe_name.as_ptr()), 250) }.as_bool() +} + +struct NetEventCollectionGuard { + engine: HANDLE, + restore_disabled: bool, +} + +unsafe impl Send for NetEventCollectionGuard {} + +struct FwpmAllocatedValue(NonNull); + +impl FwpmAllocatedValue { + fn from_raw(value: *mut FWP_VALUE0) -> Result { + NonNull::new(value) + .map(Self) + .ok_or_else(|| "FwpmEngineGetOption0 returned a null value".into()) + } + + fn get(&self) -> &FWP_VALUE0 { + // SAFETY: a successful FwpmEngineGetOption0 call returns an allocated + // FWP_VALUE0 that remains readable until FwpmFreeMemory0 releases it. + unsafe { self.0.as_ref() } + } +} + +impl Drop for FwpmAllocatedValue { + fn drop(&mut self) { + let mut value = self.0.as_ptr(); + unsafe { + FwpmFreeMemory0((&mut value as *mut *mut FWP_VALUE0).cast()); + } + } +} + +impl NetEventCollectionGuard { + fn enable() -> Result { + let mut engine = HANDLE::default(); + check_wfp( + unsafe { FwpmEngineOpen0(PCWSTR::null(), RPC_C_AUTHN_WINNT, None, None, &mut engine) }, + "FwpmEngineOpen0(audit)", + )?; + + let mut current = MaybeUninit::<*mut FWP_VALUE0>::uninit(); + let get_result = check_wfp( + unsafe { + FwpmEngineGetOption0(engine, FWPM_ENGINE_COLLECT_NET_EVENTS, current.as_mut_ptr()) + }, + "FwpmEngineGetOption0(net events)", + ); + if let Err(error) = get_result { + unsafe { + let _ = FwpmEngineClose0(engine); + } + return Err(error); + } + // SAFETY: FwpmEngineGetOption0 initializes the out parameter on success. + let current = unsafe { current.assume_init() }; + let current = match FwpmAllocatedValue::from_raw(current) { + Ok(current) => current, + Err(error) => { + unsafe { + let _ = FwpmEngineClose0(engine); + } + return Err(error); + } + }; + let value = current.get(); + let was_enabled = unsafe { value.r#type == FWP_UINT32 && value.Anonymous.uint32 != 0 }; + drop(current); + + if !was_enabled && let Err(error) = set_net_event_collection(engine, true) { + unsafe { + let _ = FwpmEngineClose0(engine); + } + return Err(error); + } + Ok(Self { + engine, + restore_disabled: !was_enabled, + }) + } +} + +impl Drop for NetEventCollectionGuard { + fn drop(&mut self) { + if self.restore_disabled { + let _ = set_net_event_collection(self.engine, false); + } + unsafe { + let _ = FwpmEngineClose0(self.engine); + } + } +} + +fn set_net_event_collection(engine: HANDLE, enabled: bool) -> Result<(), String> { + let value = FWP_VALUE0 { + r#type: FWP_UINT32, + Anonymous: FWP_VALUE0_0 { + uint32: u32::from(enabled), + }, + }; + check_wfp( + unsafe { FwpmEngineSetOption0(engine, FWPM_ENGINE_COLLECT_NET_EVENTS, &value) }, + "FwpmEngineSetOption0(net events)", + ) +} + +struct BrokerInstanceGuard(HANDLE); + +impl BrokerInstanceGuard { + fn acquire() -> Result { + let handle = unsafe { + CreateMutexW( + None, + false, + PCWSTR(wide(r"Global\AxisWfpBrokerV1").as_ptr()), + ) + } + .map_err(|e| format!("create broker singleton mutex: {e}"))?; + if unsafe { GetLastError() } == ERROR_ALREADY_EXISTS { + unsafe { + let _ = CloseHandle(handle); + } + return Err("another AXIS WFP broker instance is already running".into()); + } + Ok(Self(handle)) + } +} + +impl Drop for BrokerInstanceGuard { + fn drop(&mut self) { + unsafe { + let _ = CloseHandle(self.0); + } + } +} + +/// Serve lease requests until `stop` becomes true. +/// +/// Each accepted connection owns one dynamic WFP engine. The connection is +/// held open by `wxc-exec` for the complete sandbox lifetime, so disconnect, +/// executor crash, broker process exit, or service stop removes every filter +/// in the lease without a persistent recovery journal. +pub fn serve(config: BrokerConfig, stop: Arc) -> Result<(), String> { + let _instance = BrokerInstanceGuard::acquire()?; + let _net_event_collection = NetEventCollectionGuard::enable()?; + reap_stale_leases(&config.lease_dir, &config.audit_log)?; + while !stop.load(Ordering::Acquire) { + let pipe = create_pipe(&config.pipe_name)?; + let connected = unsafe { ConnectNamedPipe(pipe, None) }; + if connected.is_err() { + let err = unsafe { GetLastError() }; + if err != ERROR_PIPE_CONNECTED { + unsafe { + let _ = CloseHandle(pipe); + } + if stop.load(Ordering::Acquire) { + return Ok(()); + } + return Err(format!("ConnectNamedPipe failed: {err:?}")); + } + } + + let child_config = config.clone(); + let raw_pipe = pipe.0 as usize; + std::thread::spawn(move || { + // SAFETY: ownership of the connected handle moves into File and is + // released exactly once at the end of this worker. + let mut stream = unsafe { File::from_raw_handle(raw_pipe as *mut c_void) }; + if let Err(error) = handle_connection(&mut stream, &child_config) { + let _ = append_audit( + &child_config.audit_log, + serde_json::json!({ + "event": "lease_error", + "error": error, + }), + ); + } + let raw = stream.into_raw_handle(); + unsafe { + let handle = HANDLE(raw); + let _ = DisconnectNamedPipe(handle); + let _ = CloseHandle(handle); + } + }); + } + Ok(()) +} + +fn handle_connection(stream: &mut File, config: &BrokerConfig) -> Result<(), String> { + let caller_pid = pipe_client_pid(stream)?; + let request_bytes = read_frame(stream)?; + let parsed: Result = serde_json::from_slice(&request_bytes); + let request = match parsed { + Ok(request) => request, + Err(error) => { + let response = + LeaseResponse::rejected(Uuid::nil(), format!("invalid request: {error}")); + write_frame( + stream, + &serde_json::to_vec(&response).map_err(|e| e.to_string())?, + )?; + return Err(response.error.unwrap_or_default()); + } + }; + + let result = (|| { + request.validate()?; + validate_executor_and_child(caller_pid, request.child_pid)?; + let identity = read_app_container_identity(request.child_pid)?; + let lease = WfpLease::install( + &request, + &identity.sid_bytes, + &config.audit_log, + &config.lease_dir, + )?; + Ok::<_, String>((identity.sid_string, lease)) + })(); + + match result { + Ok((sid, lease)) => { + let response = LeaseResponse::accepted(&request, sid.clone(), lease.filter_count); + write_frame( + stream, + &serde_json::to_vec(&response).map_err(|e| e.to_string())?, + )?; + append_audit( + &config.audit_log, + serde_json::json!({ + "event": "lease_installed", + "leaseId": request.lease_id, + "callerPid": caller_pid, + "childPid": request.child_pid, + "appContainerSid": sid, + "proxyAddress": request.proxy_address, + "proxyPort": request.proxy_port, + "filterCount": lease.filter_count, + }), + )?; + + // The executor writes no more data. EOF is the lease-release signal. + let mut discard = [0u8; 128]; + match stream.read(&mut discard) { + Ok(_) => {} + Err(error) if error.raw_os_error() == Some(ERROR_BROKEN_PIPE.0 as i32) => {} + Err(error) => return Err(format!("lease pipe read failed: {error}")), + } + drop(lease); + append_audit( + &config.audit_log, + serde_json::json!({ + "event": "lease_removed", + "leaseId": request.lease_id, + "childPid": request.child_pid, + }), + )?; + Ok(()) + } + Err(error) => { + let response = LeaseResponse::rejected(request.lease_id, error.clone()); + write_frame( + stream, + &serde_json::to_vec(&response).map_err(|e| e.to_string())?, + )?; + append_audit( + &config.audit_log, + serde_json::json!({ + "event": "lease_rejected", + "leaseId": request.lease_id, + "callerPid": caller_pid, + "childPid": request.child_pid, + "error": error, + }), + )?; + Err(response.error.unwrap_or_default()) + } + } +} + +fn create_pipe(name: &str) -> Result { + let wide_name = wide(name); + // Deny AppContainer clients explicitly. Authenticated host callers may + // connect, after which process-image, parent, and child-token validation + // narrow the accepted operation. + let sddl = wide("D:P(D;;GA;;;AC)(A;;GA;;;SY)(A;;GA;;;BA)(A;;GRGW;;;AU)"); + let mut descriptor = PSECURITY_DESCRIPTOR::default(); + unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + PCWSTR(sddl.as_ptr()), + SDDL_REVISION_1, + &mut descriptor, + None, + ) + .map_err(|e| format!("pipe security descriptor: {e}"))?; + } + let attributes = windows::Win32::Security::SECURITY_ATTRIBUTES { + nLength: size_of::() as u32, + lpSecurityDescriptor: descriptor.0, + bInheritHandle: false.into(), + }; + let pipe = unsafe { + CreateNamedPipeW( + PCWSTR(wide_name.as_ptr()), + PIPE_ACCESS_DUPLEX, + PIPE_TYPE_BYTE | PIPE_READMODE_BYTE | PIPE_WAIT | PIPE_REJECT_REMOTE_CLIENTS, + 255, + PIPE_BUFFER_BYTES, + PIPE_BUFFER_BYTES, + 0, + Some(&attributes), + ) + }; + unsafe { + let _ = LocalFree(Some(HLOCAL(descriptor.0))); + } + if pipe == INVALID_HANDLE_VALUE { + Err(format!("CreateNamedPipeW failed: {:?}", unsafe { + GetLastError() + })) + } else { + Ok(pipe) + } +} + +fn pipe_client_pid(stream: &File) -> Result { + use std::os::windows::io::AsRawHandle; + let mut pid = 0; + let handle = HANDLE(stream.as_raw_handle()); + unsafe { GetNamedPipeClientProcessId(handle, &mut pid) } + .map_err(|e| format!("GetNamedPipeClientProcessId failed: {e}"))?; + if pid == 0 { + Err("named-pipe client PID was zero".into()) + } else { + Ok(pid) + } +} + +fn validate_executor_and_child(caller_pid: u32, child_pid: u32) -> Result<(), String> { + if caller_pid == unsafe { GetCurrentProcessId() } || caller_pid == child_pid { + return Err("invalid executor/child PID relationship".into()); + } + let image = process_image(caller_pid)?; + let basename = Path::new(&image) + .file_name() + .and_then(|value| value.to_str()) + .unwrap_or_default(); + if !basename.eq_ignore_ascii_case("wxc-exec.exe") { + return Err(format!( + "broker caller image must be wxc-exec.exe (got {basename:?})" + )); + } + let parent = process_parent_pid(child_pid)?; + if parent != caller_pid { + return Err(format!( + "suspended child PID {child_pid} is not owned by executor PID {caller_pid}" + )); + } + Ok(()) +} + +fn process_image(pid: u32) -> Result { + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } + .map_err(|e| format!("OpenProcess({pid}) failed: {e}"))?; + let mut buffer = vec![0u16; 32_768]; + let mut len = buffer.len() as u32; + let result = unsafe { + QueryFullProcessImageNameW( + process, + PROCESS_NAME_WIN32, + PWSTR(buffer.as_mut_ptr()), + &mut len, + ) + }; + unsafe { + let _ = CloseHandle(process); + } + result.map_err(|e| format!("QueryFullProcessImageNameW({pid}) failed: {e}"))?; + Ok(String::from_utf16_lossy(&buffer[..len as usize])) +} + +fn process_parent_pid(pid: u32) -> Result { + let snapshot = unsafe { CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0) } + .map_err(|e| format!("CreateToolhelp32Snapshot failed: {e}"))?; + let mut entry = PROCESSENTRY32W { + dwSize: size_of::() as u32, + ..Default::default() + }; + let mut found = None; + if unsafe { Process32FirstW(snapshot, &mut entry) }.is_ok() { + loop { + if entry.th32ProcessID == pid { + found = Some(entry.th32ParentProcessID); + break; + } + if unsafe { Process32NextW(snapshot, &mut entry) }.is_err() { + break; + } + } + } + unsafe { + let _ = CloseHandle(snapshot); + } + found.ok_or_else(|| format!("child PID {pid} not found in process snapshot")) +} + +struct AppContainerIdentity { + sid_bytes: Vec, + sid_string: String, +} + +fn read_app_container_identity(pid: u32) -> Result { + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } + .map_err(|e| format!("OpenProcess({pid}) failed: {e}"))?; + let mut token = HANDLE::default(); + let token_result = unsafe { OpenProcessToken(process, TOKEN_QUERY, &mut token) }; + unsafe { + let _ = CloseHandle(process); + } + token_result.map_err(|e| format!("OpenProcessToken({pid}) failed: {e}"))?; + + let result = (|| { + let mut is_app_container = 0u32; + let mut returned = 0u32; + unsafe { + GetTokenInformation( + token, + TokenIsAppContainer, + Some((&mut is_app_container as *mut u32).cast()), + size_of::() as u32, + &mut returned, + ) + } + .map_err(|e| format!("GetTokenInformation(TokenIsAppContainer): {e}"))?; + if is_app_container == 0 { + return Err("target child is not running with an AppContainer token".into()); + } + + let mut required = 0u32; + let _ = unsafe { GetTokenInformation(token, TokenAppContainerSid, None, 0, &mut required) }; + if required < size_of::() as u32 { + return Err(format!( + "TokenAppContainerSid reported an invalid buffer size {required}" + )); + } + let words = (required as usize).div_ceil(size_of::()); + let mut info_buffer = vec![0usize; words]; + unsafe { + GetTokenInformation( + token, + TokenAppContainerSid, + Some(info_buffer.as_mut_ptr().cast()), + required, + &mut returned, + ) + } + .map_err(|e| format!("GetTokenInformation(TokenAppContainerSid): {e}"))?; + let info = unsafe { + &*(info_buffer + .as_ptr() + .cast::()) + }; + if info.TokenAppContainer.is_invalid() { + return Err("AppContainer token returned a null package SID".into()); + } + + let sid_len = unsafe { GetLengthSid(info.TokenAppContainer) } as usize; + if sid_len == 0 { + return Err("GetLengthSid returned zero".into()); + } + let sid_bytes = unsafe { + std::slice::from_raw_parts(info.TokenAppContainer.0.cast::(), sid_len).to_vec() + }; + let mut sid_text = PWSTR::null(); + unsafe { ConvertSidToStringSidW(info.TokenAppContainer, &mut sid_text) } + .map_err(|e| format!("ConvertSidToStringSidW failed: {e}"))?; + let sid_string = unsafe { sid_text.to_string() } + .map_err(|e| format!("AppContainer SID was not valid UTF-16: {e}"))?; + unsafe { + let _ = LocalFree(Some(HLOCAL(sid_text.0.cast()))); + } + Ok(AppContainerIdentity { + sid_bytes, + sid_string, + }) + })(); + unsafe { + let _ = CloseHandle(token); + } + result +} + +#[derive(Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase", deny_unknown_fields)] +struct LeaseJournal { + version: u32, + lease_id: Uuid, + child_pid: u32, + child_creation_time: u64, +} + +fn write_lease_journal(directory: &Path, journal: &LeaseJournal) -> Result { + std::fs::create_dir_all(directory) + .map_err(|e| format!("create WFP lease journal directory: {e}"))?; + let path = directory.join(format!("{}.json", journal.lease_id)); + let mut file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&path) + .map_err(|e| format!("create WFP lease journal {}: {e}", path.display()))?; + serde_json::to_writer(&mut file, journal) + .map_err(|e| format!("write WFP lease journal {}: {e}", path.display()))?; + file.write_all(b"\n") + .and_then(|_| file.sync_all()) + .map_err(|e| format!("flush WFP lease journal {}: {e}", path.display()))?; + Ok(path) +} + +fn process_creation_time(pid: u32) -> Result { + let process = unsafe { OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, false, pid) } + .map_err(|e| format!("OpenProcess({pid}) for creation time failed: {e}"))?; + let mut creation = FILETIME::default(); + let mut exit = FILETIME::default(); + let mut kernel = FILETIME::default(); + let mut user = FILETIME::default(); + let result = + unsafe { GetProcessTimes(process, &mut creation, &mut exit, &mut kernel, &mut user) }; + unsafe { + let _ = CloseHandle(process); + } + result.map_err(|e| format!("GetProcessTimes({pid}) failed: {e}"))?; + Ok((u64::from(creation.dwHighDateTime) << 32) | u64::from(creation.dwLowDateTime)) +} + +fn reap_stale_leases(directory: &Path, audit_log: &Path) -> Result<(), String> { + std::fs::create_dir_all(directory) + .map_err(|e| format!("create WFP lease journal directory: {e}"))?; + let mut journals = Vec::new(); + for entry in std::fs::read_dir(directory) + .map_err(|e| format!("read WFP lease journal directory: {e}"))? + { + let entry = entry.map_err(|e| format!("read WFP lease journal entry: {e}"))?; + if entry.path().extension().and_then(|value| value.to_str()) != Some("json") { + continue; + } + let bytes = std::fs::read(entry.path()) + .map_err(|e| format!("read WFP lease journal {}: {e}", entry.path().display()))?; + let journal: LeaseJournal = serde_json::from_slice(&bytes) + .map_err(|e| format!("parse WFP lease journal {}: {e}", entry.path().display()))?; + if journal.version != 1 + || entry.file_name().to_string_lossy() != format!("{}.json", journal.lease_id) + { + return Err(format!( + "invalid WFP lease journal identity in {}", + entry.path().display() + )); + } + journals.push((entry.path(), journal)); + } + if journals.is_empty() { + return Ok(()); + } + + let mut engine = HANDLE::default(); + check_wfp( + unsafe { FwpmEngineOpen0(PCWSTR::null(), RPC_C_AUTHN_WINNT, None, None, &mut engine) }, + "FwpmEngineOpen0(recovery)", + )?; + let result = (|| { + for (path, journal) in journals { + terminate_matching_process(&journal)?; + cleanup_persistent_blocks(engine, journal.lease_id)?; + std::fs::remove_file(&path) + .map_err(|e| format!("remove recovered WFP journal {}: {e}", path.display()))?; + append_audit( + audit_log, + serde_json::json!({ + "event": "stale_lease_reaped", + "leaseId": journal.lease_id, + "childPid": journal.child_pid, + }), + )?; + } + Ok(()) + })(); + unsafe { + let _ = FwpmEngineClose0(engine); + } + result +} + +fn terminate_matching_process(journal: &LeaseJournal) -> Result<(), String> { + let process = match unsafe { + OpenProcess( + PROCESS_QUERY_LIMITED_INFORMATION + | PROCESS_TERMINATE + | PROCESS_ACCESS_RIGHTS(0x0010_0000), + false, + journal.child_pid, + ) + } { + Ok(process) => process, + Err(_) => return Ok(()), + }; + let current_creation = process_creation_time(journal.child_pid); + if current_creation == Ok(journal.child_creation_time) { + unsafe { + let _ = TerminateProcess(process, u32::MAX); + let _ = WaitForSingleObject(process, 5_000); + } + } + unsafe { + let _ = CloseHandle(process); + } + current_creation.map(|_| ()) +} + +fn cleanup_persistent_blocks(engine: HANDLE, lease_id: Uuid) -> Result<(), String> { + for discriminator in [10, 11] { + let status = unsafe { FwpmFilterDeleteByKey0(engine, &guid_for(lease_id, discriminator)) }; + if status != 0 && status != FWP_E_FILTER_NOT_FOUND.0 as u32 { + return Err(format!( + "FwpmFilterDeleteByKey0(recovery) failed with 0x{status:08X}" + )); + } + } + let status = unsafe { FwpmSubLayerDeleteByKey0(engine, &guid_for(lease_id, 0)) }; + if status != 0 && status != FWP_E_SUBLAYER_NOT_FOUND.0 as u32 { + return Err(format!( + "FwpmSubLayerDeleteByKey0(recovery) failed with 0x{status:08X}" + )); + } + Ok(()) +} + +struct WfpLease { + persistent_engine: HANDLE, + dynamic_engine: HANDLE, + filter_count: u32, + block_filter_ids: Vec, + sublayer_key: GUID, + lease_id: Uuid, + event_handle: HANDLE, + audit_context: Option>, + journal_path: Option, +} + +unsafe impl Send for WfpLease {} + +impl Drop for WfpLease { + fn drop(&mut self) { + if !self.event_handle.is_invalid() { + unsafe { + let _ = FwpmNetEventUnsubscribe0(self.persistent_engine, self.event_handle); + } + self.event_handle = HANDLE::default(); + } + self.audit_context.take(); + // Remove the proxy permit first. If cleanup is interrupted after this + // point, the persistent blocks leave the ephemeral identity fail-closed. + if !self.dynamic_engine.is_invalid() { + unsafe { + let _ = FwpmEngineClose0(self.dynamic_engine); + } + self.dynamic_engine = HANDLE::default(); + } + if !self.persistent_engine.is_invalid() { + let cleanup = cleanup_persistent_blocks(self.persistent_engine, self.lease_id); + unsafe { + let _ = FwpmEngineClose0(self.persistent_engine); + } + self.persistent_engine = HANDLE::default(); + if cleanup.is_ok() + && let Some(path) = self.journal_path.take() + { + let _ = std::fs::remove_file(path); + } + } + } +} + +impl WfpLease { + fn install( + request: &LeaseRequest, + sid_bytes: &[u8], + audit_log: &Path, + lease_dir: &Path, + ) -> Result { + let mut persistent_engine = HANDLE::default(); + check_wfp( + unsafe { + FwpmEngineOpen0( + PCWSTR::null(), + RPC_C_AUTHN_WINNT, + None, + None, + &mut persistent_engine, + ) + }, + "FwpmEngineOpen0(persistent blocks)", + )?; + let sublayer_key = guid_for(request.lease_id, 0); + let mut lease = Self { + persistent_engine, + dynamic_engine: HANDLE::default(), + filter_count: 0, + block_filter_ids: Vec::with_capacity(2), + sublayer_key, + lease_id: request.lease_id, + event_handle: HANDLE::default(), + audit_context: None, + journal_path: None, + }; + + let journal = LeaseJournal { + version: 1, + lease_id: request.lease_id, + child_pid: request.child_pid, + child_creation_time: process_creation_time(request.child_pid)?, + }; + lease.journal_path = Some(write_lease_journal(lease_dir, &journal)?); + + check_wfp( + unsafe { FwpmTransactionBegin0(lease.persistent_engine, 0) }, + "FwpmTransactionBegin0(persistent blocks)", + )?; + match lease.install_persistent_blocks(request, sid_bytes) { + Ok(()) => { + if let Err(error) = check_wfp( + unsafe { FwpmTransactionCommit0(lease.persistent_engine) }, + "FwpmTransactionCommit0(persistent blocks)", + ) { + unsafe { + let _ = FwpmTransactionAbort0(lease.persistent_engine); + } + return Err(error); + } + } + Err(error) => { + unsafe { + let _ = FwpmTransactionAbort0(lease.persistent_engine); + } + return Err(error); + } + } + + let session_name = wide(&format!("AXIS strict proxy permit {}", request.lease_id)); + let session = FWPM_SESSION0 { + displayData: FWPM_DISPLAY_DATA0 { + name: PWSTR(session_name.as_ptr() as *mut u16), + description: PWSTR::null(), + }, + flags: FWPM_SESSION_FLAG_DYNAMIC, + txnWaitTimeoutInMSec: 10_000, + ..Default::default() + }; + check_wfp( + unsafe { + FwpmEngineOpen0( + PCWSTR::null(), + RPC_C_AUTHN_WINNT, + None, + Some(&session), + &mut lease.dynamic_engine, + ) + }, + "FwpmEngineOpen0(dynamic permit)", + )?; + check_wfp( + unsafe { FwpmTransactionBegin0(lease.dynamic_engine, 0) }, + "FwpmTransactionBegin0(dynamic permit)", + )?; + if let Err(error) = lease.install_dynamic_allow(request, sid_bytes) { + unsafe { + let _ = FwpmTransactionAbort0(lease.dynamic_engine); + } + return Err(error); + } + if let Err(error) = check_wfp( + unsafe { FwpmTransactionCommit0(lease.dynamic_engine) }, + "FwpmTransactionCommit0(dynamic permit)", + ) { + unsafe { + let _ = FwpmTransactionAbort0(lease.dynamic_engine); + } + return Err(error); + } + lease.filter_count = 3; + lease.subscribe_to_block_events(request, audit_log)?; + Ok(lease) + } + + fn install_persistent_blocks( + &mut self, + request: &LeaseRequest, + sid_bytes: &[u8], + ) -> Result<(), String> { + let sublayer_name = wide(&format!("AXIS lease {}", request.lease_id)); + let sublayer = FWPM_SUBLAYER0 { + subLayerKey: self.sublayer_key, + displayData: FWPM_DISPLAY_DATA0 { + name: PWSTR(sublayer_name.as_ptr() as *mut u16), + description: PWSTR::null(), + }, + weight: u16::MAX - 1, + ..Default::default() + }; + check_wfp( + unsafe { FwpmSubLayerAdd0(self.persistent_engine, &sublayer, None) }, + "FwpmSubLayerAdd0", + )?; + + let mut sid = sid_bytes.to_vec(); + let sid_ptr = PSID(sid.as_mut_ptr().cast()); + let block_v4 = self.add_block( + request, + self.sublayer_key, + sid_ptr, + FWPM_LAYER_ALE_AUTH_CONNECT_V4, + 10, + )?; + self.block_filter_ids.push(block_v4); + let block_v6 = self.add_block( + request, + self.sublayer_key, + sid_ptr, + FWPM_LAYER_ALE_AUTH_CONNECT_V6, + 11, + )?; + self.block_filter_ids.push(block_v6); + Ok(()) + } + + fn install_dynamic_allow( + &self, + request: &LeaseRequest, + sid_bytes: &[u8], + ) -> Result<(), String> { + let permit_sublayer_key = guid_for(request.lease_id, 3); + let name = wide(&format!("AXIS proxy permit {}", request.lease_id)); + let sublayer = FWPM_SUBLAYER0 { + subLayerKey: permit_sublayer_key, + displayData: FWPM_DISPLAY_DATA0 { + name: PWSTR(name.as_ptr() as *mut u16), + description: PWSTR::null(), + }, + weight: u16::MAX, + ..Default::default() + }; + check_wfp( + unsafe { FwpmSubLayerAdd0(self.dynamic_engine, &sublayer, None) }, + "FwpmSubLayerAdd0(dynamic permit)", + )?; + let mut sid = sid_bytes.to_vec(); + let sid_ptr = PSID(sid.as_mut_ptr().cast()); + match request.proxy_address { + IpAddr::V4(address) => { + self.add_allow_v4(request, permit_sublayer_key, sid_ptr, address.octets())?; + } + IpAddr::V6(address) => { + self.add_allow_v6(request, permit_sublayer_key, sid_ptr, address.octets())?; + } + } + Ok(()) + } + + fn add_allow_v4( + &self, + request: &LeaseRequest, + sublayer_key: GUID, + sid: PSID, + address: [u8; 4], + ) -> Result { + let mut addr = FWP_V4_ADDR_AND_MASK { + addr: u32::from_be_bytes(address), + mask: u32::MAX, + }; + let mut conditions = common_allow_conditions(sid, request.proxy_port); + conditions.push(FWPM_FILTER_CONDITION0 { + fieldKey: FWPM_CONDITION_IP_REMOTE_ADDRESS, + matchType: FWP_MATCH_EQUAL, + conditionValue: FWP_CONDITION_VALUE0 { + r#type: FWP_V4_ADDR_MASK, + Anonymous: FWP_CONDITION_VALUE0_0 { + v4AddrMask: &mut addr, + }, + }, + }); + let _ = self.add_filter( + self.dynamic_engine, + request, + sublayer_key, + FWPM_LAYER_ALE_AUTH_CONNECT_V4, + 1, + u64::MAX, + FWP_ACTION_PERMIT, + &mut conditions, + )?; + Ok(1) + } + + fn add_allow_v6( + &self, + request: &LeaseRequest, + sublayer_key: GUID, + sid: PSID, + address: [u8; 16], + ) -> Result { + let mut addr = FWP_V6_ADDR_AND_MASK { + addr: address, + prefixLength: 128, + }; + let mut conditions = common_allow_conditions(sid, request.proxy_port); + conditions.push(FWPM_FILTER_CONDITION0 { + fieldKey: FWPM_CONDITION_IP_REMOTE_ADDRESS, + matchType: FWP_MATCH_EQUAL, + conditionValue: FWP_CONDITION_VALUE0 { + r#type: FWP_V6_ADDR_MASK, + Anonymous: FWP_CONDITION_VALUE0_0 { + v6AddrMask: &mut addr, + }, + }, + }); + let _ = self.add_filter( + self.dynamic_engine, + request, + sublayer_key, + FWPM_LAYER_ALE_AUTH_CONNECT_V6, + 2, + u64::MAX, + FWP_ACTION_PERMIT, + &mut conditions, + )?; + Ok(1) + } + + fn add_block( + &self, + request: &LeaseRequest, + sublayer_key: GUID, + sid: PSID, + layer: GUID, + discriminator: u8, + ) -> Result { + let mut conditions = vec![sid_condition(sid)]; + self.add_filter( + self.persistent_engine, + request, + sublayer_key, + layer, + discriminator, + 1, + FWP_ACTION_BLOCK, + &mut conditions, + ) + } + + #[allow(clippy::too_many_arguments)] + fn add_filter( + &self, + engine: HANDLE, + request: &LeaseRequest, + sublayer_key: GUID, + layer: GUID, + discriminator: u8, + mut weight_value: u64, + action_type: windows::Win32::NetworkManagement::WindowsFilteringPlatform::FWP_ACTION_TYPE, + conditions: &mut [FWPM_FILTER_CONDITION0], + ) -> Result { + let name = wide(&format!( + "AXIS lease {} filter {discriminator}", + request.lease_id + )); + let filter = FWPM_FILTER0 { + filterKey: guid_for(request.lease_id, discriminator), + displayData: FWPM_DISPLAY_DATA0 { + name: PWSTR(name.as_ptr() as *mut u16), + description: PWSTR::null(), + }, + flags: FWPM_FILTER_FLAG_CLEAR_ACTION_RIGHT, + layerKey: layer, + subLayerKey: sublayer_key, + weight: FWP_VALUE0 { + r#type: FWP_UINT64, + Anonymous: FWP_VALUE0_0 { + uint64: &mut weight_value, + }, + }, + numFilterConditions: conditions.len() as u32, + filterCondition: conditions.as_mut_ptr(), + action: FWPM_ACTION0 { + r#type: action_type, + ..Default::default() + }, + ..Default::default() + }; + let mut id = 0u64; + check_wfp( + unsafe { FwpmFilterAdd0(engine, &filter, None, Some(&mut id)) }, + "FwpmFilterAdd0", + )?; + Ok(id) + } + + fn subscribe_to_block_events( + &mut self, + request: &LeaseRequest, + audit_log: &Path, + ) -> Result<(), String> { + if self.block_filter_ids.len() != 2 { + return Err("strict-proxy lease did not retain both block-filter IDs".into()); + } + let mut context = Box::new(WfpAuditContext { + audit_log: audit_log.to_path_buf(), + lease_id: request.lease_id, + child_pid: request.child_pid, + block_filter_ids: [self.block_filter_ids[0], self.block_filter_ids[1]], + }); + let subscription = FWPM_NET_EVENT_SUBSCRIPTION0::default(); + let mut event_handle = HANDLE::default(); + check_wfp( + unsafe { + FwpmNetEventSubscribe4( + self.persistent_engine, + &subscription, + Some(wfp_net_event_callback), + Some((&mut *context as *mut WfpAuditContext).cast()), + &mut event_handle, + ) + }, + "FwpmNetEventSubscribe4", + )?; + self.event_handle = event_handle; + self.audit_context = Some(context); + Ok(()) + } +} + +struct WfpAuditContext { + audit_log: PathBuf, + lease_id: Uuid, + child_pid: u32, + block_filter_ids: [u64; 2], +} + +unsafe extern "system" fn wfp_net_event_callback( + context: *mut c_void, + event: *const FWPM_NET_EVENT5, +) { + if context.is_null() || event.is_null() { + return; + } + let context = unsafe { &*(context.cast::()) }; + let event = unsafe { &*event }; + if event.r#type != FWPM_NET_EVENT_TYPE_CLASSIFY_DROP { + return; + } + let drop_event = unsafe { event.Anonymous.classifyDrop }; + if drop_event.is_null() { + return; + } + let filter_id = unsafe { (*drop_event).filterId }; + if !context.block_filter_ids.contains(&filter_id) { + return; + } + + let header = &event.header; + let remote_address = if header.flags & FWPM_NET_EVENT_FLAG_REMOTE_ADDR_SET == 0 { + None + } else if header.ipVersion == FWP_IP_VERSION_V4 { + let value = unsafe { header.Anonymous2.remoteAddrV4 }; + Some(IpAddr::V4(Ipv4Addr::from(value.to_be_bytes()))) + } else if header.ipVersion == FWP_IP_VERSION_V6 { + let value = unsafe { header.Anonymous2.remoteAddrV6.byteArray16 }; + Some(IpAddr::V6(Ipv6Addr::from(value))) + } else { + None + }; + let remote_port = + (header.flags & FWPM_NET_EVENT_FLAG_REMOTE_PORT_SET != 0).then_some(header.remotePort); + let protocol = + (header.flags & FWPM_NET_EVENT_FLAG_IP_PROTOCOL_SET != 0).then_some(header.ipProtocol); + let ip_version = (header.flags & FWPM_NET_EVENT_FLAG_IP_VERSION_SET != 0).then(|| { + if header.ipVersion == FWP_IP_VERSION_V4 { + 4 + } else if header.ipVersion == FWP_IP_VERSION_V6 { + 6 + } else { + 0 + } + }); + + let _ = append_audit( + &context.audit_log, + serde_json::json!({ + "event": "connection_blocked", + "leaseId": context.lease_id, + "childPid": context.child_pid, + "filterId": filter_id, + "ipVersion": ip_version, + "protocol": protocol, + "remoteAddress": remote_address, + "remotePort": remote_port, + }), + ); +} + +fn common_allow_conditions(sid: PSID, port: u16) -> Vec { + vec![ + sid_condition(sid), + FWPM_FILTER_CONDITION0 { + fieldKey: FWPM_CONDITION_IP_PROTOCOL, + matchType: FWP_MATCH_EQUAL, + conditionValue: FWP_CONDITION_VALUE0 { + r#type: FWP_UINT8, + Anonymous: FWP_CONDITION_VALUE0_0 { uint8: IPPROTO_TCP }, + }, + }, + FWPM_FILTER_CONDITION0 { + fieldKey: FWPM_CONDITION_IP_REMOTE_PORT, + matchType: FWP_MATCH_EQUAL, + conditionValue: FWP_CONDITION_VALUE0 { + r#type: FWP_UINT16, + Anonymous: FWP_CONDITION_VALUE0_0 { uint16: port }, + }, + }, + ] +} + +fn sid_condition(sid: PSID) -> FWPM_FILTER_CONDITION0 { + FWPM_FILTER_CONDITION0 { + fieldKey: FWPM_CONDITION_ALE_PACKAGE_ID, + matchType: FWP_MATCH_EQUAL, + conditionValue: FWP_CONDITION_VALUE0 { + r#type: FWP_SID, + Anonymous: FWP_CONDITION_VALUE0_0 { sid: sid.0.cast() }, + }, + } +} + +fn guid_for(lease_id: Uuid, discriminator: u8) -> GUID { + let mut bytes = *lease_id.as_bytes(); + bytes[15] ^= discriminator; + GUID::from_u128(u128::from_be_bytes(bytes)) +} + +fn check_wfp(status: u32, operation: &str) -> Result<(), String> { + if status == 0 { + Ok(()) + } else { + Err(format!("{operation} failed with WFP status 0x{status:08X}")) + } +} + +fn read_frame(stream: &mut File) -> Result, String> { + let mut length = [0u8; 4]; + stream + .read_exact(&mut length) + .map_err(|e| format!("read request length: {e}"))?; + let length = u32::from_le_bytes(length) as usize; + if length == 0 || length > MAX_MESSAGE_BYTES { + return Err(format!("invalid request length {length}")); + } + let mut payload = vec![0u8; length]; + stream + .read_exact(&mut payload) + .map_err(|e| format!("read request payload: {e}"))?; + Ok(payload) +} + +fn write_frame(stream: &mut File, payload: &[u8]) -> Result<(), String> { + if payload.is_empty() || payload.len() > MAX_MESSAGE_BYTES { + return Err(format!("invalid response length {}", payload.len())); + } + stream + .write_all(&(payload.len() as u32).to_le_bytes()) + .and_then(|_| stream.write_all(payload)) + .and_then(|_| stream.flush()) + .map_err(|e| format!("write response: {e}")) +} + +fn append_audit(path: &Path, mut event: serde_json::Value) -> Result<(), String> { + let _guard = AUDIT_WRITE_LOCK + .get_or_init(|| Mutex::new(())) + .lock() + .unwrap_or_else(|poisoned| poisoned.into_inner()); + if let Some(parent) = path.parent() { + std::fs::create_dir_all(parent).map_err(|e| format!("create WFP audit directory: {e}"))?; + } + if let Some(object) = event.as_object_mut() { + object.insert( + "timestampUnixMs".into(), + serde_json::Value::from( + SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_millis() as u64, + ), + ); + } + let mut file = OpenOptions::new() + .create(true) + .append(true) + .open(path) + .map_err(|e| format!("open WFP audit log: {e}"))?; + let mut line = + serde_json::to_vec(&event).map_err(|e| format!("encode WFP audit event: {e}"))?; + line.push(b'\n'); + // One append write prevents records from separate broker generations from + // interleaving during a service restart. + file.write_all(&line) + .map_err(|e| format!("write WFP audit event: {e}")) +} + +fn wide(value: &str) -> Vec { + value.encode_utf16().chain(Some(0)).collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn request() -> LeaseRequest { + LeaseRequest { + version: PROTOCOL_VERSION, + lease_id: Uuid::new_v4(), + child_pid: 123, + proxy_address: "127.0.0.1".parse().unwrap(), + proxy_port: 31_280, + } + } + + #[test] + fn lease_request_accepts_only_concrete_nonzero_endpoint() { + request().validate().unwrap(); + for address in ["0.0.0.0", "::", "224.0.0.1", "ff02::1"] { + let mut invalid = request(); + invalid.proxy_address = address.parse().unwrap(); + assert!(invalid.validate().is_err(), "{address}"); + } + let mut invalid = request(); + invalid.proxy_port = 0; + assert!(invalid.validate().is_err()); + } + + #[test] + fn protocol_rejects_unknown_fields() { + let mut value = serde_json::to_value(request()).unwrap(); + value["arbitraryFilter"] = serde_json::json!("permit all"); + assert!(serde_json::from_value::(value).is_err()); + } + + #[test] + fn lease_guids_are_stable_and_distinct() { + let lease = Uuid::new_v4(); + assert_eq!(guid_for(lease, 0), guid_for(lease, 0)); + assert_ne!(guid_for(lease, 0), guid_for(lease, 1)); + assert_ne!(guid_for(lease, 1), guid_for(Uuid::new_v4(), 1)); + } +} diff --git a/crates/axis-sandbox/src/workspace.rs b/crates/axis-sandbox/src/workspace.rs index 65e9724..22ade32 100644 --- a/crates/axis-sandbox/src/workspace.rs +++ b/crates/axis-sandbox/src/workspace.rs @@ -12,6 +12,28 @@ use std::collections::HashSet; use std::io::Write; use std::path::{Component, Path, PathBuf}; +#[cfg(windows)] +#[link(name = "advapi32")] +unsafe extern "system" { + fn OpenProcessToken(process: isize, access: u32, token: *mut isize) -> i32; + fn GetTokenInformation( + token: isize, + information_class: u32, + information: *mut std::ffi::c_void, + information_length: u32, + return_length: *mut u32, + ) -> i32; + fn ConvertSidToStringSidW(sid: *mut std::ffi::c_void, text: *mut *mut u16) -> i32; +} + +#[cfg(windows)] +#[link(name = "kernel32")] +unsafe extern "system" { + fn GetCurrentProcess() -> isize; + fn CloseHandle(handle: isize) -> i32; + fn LocalFree(memory: *mut std::ffi::c_void) -> *mut std::ffi::c_void; +} + /// Agent state directory mappings. /// Maps the path agents expect to write to → the directory name under .axis/agents//. const AGENT_DIR_MAPPINGS: &[(&str, &str)] = &[ @@ -204,6 +226,11 @@ pub(crate) fn prepare_managed_home_workspace( if let Some(parent) = managed_path.parent() { create_axis_private_dir(parent, 0o700, "managed home parent")?; } + if normalize_path(contained_dir.clone()) == normalize_path(managed_path.clone()) { + // Windows MXC uses physical directories inside the managed HOME. + // It must not depend on developer-mode/admin symlink privileges. + continue; + } if managed_path.is_symlink() { std::fs::remove_file(&managed_path).map_err(|e| { format!( @@ -545,6 +572,8 @@ where // Generate SSH config. if ssh_policy.generate_config && !config_entries.is_empty() { + #[cfg(windows)] + install_windows_ssh_proxy_helper(ssh_dir, set_permissions)?; let mut config = String::new(); config.push_str("# Auto-generated by AXIS — only allowed SSH hosts\n"); config.push_str("# Do not edit — this file is managed by the sandbox.\n\n"); @@ -553,14 +582,40 @@ where config.push_str(&format!("Host {hosts}\n")); config.push_str(&format!(" IdentityFile ~/.ssh/{key_file}\n")); config.push_str(" IdentitiesOnly yes\n"); - config.push_str(" StrictHostKeyChecking accept-new\n\n"); + config.push_str(" BatchMode yes\n"); + config.push_str(" PasswordAuthentication no\n"); + config.push_str(" KbdInteractiveAuthentication no\n"); + config.push_str(" PreferredAuthentications publickey\n"); + config.push_str(" StrictHostKeyChecking yes\n"); + config.push_str(" UserKnownHostsFile ~/.ssh/known_hosts\n"); + config.push_str(if cfg!(windows) { + " GlobalKnownHostsFile NUL\n" + } else { + " GlobalKnownHostsFile /dev/null\n" + }); + config.push_str(" ForwardAgent no\n"); + config.push_str(" ForwardX11 no\n"); + config.push_str(" ClearAllForwardings yes\n"); + #[cfg(windows)] + config.push_str(" ProxyCommand \"%d/.ssh/axis-ssh-proxy.exe\" %h %p\n"); + config.push('\n'); } // Block all other hosts. config.push_str("# Deny all other SSH connections\n"); config.push_str("Host *\n"); - config.push_str(" IdentityFile /dev/null\n"); + config.push_str(if cfg!(windows) { + " IdentityFile NUL\n" + } else { + " IdentityFile /dev/null\n" + }); config.push_str(" IdentitiesOnly yes\n"); + config.push_str(" BatchMode yes\n"); + config.push_str(" PasswordAuthentication no\n"); + config.push_str(" KbdInteractiveAuthentication no\n"); + config.push_str(" ForwardAgent no\n"); + config.push_str(" ForwardX11 no\n"); + config.push_str(" ClearAllForwardings yes\n"); write_file_create_new(&ssh_dir.join("config"), config.as_bytes()) .map_err(|e| format!("write ssh config: {e}"))?; @@ -578,16 +633,22 @@ where all_hosts.dedup(); if !all_hosts.is_empty() { - let output = std::process::Command::new("ssh-keyscan") - .args(&all_hosts) - .output(); + let output = ssh_keyscan_command()?.args(&all_hosts).output(); - if let Ok(output) = output - && output.status.success() - { - write_file_create_new(&ssh_dir.join("known_hosts"), &output.stdout) - .map_err(|e| format!("write known_hosts: {e}"))?; - tracing::info!("ssh: generated known_hosts for {} hosts", all_hosts.len()); + match output { + Ok(output) if output.status.success() && !output.stdout.is_empty() => { + write_file_create_new(&ssh_dir.join("known_hosts"), &output.stdout) + .map_err(|e| format!("write known_hosts: {e}"))?; + tracing::info!("ssh: generated known_hosts for {} hosts", all_hosts.len()); + } + Ok(output) => { + return Err(format!( + "ssh-keyscan failed for scoped hosts (status {:?}): {}", + output.status.code(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } + Err(error) => return Err(format!("launch ssh-keyscan: {error}")), } } } @@ -601,6 +662,68 @@ where Ok(()) } +fn ssh_keyscan_command() -> Result { + #[cfg(windows)] + if std::env::var_os("AXIS_RUN_WINDOWS_SSH_E2E").as_deref() == Some(std::ffi::OsStr::new("1")) + && let Some(path) = std::env::var_os("AXIS_TEST_SSH_KEYSCAN") + { + let path = PathBuf::from(path); + let metadata = std::fs::symlink_metadata(&path) + .map_err(|error| format!("test ssh-keyscan override '{}': {error}", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "test ssh-keyscan override '{}' must be a regular file", + path.display() + )); + } + return Ok(std::process::Command::new(path)); + } + Ok(std::process::Command::new("ssh-keyscan")) +} + +#[cfg(all(windows, not(test)))] +fn install_windows_ssh_proxy_helper( + ssh_dir: &Path, + set_permissions: &mut F, +) -> Result<(), String> +where + F: FnMut(&Path, u32) -> Result<(), String>, +{ + let source = std::env::current_exe() + .map_err(|error| format!("resolve AXIS executable for SSH helper: {error}"))? + .with_file_name("axis-ssh-proxy.exe"); + let metadata = std::fs::symlink_metadata(&source).map_err(|error| { + format!( + "SSH proxy helper '{}' is unavailable: {error}", + source.display() + ) + })?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + return Err(format!( + "SSH proxy helper '{}' must be a regular file", + source.display() + )); + } + let destination = ssh_dir.join("axis-ssh-proxy.exe"); + copy_file_create_new(&source, &destination) + .map_err(|error| format!("copy Windows SSH proxy helper: {error}"))?; + set_permissions(&destination, 0o500) +} + +#[cfg(all(windows, test))] +fn install_windows_ssh_proxy_helper( + ssh_dir: &Path, + set_permissions: &mut F, +) -> Result<(), String> +where + F: FnMut(&Path, u32) -> Result<(), String>, +{ + let destination = ssh_dir.join("axis-ssh-proxy.exe"); + write_file_create_new(&destination, b"unit-test-helper") + .map_err(|error| format!("write test SSH proxy helper: {error}"))?; + set_permissions(&destination, 0o500) +} + #[cfg(unix)] fn set_private_permissions(path: &Path, mode: u32) -> Result<(), String> { use std::os::unix::fs::PermissionsExt; @@ -608,11 +731,109 @@ fn set_private_permissions(path: &Path, mode: u32) -> Result<(), String> { .map_err(|e| format!("set permissions {mode:o} on '{}': {e}", path.display())) } -#[cfg(not(unix))] -fn set_private_permissions(_path: &Path, _mode: u32) -> Result<(), String> { +#[cfg(windows)] +fn set_private_permissions(path: &Path, _mode: u32) -> Result<(), String> { + let user_sid = current_process_user_sid()?; + let system_sid = "S-1-5-18"; + let inheritance = if path.is_dir() { "(OI)(CI)" } else { "" }; + let user_grant = format!("*{user_sid}:{inheritance}F"); + let system_grant = format!("*{system_sid}:{inheritance}F"); + let system_root = std::env::var_os("SystemRoot") + .ok_or_else(|| "SystemRoot is unavailable while hardening SSH state".to_string())?; + let icacls = PathBuf::from(system_root) + .join("System32") + .join("icacls.exe"); + let output = std::process::Command::new(&icacls) + .arg(path) + .args(["/inheritance:r", "/grant:r"]) + .arg(user_grant) + .arg(system_grant) + .output() + .map_err(|error| format!("launch '{}': {error}", icacls.display()))?; + if !output.status.success() { + return Err(format!( + "harden Windows ACL on '{}': {}", + path.display(), + String::from_utf8_lossy(&output.stderr).trim() + )); + } Ok(()) } +#[cfg(windows)] +fn current_process_user_sid() -> Result { + const TOKEN_QUERY: u32 = 0x0008; + const TOKEN_USER_CLASS: u32 = 1; + let mut token = 0isize; + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(format!( + "OpenProcessToken for SSH ACL: {}", + std::io::Error::last_os_error() + )); + } + struct TokenGuard(isize); + impl Drop for TokenGuard { + fn drop(&mut self) { + unsafe { + let _ = CloseHandle(self.0); + } + } + } + let _token_guard = TokenGuard(token); + let mut required = 0u32; + unsafe { + let _ = GetTokenInformation( + token, + TOKEN_USER_CLASS, + std::ptr::null_mut(), + 0, + &mut required, + ); + } + if required < std::mem::size_of::() as u32 { + return Err("GetTokenInformation(TokenUser) returned an invalid size".into()); + } + let mut buffer = vec![0u8; required as usize]; + if unsafe { + GetTokenInformation( + token, + TOKEN_USER_CLASS, + buffer.as_mut_ptr().cast(), + required, + &mut required, + ) + } == 0 + { + return Err(format!( + "GetTokenInformation(TokenUser): {}", + std::io::Error::last_os_error() + )); + } + let sid = unsafe { std::ptr::read_unaligned(buffer.as_ptr().cast::<*mut std::ffi::c_void>()) }; + let mut text = std::ptr::null_mut::(); + if sid.is_null() || unsafe { ConvertSidToStringSidW(sid, &mut text) } == 0 { + return Err(format!( + "ConvertSidToStringSidW: {}", + std::io::Error::last_os_error() + )); + } + struct LocalGuard(*mut u16); + impl Drop for LocalGuard { + fn drop(&mut self) { + unsafe { + let _ = LocalFree(self.0.cast()); + } + } + } + let _text_guard = LocalGuard(text); + let length = (0..) + .find(|&index| unsafe { *text.add(index) } == 0) + .ok_or_else(|| "unterminated SID text".to_string())?; + Ok(String::from_utf16_lossy(unsafe { + std::slice::from_raw_parts(text, length) + })) +} + fn symlink_dir(target: &Path, link: &Path) -> Result<(), String> { #[cfg(unix)] { @@ -932,11 +1153,22 @@ fn managed_home_agent_state_mapping_for_policy_path_with_home( Err(_) => return Ok(None), }; // Keep managed-HOME AXIS state separate from private setup siblings such as ssh-staging. - let contained_dir = if relative == Path::new(".axis") || expanded == agent_root { + let mapped_agent_state = if relative == Path::new(".axis") || expanded == agent_root { Some(agent_root.join("axis")) } else { contained_agent_dir_for_relative(&relative, agent_root) }; + if mapped_agent_state.is_none() { + return Ok(None); + } + #[cfg(target_os = "windows")] + let contained_dir = if expanded == agent_root { + mapped_agent_state + } else { + Some(agent_root.join("home").join(&relative)) + }; + #[cfg(not(target_os = "windows"))] + let contained_dir = mapped_agent_state; let Some(contained_dir) = contained_dir else { return Ok(None); }; diff --git a/docs/axis-isolation-contract.md b/docs/axis-isolation-contract.md index 005a4e8..49b4d7d 100644 --- a/docs/axis-isolation-contract.md +++ b/docs/axis-isolation-contract.md @@ -132,6 +132,15 @@ Required behavior: proxy credentials unless AXIS intentionally adds sanitized values. - `run_as_user` must not run as root and must fail if the requested identity cannot be prepared safely. +- `identity: isolated` is portable intent for a backend-owned isolated token or + account. It must not be implemented by guessing a host username. Windows MXC + maps it to the actual least-privilege ProcessContainer token; legacy + `run_as_user` remains unsupported there. +- `child_processes: deny` means the workload may have no descendants. Backends + enforce it as an atomic one-process tree limit; it is not translated into a + guessed list of `fork`, `clone`, or process-creation API names. +- `blocked_syscalls` remains an explicitly Linux-specific seccomp surface. + Portable restrictions must use semantic fields rather than syscall names. - Process-tree limits, process groups, sessions, and job handles must not allow child processes to escape timeout or destroy behavior. - PTY support must be planned explicitly. A backend without PTY support is @@ -140,6 +149,12 @@ Required behavior: workload, not an unrelated wrapper failure unless wrapper setup failed before spawn. +Per-binary network policy remains unsupported on Windows ProcessContainer. A +SID-scoped WFP lease proves the sandbox identity but not the executable behind +each proxied CONNECT; user-mode PID/tuple reconstruction is vulnerable to PID +reuse and short-lived-connection races. AXIS will not weaken that requirement +without ALE process metadata or an equivalent race-safe boundary. + ## Credential Semantics Raw credentials are host-side policy material. The sandbox may receive diff --git a/docs/backend-default-decisions.md b/docs/backend-default-decisions.md index 4ba80fb..c0edc46 100644 --- a/docs/backend-default-decisions.md +++ b/docs/backend-default-decisions.md @@ -46,7 +46,7 @@ dependency gates, and status meanings. | --- | --- | --- | | Linux | `mxc-linux-bubblewrap` | Use the MXC process backend by default while AXIS supplies seccomp, resource, credential, proxy policy, and cleanup layers around it. | | macOS | `axis-native-macos-seatbelt` | Retain native default while direct Seatbelt profile generation remains the proven no-extra-runtime path. | -| Windows | None (`axis-native-windows` disabled) | Reject native user-command launches before process creation until Job Object, AppContainer/token, ACL, proxy, environment, lifecycle, and cleanup enforcement are implemented and proven together. | +| Windows | `mxc-windows-processcontainer` | Require packaged MXC BaseContainer with least-privilege mode while AXIS rejects unsupported policy surfaces and owns sanitized executor launch, timeout, and cleanup. AppContainer/DACL fallback remains disabled, and the legacy native host-spawn path is disabled. | Non-default MXC process backends are candidates. They can become defaults only after they match AXIS policy semantics and have benchmark evidence for startup, @@ -75,7 +75,6 @@ AXIS_RUNTIME_METRICS_PROFILES=mxc_process \ | `axis-native-linux` | Process | Retained | `AXIS_RUNTIME_METRICS_PROVIDERS=axis_native cargo run --locked --release -p axis-bench --bin runtime-metrics` | | `axis-native-windows` | Process | Blocked containment target | No benchmark gate until the pre-execution containment path is implemented | | `mxc-macos-seatbelt` | Process | Candidate | `AXIS_BENCH_MXC_MACOS_SEATBELT=1` | -| `mxc-windows-processcontainer` | Process | Candidate | `AXIS_BENCH_MXC_WINDOWS_PROCESSCONTAINER=1` | | `mxc-linux-lxc` | Container | Candidate | `AXIS_BENCH_MXC_LXC=1` | | `mxc-windows-wslc` | Container | Candidate | `AXIS_BENCH_MXC_WINDOWS_WSLC=1` | | `mxc-linux-microvm` | VM | Experimental opt-in | `AXIS_BENCH_MXC_MICROVM=1` | diff --git a/docs/install-and-runtime-dependencies.md b/docs/install-and-runtime-dependencies.md index 23b54f3..5c7b14a 100644 --- a/docs/install-and-runtime-dependencies.md +++ b/docs/install-and-runtime-dependencies.md @@ -26,11 +26,11 @@ administrator-controlled package steps, or disposable CI/e2e scripts. | Runtime path | Runtime dependencies | Privilege boundary | | --- | --- | --- | -| AXIS-native process sandbox | Landlock and seccomp on Linux and Seatbelt on macOS. The Windows containment target requires Job Object, AppContainer or equivalent token isolation, filesystem ACLs, proxy enforcement, and environment isolation to be applied before user code executes. | Supported native paths reject before spawn when a requested policy cannot be enforced. The Windows native path is disabled and rejects every user-command launch before process creation until its full containment target is implemented and proven. | -| MXC process sandbox | Packaged MXC executor for the selected platform backend. Bubblewrap, unprivileged user namespaces, and AXIS seccomp launcher support are the Linux process-backend dependencies. | Default user install for packaged non-privileged executors. Optional tools are discovered safely and are not installed as part of normal tests. | +| AXIS-native process sandbox | Landlock and seccomp on Linux and Seatbelt on macOS. The Windows native path is disabled until Job Object, AppContainer or equivalent token isolation, filesystem controls, proxy enforcement, and environment isolation can be applied before user code executes. | Supported native paths reject before spawn when a requested policy cannot be enforced. Windows uses the MXC process backend instead of the incomplete native host-spawn path. | +| MXC process sandbox | Packaged MXC executor for the selected platform backend: `lxc-exec` on Linux and `wxc-exec.exe` on Windows. Bubblewrap, unprivileged user namespaces, and AXIS seccomp launcher support are additional Linux dependencies. Windows ProcessContainer also requires a supported Windows build/API state. | Default user install for packaged non-privileged executors. Optional host features are discovered safely and missing enforcement fails closed. | | MXC container sandbox | LXC for Linux container launches or WSL2 for Windows container launches, plus configured rootfs/image inputs. | Host runtime setup is explicit and backend-specific. Unsupported or unavailable runtime state must reject before spawn rather than falling back silently. | | MXC VM-style sandbox | KVM on Linux, WHP on Windows, Windows Sandbox, Hyperlight runtime artifacts, microVM images, snapshots, or guest-agent assets depending on the selected backend. | VM and host-feature enablement is explicit setup. VM-style backends remain gated until AXIS can prove command, filesystem, network, lifecycle, and cleanup semantics for the requested policy. | -| AXIS proxy networking | The AXIS proxy plus platform network controls. Linux strict native proxy mode needs `ip`, `iptables`, and either native `CAP_NET_ADMIN` or the optional `axis-netns-helper`; binary-restricted Linux proxy policies additionally need connect-time attribution from the native seccomp-notify path. | The default quickstart does not require proxy-mode privileges. Privileged helper install and file capability setup are explicit choices. | +| AXIS proxy networking | The AXIS proxy plus platform network controls. Linux strict native proxy mode needs `ip`, `iptables`, and either native `CAP_NET_ADMIN` or the optional `axis-netns-helper`; binary-restricted Linux proxy policies additionally need connect-time attribution from the native seccomp-notify path. Windows BaseContainer strict proxy mode needs the installed `AxisWfpBroker` service and patched MXC executor. | The default quickstart does not require proxy-mode privileges. Linux helper/capability setup and Windows WFP service installation are explicit administrator choices. Missing broker state rejects proxy policies before spawn. | | Source builds | Rust toolchain and platform build tools. Xcode Command Line Tools may be needed to build or test macOS binaries from source. | Build tools are developer dependencies, not runtime prerequisites for installing release artifacts. | ## Optional Backend Dependencies @@ -81,6 +81,9 @@ Base packages install ordinary AXIS binaries and non-privileged executor/helper files. Linux base packages include the MXC `lxc-exec` executor and the AXIS `axis-seccomp-launcher`; they do not install the privileged `axis-netns-helper` as setuid content by default. +Windows archives include the pinned `wxc-exec.exe` beside AXIS. They require +BaseContainer but do not enable Windows feature keys or consent to MXC's +host-DACL fallback. Privileged setup is intentionally separate from the base package contract: diff --git a/docs/security-test-tiers.md b/docs/security-test-tiers.md index bd0512a..d666983 100644 --- a/docs/security-test-tiers.md +++ b/docs/security-test-tiers.md @@ -107,7 +107,10 @@ visible skip into a failure. | `AXIS_REAL_NETNS_TESTS=1` | 3 | Run real network namespace tests on hosts with required namespace support. | | `AXIS_TEST_SECCOMP_NOTIFY_ATTRIBUTION=1` | 3 | Run seccomp-notify connect-attribution proofs. | | `AXIS_TEST_RUN_AS_USER=` | 3 | Run identity/resource fallback tests for an existing non-root user. | -| `AXIS_RUN_MXC_PROCESS_E2E=1` | 3 | Run platform MXC process-runtime proofs when a safe MXC executor and backend prerequisites are present. Linux Bubblewrap proofs also require `bwrap`, unprivileged user namespaces, and `python3`. | +| `AXIS_RUN_MXC_PROCESS_E2E=1` | 3 | Run Linux MXC process-runtime proofs when a safe executor and backend prerequisites are present. The Bubblewrap proofs also require `bwrap`, unprivileged user namespaces, and `python3`. | +| `AXIS_RUN_MXC_BASECONTAINER_E2E=1` | 3 | Require and exercise MXC BaseContainer on a feature-enabled Windows host with DACL fallback disabled. The proof covers least-privilege launch, managed-profile and environment filtering, filesystem read-only/read-write/default-deny behavior, child-tree process/aggregate-memory/single-CPU-equivalent rate limits, network allow/block behavior, timeout descendant cleanup, and host-ACL non-mutation. | +| `AXIS_SKIP_UNAVAILABLE_MXC_BASECONTAINER_E2E=1` | CI only | Permit the Windows BaseContainer suite to report an explicit skip only when MXC returns the exact unavailable-BaseContainer and disabled-DACL-fallback result. Missing-executor fail-closed behavior is still proved first; all other launch or policy failures remain fatal. Do not set this on a runtime-qualified BaseContainer host. | +| `AXIS_RUN_WINDOWS_WFP_E2E=1` | 4 | Run the elevated Windows BaseContainer strict-proxy suite with the installed `AxisWfpBroker`. Proves allowed proxy transport, endpoint denial, raw TCP/DNS/QUIC/IPv6 bypass resistance, WFP audit correlation, concurrent lease isolation, and persistent-block crash recovery. | | `AXIS_REAL_MXC_PROXY_TESTS=1` | 3 | Add real MXC strict proxy reachability proofs to the MXC process e2e wrapper when executor, launcher, and network prerequisites are present. | | `AXIS_RUN_MXC_LXC_E2E=1` | 3 | Run Linux MXC LXC smoke tests when a safe `lxc-exec` or `AXIS_TEST_MXC_EXECUTOR` and configured LXC runtime/image inputs are present. | | `AXIS_RUN_MXC_WSLC_E2E=1` | 3 | Run Windows MXC WSLC smoke tests when a safe MXC executor, WSL2, and configured distribution inputs are present. | diff --git a/docs/setup-and-install.md b/docs/setup-and-install.md index 6e36b04..43bc270 100644 --- a/docs/setup-and-install.md +++ b/docs/setup-and-install.md @@ -29,6 +29,8 @@ On Linux, release archives and packages include `axis`, `axisd`, `axis-seccomp-launcher`, and the MXC `lxc-exec` executor. The default install does not install setuid content, grant file capabilities, configure cgroups, enable VM features, or modify firewall rules. +Windows archives include the pinned MXC `wxc-exec.exe` used by the default +ProcessContainer path. Verify the basic process sandbox: @@ -86,6 +88,52 @@ Do not root-install checkout-built privileged helpers as part of normal testing. Privileged helper proofs belong in the gated e2e scripts described below. +For a Windows source checkout, build the same pinned executor used by release +jobs and place it beside `axis.exe`: + +```powershell +.\scripts\setup_windows_mxc.ps1 +``` + +The script keeps its MXC checkout under `%LOCALAPPDATA%\axis-dev\mxc`, pins +the tested revision, applies AXIS's compatibility patch idempotently, builds +both release binaries, and configures the current PowerShell process to use +BaseContainer. Pass `-SkipAxisBuild` when only `wxc-exec.exe` needs rebuilding. + +Strict Windows proxy policies additionally require the narrowly privileged +AXIS WFP broker. From an elevated PowerShell session, build/install it once: + +```powershell +.\scripts\install_windows_wfp_broker.ps1 +Get-Service AxisWfpBroker +``` + +The service accepts only a fixed lease operation for a suspended +`wxc-exec.exe` child. It reads the real AppContainer SID from that child, +allows TCP only to the exact per-sandbox AXIS proxy endpoint, and blocks other +IPv4/IPv6 connects. Dynamic proxy permits disappear on broker failure; +persistent fail-closed blocks are journaled under +`%ProgramData%\axis\wfp-leases` and reaped on restart after PID creation-time +validation. The install script restricts that journal to SYSTEM and +Administrators. Use `-Uninstall` only after all sandboxes have exited. + +Windows `auto` and `mxc` policies use ProcessContainer. `axis_native` is +disabled because the legacy path does not enforce the AXIS policy boundary. +The supported policy slice includes filesystem read-only/read-write allowlists, +non-overlapping deny rules that are redundant under BaseContainer default-deny, +a managed Windows profile, child-tree process/aggregate-memory/CPU limits, +default allow/block networking, and broker-backed BaseContainer strict proxy +routing. Nested deny rules and other unmapped Windows +process-policy surfaces fail before launch. AXIS leaves MXC's upstream +BaseContainer-first tier selection intact and always emits +`fallback.allowDaclMutation=false`. An unavailable BaseContainer therefore fails +closed instead of selecting an older AppContainer/DACL tier or temporarily +changing host ACLs. + +MXC least-privilege mode remains enabled for BaseContainer. AXIS does not select +an older tier solely to gain a feature, including ConPTY, that the current +BaseContainer API cannot provide. + ## Linux Host Packages The package names below are the common Debian/Ubuntu names. Other @@ -122,7 +170,7 @@ needs the runtime tools that each selected backend uses. | MXC LXC container backend | Safe `lxc-exec`, prepared LXC runtime usable by the current user, configured distribution/release or image inputs, `python3` for the smoke harness | Backend-specific host setup; no AXIS helper install required | `AXIS_RUN_MXC_LXC_E2E=1 bash e2e/linux/test_mxc_lxc_smoke.sh` | | MXC microVM backend | Safe `lxc-exec`, readable/writable `/dev/kvm`, MXC microVM runtime artifacts, guest/runtime image inputs | KVM access must be granted by the host; backend is experimental | `AXIS_RUN_MXC_MICROVM_E2E=1 bash e2e/linux/test_mxc_vm_smoke.sh` | | MXC Hyperlight backend | Safe `lxc-exec`, readable/writable `/dev/kvm`, MXC Hyperlight runtime artifacts or snapshots | KVM access must be granted by the host; backend is experimental | `AXIS_RUN_MXC_HYPERLIGHT_E2E=1 bash e2e/linux/test_mxc_vm_smoke.sh` | -| Windows native process sandbox | Disabled until process creation atomically applies Job Object, AppContainer or equivalent token isolation, filesystem ACLs, proxy enforcement, and environment isolation | No user-command launch is currently supported; AXIS rejects before process creation | On Windows: `cargo test --locked -p axis-sandbox native_launcher_rejects_before_workspace_or_process_setup` | +| Windows process sandbox | Packaged `wxc-exec.exe`, supported MXC BaseContainer, and Windows Job Objects for AXIS lifecycle cleanup | Normal user launch on a BaseContainer-enabled host | `axis run -- python -c "print('hello from axis')"` | | Windows VM-style backends | Windows Sandbox, WSL2, Windows Hypervisor Platform, Isolation Session, microVM, or Hyperlight features depending on backend | Explicit Windows feature enablement | Backend-specific gated smoke or benchmark command | | macOS Seatbelt process sandbox | macOS Seatbelt profile execution support; Xcode Command Line Tools for source builds and platform test builds | Normal user launch | `axis run -- python3 -c 'print("hello from axis")'` | @@ -231,6 +279,76 @@ AXIS_RUN_MXC_HYPERLIGHT_E2E=1 bash e2e/linux/test_mxc_vm_smoke.sh cargo run --locked -p axis-bench --bin mxc-isolation-matrix ``` +On a host with the BaseContainer feature enabled, run the AXIS-through-MXC +ProcessContainer proof with a trusted pinned executor: + +```powershell +$env:AXIS_RUN_MXC_BASECONTAINER_E2E = "1" +$env:AXIS_TEST_MXC_EXECUTOR = (Resolve-Path ".\target\release\wxc-exec.exe") +.\e2e\windows\test_mxc_processcontainer.ps1 ` + -AxisBin .\target\release\axis.exe +``` + +The suite tests command execution, environment filtering, managed-profile +projection, explicit/default-deny filesystem behavior, network allow/block +behavior, process count, aggregate memory, single-CPU-equivalent rate limits, +timeout descendant cleanup, and absence of host directory ACL mutation. +Feature-key, fallback, policy, launch, isolation, resource, and cleanup errors +are test failures once the gate is set. + +Run the privileged strict-proxy adversarial suite from an elevated PowerShell +session after installing the broker: + +```powershell +$env:AXIS_RUN_WINDOWS_WFP_E2E = "1" +$env:AXIS_TEST_MXC_EXECUTOR = (Resolve-Path ".\target\release\wxc-exec.exe") +.\e2e\windows\test_mxc_strict_proxy.ps1 ` + -AxisBin .\target\release\axis.exe +``` + +It proves allowed HTTPS through the proxy; endpoint denial; raw TCP, DNS, +QUIC/UDP, and IPv6 bypass resistance; correlated WFP events; concurrent lease +isolation; and fail-closed broker restart recovery. + +With the same broker and BaseContainer prerequisites, run the managed-inference +boundary proof: + +```powershell +$env:AXIS_RUN_WINDOWS_INFERENCE_E2E = "1" +$env:AXIS_TEST_MXC_EXECUTOR = (Resolve-Path ".\target\release\wxc-exec.exe") +.\e2e\windows\test_mxc_inference.ps1 ` + -AxisBin .\target\release\axis.exe +``` + +This uses a host mock provider to prove streaming, host-only credential +injection, guest secret absence, and token-budget rejection before provider +bytes are forwarded. Windows inference policies require strict proxy mode and +therefore the BaseContainer/WFP tier. `action_on_exhaust: reject` is currently +the only exact budget action; queue and fallback require the future trusted +request scheduler and fail during proxy initialization. + +For standalone `axis run`, set `AXIS_INFERENCE_ENDPOINT=127.0.0.1:` to +map the sandbox-visible `inference.local` route to a managed host provider. +AXIS consumes this value in the host proxy and does not project it into the +sandbox environment. + +Scoped SSH uses that same BaseContainer/WFP boundary and requires the packaged +`axis-ssh-proxy.exe` beside `axis.exe`. To run its gated proof: + +```powershell +$env:AXIS_RUN_WINDOWS_SSH_E2E = "1" +$env:AXIS_TEST_MXC_EXECUTOR = (Resolve-Path ".\target\release\wxc-exec.exe") +.\e2e\windows\test_mxc_scoped_ssh.ps1 ` + -AxisBin .\target\release\axis.exe +``` + +For enforceable raw-key projection, all selected keys must declare the same +literal host set, generated config and known-hosts must be enabled, and that +host set must exactly match the strict network policy's port-22 endpoints. +This prevents a custom SSH client from using one projected key against another +key's destination. More granular key-to-host sets require a future signing +broker rather than readable private-key copies. + Benchmark checks: ```bash diff --git a/e2e/windows/CAPABILITY_MATRIX.md b/e2e/windows/CAPABILITY_MATRIX.md index e077e1a..9588cc8 100644 --- a/e2e/windows/CAPABILITY_MATRIX.md +++ b/e2e/windows/CAPABILITY_MATRIX.md @@ -2,6 +2,10 @@ | Capability | Gate | Coverage | | --- | --- | --- | -| MXC ProcessContainer benchmark | `AXIS_BENCH_MXC_WINDOWS_PROCESSCONTAINER=1 pwsh -NoProfile -File e2e/windows/bench_mxc_runtime.ps1` | Reports JSON for cold lifecycle, warm lifecycle distribution, executor peak working set, and density. Tunables: `AXIS_MXC_WINDOWS_BENCH_RUNS`, `AXIS_MXC_WINDOWS_BENCH_DENSITY`, `AXIS_MXC_WINDOWS_BENCH_TIMEOUT_SECONDS`, and `AXIS_MXC_WINDOWS_BENCH_OUTPUT`. | +| AXIS-through-MXC BaseContainer smoke/security | `AXIS_RUN_MXC_BASECONTAINER_E2E=1`, `AXIS_TEST_MXC_EXECUTOR=`, then `.\e2e\windows\test_mxc_processcontainer.ps1 -AxisBin .\target\release\axis.exe` | Requires MXC BaseContainer with DACL consent absent and least-privilege mode enabled. Proves missing-executor fail-closed behavior, command execution, managed-profile and secret-environment behavior, filesystem read-only/read-write/default-deny behavior, paired network allow/block behavior, process-count and child-denial mapping, aggregate memory, CPU-rate enforcement, timeout descendant cleanup, arbitrary granted-image launch, and host-directory ACL non-mutation. Hosted CI additionally sets `AXIS_SKIP_UNAVAILABLE_MXC_BASECONTAINER_E2E=1`; that accepts only MXC's exact unavailable-BaseContainer/disabled-DACL result after the missing-executor proof, while every other failure remains fatal. | +| AXIS/MXC BaseContainer strict WFP proxy | Elevated session with installed `AxisWfpBroker`, `AXIS_RUN_WINDOWS_WFP_E2E=1`, `AXIS_TEST_MXC_EXECUTOR=`, then `.\e2e\windows\test_mxc_strict_proxy.ps1 -AxisBin .\target\release\axis.exe` | Proves allowed HTTPS through the exact proxy permit; endpoint denial; raw TCP, DNS, QUIC/UDP, and IPv6 block behavior; correlated WFP block events; two simultaneous SID-scoped leases; MXC termination on broker loss; persistent fail-closed blocks; and stale-journal recovery on restart. | +| AXIS/MXC BaseContainer managed inference | Installed/running `AxisWfpBroker`, `AXIS_RUN_WINDOWS_INFERENCE_E2E=1`, `AXIS_TEST_MXC_EXECUTOR=`, then `.\e2e\windows\test_mxc_inference.ps1 -AxisBin .\target\release\axis.exe` | Runs a real BaseContainer client through the strict WFP-only proxy path to a host mock provider. Proves streaming, provider credential injection, absence of the provider secret from guest environment/output, and pre-forward token-budget rejection. | +| AXIS/MXC BaseContainer scoped SSH | Installed/running `AxisWfpBroker`, packaged `axis-ssh-proxy.exe`, `AXIS_RUN_WINDOWS_SSH_E2E=1`, `AXIS_TEST_MXC_EXECUTOR=`, then `.\e2e\windows\test_mxc_scoped_ssh.ps1 -AxisBin .\target\release\axis.exe` | Proves managed-home key/config/known-host/helper projection, Windows OpenSSH hardening, original-key default denial, a working allowed CONNECT byte stream, and proxy rejection for an unlisted destination. | +| MXC BaseContainer benchmark | `AXIS_BENCH_MXC_WINDOWS_PROCESSCONTAINER=1 pwsh -NoProfile -File e2e/windows/bench_mxc_runtime.ps1` | Requires BaseContainer with least-privilege mode, then reports JSON for cold lifecycle, warm lifecycle distribution, executor peak working set, and density. Tunables: `AXIS_MXC_WINDOWS_BENCH_RUNS`, `AXIS_MXC_WINDOWS_BENCH_DENSITY`, `AXIS_MXC_WINDOWS_BENCH_TIMEOUT_SECONDS`, and `AXIS_MXC_WINDOWS_BENCH_OUTPUT`. | | MXC WSLC benchmark | `AXIS_BENCH_MXC_WINDOWS_WSLC=1 pwsh -NoProfile -File e2e/windows/bench_mxc_runtime.ps1` | Reports JSON for WSLC cold/warm lifecycle, executor peak working set, and density when `AXIS_MXC_WSLC_IMAGE_TAR_PATH` names repeatable input. Optional inputs: `AXIS_MXC_WSLC_IMAGE` and `AXIS_MXC_WSLC_STORAGE_PATH`. | | MXC Windows VM-style benchmark | `AXIS_BENCH_MXC_WINDOWS_SANDBOX=1`, `AXIS_BENCH_MXC_WINDOWS_ISOLATION_SESSION=1`, `AXIS_BENCH_MXC_WINDOWS_MICROVM=1`, or `AXIS_BENCH_MXC_WINDOWS_HYPERLIGHT=1` with `pwsh -NoProfile -File e2e/windows/bench_mxc_runtime.ps1` | Reports JSON for cold start, warm start distribution, executor peak working set, and density for the selected VM-style backend. Reports metric gaps for teardown, descriptor count, and process count until richer host metrics are collected. | diff --git a/e2e/windows/basecontainer_smoke.yaml b/e2e/windows/basecontainer_smoke.yaml new file mode 100644 index 0000000..cc35300 --- /dev/null +++ b/e2e/windows/basecontainer_smoke.yaml @@ -0,0 +1,16 @@ +version: 1 +name: windows-basecontainer-smoke +runtime: + containment: process + provider: mxc +filesystem: + read_write: + - "{workspace}" + compatibility: hard_requirement +process: + max_processes: 0 + max_memory_mb: 0 + cpu_rate_percent: 0 + timeout_sec: 15 +network: + mode: block diff --git a/e2e/windows/bench_mxc_runtime.ps1 b/e2e/windows/bench_mxc_runtime.ps1 index af9f1bf..a85a584 100644 --- a/e2e/windows/bench_mxc_runtime.ps1 +++ b/e2e/windows/bench_mxc_runtime.ps1 @@ -43,14 +43,14 @@ function Resolve-MxcExecutor { return (Resolve-Path -LiteralPath $env:AXIS_TEST_MXC_EXECUTOR).Path } - foreach ($candidate in @("wxc.exe", "wxc", "mxc-exec.exe", "mxc-exec", "lxc-exec.exe", "lxc-exec")) { + foreach ($candidate in @("wxc-exec.exe", "wxc.exe", "wxc", "mxc-exec.exe", "mxc-exec", "lxc-exec.exe", "lxc-exec")) { $command = Get-Command $candidate -ErrorAction SilentlyContinue if ($command) { return $command.Source } } - Exit-Fail "set AXIS_TEST_MXC_EXECUTOR or provide wxc, mxc-exec, or lxc-exec on PATH" + Exit-Fail "set AXIS_TEST_MXC_EXECUTOR or provide wxc-exec, wxc, mxc-exec, or lxc-exec on PATH" } function Quote-Arg([string]$Value) { @@ -112,7 +112,7 @@ if ($outputPath) { } Write-Host "" -function New-CommandLine([object]$Backend, [string]$Marker, [int]$SleepMilliseconds) { +function New-CommandLine([object]$Backend, [string]$Marker, [int]$SleepMilliseconds, [string]$ConfigPath) { if ($Backend.CommandFamily -eq "linux") { $command = "echo $Marker" if ($SleepMilliseconds -gt 0) { @@ -122,6 +122,26 @@ function New-CommandLine([object]$Backend, [string]$Marker, [int]$SleepMilliseco return "sh -c '$command'" } + if ($Backend.Containment -eq "processcontainer") { + $scriptPath = "$ConfigPath.cmd" + $script = "@echo off`r`necho $Marker`r`n" + if ($SleepMilliseconds -gt 0) { + $ticks = [Math]::Max(1, [Math]::Ceiling($SleepMilliseconds / 10)) + $script += @" +set /a ticks=0 +set "lastTick=%time:~6,5%" +:waitForTick +set "currentTick=%time:~6,5%" +if "%currentTick%"=="%lastTick%" goto waitForTick +set "lastTick=%currentTick%" +set /a ticks+=1 +if %ticks% LSS $ticks goto waitForTick +"@ + } + Set-Content -LiteralPath $scriptPath -Value $script -Encoding ASCII + return 'cmd.exe /d /s /c ""' + $scriptPath + '""' + } + $command = "Write-Output '$Marker'" if ($SleepMilliseconds -gt 0) { $command = "$command; Start-Sleep -Milliseconds $SleepMilliseconds" @@ -136,7 +156,7 @@ function Write-MxcConfig([string]$Path, [object]$Backend, [string]$Marker, [int] containment = $Backend.Containment platform = "windows" process = [ordered]@{ - commandLine = New-CommandLine $Backend $Marker $SleepMilliseconds + commandLine = New-CommandLine $Backend $Marker $SleepMilliseconds $Path timeout = $timeoutSeconds * 1000 } filesystem = [ordered]@{ @@ -152,7 +172,8 @@ function Write-MxcConfig([string]$Path, [object]$Backend, [string]$Marker, [int] } if ($Backend.Containment -eq "processcontainer") { - $config.processContainer = [ordered]@{ leastPrivilege = $false } + $config.filesystem.readonlyPaths = @("$Path.cmd") + $config.processContainer = [ordered]@{ leastPrivilege = $true } $config.fallback = [ordered]@{ allowDaclMutation = $false } } elseif ($Backend.Containment -eq "wslc") { $config.experimental = [ordered]@{ @@ -179,10 +200,12 @@ function Write-MxcConfig([string]$Path, [object]$Backend, [string]$Marker, [int] } } - $config | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $Path -Encoding UTF8 + # Windows PowerShell 5.1 writes a UTF-8 BOM that MXC rejects. Benchmark + # fixtures are ASCII-only, so keep them portable across powershell/pwsh. + $config | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $Path -Encoding ASCII } -function Start-MxcConfig([string]$ConfigPath, [string]$Marker) { +function Start-MxcConfig([string]$ConfigPath, [string]$Marker, [string]$Containment) { $psi = [System.Diagnostics.ProcessStartInfo]::new() $psi.FileName = $executor $psi.Arguments = "--experimental --config " + (Quote-Arg $ConfigPath) @@ -233,8 +256,8 @@ function Complete-MxcConfig([object]$Handle) { } } -function Invoke-MxcConfig([string]$ConfigPath, [string]$Marker) { - return Complete-MxcConfig (Start-MxcConfig $ConfigPath $Marker) +function Invoke-MxcConfig([string]$ConfigPath, [string]$Marker, [string]$Containment) { + return Complete-MxcConfig (Start-MxcConfig $ConfigPath $Marker $Containment) } function Get-Summary([double[]]$Values) { @@ -252,14 +275,14 @@ function Invoke-BackendBenchmark([object]$Backend) { $coldMarker = ("AXIS_MXC_BENCH_" + $Backend.Containment.ToUpperInvariant() + "_COLD") $coldConfig = Join-Path $tmpdir ($Backend.Containment + "-cold.json") Write-MxcConfig $coldConfig $Backend $coldMarker 0 - $cold = Invoke-MxcConfig $coldConfig $coldMarker + $cold = Invoke-MxcConfig $coldConfig $coldMarker $Backend.Containment $warmResults = @() for ($i = 0; $i -lt $runs; $i++) { $marker = ("AXIS_MXC_BENCH_" + $Backend.Containment.ToUpperInvariant() + "_WARM_" + $i) $config = Join-Path $tmpdir ($Backend.Containment + "-warm-" + $i + ".json") Write-MxcConfig $config $Backend $marker 0 - $warmResults += Invoke-MxcConfig $config $marker + $warmResults += Invoke-MxcConfig $config $marker $Backend.Containment } $densityHandles = @() @@ -268,7 +291,7 @@ function Invoke-BackendBenchmark([object]$Backend) { $marker = ("AXIS_MXC_BENCH_" + $Backend.Containment.ToUpperInvariant() + "_DENSITY_" + $i) $config = Join-Path $tmpdir ($Backend.Containment + "-density-" + $i + ".json") Write-MxcConfig $config $Backend $marker 250 - $densityHandles += Start-MxcConfig $config $marker + $densityHandles += Start-MxcConfig $config $marker $Backend.Containment } $densityResults = @() foreach ($handle in $densityHandles) { diff --git a/e2e/windows/helpers/inference_probe.rs b/e2e/windows/helpers/inference_probe.rs new file mode 100644 index 0000000..224c69f --- /dev/null +++ b/e2e/windows/helpers/inference_probe.rs @@ -0,0 +1,134 @@ +// Copyright 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::io::{Read, Write}; +use std::net::{TcpListener, TcpStream}; +use std::path::Path; +use std::time::Duration; + +fn main() { + let arguments = std::env::args().skip(1).collect::>(); + let result = match arguments.first().map(String::as_str) { + Some("provider") if arguments.len() == 2 => run_provider(Path::new(&arguments[1])), + Some("client") if arguments.len() == 4 => { + run_client(&arguments[1], &arguments[2], &arguments[3]) + } + _ => Err("usage: inference_probe provider REQUEST_FILE | client HOST PORT MAX_TOKENS".into()), + }; + if let Err(error) = result { + eprintln!("INFERENCE_ERROR={error}"); + std::process::exit(2); + } +} + +fn run_provider(request_file: &Path) -> Result<(), Box> { + let listener = TcpListener::bind("127.0.0.1:0")?; + println!("PORT={}", listener.local_addr()?.port()); + std::io::stdout().flush()?; + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(10)))?; + let request = read_http_message(&mut stream).unwrap_or_default(); + std::fs::write(request_file, &request)?; + if !request.is_empty() { + let body = b"data: {\"id\":\"axis-test\"}\n\ndata: [DONE]\n\n"; + write!( + stream, + "HTTP/1.1 200 OK\r\nContent-Type: text/event-stream\r\nContent-Length: {}\r\nConnection: close\r\n\r\n", + body.len() + )?; + stream.write_all(body)?; + stream.flush()?; + } + Ok(()) +} + +fn run_client(host: &str, port: &str, max_tokens: &str) -> Result<(), Box> { + if std::env::var_os("AXIS_TEST_WINDOWS_PROVIDER_KEY").is_some() { + return Err("provider secret environment variable crossed the sandbox boundary".into()); + } + println!("SECRET_ENV=0"); + let proxy = std::env::var("HTTP_PROXY").or_else(|_| std::env::var("http_proxy"))?; + let proxy = proxy + .strip_prefix("http://") + .ok_or("HTTP_PROXY is not an http URL")?; + let mut stream = TcpStream::connect(proxy)?; + stream.set_read_timeout(Some(Duration::from_secs(10)))?; + write!( + stream, + "CONNECT {host}:{port} HTTP/1.1\r\nHost: {host}:{port}\r\n\r\n" + )?; + stream.flush()?; + let connect_response = read_http_head(&mut stream)?; + if !connect_response.starts_with("HTTP/1.1 200") { + return Err(format!("CONNECT rejected: {connect_response:?}").into()); + } + + let body = format!("{{\"model\":\"axis-test\",\"max_tokens\":{max_tokens}}}"); + write!( + stream, + "POST /v1/chat/completions HTTP/1.1\r\nHost: {host}:{port}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}", + body.len() + )?; + stream.flush()?; + let mut response = String::new(); + stream.read_to_string(&mut response)?; + if !response.contains("200 OK") || !response.contains("data: [DONE]") { + return Err(format!("provider response missing streaming proof: {response:?}").into()); + } + println!("STREAMING=1"); + Ok(()) +} + +fn read_http_head(stream: &mut TcpStream) -> Result> { + let mut bytes = Vec::new(); + let mut byte = [0u8; 1]; + while bytes.len() < 64 * 1024 { + let read = stream.read(&mut byte)?; + if read == 0 { + break; + } + bytes.push(byte[0]); + if bytes.ends_with(b"\r\n\r\n") { + break; + } + } + Ok(String::from_utf8(bytes)?) +} + +fn read_http_message(stream: &mut TcpStream) -> Result, Box> { + let mut bytes = Vec::new(); + let mut buffer = [0u8; 4096]; + loop { + match stream.read(&mut buffer) { + Ok(0) => break, + Ok(read) => bytes.extend_from_slice(&buffer[..read]), + Err(error) + if matches!( + error.kind(), + std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut + ) => + { + break; + } + Err(error) => return Err(error.into()), + } + if let Some(head_end) = bytes.windows(4).position(|part| part == b"\r\n\r\n") { + let head_end = head_end + 4; + let head = String::from_utf8_lossy(&bytes[..head_end]); + let content_length = head + .lines() + .find_map(|line| { + line.split_once(':').and_then(|(name, value)| { + name.eq_ignore_ascii_case("content-length") + .then(|| value.trim().parse::().ok()) + .flatten() + }) + }) + .unwrap_or(0); + if bytes.len() >= head_end + content_length { + break; + } + } + } + Ok(bytes) +} diff --git a/e2e/windows/helpers/network_probe.rs b/e2e/windows/helpers/network_probe.rs new file mode 100644 index 0000000..b2db3d8 --- /dev/null +++ b/e2e/windows/helpers/network_probe.rs @@ -0,0 +1,154 @@ +// Copyright 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::env; +use std::net::{IpAddr, SocketAddr, TcpStream, UdpSocket}; +use std::process::ExitCode; +use std::time::Duration; + +fn main() -> ExitCode { + match run() { + Ok(message) => { + println!("{message}"); + ExitCode::SUCCESS + } + Err(message) => { + eprintln!("{message}"); + ExitCode::from(10) + } + } +} + +fn run() -> Result { + let mut args = env::args().skip(1); + let operation = args.next().ok_or_else(usage)?; + if operation == "hold" { + let seconds = args + .next() + .ok_or_else(usage)? + .parse::() + .map_err(|error| format!("invalid hold duration: {error}"))?; + if args.next().is_some() { + return Err(usage()); + } + println!("HOLDING pid={} seconds={seconds}", std::process::id()); + std::thread::sleep(Duration::from_secs(seconds)); + return Ok("HOLD_COMPLETE".into()); + } + let delay = if operation == "delay-tcp" { + Some( + args.next() + .ok_or_else(usage)? + .parse::() + .map_err(|error| format!("invalid delay: {error}"))?, + ) + } else { + None + }; + let address = args + .next() + .ok_or_else(usage)? + .parse::() + .map_err(|error| format!("invalid address: {error}"))?; + let port = args + .next() + .ok_or_else(usage)? + .parse::() + .map_err(|error| format!("invalid port: {error}"))?; + if args.next().is_some() { + return Err(usage()); + } + let remote = SocketAddr::new(address, port); + match operation.as_str() { + "tcp" | "tcp-hold" | "delay-tcp" => { + if let Some(seconds) = delay { + std::thread::sleep(Duration::from_secs(seconds)); + } + match TcpStream::connect_timeout(&remote, Duration::from_secs(4)) { + Ok(_) => Ok(format!("TCP_CONNECTED {remote}")), + Err(error) => { + if operation == "tcp-hold" { + std::thread::sleep(Duration::from_secs(2)); + } + Err(format!("TCP_BLOCKED {remote}: {error}")) + } + } + }, + "udp" => udp_probe(remote, UdpProbe::SendOnly), + "dns" => udp_probe(remote, UdpProbe::Dns), + "quic" => udp_probe(remote, UdpProbe::QuicVersionNegotiation), + _ => Err(usage()), + } +} + +enum UdpProbe { + SendOnly, + Dns, + QuicVersionNegotiation, +} + +fn udp_probe(remote: SocketAddr, probe: UdpProbe) -> Result { + let bind = if remote.is_ipv4() { "0.0.0.0:0" } else { "[::]:0" }; + let socket = UdpSocket::bind(bind).map_err(|error| format!("UDP_BIND_FAILED: {error}"))?; + socket + .set_read_timeout(Some(Duration::from_secs(4))) + .map_err(|error| format!("UDP_TIMEOUT_CONFIG_FAILED: {error}"))?; + socket + .connect(remote) + .map_err(|error| format!("UDP_BLOCKED {remote}: {error}"))?; + + let payload = match probe { + UdpProbe::Dns => { + // Standard recursive A query for example.com with transaction id 0x4158. + b"\x41\x58\x01\x00\x00\x01\x00\x00\x00\x00\x00\x00\x07example\x03com\x00\x00\x01\x00\x01".to_vec() + } + UdpProbe::QuicVersionNegotiation => { + // A 1200-byte long-header packet with an unsupported version. A + // reachable QUIC listener answers with Version Negotiation before + // it needs TLS keys, making this an adversarial raw-UDP proof. + let mut packet = vec![0u8; 1200]; + packet[0] = 0xc0; + packet[1..5].copy_from_slice(&0xface_b00cu32.to_be_bytes()); + packet[5] = 8; + packet[6..14].copy_from_slice(b"AXISDCID"); + packet[14] = 8; + packet[15..23].copy_from_slice(b"AXISSCID"); + packet + } + UdpProbe::SendOnly => b"axis-wfp-udp-probe".to_vec(), + }; + socket + .send(&payload) + .map_err(|error| format!("UDP_BLOCKED {remote}: {error}"))?; + if matches!(probe, UdpProbe::SendOnly) { + return Ok(format!("UDP_SENT {remote}")); + } + + let mut response = [0u8; 2048]; + let response_kind = if matches!(probe, UdpProbe::Dns) { + "DNS" + } else { + "QUIC" + }; + let length = socket + .recv(&mut response) + .map_err(|error| format!("{response_kind}_BLOCKED {remote}: {error}"))?; + match probe { + UdpProbe::Dns if length >= 2 && response[..2] == [0x41, 0x58] => { + Ok(format!("DNS_RESPONSE {remote} bytes={length}")) + } + UdpProbe::QuicVersionNegotiation if length >= 5 && response[0] & 0x80 != 0 => { + Ok(format!("QUIC_RESPONSE {remote} bytes={length}")) + } + UdpProbe::Dns => Err(format!("DNS_INVALID_RESPONSE {remote} bytes={length}")), + UdpProbe::QuicVersionNegotiation => { + Err(format!("QUIC_INVALID_RESPONSE {remote} bytes={length}")) + } + UdpProbe::SendOnly => unreachable!(), + } +} + +fn usage() -> String { + "usage: network_probe hold | delay-tcp | " + .into() +} diff --git a/e2e/windows/helpers/resource_probe.rs b/e2e/windows/helpers/resource_probe.rs new file mode 100644 index 0000000..2d2bfa1 --- /dev/null +++ b/e2e/windows/helpers/resource_probe.rs @@ -0,0 +1,136 @@ +use std::env; +use std::convert::TryFrom; +use std::fs; +use std::hint::black_box; +use std::path::PathBuf; +use std::process::{self, Command}; +use std::thread; +use std::time::{Duration, Instant}; + +fn main() { + let args = env::args().skip(1).collect::>(); + let result = match args.first().map(String::as_str) { + Some("allocate") => allocate(&args[1..]), + Some("spawn-allocate") => spawn_allocate(&args[1..]), + Some("hold") => hold(&args[1..]), + Some("spawn-hold") => spawn_hold(&args[1..]), + Some("burn") => burn(&args[1..]), + _ => Err("usage: resource-probe ...".into()), + }; + if let Err(err) = result { + eprintln!("resource probe: {err}"); + process::exit(2); + } +} + +fn parse_u64(value: Option<&String>, name: &str) -> Result { + value + .ok_or_else(|| format!("missing {name}"))? + .parse() + .map_err(|_| format!("invalid {name}")) +} + +fn allocate(args: &[String]) -> Result<(), String> { + let megabytes = parse_u64(args.first(), "megabytes")?; + let hold_ms = parse_u64(args.get(1), "hold_ms")?; + let marker = args.get(2).map(PathBuf::from); + let bytes = usize::try_from( + megabytes + .checked_mul(1024 * 1024) + .ok_or("allocation size overflow")?, + ) + .map_err(|_| "allocation does not fit usize")?; + let mut allocation = Vec::::new(); + allocation + .try_reserve_exact(bytes) + .map_err(|err| format!("allocation failed: {err}"))?; + allocation.resize(bytes, 0); + for offset in (0..bytes).step_by(4096) { + allocation[offset] = 0xA5; + } + black_box(&allocation); + if let Some(marker) = marker { + fs::write(marker, b"allocated").map_err(|err| err.to_string())?; + } + thread::sleep(Duration::from_millis(hold_ms)); + Ok(()) +} + +fn spawn_allocate(args: &[String]) -> Result<(), String> { + let count = parse_u64(args.first(), "count")?; + let megabytes = parse_u64(args.get(1), "megabytes")?; + let hold_ms = parse_u64(args.get(2), "hold_ms")?; + let marker_dir = PathBuf::from(args.get(3).ok_or("missing marker_dir")?); + fs::create_dir_all(&marker_dir).map_err(|err| err.to_string())?; + let exe = env::current_exe().map_err(|err| err.to_string())?; + let mut children = Vec::new(); + for index in 0..count { + let marker = marker_dir.join(format!("allocated-{index}.txt")); + children.push( + Command::new(&exe) + .arg("allocate") + .arg(megabytes.to_string()) + .arg(hold_ms.to_string()) + .arg(marker) + .spawn() + .map_err(|err| format!("spawn allocation child {index}: {err}"))?, + ); + } + for mut child in children { + let status = child.wait().map_err(|err| err.to_string())?; + if !status.success() { + return Err(format!("allocation child exited with {status}")); + } + } + Ok(()) +} + +fn hold(args: &[String]) -> Result<(), String> { + let marker = PathBuf::from(args.first().ok_or("missing marker")?); + let hold_ms = parse_u64(args.get(1), "hold_ms")?; + fs::write(marker, b"started").map_err(|err| err.to_string())?; + thread::sleep(Duration::from_millis(hold_ms)); + Ok(()) +} + +fn spawn_hold(args: &[String]) -> Result<(), String> { + let count = parse_u64(args.first(), "count")?; + let hold_ms = parse_u64(args.get(1), "hold_ms")?; + let marker_dir = PathBuf::from(args.get(2).ok_or("missing marker_dir")?); + fs::create_dir_all(&marker_dir).map_err(|err| err.to_string())?; + let exe = env::current_exe().map_err(|err| err.to_string())?; + let mut children = Vec::new(); + for index in 0..count { + let marker = marker_dir.join(format!("started-{index}.txt")); + children.push( + Command::new(&exe) + .arg("hold") + .arg(marker) + .arg(hold_ms.to_string()) + .spawn() + .map_err(|err| format!("spawn hold child {index}: {err}"))?, + ); + } + for mut child in children { + let status = child.wait().map_err(|err| err.to_string())?; + if !status.success() { + return Err(format!("hold child exited with {status}")); + } + } + Ok(()) +} + +fn burn(args: &[String]) -> Result<(), String> { + let iterations = parse_u64(args.first(), "iterations")?; + let started = Instant::now(); + let mut value = 0x9E37_79B9_7F4A_7C15u64; + for index in 0..iterations { + value ^= value << 13; + value ^= value >> 7; + value ^= value << 17; + value = value.wrapping_add(index); + black_box(value); + } + println!("burn checksum={value} elapsed_ms={}", started.elapsed().as_millis()); + Ok(()) +} diff --git a/e2e/windows/helpers/ssh_probe.rs b/e2e/windows/helpers/ssh_probe.rs new file mode 100644 index 0000000..bc0af5a --- /dev/null +++ b/e2e/windows/helpers/ssh_probe.rs @@ -0,0 +1,63 @@ +// Copyright 2026 Advanced Micro Devices, Inc. +// SPDX-License-Identifier: Apache-2.0 + +use std::io::{Read, Write}; +use std::net::TcpListener; +use std::path::Path; +use std::time::Duration; + +fn main() { + let arguments = std::env::args().skip(1).collect::>(); + let result = match arguments.first().map(String::as_str) { + Some("server") if arguments.len() == 2 => server(Path::new(&arguments[1])), + Some("inspect") if arguments.len() == 3 => inspect(&arguments[1], Path::new(&arguments[2])), + _ => Err("usage: ssh_probe server REQUEST_FILE | inspect KEY_FILE ORIGINAL_KEY".into()), + }; + if let Err(error) = result { + eprintln!("SSH_PROBE_ERROR={error}"); + std::process::exit(2); + } +} + +fn server(request_file: &Path) -> Result<(), Box> { + let listener = TcpListener::bind("127.0.0.1:22")?; + println!("PORT={}", listener.local_addr()?.port()); + std::io::stdout().flush()?; + let (mut stream, _) = listener.accept()?; + stream.set_read_timeout(Some(Duration::from_secs(10)))?; + let mut request = [0u8; 1024]; + let read = stream.read(&mut request)?; + std::fs::write(request_file, &request[..read])?; + stream.write_all(b"SSH_TUNNEL_OK\n")?; + stream.flush()?; + Ok(()) +} + +fn inspect(key_file: &str, original_key: &Path) -> Result<(), Box> { + let home = std::env::var("USERPROFILE")?; + let ssh = Path::new(&home).join(".ssh"); + let config = std::fs::read_to_string(ssh.join("config"))?; + for required in [ + "BatchMode yes", + "IdentitiesOnly yes", + "GlobalKnownHostsFile NUL", + "ProxyCommand \"%d/.ssh/axis-ssh-proxy.exe\" %h %p", + "ForwardAgent no", + "ClearAllForwardings yes", + ] { + if !config.contains(required) { + return Err(format!("generated config missing {required:?}").into()); + } + } + if config.contains("/dev/null") || !ssh.join("known_hosts").is_file() { + return Err("generated Windows SSH files are incomplete".into()); + } + if !ssh.join(key_file).is_file() || !ssh.join("axis-ssh-proxy.exe").is_file() { + return Err("selected key or CONNECT helper was not projected".into()); + } + if std::fs::read(original_key).is_ok() { + return Err("original host private key remained readable".into()); + } + println!("SSH_PROJECTION=1"); + Ok(()) +} diff --git a/e2e/windows/strict_proxy_smoke.yaml b/e2e/windows/strict_proxy_smoke.yaml new file mode 100644 index 0000000..6a37e93 --- /dev/null +++ b/e2e/windows/strict_proxy_smoke.yaml @@ -0,0 +1,27 @@ +version: 1 +name: windows-mxc-strict-proxy-smoke + +runtime: + containment: process + provider: mxc + +filesystem: + read_only: [] + read_write: + - "{workspace}" + deny: [] + compatibility: hard_requirement + +process: + max_processes: 0 + max_memory_mb: 0 + cpu_rate_percent: 0 + timeout_sec: 180 + +network: + mode: proxy + policies: + - name: github-api + endpoints: + - host: "api.github.com" + port: 443 diff --git a/e2e/windows/test_mxc_inference.ps1 b/e2e/windows/test_mxc_inference.ps1 new file mode 100644 index 0000000..d5671fd --- /dev/null +++ b/e2e/windows/test_mxc_inference.ps1 @@ -0,0 +1,155 @@ +# Gated BaseContainer managed-inference, credential, and token-budget proof. +param( + [string]$AxisBin = ".\target\release\axis.exe" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Exit-Fail([string]$Message) { throw "FAIL: $Message" } + +if ($env:AXIS_RUN_WINDOWS_INFERENCE_E2E -ne "1") { + Write-Host "SKIP: AXIS_RUN_WINDOWS_INFERENCE_E2E=1 not set" + exit 0 +} +if ($env:OS -ne "Windows_NT") { Exit-Fail "inference E2E requires Windows" } +$service = Get-Service -Name AxisWfpBroker -ErrorAction SilentlyContinue +if (-not $service -or $service.Status -ne "Running") { + Exit-Fail "AxisWfpBroker must be installed and running" +} +foreach ($path in @($AxisBin, $env:AXIS_TEST_MXC_EXECUTOR)) { + if (-not $path -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { + Exit-Fail "required executable not found: $path" + } +} + +$AxisBin = (Resolve-Path -LiteralPath $AxisBin).Path +$root = Join-Path ([IO.Path]::GetTempPath()) ("axis-inference-e2e-" + [guid]::NewGuid().ToString("N")) +$workspace = Join-Path $root "workspace" +$probe = Join-Path $workspace "axis-inference-probe.exe" +$policy = Join-Path $root "policy.yaml" +$providerOut = Join-Path $root "provider.out" +$providerErr = Join-Path $root "provider.err" +$requestFile = Join-Path $root "provider-request.bin" +$savedSecret = $env:AXIS_TEST_WINDOWS_PROVIDER_KEY +$savedInferenceEndpoint = $env:AXIS_INFERENCE_ENDPOINT +$env:AXIS_RUN_MXC_BASECONTAINER_E2E = "1" + +try { + New-Item -ItemType Directory -Path $workspace -Force | Out-Null + & rustc -O (Join-Path $PSScriptRoot "helpers\inference_probe.rs") -o $probe + if ($LASTEXITCODE -ne 0) { Exit-Fail "failed to compile inference probe" } + $env:AXIS_TEST_WINDOWS_PROVIDER_KEY = "axis-provider-secret-7f3a" + + function Start-Provider { + Remove-Item -LiteralPath $providerOut, $providerErr, $requestFile -Force -ErrorAction SilentlyContinue + $process = Start-Process -FilePath $probe -WindowStyle Hidden -PassThru ` + -RedirectStandardOutput $providerOut -RedirectStandardError $providerErr ` + -ArgumentList @("provider", $requestFile) + $port = $null + for ($attempt = 0; $attempt -lt 100; $attempt++) { + if (Test-Path -LiteralPath $providerOut) { + $line = Get-Content -LiteralPath $providerOut -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($line -match '^PORT=(\d+)$') { $port = [int]$Matches[1]; break } + } + Start-Sleep -Milliseconds 50 + } + if (-not $port) { $process.Kill(); Exit-Fail "mock provider did not publish its port" } + return [pscustomobject]@{ Process = $process; Port = $port } + } + + function Write-Policy([int]$Port) { + $text = @" +version: 1 +name: windows-mxc-inference-e2e +runtime: + containment: process + provider: mxc +filesystem: + read_write: + - "{workspace}" + compatibility: hard_requirement +process: + timeout_sec: 20 +network: + mode: proxy + policies: + - name: mock-provider + endpoints: + - host: "inference.local" + port: $Port + access: read-write +inference: + routes: + - name: mock-provider + endpoint: "http://inference.local:$Port" + api_key_env: AXIS_TEST_WINDOWS_PROVIDER_KEY + protocols: ["openai-chat-stream"] + token_budget: + max_tokens_per_hour: 1000 + max_tokens_per_request: 200 + action_on_exhaust: reject +"@ + Set-Content -LiteralPath $policy -Value $text -Encoding ASCII + } + + function Invoke-Inference([int]$Port, [int]$MaxTokens) { + Push-Location $workspace + $saved = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $output = "" | & $AxisBin run --policy $policy -- $probe client inference.local "$Port" "$MaxTokens" 2>&1 | Out-String + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } + } finally { + $ErrorActionPreference = $saved + Pop-Location + } + } + + $provider = Start-Provider + Write-Policy $provider.Port + $env:AXIS_INFERENCE_ENDPOINT = "127.0.0.1:$($provider.Port)" + $allowed = Invoke-Inference $provider.Port 10 + if ($allowed.ExitCode -ne 0) { + $provider.Process.Kill() + Exit-Fail "allowed inference launch failed before the provider completed: $($allowed.Output)" + } + if (-not $provider.Process.WaitForExit(10000)) { $provider.Process.Kill(); Exit-Fail "provider did not exit" } + if (-not $allowed.Output.Contains("SECRET_ENV=0") -or -not $allowed.Output.Contains("STREAMING=1")) { + Exit-Fail "allowed inference request failed: $($allowed.Output)" + } + $request = [IO.File]::ReadAllText($requestFile) + if (-not $request.Contains("Authorization: Bearer axis-provider-secret-7f3a")) { + Exit-Fail "host credential was not injected at the proxy boundary: $request" + } + if ($request.Contains("AXIS_TEST_WINDOWS_PROVIDER_KEY")) { + Exit-Fail "credential environment name leaked upstream" + } + if ($allowed.Output.Contains("axis-provider-secret-7f3a")) { + Exit-Fail "provider credential leaked back into sandbox output" + } + + $provider = Start-Provider + Write-Policy $provider.Port + $env:AXIS_INFERENCE_ENDPOINT = "127.0.0.1:$($provider.Port)" + $denied = Invoke-Inference $provider.Port 201 + if (-not $provider.Process.WaitForExit(10000)) { $provider.Process.Kill(); Exit-Fail "denied provider connection did not close" } + $deniedLength = if (Test-Path -LiteralPath $requestFile) { (Get-Item -LiteralPath $requestFile).Length } else { 0 } + if ($denied.ExitCode -eq 0 -or $deniedLength -ne 0) { + Exit-Fail "oversize token request was not denied before forwarding: $($denied.Output)" + } + + Write-Host "PASS: AXIS Windows MXC managed inference, credentials, streaming, and token budget checks" +} finally { + if ($null -eq $savedSecret) { + Remove-Item Env:\AXIS_TEST_WINDOWS_PROVIDER_KEY -ErrorAction SilentlyContinue + } else { + $env:AXIS_TEST_WINDOWS_PROVIDER_KEY = $savedSecret + } + if ($null -eq $savedInferenceEndpoint) { + Remove-Item Env:\AXIS_INFERENCE_ENDPOINT -ErrorAction SilentlyContinue + } else { + $env:AXIS_INFERENCE_ENDPOINT = $savedInferenceEndpoint + } + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/e2e/windows/test_mxc_processcontainer.ps1 b/e2e/windows/test_mxc_processcontainer.ps1 new file mode 100644 index 0000000..dcf9f0d --- /dev/null +++ b/e2e/windows/test_mxc_processcontainer.ps1 @@ -0,0 +1,378 @@ +# Gated AXIS-through-MXC ProcessContainer smoke and security checks. +param( + [string]$AxisBin = ".\target\release\axis.exe" +) + +$ErrorActionPreference = "Stop" +$IsolationTier = "BaseContainer" + +function Exit-Skip([string]$Message) { + Write-Host "SKIP: $Message" + exit 0 +} + +function Exit-Fail([string]$Message) { + Write-Host "FAIL: $Message" + exit 1 +} + +if ($env:AXIS_RUN_MXC_BASECONTAINER_E2E -ne "1") { + Exit-Skip "AXIS_RUN_MXC_BASECONTAINER_E2E=1 not set" +} +if ($env:OS -ne "Windows_NT") { + Exit-Fail "Windows MXC ProcessContainer tests require a Windows host" +} +if (-not (Test-Path -LiteralPath $AxisBin -PathType Leaf)) { + Exit-Fail "AXIS binary not found: $AxisBin" +} +$AxisBin = (Resolve-Path -LiteralPath $AxisBin).Path +if (-not $env:AXIS_TEST_MXC_EXECUTOR) { + Exit-Fail "AXIS_TEST_MXC_EXECUTOR must name a trusted wxc-exec.exe" +} +if (-not (Test-Path -LiteralPath $env:AXIS_TEST_MXC_EXECUTOR -PathType Leaf)) { + Exit-Fail "MXC executor not found: $env:AXIS_TEST_MXC_EXECUTOR" +} + +$root = Join-Path ([System.IO.Path]::GetTempPath()) ("axis-mxc-processcontainer-e2e-" + [guid]::NewGuid().ToString("N")) +$workspace = Join-Path $root "workspace" +$outside = Join-Path $root "outside-sentinel.txt" +$readonly = Join-Path $root "readonly" +$readonlyFile = Join-Path $readonly "reference.txt" +$policy = Join-Path $root "policy.yaml" +$allowPolicy = Join-Path $root "allow-policy.yaml" +$timeoutPolicy = Join-Path $root "timeout-policy.yaml" +$processAllowPolicy = Join-Path $root "process-allow-policy.yaml" +$processLimitPolicy = Join-Path $root "process-limit-policy.yaml" +$portableProcessPolicy = Join-Path $root "portable-process-policy.yaml" +$memoryPolicy = Join-Path $root "memory-policy.yaml" +$cpuBaselinePolicy = Join-Path $root "cpu-baseline-policy.yaml" +$cpuLimitPolicy = Join-Path $root "cpu-limit-policy.yaml" +$resourceProbe = Join-Path $workspace "axis-windows-resource-probe.exe" +New-Item -ItemType Directory -Path $workspace -Force | Out-Null +New-Item -ItemType Directory -Path $readonly -Force | Out-Null +Set-Content -LiteralPath $outside -Value "AXIS_OUTSIDE_SENTINEL" -Encoding ASCII +Set-Content -LiteralPath $readonlyFile -Value "AXIS_READONLY_SENTINEL" -Encoding ASCII + +$yamlReadonly = $readonly.Replace("'", "''") +$yamlOutside = $outside.Replace("'", "''") + +$policyText = @" +version: 1 +name: windows-mxc-$($IsolationTier.ToLowerInvariant())-e2e +runtime: + containment: process + provider: mxc +filesystem: + read_only: + - '$yamlReadonly' + read_write: + - "{workspace}" + deny: + - '$yamlOutside' + compatibility: hard_requirement +process: + max_processes: 0 + max_memory_mb: 0 + cpu_rate_percent: 0 + timeout_sec: 15 +network: + mode: block +"@ +# Windows PowerShell 5.1 writes a BOM for `-Encoding UTF8`; serde_yaml does +# not accept that marker here. The fixture is intentionally ASCII-only. +Set-Content -LiteralPath $policy -Value $policyText -Encoding ASCII + +$allowPolicyText = $policyText.Replace("mode: block", "mode: allow") +Set-Content -LiteralPath $allowPolicy -Value $allowPolicyText -Encoding ASCII + +$timeoutPolicyText = $policyText.Replace("timeout_sec: 15", "timeout_sec: 1") +Set-Content -LiteralPath $timeoutPolicy -Value $timeoutPolicyText -Encoding ASCII + +if ($IsolationTier -eq "BaseContainer") { + $rustc = Get-Command rustc -ErrorAction SilentlyContinue + if (-not $rustc) { + Exit-Fail "rustc is required to compile the Windows resource-limit probe" + } + $resourceProbeSource = Join-Path $PSScriptRoot "helpers\resource_probe.rs" + if (-not (Test-Path -LiteralPath $resourceProbeSource -PathType Leaf)) { + Exit-Fail "resource probe source not found: $resourceProbeSource" + } + & $rustc.Source -O $resourceProbeSource -o $resourceProbe + if ($LASTEXITCODE -ne 0 -or -not (Test-Path -LiteralPath $resourceProbe -PathType Leaf)) { + Exit-Fail "failed to compile Windows resource-limit probe" + } + + $resourcePolicyText = $policyText.Replace("timeout_sec: 15", "timeout_sec: 60") + Set-Content -LiteralPath $processAllowPolicy -Encoding ASCII -Value ( + $resourcePolicyText.Replace("max_processes: 0", "max_processes: 4") + ) + Set-Content -LiteralPath $processLimitPolicy -Encoding ASCII -Value ( + $resourcePolicyText.Replace("max_processes: 0", "max_processes: 2") + ) + Set-Content -LiteralPath $portableProcessPolicy -Encoding ASCII -Value ( + $resourcePolicyText.Replace("max_processes: 0", "max_processes: 32`n identity: isolated`n child_processes: deny") + ) + Set-Content -LiteralPath $memoryPolicy -Encoding ASCII -Value ( + $resourcePolicyText.Replace("max_processes: 0", "max_processes: 6").Replace("max_memory_mb: 0", "max_memory_mb: 88") + ) + Set-Content -LiteralPath $cpuBaselinePolicy -Encoding ASCII -Value ( + $resourcePolicyText.Replace("cpu_rate_percent: 0", "cpu_rate_percent: 100") + ) + Set-Content -LiteralPath $cpuLimitPolicy -Encoding ASCII -Value ( + $resourcePolicyText.Replace("cpu_rate_percent: 0", "cpu_rate_percent: 10") + ) +} + +function Invoke-AxisCommand([string]$CommandLine, [string]$PolicyPath = $policy) { + Push-Location $workspace + $savedErrorActionPreference = $ErrorActionPreference + try { + # Windows PowerShell 5.1 promotes native stderr to a terminating + # NativeCommandError under Stop. AXIS status messages use stderr. + $ErrorActionPreference = "Continue" + $output = & $AxisBin run --policy $PolicyPath -- cmd.exe /d /s /c $CommandLine 2>&1 | Out-String + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = $output + } + } finally { + $ErrorActionPreference = $savedErrorActionPreference + Pop-Location + } +} + +function Invoke-AxisProgram([string]$Program, [string[]]$ProgramArgs, [string]$PolicyPath) { + Push-Location $workspace + $savedErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $output = & $AxisBin run --policy $PolicyPath -- $Program @ProgramArgs 2>&1 | Out-String + return [pscustomobject]@{ + ExitCode = $LASTEXITCODE + Output = $output + } + } finally { + $ErrorActionPreference = $savedErrorActionPreference + Pop-Location + } +} + +function Test-HostTcpConnectivity { + $client = [System.Net.Sockets.TcpClient]::new() + try { + $task = $client.ConnectAsync("1.1.1.1", 443) + return $task.Wait(5000) -and $client.Connected + } catch { + return $false + } finally { + $client.Dispose() + } +} + +$inside = Join-Path $workspace "inside-write.txt" + +$networkProbeCommand = 'curl.exe --silent --insecure --connect-timeout 5 --max-time 8 --output NUL https://1.1.1.1/' + +$childStarted = Join-Path $workspace "timeout-child-started.txt" +$escapedChild = Join-Path $workspace "timeout-child-escaped.txt" +$delayedChildScript = Join-Path $workspace "timeout-child.cmd" +$timeoutParentScript = Join-Path $workspace "timeout-parent.cmd" +Set-Content -LiteralPath $delayedChildScript -Encoding ASCII -Value @" +@echo off +>"$childStarted" echo started +set /a ticks=0 +set "lastSecond=%time:~6,2%" +:waitForTick +set "currentSecond=%time:~6,2%" +if "%currentSecond%"=="%lastSecond%" goto waitForTick +set "lastSecond=%currentSecond%" +set /a ticks+=1 +if %ticks% LSS 4 goto waitForTick +>"$escapedChild" echo escaped +"@ +Set-Content -LiteralPath $timeoutParentScript -Encoding ASCII -Value @" +@echo off +start "" /b cmd.exe /d /s /c "`"$delayedChildScript`"" +:waitForever +goto waitForever +"@ + +$savedProcessGatePresent = Test-Path Env:\AXIS_RUN_MXC_PROCESS_E2E +$savedProcessGate = $env:AXIS_RUN_MXC_PROCESS_E2E +$savedSecretPresent = Test-Path Env:\OPENAI_API_KEY +$savedSecret = $env:OPENAI_API_KEY +$savedAllowedPresent = Test-Path Env:\OPENAI_ORG_ID +$savedAllowed = $env:OPENAI_ORG_ID +$workspaceSddl = if ($IsolationTier -eq "BaseContainer") { (Get-Acl -LiteralPath $workspace).Sddl } else { $null } +$readonlySddl = if ($IsolationTier -eq "BaseContainer") { (Get-Acl -LiteralPath $readonly).Sddl } else { $null } + +try { + # The generated MXC config disables DACL fallback, so an unavailable + # BaseContainer fails closed instead of changing the isolation tier. + $env:AXIS_RUN_MXC_PROCESS_E2E = "1" + + $missingExecutor = Join-Path $root "missing-wxc-exec.exe" + $realExecutor = $env:AXIS_TEST_MXC_EXECUTOR + $env:AXIS_TEST_MXC_EXECUTOR = $missingExecutor + $fallbackMarker = Join-Path $workspace "must-not-run.txt" + $fallback = Invoke-AxisCommand "echo unsafe>`"$fallbackMarker`"" + $env:AXIS_TEST_MXC_EXECUTOR = $realExecutor + if ($fallback.ExitCode -eq 0 -or (Test-Path -LiteralPath $fallbackMarker)) { + Exit-Fail "missing MXC executor fell back to host execution" + } + + $smoke = Invoke-AxisCommand "echo AXIS_MXC_PROCESSCONTAINER_SMOKE" + if ($smoke.ExitCode -ne 0 -or -not $smoke.Output.Contains("AXIS_MXC_PROCESSCONTAINER_SMOKE")) { + $baseContainerUnavailable = + $smoke.Output.Contains("BaseContainer is unavailable on this system") -and + $smoke.Output.Contains("DACL fallback is disabled") + if ($env:AXIS_SKIP_UNAVAILABLE_MXC_BASECONTAINER_E2E -eq "1" -and + $baseContainerUnavailable) { + Exit-Skip ( + "BaseContainer is unavailable on Windows build " + + "$([System.Environment]::OSVersion.Version); verified missing-executor and " + + "disabled-DACL-fallback fail-closed behavior, but did not run the live isolation suite" + ) + } + Exit-Fail "ProcessContainer smoke failed: exit=$($smoke.ExitCode) output=$($smoke.Output)" + } + + $env:OPENAI_API_KEY = "must-not-cross-boundary" + $env:OPENAI_ORG_ID = "axis-e2e-allowed" + $environment = Invoke-AxisCommand 'if defined OPENAI_API_KEY (exit /b 41) else if "%OPENAI_ORG_ID%"=="axis-e2e-allowed" (echo AXIS_MXC_ENVIRONMENT_FILTERED) else (exit /b 42)' + if ($environment.ExitCode -ne 0 -or -not $environment.Output.Contains("AXIS_MXC_ENVIRONMENT_FILTERED")) { + Exit-Fail "environment filtering failed: exit=$($environment.ExitCode) output=$($environment.Output)" + } + + $managedProfile = Invoke-AxisCommand 'echo AXIS_HOME=%HOME%&echo AXIS_USERPROFILE=%USERPROFILE%&echo AXIS_APPDATA=%APPDATA%&echo AXIS_LOCALAPPDATA=%LOCALAPPDATA%' + $homeMatch = [regex]::Match($managedProfile.Output, "AXIS_HOME=([^\r\n]+)") + $profileMatch = [regex]::Match($managedProfile.Output, "AXIS_USERPROFILE=([^\r\n]+)") + $appdataMatch = [regex]::Match($managedProfile.Output, "AXIS_APPDATA=([^\r\n]+)") + $localAppdataMatch = [regex]::Match($managedProfile.Output, "AXIS_LOCALAPPDATA=([^\r\n]+)") + if ($managedProfile.ExitCode -ne 0 -or -not $homeMatch.Success -or -not $profileMatch.Success -or + -not $appdataMatch.Success -or -not $localAppdataMatch.Success -or + $homeMatch.Groups[1].Value -ne $profileMatch.Groups[1].Value -or + $profileMatch.Groups[1].Value -eq $env:USERPROFILE -or + -not $appdataMatch.Groups[1].Value.StartsWith($profileMatch.Groups[1].Value, [StringComparison]::OrdinalIgnoreCase) -or + -not $localAppdataMatch.Groups[1].Value.StartsWith($profileMatch.Groups[1].Value, [StringComparison]::OrdinalIgnoreCase)) { + Exit-Fail "managed Windows profile projection failed: exit=$($managedProfile.ExitCode) output=$($managedProfile.Output)" + } + + $readonlyRead = Invoke-AxisCommand "type `"$readonlyFile`"" + if ($readonlyRead.ExitCode -ne 0 -or -not $readonlyRead.Output.Contains("AXIS_READONLY_SENTINEL")) { + Exit-Fail "read-only path was not readable: exit=$($readonlyRead.ExitCode) output=$($readonlyRead.Output)" + } + + $readonlyWrite = Invoke-AxisCommand "echo forbidden>`"$readonlyFile`"" + if ((Get-Content -LiteralPath $readonlyFile -Raw).Trim() -ne "AXIS_READONLY_SENTINEL") { + Exit-Fail "read-only ProcessContainer path was modified: exit=$($readonlyWrite.ExitCode) output=$($readonlyWrite.Output)" + } + + $outsideRead = Invoke-AxisCommand "type `"$outside`"" + if ($outsideRead.Output.Contains("AXIS_OUTSIDE_SENTINEL")) { + Exit-Fail "default-deny ProcessContainer boundary allowed an outside read" + } + + $workspaceWrite = Invoke-AxisCommand "echo writable>`"$inside`"" + if ($workspaceWrite.ExitCode -ne 0 -or + -not (Test-Path -LiteralPath $inside -PathType Leaf) -or + (Get-Content -LiteralPath $inside -Raw).Trim() -ne "writable") { + Exit-Fail "workspace was not writable: exit=$($workspaceWrite.ExitCode) output=$($workspaceWrite.Output)" + } + + if (Test-HostTcpConnectivity) { + $blockedNetwork = Invoke-AxisCommand $networkProbeCommand + if ($blockedNetwork.ExitCode -eq 0) { + Exit-Fail "block-mode ProcessContainer unexpectedly reached 1.1.1.1:443" + } + $allowedNetwork = Invoke-AxisCommand $networkProbeCommand $allowPolicy + if ($allowedNetwork.ExitCode -ne 0) { + Exit-Fail "allow-mode ProcessContainer could not reach 1.1.1.1:443: exit=$($allowedNetwork.ExitCode) output=$($allowedNetwork.Output)" + } + } else { + Write-Host "WARN: host cannot reach 1.1.1.1:443; network allow/block proof skipped" + } + + if ($IsolationTier -eq "BaseContainer") { + $resourceProbeSmoke = Invoke-AxisProgram -Program $resourceProbe -ProgramArgs @("burn", "1") -PolicyPath $policy + if ($resourceProbeSmoke.ExitCode -ne 0 -or -not $resourceProbeSmoke.Output.Contains("burn checksum=")) { + Exit-Fail "resource probe was not executable through the BaseContainer filesystem grant: exit=$($resourceProbeSmoke.ExitCode) output=$($resourceProbeSmoke.Output)" + } + $processMarkers = Join-Path $workspace "process-markers" + $processAllowed = Invoke-AxisProgram -Program $resourceProbe -ProgramArgs @("spawn-hold", "3", "250", $processMarkers) -PolicyPath $processAllowPolicy + if ($processAllowed.ExitCode -ne 0 -or (Get-ChildItem -LiteralPath $processMarkers -Filter "started-*.txt" -ErrorAction SilentlyContinue).Count -ne 3) { + Exit-Fail "permitted process-tree size failed: exit=$($processAllowed.ExitCode) output=$($processAllowed.Output)" + } + Remove-Item -LiteralPath $processMarkers -Recurse -Force + $processLimited = Invoke-AxisProgram -Program $resourceProbe -ProgramArgs @("spawn-hold", "3", "250", $processMarkers) -PolicyPath $processLimitPolicy + if ($processLimited.ExitCode -eq 0 -or (Get-ChildItem -LiteralPath $processMarkers -Filter "started-*.txt" -ErrorAction SilentlyContinue).Count -ge 3) { + Exit-Fail "process-count Job limit was not enforced: exit=$($processLimited.ExitCode) output=$($processLimited.Output)" + } + Remove-Item -LiteralPath $processMarkers -Recurse -Force -ErrorAction SilentlyContinue + $portableDenied = Invoke-AxisProgram -Program $resourceProbe -ProgramArgs @("spawn-hold", "1", "250", $processMarkers) -PolicyPath $portableProcessPolicy + if ($portableDenied.ExitCode -eq 0 -or (Get-ChildItem -LiteralPath $processMarkers -Filter "started-*.txt" -ErrorAction SilentlyContinue).Count -ne 0) { + Exit-Fail "portable child_processes: deny did not map to a pre-execution one-process Job limit: exit=$($portableDenied.ExitCode) output=$($portableDenied.Output)" + } + + $memorySingleMarkers = Join-Path $workspace "memory-single" + $memorySingle = Invoke-AxisProgram -Program $resourceProbe -ProgramArgs @("spawn-allocate", "1", "48", "250", $memorySingleMarkers) -PolicyPath $memoryPolicy + if ($memorySingle.ExitCode -ne 0 -or -not (Test-Path -LiteralPath (Join-Path $memorySingleMarkers "allocated-0.txt") -PathType Leaf)) { + Exit-Fail "single allocation should fit aggregate memory limit: exit=$($memorySingle.ExitCode) output=$($memorySingle.Output)" + } + $memoryAggregateMarkers = Join-Path $workspace "memory-aggregate" + $memoryAggregate = Invoke-AxisProgram -Program $resourceProbe -ProgramArgs @("spawn-allocate", "2", "48", "500", $memoryAggregateMarkers) -PolicyPath $memoryPolicy + if ($memoryAggregate.ExitCode -eq 0 -and (Get-ChildItem -LiteralPath $memoryAggregateMarkers -Filter "allocated-*.txt" -ErrorAction SilentlyContinue).Count -eq 2) { + Exit-Fail "aggregate Job memory limit allowed two 48 MiB descendants under an 88 MiB cap" + } + + $burnIterations = 150000000 + $cpuBaseline = Invoke-AxisProgram -Program $resourceProbe -ProgramArgs @("burn", "$burnIterations") -PolicyPath $cpuBaselinePolicy + $cpuLimited = Invoke-AxisProgram -Program $resourceProbe -ProgramArgs @("burn", "$burnIterations") -PolicyPath $cpuLimitPolicy + $baselineMatch = [regex]::Match($cpuBaseline.Output, "elapsed_ms=(\d+)") + $limitedMatch = [regex]::Match($cpuLimited.Output, "elapsed_ms=(\d+)") + if ($cpuBaseline.ExitCode -ne 0 -or $cpuLimited.ExitCode -ne 0 -or -not $baselineMatch.Success -or -not $limitedMatch.Success) { + Exit-Fail "CPU resource probes failed: baseline=$($cpuBaseline.Output) limited=$($cpuLimited.Output)" + } + $baselineMs = [int64]$baselineMatch.Groups[1].Value + $limitedMs = [int64]$limitedMatch.Groups[1].Value + if ($limitedMs -lt [Math]::Max(500, $baselineMs * 2)) { + Exit-Fail "10 percent CPU Job cap did not materially throttle work: baseline=${baselineMs}ms limited=${limitedMs}ms" + } + + $timeout = Invoke-AxisCommand 'timeout-parent.cmd' $timeoutPolicy + if ($timeout.ExitCode -eq 0 -or -not $timeout.Output.Contains("timed out")) { + Exit-Fail "timeout was not enforced: exit=$($timeout.ExitCode) output=$($timeout.Output)" + } + Start-Sleep -Milliseconds 500 + if (-not (Test-Path -LiteralPath $childStarted -PathType Leaf)) { + Exit-Fail "timeout descendant fixture did not start" + } + Start-Sleep -Seconds 5 + if (Test-Path -LiteralPath $escapedChild -PathType Leaf) { + Exit-Fail "timeout descendant escaped the AXIS Job Object cleanup boundary" + } + if ((Get-Acl -LiteralPath $workspace).Sddl -ne $workspaceSddl -or + (Get-Acl -LiteralPath $readonly).Sddl -ne $readonlySddl) { + Exit-Fail "BaseContainer launch mutated host directory ACLs" + } + } + + Write-Host "PASS: AXIS Windows MXC $IsolationTier smoke and security checks" +} finally { + if ($savedSecretPresent) { + $env:OPENAI_API_KEY = $savedSecret + } else { + Remove-Item Env:OPENAI_API_KEY -ErrorAction SilentlyContinue + } + if ($savedAllowedPresent) { + $env:OPENAI_ORG_ID = $savedAllowed + } else { + Remove-Item Env:OPENAI_ORG_ID -ErrorAction SilentlyContinue + } + if ($savedProcessGatePresent) { + $env:AXIS_RUN_MXC_PROCESS_E2E = $savedProcessGate + } else { + Remove-Item Env:AXIS_RUN_MXC_PROCESS_E2E -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/e2e/windows/test_mxc_scoped_ssh.ps1 b/e2e/windows/test_mxc_scoped_ssh.ps1 new file mode 100644 index 0000000..cc49673 --- /dev/null +++ b/e2e/windows/test_mxc_scoped_ssh.ps1 @@ -0,0 +1,141 @@ +# Gated BaseContainer scoped-SSH projection and CONNECT enforcement proof. +param( + [string]$AxisBin = ".\target\release\axis.exe" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest +function Exit-Fail([string]$Message) { throw "FAIL: $Message" } + +if ($env:AXIS_RUN_WINDOWS_SSH_E2E -ne "1") { + Write-Host "SKIP: AXIS_RUN_WINDOWS_SSH_E2E=1 not set" + exit 0 +} +if ($env:OS -ne "Windows_NT") { Exit-Fail "scoped SSH E2E requires Windows" } +$service = Get-Service -Name AxisWfpBroker -ErrorAction SilentlyContinue +if (-not $service -or $service.Status -ne "Running") { Exit-Fail "AxisWfpBroker must be running" } +foreach ($path in @($AxisBin, $env:AXIS_TEST_MXC_EXECUTOR, ".\target\release\axis-ssh-proxy.exe")) { + if (-not $path -or -not (Test-Path -LiteralPath $path -PathType Leaf)) { + Exit-Fail "required executable not found: $path" + } +} + +$AxisBin = (Resolve-Path -LiteralPath $AxisBin).Path +$root = Join-Path ([IO.Path]::GetTempPath()) ("axis-ssh-e2e-" + [guid]::NewGuid().ToString("N")) +$workspace = Join-Path $root "workspace" +$probe = Join-Path $workspace "axis-ssh-probe.exe" +$privateKey = Join-Path $root "id_axis_test" +$requestFile = Join-Path $root "ssh-request.bin" +$providerOut = Join-Path $root "ssh-provider.out" +$providerErr = Join-Path $root "ssh-provider.err" +$policy = Join-Path $root "policy.yaml" +$fakeKeyscan = Join-Path $root "ssh-keyscan.cmd" +$policyName = "windows-mxc-ssh-e2e" +$managedSsh = Join-Path $env:USERPROFILE ".axis\agents\$policyName\home\.ssh" +$projectedHelper = Join-Path $managedSsh "axis-ssh-proxy.exe" +$savedPath = $env:PATH +$savedKeyscan = $env:AXIS_TEST_SSH_KEYSCAN +$provider = $null +$env:AXIS_RUN_MXC_BASECONTAINER_E2E = "1" + +try { + New-Item -ItemType Directory -Path $workspace -Force | Out-Null + Set-Content -LiteralPath $privateKey -Encoding ASCII -Value "AXIS_TEST_PRIVATE_KEY" + Set-Content -LiteralPath $fakeKeyscan -Encoding ASCII -Value @( + "@echo off", + "echo 127.0.0.1 ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIAxisTestOnly" + ) + $env:PATH = "$root;$savedPath" + $env:AXIS_TEST_SSH_KEYSCAN = $fakeKeyscan + & rustc -O (Join-Path $PSScriptRoot "helpers\ssh_probe.rs") -o $probe + if ($LASTEXITCODE -ne 0) { Exit-Fail "failed to compile SSH probe" } + + $provider = Start-Process -FilePath $probe -WindowStyle Hidden -PassThru ` + -RedirectStandardOutput $providerOut -RedirectStandardError $providerErr ` + -ArgumentList @("server", $requestFile) + $port = $null + for ($attempt = 0; $attempt -lt 100; $attempt++) { + if (Test-Path -LiteralPath $providerOut) { + $line = Get-Content -LiteralPath $providerOut -ErrorAction SilentlyContinue | Select-Object -First 1 + if ($line -match '^PORT=(\d+)$') { $port = [int]$Matches[1]; break } + } + Start-Sleep -Milliseconds 50 + } + if (-not $port) { $provider.Kill(); Exit-Fail "SSH mock server did not publish a port" } + + $yamlKey = $privateKey.Replace("'", "''") + $policyText = @" +version: 1 +name: $policyName +runtime: + containment: process + provider: mxc +filesystem: + read_write: + - "{workspace}" + compatibility: hard_requirement +process: + timeout_sec: 20 +network: + mode: proxy + policies: + - name: scoped-ssh + endpoints: + - host: "127.0.0.1" + port: $port + access: read-write +ssh: + allowed_keys: + - name: test + private_key: '$yamlKey' + allowed_hosts: ["127.0.0.1"] + generate_known_hosts: true + generate_config: true +"@ + Set-Content -LiteralPath $policy -Value $policyText -Encoding ASCII + + function Invoke-AxisProgram([string]$Program, [string[]]$Arguments, [string]$InputText) { + Push-Location $workspace + $saved = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $output = $InputText | & $AxisBin run --policy $policy -- $Program @Arguments 2>&1 | Out-String + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } + } finally { + $ErrorActionPreference = $saved + Pop-Location + } + } + + $inspect = Invoke-AxisProgram $probe @("inspect", "id_axis_test", $privateKey) "" + if ($inspect.ExitCode -ne 0 -or -not $inspect.Output.Contains("SSH_PROJECTION=1")) { + $provider.Kill() + Exit-Fail "scoped SSH projection failed: $($inspect.Output)" + } + + $allowed = Invoke-AxisProgram $projectedHelper @("127.0.0.1", "$port") "SSH_CLIENT_HELLO" + if (-not $provider.WaitForExit(10000)) { $provider.Kill(); Exit-Fail "SSH mock server did not exit" } + if ($allowed.ExitCode -ne 0 -or -not $allowed.Output.Contains("SSH_TUNNEL_OK")) { + Exit-Fail "allowed SSH CONNECT tunnel failed: $($allowed.Output)" + } + if (-not ([IO.File]::ReadAllText($requestFile)).Contains("SSH_CLIENT_HELLO")) { + Exit-Fail "SSH CONNECT tunnel did not relay client bytes" + } + + $deniedPort = if ($port -lt 65535) { $port + 1 } else { $port - 1 } + $denied = Invoke-AxisProgram $projectedHelper @("127.0.0.1", "$deniedPort") "DENIED" + if ($denied.ExitCode -eq 0 -or -not $denied.Output.Contains("proxy rejected")) { + Exit-Fail "out-of-scope SSH destination was not denied: $($denied.Output)" + } + + Write-Host "PASS: AXIS Windows MXC scoped SSH projection and CONNECT checks" +} finally { + $env:PATH = $savedPath + if ($null -eq $savedKeyscan) { + Remove-Item Env:\AXIS_TEST_SSH_KEYSCAN -ErrorAction SilentlyContinue + } else { + $env:AXIS_TEST_SSH_KEYSCAN = $savedKeyscan + } + if ($provider -and -not $provider.HasExited) { $provider.Kill() } + if (Test-Path -LiteralPath $root) { Remove-Item -LiteralPath $root -Recurse -Force } +} diff --git a/e2e/windows/test_mxc_strict_proxy.ps1 b/e2e/windows/test_mxc_strict_proxy.ps1 new file mode 100644 index 0000000..a35ce89 --- /dev/null +++ b/e2e/windows/test_mxc_strict_proxy.ps1 @@ -0,0 +1,256 @@ +# Gated AXIS/MXC BaseContainer strict-proxy adversarial suite. +param( + [string]$AxisBin = ".\target\release\axis.exe", + [string]$PolicyPath = ".\e2e\windows\strict_proxy_smoke.yaml" +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +function Exit-Fail([string]$Message) { + throw "FAIL: $Message" +} + +if ($env:AXIS_RUN_WINDOWS_WFP_E2E -ne "1") { + Write-Host "SKIP: AXIS_RUN_WINDOWS_WFP_E2E=1 not set" + exit 0 +} +if ($env:OS -ne "Windows_NT") { + Exit-Fail "strict WFP tests require Windows" +} + +$principal = [Security.Principal.WindowsPrincipal]::new( + [Security.Principal.WindowsIdentity]::GetCurrent() +) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + Exit-Fail "the gated crash-recovery proof must run elevated" +} +$service = Get-Service -Name AxisWfpBroker -ErrorAction SilentlyContinue +if (-not $service -or $service.Status -ne "Running") { + Exit-Fail "AxisWfpBroker must be installed and running" +} +if (-not $env:AXIS_TEST_MXC_EXECUTOR) { + Exit-Fail "AXIS_TEST_MXC_EXECUTOR must name the patched wxc-exec.exe" +} +foreach ($path in @($AxisBin, $PolicyPath, $env:AXIS_TEST_MXC_EXECUTOR)) { + if (-not (Test-Path -LiteralPath $path -PathType Leaf)) { + Exit-Fail "required file not found: $path" + } +} +$AxisBin = (Resolve-Path -LiteralPath $AxisBin).Path +$PolicyPath = (Resolve-Path -LiteralPath $PolicyPath).Path +$env:AXIS_RUN_MXC_BASECONTAINER_E2E = "1" + +$root = Join-Path ([IO.Path]::GetTempPath()) ("axis-wfp-e2e-" + [guid]::NewGuid().ToString("N")) +$workspace = Join-Path $root "workspace" +$probe = Join-Path $workspace "axis-windows-network-probe.exe" +$emptyInput = Join-Path $root "empty.stdin" +$auditLog = Join-Path $env:ProgramData "axis\logs\wfp-broker.jsonl" +$leaseDir = Join-Path $env:ProgramData "axis\wfp-leases" +New-Item -ItemType Directory -Path $workspace -Force | Out-Null +New-Item -ItemType File -Path $emptyInput -Force | Out-Null + +try { + & rustc -O (Join-Path $PSScriptRoot "helpers\network_probe.rs") -o $probe + if ($LASTEXITCODE -ne 0) { + Exit-Fail "failed to compile the raw-network probe" + } + + function Invoke-Probe([string[]]$Arguments) { + Push-Location $workspace + $saved = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $output = "" | & $AxisBin run --policy $PolicyPath -- $probe @Arguments 2>&1 | Out-String + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } + } finally { + $ErrorActionPreference = $saved + Pop-Location + } + } + + function Invoke-Curl([string[]]$Arguments) { + Push-Location $workspace + $saved = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + $output = "" | & $AxisBin run --policy $PolicyPath -- "$env:SystemRoot\System32\curl.exe" @Arguments 2>&1 | Out-String + return [pscustomobject]@{ ExitCode = $LASTEXITCODE; Output = $output } + } finally { + $ErrorActionPreference = $saved + Pop-Location + } + } + + # Establish that the host can reach each adversarial endpoint; otherwise a + # sandbox timeout would not distinguish WFP enforcement from host routing. + foreach ($baseline in @( + @("tcp", "1.1.1.1", "443"), + @("dns", "1.1.1.1", "53"), + @("quic", "1.1.1.1", "443") + )) { + & $probe @baseline | Out-Null + if ($LASTEXITCODE -ne 0) { + Exit-Fail "host baseline failed for $($baseline -join ' ')" + } + } + + $auditStart = [DateTimeOffset]::UtcNow.ToUnixTimeMilliseconds() + + $allowed = Invoke-Curl @( + "--silent", "--show-error", "--fail", "--ssl-no-revoke", + "--connect-timeout", "8", "--max-time", "20", + "https://api.github.com/zen" + ) + if ($allowed.ExitCode -ne 0) { + Exit-Fail "allowed HTTPS did not traverse the AXIS proxy: $($allowed.Output)" + } + + $endpointDenied = Invoke-Curl @( + "--silent", "--show-error", "--fail", "--ssl-no-revoke", + "--connect-timeout", "5", "--max-time", "10", + "https://example.com/" + ) + if ($endpointDenied.ExitCode -eq 0 -or -not $endpointDenied.Output.Contains("DENIED example.com:443")) { + Exit-Fail "proxy endpoint policy did not deny example.com: $($endpointDenied.Output)" + } + + foreach ($blocked in @( + @("tcp-hold", "1.1.1.1", "443", "TCP_BLOCKED"), + @("dns", "1.1.1.1", "53", "DNS_BLOCKED"), + @("quic", "1.1.1.1", "443", "QUIC_BLOCKED") + )) { + $result = Invoke-Probe $blocked[0..2] + if ($result.ExitCode -eq 0 -or -not $result.Output.Contains($blocked[3])) { + Exit-Fail "raw bypass was not blocked for $($blocked[0]): $($result.Output)" + } + } + + # IPv6 proof does not depend on external IPv6 routing: a listening ::1 + # endpoint is reachable on the host and must still hit the lease's V6 block. + $listener = [Net.Sockets.TcpListener]::new([Net.IPAddress]::IPv6Loopback, 0) + $listener.Start() + try { + $v6Port = $listener.LocalEndpoint.Port + $hostClient = [Net.Sockets.TcpClient]::new([Net.Sockets.AddressFamily]::InterNetworkV6) + $hostClient.Connect([Net.IPAddress]::IPv6Loopback, $v6Port) + $hostClient.Dispose() + $ipv6 = Invoke-Probe @("tcp-hold", "::1", "$v6Port") + if ($ipv6.ExitCode -eq 0 -or -not $ipv6.Output.Contains("TCP_BLOCKED")) { + Exit-Fail "direct IPv6 loopback bypass was not blocked: $($ipv6.Output)" + } + } finally { + $listener.Stop() + } + + $events = Get-Content -LiteralPath $auditLog | ForEach-Object { + try { $_ | ConvertFrom-Json } catch { $null } + } | Where-Object { + $_ -and $_.timestampUnixMs -ge $auditStart -and $_.event -eq "connection_blocked" + } + if (-not ($events | Where-Object { $_.protocol -eq 6 -and $_.remoteAddress -eq "1.1.1.1" -and $_.remotePort -eq 443 })) { + Exit-Fail "no correlated WFP TCP block event was recorded" + } + if (-not ($events | Where-Object { $_.ipVersion -eq 6 -and $_.remoteAddress -eq "::1" })) { + Exit-Fail "no correlated WFP IPv6 block event was recorded" + } + + # Two leases must coexist without filter-key collision or cross-release. + $concurrentBaseline = @(Get-ChildItem -LiteralPath $leaseDir -Filter "*.json" -ErrorAction SilentlyContinue | ForEach-Object Name) + $firstOut = Join-Path $root "concurrent-first.out" + $firstErr = Join-Path $root "concurrent-first.err" + $secondOut = Join-Path $root "concurrent-second.out" + $secondErr = Join-Path $root "concurrent-second.err" + $first = Start-Process -FilePath $AxisBin -WindowStyle Hidden -PassThru ` + -WorkingDirectory $workspace -RedirectStandardInput $emptyInput ` + -RedirectStandardOutput $firstOut -RedirectStandardError $firstErr ` + -ArgumentList @("run", "--policy", $PolicyPath, "--", $probe, "hold", "20") + $second = Start-Process -FilePath $AxisBin -WindowStyle Hidden -PassThru ` + -WorkingDirectory $workspace -RedirectStandardInput $emptyInput ` + -RedirectStandardOutput $secondOut -RedirectStandardError $secondErr ` + -ArgumentList @("run", "--policy", $PolicyPath, "--", $probe, "delay-tcp", "3", "1.1.1.1", "443") + $concurrentJournals = @() + for ($attempt = 0; $attempt -lt 100; $attempt++) { + $concurrentJournals = @(Get-ChildItem -LiteralPath $leaseDir -Filter "*.json" -ErrorAction SilentlyContinue | + Where-Object { $concurrentBaseline -notcontains $_.Name }) + if ($concurrentJournals.Count -eq 2) { break } + Start-Sleep -Milliseconds 100 + } + if ($concurrentJournals.Count -ne 2) { + $first.Kill(); $second.Kill() + Exit-Fail "two concurrent WFP lease journals did not coexist" + } + $first.Kill() + $first.WaitForExit(10000) | Out-Null + Start-Sleep -Milliseconds 200 + $remainingConcurrent = @(Get-ChildItem -LiteralPath $leaseDir -Filter "*.json" -ErrorAction SilentlyContinue | + Where-Object { $concurrentBaseline -notcontains $_.Name }) + if ($remainingConcurrent.Count -ne 1) { + $second.Kill() + Exit-Fail "releasing one sandbox removed or retained another sandbox's WFP lease" + } + if (-not $second.WaitForExit(15000)) { + $second.Kill() + Exit-Fail "second concurrent sandbox did not complete" + } + # Flush asynchronous redirected-output handlers before reading the files. + $second.WaitForExit() + $secondOutput = @( + Get-Content -LiteralPath $secondOut -ErrorAction SilentlyContinue + Get-Content -LiteralPath $secondErr -ErrorAction SilentlyContinue + ) | Out-String + if (-not $secondOutput.Contains("TCP_BLOCKED")) { + Exit-Fail "second sandbox bypassed its block after the first lease was released: $secondOutput" + } + + # Broker restart: the dynamic permit disappears, persistent blocks remain, + # MXC's pipe watchdog kills the child, and restart reaps the journal/blocks. + $beforeJournals = @(Get-ChildItem -LiteralPath $leaseDir -Filter "*.json" -ErrorAction SilentlyContinue | ForEach-Object Name) + $crashOut = Join-Path $root "crash.out" + $crashErr = Join-Path $root "crash.err" + $crashProcess = Start-Process -FilePath $AxisBin -WindowStyle Hidden -PassThru ` + -WorkingDirectory $workspace -RedirectStandardInput $emptyInput ` + -RedirectStandardOutput $crashOut -RedirectStandardError $crashErr ` + -ArgumentList @("run", "--policy", $PolicyPath, "--", $probe, "hold", "120") + $newJournal = $null + for ($attempt = 0; $attempt -lt 150; $attempt++) { + $newJournal = Get-ChildItem -LiteralPath $leaseDir -Filter "*.json" -ErrorAction SilentlyContinue | + Where-Object { $beforeJournals -notcontains $_.Name } | Select-Object -First 1 + if ($newJournal) { break } + Start-Sleep -Milliseconds 100 + } + if (-not $newJournal) { + $crashProcess.Kill() + Exit-Fail "live lease journal did not appear" + } + $leaseId = [IO.Path]::GetFileNameWithoutExtension($newJournal.Name) + + Stop-Service -Name AxisWfpBroker -Force + if (-not (Test-Path -LiteralPath $newJournal.FullName)) { + Exit-Fail "broker crash removed persistent fail-closed block journal prematurely" + } + Start-Service -Name AxisWfpBroker + if (-not $crashProcess.WaitForExit(15000)) { + $crashProcess.Kill() + Exit-Fail "MXC did not terminate the child after broker pipe loss" + } + for ($attempt = 0; $attempt -lt 100 -and (Test-Path -LiteralPath $newJournal.FullName); $attempt++) { + Start-Sleep -Milliseconds 100 + } + if (Test-Path -LiteralPath $newJournal.FullName) { + Exit-Fail "broker restart did not reap the stale persistent blocks" + } + $reaped = Get-Content -LiteralPath $auditLog | ForEach-Object { + try { $_ | ConvertFrom-Json } catch { $null } + } | Where-Object { $_ -and $_.event -eq "stale_lease_reaped" -and $_.leaseId -eq $leaseId } + if (-not $reaped) { + Exit-Fail "broker restart did not audit stale lease recovery" + } + + Write-Host "PASS: AXIS Windows MXC strict WFP proxy security checks" +} finally { + if ((Get-Service -Name AxisWfpBroker -ErrorAction SilentlyContinue).Status -ne "Running") { + Start-Service -Name AxisWfpBroker -ErrorAction SilentlyContinue + } + Remove-Item -LiteralPath $root -Recurse -Force -ErrorAction SilentlyContinue +} diff --git a/scripts/install_windows_wfp_broker.ps1 b/scripts/install_windows_wfp_broker.ps1 new file mode 100644 index 0000000..9adf868 --- /dev/null +++ b/scripts/install_windows_wfp_broker.ps1 @@ -0,0 +1,140 @@ +# Copyright 2026 Advanced Micro Devices, Inc. +# SPDX-License-Identifier: Apache-2.0 + +[CmdletBinding()] +param( + [switch]$Uninstall, + [switch]$SkipBuild, + [string]$LogPath +) + +$ErrorActionPreference = "Stop" +Set-StrictMode -Version Latest + +if ($LogPath) { + Set-Content -LiteralPath $LogPath -Value "" -Encoding UTF8 +} + +trap { + if ($LogPath) { + $_ | Out-String | Set-Content -LiteralPath $LogPath -Encoding UTF8 + } + throw +} + +$serviceName = "AxisWfpBroker" +$repoRoot = Split-Path -Parent $PSScriptRoot +$installDir = Join-Path $env:ProgramFiles "Axis" +$installedBinary = Join-Path $installDir "axis-wfp-broker.exe" +$leaseDir = Join-Path $env:ProgramData "axis\wfp-leases" +$builtBinary = Join-Path $repoRoot "target\release\axis-wfp-broker.exe" + +$identity = [Security.Principal.WindowsIdentity]::GetCurrent() +$principal = [Security.Principal.WindowsPrincipal]::new($identity) +if (-not $principal.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) { + throw "This script must run from an elevated PowerShell session because WFP policy installation is a privileged host operation." +} + +function Invoke-Sc { + param([Parameter(Mandatory = $true)][string[]]$Arguments) + + & "$env:SystemRoot\System32\sc.exe" @Arguments + if ($LASTEXITCODE -ne 0) { + throw "sc.exe $($Arguments -join ' ') failed with exit code $LASTEXITCODE" + } +} + +function Test-ServiceExists { + & "$env:SystemRoot\System32\sc.exe" query $serviceName *> $null + return $LASTEXITCODE -eq 0 +} + +if ($Uninstall) { + $activeJournals = @(Get-ChildItem -LiteralPath $leaseDir -Filter "*.json" -ErrorAction SilentlyContinue) + if ($activeJournals.Count -ne 0) { + throw "Refusing to uninstall while WFP lease journals exist. Let active sandboxes exit, or restart the broker so it can reap a crashed lease." + } + if (Test-ServiceExists) { + & "$env:SystemRoot\System32\sc.exe" stop $serviceName *> $null + for ($attempt = 0; $attempt -lt 50; $attempt++) { + $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue + if (-not $service -or $service.Status -eq "Stopped") { + break + } + Start-Sleep -Milliseconds 100 + } + Invoke-Sc -Arguments @("delete", $serviceName) + } + if (Test-Path -LiteralPath $installedBinary) { + Remove-Item -LiteralPath $installedBinary -Force + } + Write-Host "AXIS WFP broker service removed." + exit 0 +} + +if (-not $SkipBuild) { + & cargo build --release -p axis-sandbox --bin axis-wfp-broker --manifest-path (Join-Path $repoRoot "Cargo.toml") + if ($LASTEXITCODE -ne 0) { + throw "AXIS WFP broker build failed with exit code $LASTEXITCODE" + } +} +if (-not (Test-Path -LiteralPath $builtBinary -PathType Leaf)) { + throw "Built broker not found at $builtBinary. Run without -SkipBuild first." +} + +if (Test-ServiceExists) { + & "$env:SystemRoot\System32\sc.exe" stop $serviceName *> $null + for ($attempt = 0; $attempt -lt 50; $attempt++) { + $service = Get-Service -Name $serviceName -ErrorAction SilentlyContinue + if ($service.Status -eq "Stopped") { + break + } + Start-Sleep -Milliseconds 100 + } + if ((Get-Service -Name $serviceName).Status -ne "Stopped") { + throw "Existing $serviceName service did not stop" + } +} + +New-Item -ItemType Directory -Path $installDir -Force | Out-Null +Copy-Item -LiteralPath $builtBinary -Destination $installedBinary -Force +New-Item -ItemType Directory -Path $leaseDir -Force | Out-Null +& "$env:SystemRoot\System32\icacls.exe" $leaseDir /inheritance:r /grant:r '*S-1-5-18:(OI)(CI)F' '*S-1-5-32-544:(OI)(CI)F' | Out-Null +if ($LASTEXITCODE -ne 0) { + throw "failed to restrict the WFP lease journal ACL" +} + +if (-not (Test-ServiceExists)) { + $quotedCommand = '"{0}" service' -f $installedBinary + New-Service ` + -Name $serviceName ` + -BinaryPathName $quotedCommand ` + -DisplayName "AXIS WFP strict-proxy broker" ` + -StartupType Automatic | Out-Null + Invoke-Sc -Arguments @("description", $serviceName, "Installs lease-scoped WFP filters for suspended AXIS MXC ProcessContainer children.") + Invoke-Sc -Arguments @("failure", $serviceName, "reset=", "86400", "actions=", "restart/1000/restart/5000/none/0") +} + +Set-Service -Name $serviceName -StartupType Automatic + +Invoke-Sc -Arguments @("start", $serviceName) +$pipePath = "\\.\pipe\axis-wfp-broker-v1" +for ($attempt = 0; $attempt -lt 100; $attempt++) { + if ([System.IO.Directory]::GetFiles("\\.\pipe\") -contains $pipePath) { + break + } + Start-Sleep -Milliseconds 100 +} +if (-not ([System.IO.Directory]::GetFiles("\\.\pipe\") -contains $pipePath)) { + throw "The $serviceName service started but its lease pipe did not become available" +} + +& $installedBinary probe +if ($LASTEXITCODE -ne 0) { + throw "The installed broker could not open the Windows Filtering Platform engine" +} + +Write-Host "AXIS WFP broker installed and running." +Write-Host "Service: $serviceName" +Write-Host "Binary: $installedBinary" +Write-Host "Pipe: $pipePath" diff --git a/scripts/setup_windows_mxc.ps1 b/scripts/setup_windows_mxc.ps1 new file mode 100644 index 0000000..bc174db --- /dev/null +++ b/scripts/setup_windows_mxc.ps1 @@ -0,0 +1,126 @@ +# Build AXIS and its pinned Windows MXC executor from source. +param( + [string]$MxcDir = (Join-Path $env:LOCALAPPDATA "axis-dev\mxc"), + [switch]$SkipAxisBuild +) + +$ErrorActionPreference = "Stop" + +$mxcRepository = "https://github.com/microsoft/mxc" +$mxcRef = "1736b48398c3fe4d1315b2311c0951cc893eb3ae" +$repoRoot = Split-Path -Parent $PSScriptRoot +$patchPaths = @( + (Join-Path $repoRoot "third_party\mxc\patches\0001-wxc-processcontainer-resource-limits.patch"), + (Join-Path $repoRoot "third_party\mxc\patches\0002-wxc-job-list-resource-assignment.patch"), + (Join-Path $repoRoot "third_party\mxc\patches\0003-axis-wfp-strict-proxy.patch") +) +$axisReleaseDir = Join-Path $repoRoot "target\release" +$installedExecutor = Join-Path $axisReleaseDir "wxc-exec.exe" + +function Test-MxcPatch([string]$PatchPath, [switch]$Reverse) { + # Windows PowerShell 5.1 turns native stderr into an ErrorRecord when the + # caller uses Stop. A failed `git apply --check` is expected while deciding + # whether the patch is already present, so inspect its exit code explicitly. + $savedErrorActionPreference = $ErrorActionPreference + try { + $ErrorActionPreference = "Continue" + if ($Reverse) { + & git -C $MxcDir apply --reverse --check --whitespace=error $PatchPath 2>$null + } else { + & git -C $MxcDir apply --check --whitespace=error $PatchPath 2>$null + } + return $LASTEXITCODE -eq 0 + } finally { + $ErrorActionPreference = $savedErrorActionPreference + } +} + +foreach ($command in @("git", "cargo")) { + if (-not (Get-Command $command -ErrorAction SilentlyContinue)) { + throw "Required command is not available on PATH: $command" + } +} +foreach ($patchPath in $patchPaths) { + if (-not (Test-Path -LiteralPath $patchPath -PathType Leaf)) { + throw "AXIS MXC patch not found: $patchPath" + } +} + +if (-not (Test-Path -LiteralPath (Join-Path $MxcDir ".git") -PathType Container)) { + if (Test-Path -LiteralPath $MxcDir) { + throw "MXC destination exists but is not a Git checkout: $MxcDir" + } + + $mxcParent = Split-Path -Parent $MxcDir + New-Item -ItemType Directory -Path $mxcParent -Force | Out-Null + Write-Host "Cloning pinned MXC source into $MxcDir" + & git clone --filter=blob:none $mxcRepository $MxcDir + if ($LASTEXITCODE -ne 0) { + throw "MXC clone failed with exit code $LASTEXITCODE" + } +} + +Write-Host "Checking out pinned MXC revision $mxcRef" +& git -C $MxcDir fetch origin $mxcRef --depth=1 +if ($LASTEXITCODE -ne 0) { + throw "MXC fetch failed with exit code $LASTEXITCODE" +} +& git -C $MxcDir checkout --detach $mxcRef +if ($LASTEXITCODE -ne 0) { + throw "MXC checkout failed with exit code $LASTEXITCODE" +} + +# Make repeated runs safe: apply the AXIS patch only when it is not present. +if (Test-MxcPatch $patchPaths[0]) { + Write-Host "Applying AXIS MXC patches" + foreach ($patchPath in $patchPaths) { + & git -C $MxcDir apply --whitespace=error $patchPath + if ($LASTEXITCODE -ne 0) { + throw "MXC patch failed for $patchPath with exit code $LASTEXITCODE" + } + } +} else { + if (-not (Test-MxcPatch $patchPaths[-1] -Reverse)) { + throw "MXC checkout is not in a state where the AXIS patches can be applied" + } + Write-Host "AXIS MXC patches are already applied" +} + +Write-Host "Building wxc-exec.exe" +Push-Location (Join-Path $MxcDir "src") +try { + & cargo build --release -p wxc --no-default-features --locked + if ($LASTEXITCODE -ne 0) { + throw "MXC build failed with exit code $LASTEXITCODE" + } +} finally { + Pop-Location +} + +if (-not $SkipAxisBuild) { + Write-Host "Building axis.exe" + & cargo build --release -p axis-cli --manifest-path (Join-Path $repoRoot "Cargo.toml") + if ($LASTEXITCODE -ne 0) { + throw "AXIS build failed with exit code $LASTEXITCODE" + } + Write-Host "Building axis-ssh-proxy.exe" + & cargo build --release -p axis-sandbox --bin axis-ssh-proxy --manifest-path (Join-Path $repoRoot "Cargo.toml") + if ($LASTEXITCODE -ne 0) { + throw "AXIS SSH proxy helper build failed with exit code $LASTEXITCODE" + } +} + +New-Item -ItemType Directory -Path $axisReleaseDir -Force | Out-Null +$builtExecutor = Join-Path $MxcDir "src\target\release\wxc-exec.exe" +if (-not (Test-Path -LiteralPath $builtExecutor -PathType Leaf)) { + throw "MXC build completed without producing: $builtExecutor" +} +Copy-Item -LiteralPath $builtExecutor -Destination $installedExecutor -Force + +# Configure this PowerShell process for a BaseContainer launch. +$env:AXIS_MXC_EXECUTOR = (Resolve-Path -LiteralPath $installedExecutor).Path + +Write-Host "" +Write-Host "Windows MXC setup complete." +Write-Host "Executor: $env:AXIS_MXC_EXECUTOR" +Write-Host "BaseContainer mode selected (DACL fallback is disabled by AXIS policy)." diff --git a/scripts/verify_workflow_structure.py b/scripts/verify_workflow_structure.py index 9b395f5..182405c 100644 --- a/scripts/verify_workflow_structure.py +++ b/scripts/verify_workflow_structure.py @@ -124,7 +124,7 @@ "identity": "6294ecb850d44565fa8a2943defd1395a9665c5fe562bb9e2f0da3bd3c5c148b", "gate": "fc94d9feb6f93110b6bedce5e09da23c7d031574a6d575c4192fd06eb05b4a16", "gui": "385abeea4a0aac4cb93b8fc4f50c08bdf89d6949b9a217c2c000efa04bc3eaeb", - "build": "4e183d9e7aeab170221e195546c1e928bc8b9b609fe69aff28873b54169af89a", + "build": "839a3b40e18fa9ce04f72b7340319ea6d0e5554fe70289751a2238dcb33b78b4", "package-linux": "21e55ae45b81a72b31525ffe4109bd3247661c931ee36a05c630f5b7622afea4", "sbom": "5ad3b64222cba6805099381d74434212b7762f86fdaef758857f127a36723c3a", "checksums": "fc4e16e505ca0b1615f92184a47701a1fee165c9c84faa89c18fd4c68c424244", @@ -135,7 +135,7 @@ "source": "d63a4365d5bee45367b514de586c88040c4f1e1ce35622dd5459bf8833a5b103", "gate": "b84a2d8debf8476fb38e7fcd255ebc7b17eb54e9ecf8549bad7a2ce7aa4ffe94", "gui": "d2e806ebd8d87b3d84ea52d7d7b8f171117bc344abfd76e0e77c7f46e1e2e740", - "build": "476b88832348bd78320dcb9c0ab899d878de6c818f5abf14952bb4c8cd86cdc3", + "build": "7369787aa1a9c99e7216d74cf2983323b2d6e4957332ed43546471a10c393315", "sbom": "150ce03d29e1847b65425b801d31640da6085adc3e475d0eee1c92c1c14cd41d", "checksums": "a49bf91fc21806960ce8e444c4e5ea615798c8f4f518f94c1e7765113e808a63", "attest": "1afb5eaa52877a128c776660566c1e076615468790fcaeecf4b3cbaa61c4ea16", @@ -240,12 +240,14 @@ REVIEWED_SHELLS = { "release": [ ("build", "Build (Windows)", "pwsh"), + ("build", "Build MXC Windows executor", "pwsh"), ("build", "Install Rust toolchain (Windows)", "pwsh"), ("build", "Package (Windows)", "pwsh"), ("build", "Verify Windows release archive", "pwsh"), ], "nightly": [ ("build", "Build (Windows)", "pwsh"), + ("build", "Build MXC Windows executor", "pwsh"), ("build", "Install Rust toolchain (Windows)", "pwsh"), ("build", "Package (Windows)", "pwsh"), ("build", "Verify Windows nightly archive", "pwsh"), @@ -257,9 +259,15 @@ ("test-windows", "Agent policy validation", "pwsh"), ("test-windows", "Agent safety tests", "pwsh"), ("test-windows", "Assemble and install Windows release archive", "pwsh"), + ("test-windows", "Build pinned MXC Windows executor", "pwsh"), ("test-windows", "PowerShell installer bounded output tests", "pwsh"), ("test-windows", "Reject invalid Windows installer checksums", "pwsh"), ("test-windows", "Test agent install (PowerShell)", "pwsh"), + ( + "test-windows", + "Windows MXC ProcessContainer smoke and security tests", + "pwsh", + ), ], "security": [], } @@ -892,6 +900,28 @@ def verify_common_release_build(workflow: dict[str, Any], *, nightly: bool) -> N 'cp "$mxc_dir/src/target/release/lxc-exec" "target/$TARGET/release/lxc-exec"', "MXC build step", ) + windows_mxc_step = require_step( + build, + "Build MXC Windows executor", + condition="runner.os == 'Windows'", + ) + expected_windows_mxc_step = { + "name": "Build MXC Windows executor", + "if": "runner.os == 'Windows'", + "shell": "pwsh", + "run": """$mxcDir = Join-Path $env:RUNNER_TEMP "mxc" +git clone --filter=blob:none $env:MXC_REPOSITORY $mxcDir +git -C $mxcDir checkout $env:MXC_REF +Get-ChildItem (Join-Path $env:GITHUB_WORKSPACE "third_party\\mxc\\patches\\*.patch") | + Sort-Object Name | ForEach-Object { git -C $mxcDir apply --whitespace=error $_.FullName } +Push-Location (Join-Path $mxcDir "src") +cargo build --release -p wxc --no-default-features --locked --target ${{ matrix.target }} +Pop-Location +Copy-Item (Join-Path $mxcDir "src\\target\\${{ matrix.target }}\\release\\wxc-exec.exe") target\\${{ matrix.target }}\\release\\wxc-exec.exe +""", + } + if windows_mxc_step != expected_windows_mxc_step: + raise WorkflowError("MXC Windows build step is not exact") package_step = require_step( build, "Package (Unix)", condition="runner.os != 'Windows'" ) @@ -932,6 +962,10 @@ def verify_common_release_build(workflow: dict[str, Any], *, nightly: bool) -> N "python scripts/verify_release_archive.py " '"dist/axis-$env:PLATFORM.zip" "axis-$env:PLATFORM" ' "--require axis.exe --require axisd.exe " + "--require wxc-exec.exe " + "--require axis-wfp-broker.exe " + "--require axis-ssh-proxy.exe " + "--require install_windows_wfp_broker.ps1 " "--require REPRODUCIBILITY.json" ), }, @@ -945,6 +979,14 @@ def verify_common_release_build(workflow: dict[str, Any], *, nightly: bool) -> N "--require", "axisd.exe", "--require", + "wxc-exec.exe", + "--require", + "axis-wfp-broker.exe", + "--require", + "axis-ssh-proxy.exe", + "--require", + "install_windows_wfp_broker.ps1", + "--require", "REPRODUCIBILITY.json", ], f"{windows_name} step", @@ -1658,6 +1700,48 @@ def verify_ci_workflow(workflow: dict[str, Any]) -> None: 'cp "$mxc_dir/src/target/release/lxc-exec" target/release/lxc-exec', "CI MXC build step", ) + windows = require_job(workflow, "test-windows") + windows_mxc_step = require_step(windows, "Build pinned MXC Windows executor") + if windows_mxc_step != { + "name": "Build pinned MXC Windows executor", + "shell": "pwsh", + "run": """$mxcDir = Join-Path $env:RUNNER_TEMP "mxc" +git clone --filter=blob:none $env:MXC_REPOSITORY $mxcDir +git -C $mxcDir checkout $env:MXC_REF +Get-ChildItem (Join-Path $env:GITHUB_WORKSPACE "third_party\\mxc\\patches\\*.patch") | + Sort-Object Name | ForEach-Object { git -C $mxcDir apply --whitespace=error $_.FullName } +Push-Location (Join-Path $mxcDir "src") +cargo build --release -p wxc --no-default-features --locked +Pop-Location +Copy-Item (Join-Path $mxcDir "src\\target\\release\\wxc-exec.exe") target\\release\\wxc-exec.exe +""", + }: + raise WorkflowError("CI MXC Windows build step is not exact") + require_exact_command_step( + windows, + { + "name": "Windows MXC ProcessContainer smoke and security tests", + "shell": "pwsh", + "env": { + "AXIS_RUN_MXC_BASECONTAINER_E2E": "1", + "AXIS_SKIP_UNAVAILABLE_MXC_BASECONTAINER_E2E": "1", + "AXIS_TEST_MXC_EXECUTOR": "${{ github.workspace }}\\target\\release\\wxc-exec.exe", + }, + "run": ( + "pwsh -NoProfile -File e2e/windows/test_mxc_processcontainer.ps1 " + "-AxisBin ./target/release/axis.exe" + ), + }, + [ + "pwsh", + "-NoProfile", + "-File", + "e2e/windows/test_mxc_processcontainer.ps1", + "-AxisBin", + "./target/release/axis.exe", + ], + "CI Windows MXC smoke step", + ) package_step = require_step(linux, "Build and inspect Linux native packages") require_command_line( package_step, diff --git a/third_party/mxc/README.md b/third_party/mxc/README.md new file mode 100644 index 0000000..48203fa --- /dev/null +++ b/third_party/mxc/README.md @@ -0,0 +1,37 @@ +# Pinned MXC patches + +AXIS builds the Windows `wxc-exec.exe` artifact from MXC commit +`1736b48398c3fe4d1315b2311c0951cc893eb3ae` and applies the patches in +`patches/` before compilation. + +`0001-wxc-processcontainer-resource-limits.patch` adds process-count, +aggregate-memory, and CPU-rate fields to MXC's ProcessContainer schema and Job +Object implementation. Remove the patch when upstream MXC exposes equivalent +resource fields and enforcement. + +`0002-wxc-job-list-resource-assignment.patch` makes process count, +aggregate memory, and CPU-rate limits child-only. BaseContainer explicitly +breaks away from AXIS's outer lifecycle Job while suspended, then MXC assigns +it to the inner resource Job before resuming it. Silent breakaway stays +disabled, and closing either executor boundary still kills the sandbox tree. +Remove this patch when upstream MXC exposes equivalent ProcessContainer +resource fields and atomic BaseContainer Job assignment. + +`0003-axis-wfp-strict-proxy.patch` adds the narrow AXIS WFP lease contract +to MXC's BaseContainer runner. It creates the child suspended, sends its PID and +the exact proxy endpoint to the installed broker, validates the broker's +SID/filter response, and resumes only after the lease is active. It also injects +sanitized proxy environment variables. A pipe watchdog terminates the child if +the broker disappears. Remove this patch when upstream MXC provides an +equivalent pre-resume broker hook and fail-closed lease lifecycle. + +AXIS uses MXC BaseContainer only. The generated configuration leaves +`fallback.allowDaclMutation=false`, so an unavailable BaseContainer fails closed +instead of selecting an AppContainer/DACL tier. + +The supported +`Experimental_CreateProcessInSandbox` API rejects +`PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE` with `ERROR_INVALID_HANDLE` on Windows +build 26300. AXIS does not downgrade to AppContainer/DACL solely to provide +ConPTY. Enable the BaseContainer interactive path only after a runtime +capability probe proves the OS accepts pseudoconsole startup handles. diff --git a/third_party/mxc/patches/0001-wxc-processcontainer-resource-limits.patch b/third_party/mxc/patches/0001-wxc-processcontainer-resource-limits.patch new file mode 100644 index 0000000..3371d5c --- /dev/null +++ b/third_party/mxc/patches/0001-wxc-processcontainer-resource-limits.patch @@ -0,0 +1,334 @@ +diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs +--- a/src/backends/appcontainer/common/src/base_container_runner.rs ++++ b/src/backends/appcontainer/common/src/base_container_runner.rs +@@ -25,11 +25,12 @@ use windows::Win32::System::LibraryLoader::{ + GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32, + }; + use windows::Win32::System::Threading::{ +- GetExitCodeProcess, TerminateProcess, WaitForSingleObject, PROCESS_INFORMATION, +- STARTF_USESTDHANDLES, STARTUPINFOW, ++ GetExitCodeProcess, ResumeThread, TerminateProcess, WaitForSingleObject, CREATE_SUSPENDED, ++ PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOW, + }; + use windows_core::PCWSTR; + ++use crate::job_object::UiJobObject; + use crate::launch_diagnostics::{ + diagnose_create_process_failure, diagnose_environment_not_supported, diagnose_process_exit, + is_environment_not_supported, +@@ -420,6 +421,11 @@ impl ScriptRunner for BaseContainerRunner { + wxc_common::error::HOST_LISTS_NOT_SUPPORTED_MSG, + )); + } ++ if request.policy.resources.cpu_rate_percent > 100 { ++ return Err(ScriptResponse::error( ++ "processContainer.resources.cpuRatePercent must be between 0 and 100", ++ )); ++ } + Self::is_base_container_api_present().map_err(|e| { + let hint = if !request.experimental_enabled { + format!( +@@ -677,12 +683,6 @@ impl ScriptRunner for BaseContainerRunner { + .as_ref() + .map(|b| b.as_ptr() as *const c_void) + .unwrap_or(ptr::null()); +- let creation_flags = if env_block.is_some() { +- CREATE_UNICODE_ENVIRONMENT.0 +- } else { +- 0 +- }; +- + let _ = writeln!(logger, "launching: {}", request.script_code); + let _ = writeln!(logger, "identity: {identity}"); + +@@ -710,6 +710,47 @@ impl ScriptRunner for BaseContainerRunner { + }; + } + ++ let resources_requested = request.policy.resources.max_processes > 0 ++ || request.policy.resources.max_memory_mb > 0 ++ || request.policy.resources.cpu_rate_percent > 0; ++ let resource_job = if resources_requested { ++ let job = match UiJobObject::new().and_then(|job| { ++ job.set_resource_limits(&request.policy.resources)?; ++ Ok(job) ++ }) { ++ Ok(job) => job, ++ Err(err) => { ++ return ScriptResponse::error(&format!( ++ "failed to create ProcessContainer resource Job Object: {err}" ++ )); ++ } ++ }; ++ let _ = writeln!( ++ logger, ++ "resource job: maxProcesses={}, maxMemoryMb={}, cpuRatePercent={}", ++ request.policy.resources.max_processes, ++ request.policy.resources.max_memory_mb, ++ request.policy.resources.cpu_rate_percent ++ ); ++ Some(job) ++ } else { ++ None ++ }; ++ ++ // A child that requires Job limits is created suspended so no ++ // untrusted instruction can execute before assignment succeeds. ++ let base_creation_flags = if resource_job.is_some() { ++ CREATE_SUSPENDED.0 ++ } else { ++ 0 ++ }; ++ let creation_flags = base_creation_flags ++ | if env_block.is_some() { ++ CREATE_UNICODE_ENVIRONMENT.0 ++ } else { ++ 0 ++ }; ++ + // 4. Call Experimental_CreateProcessInSandbox. + // If the OS returns ERROR_NOT_SUPPORTED (0x32) and we passed a non-null + // environment block, this is a downlevel build that doesn't support the +@@ -776,7 +817,7 @@ impl ScriptRunner for BaseContainerRunner { + + // Retry without the environment block. + current_env_ptr = ptr::null(); +- current_creation_flags = 0; ++ current_creation_flags = base_creation_flags; + continue; + } + +@@ -834,6 +875,34 @@ impl ScriptRunner for BaseContainerRunner { + }; + } + ++ if let Some(job) = resource_job.as_ref() { ++ if let Err(err) = job.assign_process(pi.hProcess) { ++ unsafe { ++ let _ = TerminateProcess(pi.hProcess, u32::MAX); ++ let _ = WaitForSingleObject(pi.hProcess, 5000); ++ let _ = CloseHandle(pi.hProcess); ++ let _ = CloseHandle(pi.hThread); ++ } ++ return ScriptResponse::error(&format!( ++ "failed to assign suspended ProcessContainer child to resource Job Object: {err}" ++ )); ++ } ++ ++ let resume_result = unsafe { ResumeThread(pi.hThread) }; ++ if resume_result == u32::MAX { ++ let err = unsafe { GetLastError() }; ++ unsafe { ++ let _ = TerminateProcess(pi.hProcess, u32::MAX); ++ let _ = WaitForSingleObject(pi.hProcess, 5000); ++ let _ = CloseHandle(pi.hProcess); ++ let _ = CloseHandle(pi.hThread); ++ } ++ return ScriptResponse::error(&format!( ++ "failed to resume resource-limited ProcessContainer child: {err:?}" ++ )); ++ } ++ } ++ + let _ = writeln!(logger, "process created (PID: {})", pi.dwProcessId); + + let _ = writeln!(logger, "{EMOJI_SECTION} SECTION: Wait for exit"); +diff --git a/src/backends/appcontainer/common/src/job_object.rs b/src/backends/appcontainer/common/src/job_object.rs +--- a/src/backends/appcontainer/common/src/job_object.rs ++++ b/src/backends/appcontainer/common/src/job_object.rs +@@ -19,7 +19,11 @@ use std::sync::OnceLock; + use windows::Win32::Foundation::{CloseHandle, HANDLE}; + use windows::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectBasicUIRestrictions, +- SetInformationJobObject, JOBOBJECT_BASIC_UI_RESTRICTIONS, JOB_OBJECT_UILIMIT, ++ JobObjectCpuRateControlInformation, JobObjectExtendedLimitInformation, SetInformationJobObject, ++ JOBOBJECT_BASIC_UI_RESTRICTIONS, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION, ++ JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_CPU_RATE_CONTROL_ENABLE, ++ JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, ++ JOB_OBJECT_LIMIT_JOB_MEMORY, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_UILIMIT, + JOB_OBJECT_UILIMIT_DESKTOP, JOB_OBJECT_UILIMIT_DISPLAYSETTINGS, JOB_OBJECT_UILIMIT_EXITWINDOWS, + JOB_OBJECT_UILIMIT_GLOBALATOMS, JOB_OBJECT_UILIMIT_HANDLES, JOB_OBJECT_UILIMIT_READCLIPBOARD, + JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS, JOB_OBJECT_UILIMIT_WRITECLIPBOARD, +@@ -28,6 +32,7 @@ use windows::Win32::System::SystemServices::JOB_OBJECT_UILIMIT_IME; + use windows_core::PCWSTR; + + use wxc_common::error::WxcError; ++use wxc_common::models::ProcessResourceLimits; + use wxc_common::ui_policy::EffectiveUiRestrictions; + + /// Helper for loading `RtlGetVersion` from `ntdll.dll` to get the true +@@ -265,6 +270,65 @@ impl UiJobObject { + .map_err(|e| WxcError::Process(format!("SetInformationJobObject(UI): {e}"))) + } + ++ /// Apply limits to this job's complete process tree. The job is configured ++ /// kill-on-close so every descendant is terminated if the executor exits. ++ pub fn set_resource_limits(&self, limits: &ProcessResourceLimits) -> Result<(), WxcError> { ++ if limits.cpu_rate_percent > 100 { ++ return Err(WxcError::Process(format!( ++ "cpuRatePercent must be between 0 and 100, got {}", ++ limits.cpu_rate_percent ++ ))); ++ } ++ ++ let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); ++ info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; ++ if limits.max_processes > 0 { ++ info.BasicLimitInformation.ActiveProcessLimit = limits.max_processes; ++ info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS; ++ } ++ if limits.max_memory_mb > 0 { ++ let bytes = limits ++ .max_memory_mb ++ .checked_mul(1024 * 1024) ++ .and_then(|bytes| usize::try_from(bytes).ok()) ++ .ok_or_else(|| { ++ WxcError::Process(format!( ++ "maxMemoryMb {} exceeds the host Job Object limit", ++ limits.max_memory_mb ++ )) ++ })?; ++ info.JobMemoryLimit = bytes; ++ info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_JOB_MEMORY; ++ } ++ ++ unsafe { ++ SetInformationJobObject( ++ self.handle, ++ JobObjectExtendedLimitInformation, ++ &info as *const _ as *const c_void, ++ size_of::() as u32, ++ ) ++ } ++ .map_err(|e| WxcError::Process(format!("SetInformationJobObject(resources): {e}")))?; ++ ++ if limits.cpu_rate_percent > 0 && limits.cpu_rate_percent < 100 { ++ let mut cpu = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION::default(); ++ cpu.ControlFlags = ++ JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP; ++ cpu.Anonymous.CpuRate = limits.cpu_rate_percent * 100; ++ unsafe { ++ SetInformationJobObject( ++ self.handle, ++ JobObjectCpuRateControlInformation, ++ &cpu as *const _ as *const c_void, ++ size_of::() as u32, ++ ) ++ } ++ .map_err(|e| WxcError::Process(format!("SetInformationJobObject(CPU): {e}")))?; ++ } ++ Ok(()) ++ } ++ + /// Assigns the given process handle to this job. The process and any + /// future descendants will inherit the job's UI restrictions. + pub fn assign_process(&self, process_handle: HANDLE) -> Result<(), WxcError> { +@@ -306,6 +370,29 @@ mod tests { + drop(job); + } + ++ #[test] ++ fn resource_limits_use_aggregate_job_accounting() { ++ let job = UiJobObject::new().expect("create"); ++ job.set_resource_limits(&ProcessResourceLimits { ++ max_processes: 4, ++ max_memory_mb: 256, ++ cpu_rate_percent: 50, ++ }) ++ .expect("set resources"); ++ } ++ ++ #[test] ++ fn resource_limits_reject_invalid_cpu_rate() { ++ let job = UiJobObject::new().expect("create"); ++ let err = job ++ .set_resource_limits(&ProcessResourceLimits { ++ cpu_rate_percent: 101, ++ ..Default::default() ++ }) ++ .unwrap_err(); ++ assert!(err.to_string().contains("between 0 and 100")); ++ } ++ + #[test] + fn encoder_known_bit_positions() { + // Sanity-check that the encoder produces the documented winnt.h +diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs +--- a/src/core/wxc_common/src/config_parser.rs ++++ b/src/core/wxc_common/src/config_parser.rs +@@ -45,6 +45,18 @@ struct RawProcessContainer { + learning_mode: Option, + capabilities: Option>, + ui: Option, ++ resources: Option, ++} ++ ++#[derive(Deserialize, Default)] ++#[serde(default)] ++struct RawProcessResources { ++ #[serde(rename = "maxProcesses")] ++ max_processes: Option, ++ #[serde(rename = "maxMemoryMb")] ++ max_memory_mb: Option, ++ #[serde(rename = "cpuRatePercent")] ++ cpu_rate_percent: Option, + } + + #[derive(Deserialize, Default)] +@@ -951,6 +963,12 @@ fn convert_raw_config_inner( + raw_ui.system_settings.unwrap_or_else(|| "none".to_string()); + policy.base_process_ui.ime = raw_ui.ime.unwrap_or(false); + } ++ ++ if let Some(resources) = ac.resources { ++ policy.resources.max_processes = resources.max_processes.unwrap_or(0); ++ policy.resources.max_memory_mb = resources.max_memory_mb.unwrap_or(0); ++ policy.resources.cpu_rate_percent = resources.cpu_rate_percent.unwrap_or(0); ++ } + } + + // Filesystem section +@@ -1625,7 +1643,12 @@ mod tests { + }, + "processContainer": { + "leastPrivilege": true, +- "capabilities": ["internetClient"] ++ "capabilities": ["internetClient"], ++ "resources": { ++ "maxProcesses": 12, ++ "maxMemoryMb": 768, ++ "cpuRatePercent": 35 ++ } + }, + "filesystem": { + "readwritePaths": ["C:\\rw"], +@@ -1655,6 +1678,9 @@ mod tests { + assert_eq!(req.policy.readwrite_paths, vec!["C:\\rw"]); + assert_eq!(req.policy.readonly_paths, vec!["C:\\ro"]); + assert_eq!(req.policy.denied_paths, vec!["C:\\denied"]); ++ assert_eq!(req.policy.resources.max_processes, 12); ++ assert_eq!(req.policy.resources.max_memory_mb, 768); ++ assert_eq!(req.policy.resources.cpu_rate_percent, 35); + assert_eq!(req.policy.default_network_policy, NetworkPolicy::Block); + assert_eq!( + req.policy.network_enforcement_mode, +diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs +--- a/src/core/wxc_common/src/models.rs ++++ b/src/core/wxc_common/src/models.rs +@@ -447,6 +447,16 @@ pub struct ContainerPolicy { + pub ui: UiPolicy, + /// BaseProcessContainer-specific UI config (Windows only, from processContainer.ui). + pub base_process_ui: BaseProcessUiConfig, ++ /// Resource limits for the ProcessContainer child and all descendants. ++ pub resources: ProcessResourceLimits, ++} ++ ++#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)] ++#[serde(default)] ++pub struct ProcessResourceLimits { ++ pub max_processes: u32, ++ pub max_memory_mb: u64, ++ pub cpu_rate_percent: u32, + } + + /// Port mapping for host↔container port forwarding. diff --git a/third_party/mxc/patches/0002-wxc-job-list-resource-assignment.patch b/third_party/mxc/patches/0002-wxc-job-list-resource-assignment.patch new file mode 100644 index 0000000..ed436bb --- /dev/null +++ b/third_party/mxc/patches/0002-wxc-job-list-resource-assignment.patch @@ -0,0 +1,64 @@ +diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs +--- a/src/backends/appcontainer/common/src/base_container_runner.rs ++++ b/src/backends/appcontainer/common/src/base_container_runner.rs +@@ -25,8 +25,9 @@ use windows::Win32::System::LibraryLoader::{ + GetProcAddress, LoadLibraryExW, LOAD_LIBRARY_SEARCH_SYSTEM32, + }; + use windows::Win32::System::Threading::{ +- GetExitCodeProcess, ResumeThread, TerminateProcess, WaitForSingleObject, CREATE_SUSPENDED, +- PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOW, ++ GetExitCodeProcess, ResumeThread, TerminateProcess, WaitForSingleObject, ++ CREATE_BREAKAWAY_FROM_JOB, CREATE_SUSPENDED, PROCESS_INFORMATION, STARTF_USESTDHANDLES, ++ STARTUPINFOW, + }; + use windows_core::PCWSTR; + +@@ -742,7 +743,7 @@ impl ScriptRunner for BaseContainerRunner { + // A child that requires Job limits is created suspended so no + // untrusted instruction can execute before assignment succeeds. + let base_creation_flags = if resource_job.is_some() { +- CREATE_SUSPENDED.0 ++ CREATE_SUSPENDED.0 | CREATE_BREAKAWAY_FROM_JOB.0 + } else { + 0 + }; +@@ -827,7 +828,7 @@ impl ScriptRunner for BaseContainerRunner { + return ScriptResponse { + exit_code: -1, + error_message: diag.message.clone(), +- standard_err: diag.message, ++ standard_err: format!("{}\n{}", diag.message, extended_error), + extended_error, + failure_phase: FailurePhase::LaunchFailed, + ..Default::default() +diff --git a/src/backends/appcontainer/common/src/job_object.rs b/src/backends/appcontainer/common/src/job_object.rs +--- a/src/backends/appcontainer/common/src/job_object.rs ++++ b/src/backends/appcontainer/common/src/job_object.rs +@@ -29,6 +29,7 @@ use windows::Win32::System::JobObjects::{ + JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS, JOB_OBJECT_UILIMIT_WRITECLIPBOARD, + }; + use windows::Win32::System::SystemServices::JOB_OBJECT_UILIMIT_IME; ++use windows::Win32::System::Threading::{GetActiveProcessorCount, ALL_PROCESSOR_GROUPS}; + use windows_core::PCWSTR; + + use wxc_common::error::WxcError; +@@ -311,11 +312,17 @@ impl UiJobObject { + } + .map_err(|e| WxcError::Process(format!("SetInformationJobObject(resources): {e}")))?; + +- if limits.cpu_rate_percent > 0 && limits.cpu_rate_percent < 100 { ++ if limits.cpu_rate_percent > 0 { + let mut cpu = JOBOBJECT_CPU_RATE_CONTROL_INFORMATION::default(); + cpu.ControlFlags = + JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP; +- cpu.Anonymous.CpuRate = limits.cpu_rate_percent * 100; ++ // Job CPU rate is a fraction of all active processor capacity, ++ // while AXIS defines 100% as one Linux-cgroup CPU. Divide by the ++ // machine's logical processor count to preserve that meaning. ++ let processors = unsafe { GetActiveProcessorCount(ALL_PROCESSOR_GROUPS) }.max(1); ++ cpu.Anonymous.CpuRate = (limits.cpu_rate_percent * 100) ++ .div_ceil(processors) ++ .clamp(1, 10_000); + unsafe { + SetInformationJobObject( + self.handle, diff --git a/third_party/mxc/patches/0003-axis-wfp-strict-proxy.patch b/third_party/mxc/patches/0003-axis-wfp-strict-proxy.patch new file mode 100644 index 0000000..78834ee --- /dev/null +++ b/third_party/mxc/patches/0003-axis-wfp-strict-proxy.patch @@ -0,0 +1,437 @@ +diff --git a/src/backends/appcontainer/common/src/axis_wfp_client.rs b/src/backends/appcontainer/common/src/axis_wfp_client.rs +new file mode 100644 +index 0000000..8a976cb +--- /dev/null ++++ b/src/backends/appcontainer/common/src/axis_wfp_client.rs +@@ -0,0 +1,230 @@ ++// Copyright (c) Microsoft Corporation. ++// Licensed under the MIT License. ++ ++//! Client for AXIS's privileged strict-proxy WFP broker. ++//! ++//! The returned lease owns the named-pipe connection. Dropping it is the ++//! explicit release signal; a crash closes the handle and therefore removes ++//! the broker's dynamic WFP engine session as well. ++ ++use serde::{Deserialize, Serialize}; ++use std::fs::{File, OpenOptions}; ++use std::io::{Read, Write}; ++use std::net::IpAddr; ++use std::os::windows::io::AsRawHandle; ++use std::sync::atomic::{AtomicBool, Ordering}; ++use std::sync::Arc; ++use std::thread::JoinHandle; ++use windows::Win32::Foundation::{CloseHandle, HANDLE}; ++use windows::Win32::System::Pipes::PeekNamedPipe; ++use windows::Win32::System::Threading::{ ++ OpenProcess, TerminateProcess, PROCESS_TERMINATE, ++}; ++use wxc_common::error::WxcError; ++use wxc_common::models::ExecutionRequest; ++ ++const PROTOCOL_VERSION: u32 = 1; ++const MAX_MESSAGE_BYTES: usize = 64 * 1024; ++ ++#[derive(Debug, Serialize)] ++#[serde(rename_all = "camelCase")] ++struct LeaseRequest<'a> { ++ version: u32, ++ lease_id: &'a str, ++ child_pid: u32, ++ proxy_address: IpAddr, ++ proxy_port: u16, ++} ++ ++#[derive(Debug, Deserialize)] ++#[serde(rename_all = "camelCase", deny_unknown_fields)] ++struct LeaseResponse { ++ version: u32, ++ lease_id: String, ++ accepted: bool, ++ app_container_sid: Option, ++ filter_count: u32, ++ error: Option, ++} ++ ++pub struct AxisWfpLease { ++ pipe: File, ++ completed: Arc, ++ watchdog: Option>, ++ pub app_container_sid: String, ++ pub filter_count: u32, ++} ++ ++impl Drop for AxisWfpLease { ++ fn drop(&mut self) { ++ self.completed.store(true, Ordering::Release); ++ // Any post-acceptance byte is an explicit release signal. The broker ++ // closes its side after removing the lease, which wakes the watchdog. ++ let _ = self.pipe.write_all(&[0]); ++ let _ = self.pipe.flush(); ++ if let Some(watchdog) = self.watchdog.take() { ++ let _ = watchdog.join(); ++ } ++ } ++} ++ ++pub fn install_if_requested( ++ request: &ExecutionRequest, ++ child_pid: u32, ++) -> Result, WxcError> { ++ let Some(config) = request.policy.axis_wfp.as_ref() else { ++ return Ok(None); ++ }; ++ let proxy = request ++ .policy ++ .network_proxy ++ .address ++ .as_ref() ++ .ok_or_else(|| WxcError::Firewall("axisWfp requires network.proxy".into()))?; ++ let proxy_address = proxy.host().parse::().map_err(|_| { ++ WxcError::Firewall(format!( ++ "axisWfp requires a literal proxy IP address, got {:?}", ++ proxy.host() ++ )) ++ })?; ++ if proxy.port() == 0 { ++ return Err(WxcError::Firewall( ++ "axisWfp requires a nonzero proxy port".into(), ++ )); ++ } ++ ++ let mut pipe = OpenOptions::new() ++ .read(true) ++ .write(true) ++ .open(&config.pipe_name) ++ .map_err(|e| { ++ WxcError::Firewall(format!( ++ "open AXIS WFP broker pipe {:?}: {e}", ++ config.pipe_name ++ )) ++ })?; ++ let payload = serde_json::to_vec(&LeaseRequest { ++ version: PROTOCOL_VERSION, ++ lease_id: &config.lease_id, ++ child_pid, ++ proxy_address, ++ proxy_port: proxy.port(), ++ }) ++ .map_err(|e| WxcError::Firewall(format!("serialize AXIS WFP lease: {e}")))?; ++ write_frame(&mut pipe, &payload)?; ++ let response: LeaseResponse = serde_json::from_slice(&read_frame(&mut pipe)?) ++ .map_err(|e| WxcError::Firewall(format!("decode AXIS WFP response: {e}")))?; ++ if response.version != PROTOCOL_VERSION || response.lease_id != config.lease_id { ++ return Err(WxcError::Firewall( ++ "AXIS WFP broker response did not match the requested lease".into(), ++ )); ++ } ++ if !response.accepted { ++ return Err(WxcError::Firewall(format!( ++ "AXIS WFP broker rejected strict-proxy lease: {}", ++ response.error.unwrap_or_else(|| "unspecified error".into()) ++ ))); ++ } ++ if response.filter_count < 3 { ++ return Err(WxcError::Firewall(format!( ++ "AXIS WFP broker installed only {} filters; expected an allow plus IPv4/IPv6 blocks", ++ response.filter_count ++ ))); ++ } ++ let app_container_sid = response.app_container_sid.ok_or_else(|| { ++ WxcError::Firewall("AXIS WFP broker omitted the verified AppContainer SID".into()) ++ })?; ++ let completed = Arc::new(AtomicBool::new(false)); ++ let watchdog_pipe = pipe.as_raw_handle() as usize; ++ let watchdog_completed = completed.clone(); ++ let watchdog = std::thread::spawn(move || { ++ let disconnected = loop { ++ if watchdog_completed.load(Ordering::Acquire) { ++ break false; ++ } ++ if unsafe { ++ PeekNamedPipe( ++ HANDLE(watchdog_pipe as *mut core::ffi::c_void), ++ None, ++ 0, ++ None, ++ None, ++ None, ++ ) ++ } ++ .is_err() ++ { ++ break true; ++ } ++ std::thread::sleep(std::time::Duration::from_millis(50)); ++ }; ++ if disconnected && !watchdog_completed.load(Ordering::Acquire) { ++ // Broker failure removes a dynamic permit. Kill the child so a ++ // strict policy cannot continue without its control plane. ++ if let Ok(process) = unsafe { OpenProcess(PROCESS_TERMINATE, false, child_pid) } { ++ unsafe { ++ let _ = TerminateProcess(process, u32::MAX); ++ let _ = CloseHandle(process); ++ } ++ } ++ } ++ }); ++ Ok(Some(AxisWfpLease { ++ pipe, ++ completed, ++ watchdog: Some(watchdog), ++ app_container_sid, ++ filter_count: response.filter_count, ++ })) ++} ++ ++fn read_frame(stream: &mut File) -> Result, WxcError> { ++ let mut length = [0u8; 4]; ++ stream ++ .read_exact(&mut length) ++ .map_err(|e| WxcError::Firewall(format!("read AXIS WFP response length: {e}")))?; ++ let length = u32::from_le_bytes(length) as usize; ++ if length == 0 || length > MAX_MESSAGE_BYTES { ++ return Err(WxcError::Firewall(format!( ++ "invalid AXIS WFP response length {length}" ++ ))); ++ } ++ let mut payload = vec![0; length]; ++ stream ++ .read_exact(&mut payload) ++ .map_err(|e| WxcError::Firewall(format!("read AXIS WFP response payload: {e}")))?; ++ Ok(payload) ++} ++ ++fn write_frame(stream: &mut File, payload: &[u8]) -> Result<(), WxcError> { ++ if payload.is_empty() || payload.len() > MAX_MESSAGE_BYTES { ++ return Err(WxcError::Firewall(format!( ++ "invalid AXIS WFP request length {}", ++ payload.len() ++ ))); ++ } ++ stream ++ .write_all(&(payload.len() as u32).to_le_bytes()) ++ .and_then(|_| stream.write_all(payload)) ++ .and_then(|_| stream.flush()) ++ .map_err(|e| WxcError::Firewall(format!("write AXIS WFP request: {e}"))) ++} ++ ++#[cfg(test)] ++mod tests { ++ use super::*; ++ ++ #[test] ++ fn response_rejects_unknown_fields() { ++ let json = br#"{ ++ "version":1, ++ "leaseId":"00000000-0000-0000-0000-000000000001", ++ "accepted":false, ++ "appContainerSid":null, ++ "filterCount":0, ++ "error":"no", ++ "arbitraryFilter":true ++ }"#; ++ assert!(serde_json::from_slice::(json).is_err()); ++ } ++} +diff --git a/src/backends/appcontainer/common/src/base_container_runner.rs b/src/backends/appcontainer/common/src/base_container_runner.rs +index b37e5cc..9270bd8 100644 +--- a/src/backends/appcontainer/common/src/base_container_runner.rs ++++ b/src/backends/appcontainer/common/src/base_container_runner.rs +@@ -75,6 +75,28 @@ fn encode_env_block(env_vars: &[String]) -> Vec { + block + } + ++/// Remove caller-controlled proxy variables and inject only the AXIS endpoint ++/// represented by the same strict WFP lease request. ++fn strict_proxy_environment(request: &ExecutionRequest) -> Vec { ++ const PROXY_KEYS: &[&str] = &["HTTP_PROXY", "HTTPS_PROXY", "ALL_PROXY", "NO_PROXY"]; ++ let mut env = request ++ .env ++ .iter() ++ .filter(|entry| { ++ entry.split_once('=').is_none_or(|(key, _)| { ++ !PROXY_KEYS.iter().any(|name| key.eq_ignore_ascii_case(name)) ++ }) ++ }) ++ .cloned() ++ .collect::>(); ++ if let Some(proxy) = request.policy.network_proxy.address.as_ref() { ++ let url = proxy.to_url(); ++ env.push(format!("HTTP_PROXY={url}")); ++ env.push(format!("HTTPS_PROXY={url}")); ++ } ++ env ++} ++ + /// Function pointer type matching `Experimental_CreateProcessInSandbox` from processmodel.dll. + type PfnCreateProcessInSandbox = unsafe extern "system" fn( + application_name: *const u16, +@@ -651,8 +673,6 @@ impl ScriptRunner for BaseContainerRunner { + } + } + +- // STARTUPINFOW -- in pipe mode, pass parent handles via STARTF_USESTDHANDLES +- // so child output streams directly to the SDK caller. + let si = STARTUPINFOW { + cb: std::mem::size_of::() as u32, + dwFlags: if pipe_mode { +@@ -665,6 +685,7 @@ impl ScriptRunner for BaseContainerRunner { + hStdError: h_stderr, + ..unsafe { std::mem::zeroed() } + }; ++ + #[allow(unused_assignments)] + let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + +@@ -672,12 +693,13 @@ impl ScriptRunner for BaseContainerRunner { + // If the caller specified explicit env vars, use only those. + // Otherwise, pass NULL to let the OS provide the default environment + // for the sandbox (CreateProcessInSandbox handles this internally). +- let env_block: Option> = if request.env.is_empty() { ++ let effective_env = strict_proxy_environment(&request); ++ let env_block: Option> = if effective_env.is_empty() { + // TODO: consider calling CreateEnvironmentBlock(NULL, FALSE) here + // for a cleansed default env if the OS API doesn't do it for us. + None + } else { +- Some(encode_env_block(&request.env)) ++ Some(encode_env_block(&effective_env)) + }; + + let env_ptr = env_block +@@ -738,13 +760,17 @@ impl ScriptRunner for BaseContainerRunner { + None + }; + +- // A child that requires Job limits is created suspended so no +- // untrusted instruction can execute before assignment succeeds. +- let base_creation_flags = if resource_job.is_some() { +- CREATE_SUSPENDED.0 | CREATE_BREAKAWAY_FROM_JOB.0 +- } else { +- 0 +- }; ++ // A child that requires Job or WFP limits is created suspended so no ++ // untrusted instruction can execute before every requested boundary is ++ // installed. Explicit Job breakaway is needed only for the inner Job. ++ let strict_wfp_requested = request.policy.axis_wfp.is_some(); ++ let mut base_creation_flags = 0; ++ if resource_job.is_some() || strict_wfp_requested { ++ base_creation_flags |= CREATE_SUSPENDED.0; ++ } ++ if resource_job.is_some() { ++ base_creation_flags |= CREATE_BREAKAWAY_FROM_JOB.0; ++ } + let creation_flags = base_creation_flags + | if env_block.is_some() { + CREATE_UNICODE_ENVIRONMENT.0 +@@ -888,7 +914,36 @@ impl ScriptRunner for BaseContainerRunner { + "failed to assign suspended ProcessContainer child to resource Job Object: {err}" + )); + } ++ } ++ ++ let _axis_wfp_lease = match crate::axis_wfp_client::install_if_requested( ++ &request, ++ pi.dwProcessId, ++ ) { ++ Ok(lease) => { ++ if let Some(lease) = lease.as_ref() { ++ let _ = writeln!( ++ logger, ++ "AXIS WFP strict-proxy lease installed for {} with {} filters", ++ lease.app_container_sid, lease.filter_count ++ ); ++ } ++ lease ++ } ++ Err(err) => { ++ unsafe { ++ let _ = TerminateProcess(pi.hProcess, u32::MAX); ++ let _ = WaitForSingleObject(pi.hProcess, 5000); ++ let _ = CloseHandle(pi.hProcess); ++ let _ = CloseHandle(pi.hThread); ++ } ++ return ScriptResponse::error(&format!( ++ "failed to install suspended ProcessContainer WFP lease: {err}" ++ )); ++ } ++ }; + ++ if resource_job.is_some() || strict_wfp_requested { + let resume_result = unsafe { ResumeThread(pi.hThread) }; + if resume_result == u32::MAX { + let err = unsafe { GetLastError() }; +@@ -899,7 +954,7 @@ impl ScriptRunner for BaseContainerRunner { + let _ = CloseHandle(pi.hThread); + } + return ScriptResponse::error(&format!( +- "failed to resume resource-limited ProcessContainer child: {err:?}" ++ "failed to resume policy-constrained ProcessContainer child: {err:?}" + )); + } + } +diff --git a/src/backends/appcontainer/common/src/lib.rs b/src/backends/appcontainer/common/src/lib.rs +index 361eb67..88ca25a 100644 +--- a/src/backends/appcontainer/common/src/lib.rs ++++ b/src/backends/appcontainer/common/src/lib.rs +@@ -14,6 +14,8 @@ + #[cfg(target_os = "windows")] + pub mod appcontainer_runner; + #[cfg(target_os = "windows")] ++pub mod axis_wfp_client; ++#[cfg(target_os = "windows")] + pub mod base_container_runner; + #[cfg(target_os = "windows")] + pub mod dispatcher; +diff --git a/src/core/wxc_common/src/config_parser.rs b/src/core/wxc_common/src/config_parser.rs +index a17a49f..4053a34 100644 +--- a/src/core/wxc_common/src/config_parser.rs ++++ b/src/core/wxc_common/src/config_parser.rs +@@ -10,7 +10,7 @@ use crate::encoding::base64_decode; + use crate::error::WxcError; + use crate::logger::Logger; + use crate::models::{ +- ClipboardPolicy, ContainerPolicy, ContainmentBackend, ExecutionRequest, ExperimentalConfig, ++ AxisWfpConfig, ClipboardPolicy, ContainerPolicy, ContainmentBackend, ExecutionRequest, ExperimentalConfig, + IsolationSessionConfig, IsolationSessionUser, LifecycleConfig, LxcConfig, + NetworkEnforcementMode, NetworkPolicy, PortMapping, ProxyAddress, ProxyConfig, SeatbeltConfig, + TestFeatureConfig, UiPolicy, WindowsSandboxConfig, WslcConfig, +@@ -46,6 +46,8 @@ struct RawProcessContainer { + capabilities: Option>, + ui: Option, + resources: Option, ++ #[serde(rename = "axisWfp")] ++ axis_wfp: Option, + } + + #[derive(Deserialize, Default)] +@@ -969,6 +971,7 @@ fn convert_raw_config_inner( + policy.resources.max_memory_mb = resources.max_memory_mb.unwrap_or(0); + policy.resources.cpu_rate_percent = resources.cpu_rate_percent.unwrap_or(0); + } ++ policy.axis_wfp = ac.axis_wfp; + } + + // Filesystem section +diff --git a/src/core/wxc_common/src/models.rs b/src/core/wxc_common/src/models.rs +index 36eac37..ee2bc44 100644 +--- a/src/core/wxc_common/src/models.rs ++++ b/src/core/wxc_common/src/models.rs +@@ -449,6 +449,16 @@ pub struct ContainerPolicy { + pub base_process_ui: BaseProcessUiConfig, + /// Resource limits for the ProcessContainer child and all descendants. + pub resources: ProcessResourceLimits, ++ /// AXIS-owned strict-proxy WFP lease installed while the child is suspended. ++ pub axis_wfp: Option, ++} ++ ++#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] ++pub struct AxisWfpConfig { ++ #[serde(rename = "pipeName")] ++ pub pipe_name: String, ++ #[serde(rename = "leaseId")] ++ pub lease_id: String, + } + + #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]