From fee581f59fa8c8dbae58f4ef1e4540fc0b277b95 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 00:58:12 -0600 Subject: [PATCH 01/37] feat(windows): support Host-sealed driver tools --- .github/workflows/windows-platform.yml | 2 + crates/driver-host/Cargo.toml | 4 + crates/driver-host/src/bin/fixture.rs | 55 +++++- crates/driver-host/src/bin/tool_fixture.rs | 3 + crates/driver-host/src/lib.rs | 89 ++++++++-- .../driver-host/tests/windows_secure_host.rs | 69 +++++++- crates/driver-sdk/src/lib.rs | 40 ++++- crates/platform-api/src/launch.rs | 167 ++++++++++++++++-- crates/platform-linux-sys/src/launch.rs | 29 ++- crates/platform-windows-sys/src/launch.rs | 113 ++++++++++-- 10 files changed, 530 insertions(+), 41 deletions(-) create mode 100644 crates/driver-host/src/bin/tool_fixture.rs diff --git a/.github/workflows/windows-platform.yml b/.github/workflows/windows-platform.yml index a00050470..7b4c4cb71 100644 --- a/.github/workflows/windows-platform.yml +++ b/.github/workflows/windows-platform.yml @@ -129,6 +129,8 @@ jobs: -p semwright-daemon - name: Secure Windows Driver workspace grants run: cargo test --locked -p semwright-driver-host --test windows_secure_host -- secure_windows_driver_workspace --nocapture + - name: Secure Windows Driver sealed tools + run: cargo test --locked -p semwright-driver-host --test windows_secure_host -- secure_windows_driver_sealed_tool --nocapture - name: Secure Windows Plugin Host round-trip run: cargo test --locked -p semwright-plugin-host --features test-tools --test windows_secure_host -- --nocapture - name: Secure Windows external MCP round-trip diff --git a/crates/driver-host/Cargo.toml b/crates/driver-host/Cargo.toml index b419005c9..b8718b8e5 100644 --- a/crates/driver-host/Cargo.toml +++ b/crates/driver-host/Cargo.toml @@ -35,6 +35,10 @@ workspace = true name = "semwright-driver-fixture" path = "src/bin/fixture.rs" +[[bin]] +name = "semwright-tool-fixture" +path = "src/bin/tool_fixture.rs" + [[bin]] name = "semwright-adversarial-driver-fixture" path = "src/bin/adversarial_fixture.rs" diff --git a/crates/driver-host/src/bin/fixture.rs b/crates/driver-host/src/bin/fixture.rs index d5afb4f4d..b0ceb53af 100644 --- a/crates/driver-host/src/bin/fixture.rs +++ b/crates/driver-host/src/bin/fixture.rs @@ -1,7 +1,7 @@ use async_trait::async_trait; use semwright_driver_sdk::{ Capability, Driver, DriverExecutionContext, DriverInterfaces, descriptor_digest, serve, - workspace_mount, + tool_path, workspace_mount, }; use semwright_types::{ CommandDescriptor, Error, ErrorCode, Idempotency, JobArtifact, JobProgress, Result, Risk, @@ -76,6 +76,36 @@ fn mount_capability() -> Capability { } } +fn tool_capability() -> Capability { + Capability { + descriptor: CommandDescriptor { + name: "driver.fixture.tool_probe".into(), + version: "1".into(), + description: "Execute one Host-sealed fixture tool and probe mutation authority".into(), + input_schema: json!({"type":"object","additionalProperties":false}), + output_schema: json!({ + "type":"object", + "properties":{ + "stdout":{"type":"string"}, + "write_ok":{"type":"boolean"} + }, + "required":["stdout","write_ok"], + "additionalProperties":false + }), + requires: vec!["driver:fixture".into()], + risk: Risk::ReadOnly, + idempotency: Idempotency::ReadOnly, + timeout_ms: 2_000, + dry_run: true, + interactive_consent: false, + backends: vec!["driver:fixture".into()], + }, + aliases: vec!["tool_probe".into()], + tags: vec!["fixture".into(), "conformance".into(), "tool".into()], + object_types: vec![], + } +} + fn disconnect_capability() -> Capability { Capability { descriptor: CommandDescriptor { @@ -154,6 +184,7 @@ impl Driver for Fixture { Ok(vec![ capability(), mount_capability(), + tool_capability(), long_capability(), disconnect_capability(), ]) @@ -162,6 +193,7 @@ impl Driver for Fixture { let capability = match command { "driver.fixture.ping" => capability(), "driver.fixture.mount_probe" => mount_capability(), + "driver.fixture.tool_probe" => tool_capability(), "driver.fixture.disconnect" => disconnect_capability(), _ => { return Err(Error::new( @@ -187,6 +219,27 @@ impl Driver for Fixture { let write_ok = std::fs::write(root.join("child.txt"), b"written").is_ok(); return Ok(json!({"read":read,"write_ok":write_ok})); } + if command == "driver.fixture.tool_probe" { + if args.as_object().is_none_or(|args| !args.is_empty()) { + return Err(Error::invalid("fixture tool probe accepts an empty object")); + } + let tool = tool_path("probe")?; + let write_ok = std::fs::OpenOptions::new().write(true).open(&tool).is_ok(); + let output = std::process::Command::new(&tool).output()?; + if !output.status.success() { + return Err(Error::new( + ErrorCode::BackendFailed, + "fixture sealed tool execution failed", + )); + } + let stdout = String::from_utf8(output.stdout).map_err(|_| { + Error::new( + ErrorCode::BackendFailed, + "fixture tool output was not UTF-8", + ) + })?; + return Ok(json!({"stdout":stdout,"write_ok":write_ok})); + } if command == "driver.fixture.disconnect" { if args.as_object().is_none_or(|args| !args.is_empty()) { return Err(Error::invalid("fixture disconnect accepts an empty object")); diff --git a/crates/driver-host/src/bin/tool_fixture.rs b/crates/driver-host/src/bin/tool_fixture.rs new file mode 100644 index 000000000..325361e9e --- /dev/null +++ b/crates/driver-host/src/bin/tool_fixture.rs @@ -0,0 +1,3 @@ +fn main() { + print!("tool-ok"); +} diff --git a/crates/driver-host/src/lib.rs b/crates/driver-host/src/lib.rs index 3e770cff7..b07d432f7 100644 --- a/crates/driver-host/src/lib.rs +++ b/crates/driver-host/src/lib.rs @@ -62,12 +62,55 @@ impl SealedTool { fn sandbox_mount(&self) -> semwright_platform_api::launch::SealedToolMount { let _sealed_owner_fd = self._file.as_raw_fd(); semwright_platform_api::launch::SealedToolMount { - fd: self.data_file.as_raw_fd(), + source: semwright_platform_api::launch::SealedToolSource::UnixFd( + self.data_file.as_raw_fd(), + ), name: self.name.clone(), } } } +#[cfg(target_os = "windows")] +struct SealedTool { + name: String, + staged: Arc, + sha256: String, +} +#[cfg(target_os = "windows")] +impl SealedTool { + fn sandbox_mount(&self) -> semwright_platform_api::launch::SealedToolMount { + semwright_platform_api::launch::SealedToolMount { + source: semwright_platform_api::launch::SealedToolSource::VerifiedFile { + path: self.staged.0.clone(), + sha256: self.sha256.clone(), + }, + name: self.name.clone(), + } + } +} + +#[cfg(target_os = "windows")] +fn seal_verified_tool(path: &Path, digest: &str, name: &str, state: &Path) -> Result { + let bytes = verify_owned_executable(path, digest)?; + let staged_path = state.join(format!("driver-tool-{name}-{}.exe", unique_id())); + let staged = Arc::new(StagedFile(staged_path.clone())); + let mut file = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&staged_path)?; + file.write_all(&bytes)?; + file.sync_all()?; + drop(file); + + // Re-attest the exact private copy that will become visible to the LPAC child. + let _ = verify_owned_executable(&staged_path, digest)?; + Ok(SealedTool { + name: name.to_owned(), + staged, + sha256: digest.to_ascii_lowercase(), + }) +} + #[cfg(target_os = "linux")] fn seal_verified_tool(path: &Path, digest: &str, name: &str) -> Result { let bytes = semwright_platform_services::verify_executable(path, digest)?; @@ -441,12 +484,26 @@ fn validate_owner_permissions( .iter() .find(|grant| grant.name == tool.root) .ok_or_else(|| Error::new(ErrorCode::PolicyDenied, "Driver tool has no owner grant"))?; - if !grant.read || std::fs::canonicalize(&grant.path)? != grant.path { + if !grant.read { + return Err(Error::new( + ErrorCode::PolicyDenied, + "Driver tool requires readable owner grant", + )); + } + #[cfg(unix)] + if std::fs::canonicalize(&grant.path)? != grant.path { return Err(Error::new( ErrorCode::PolicyDenied, "Driver tool requires canonical readable owner grant", )); } + #[cfg(target_os = "windows")] + if !grant.path.is_absolute() { + return Err(Error::new( + ErrorCode::PolicyDenied, + "Windows driver tool grant must use an absolute path", + )); + } } if manifest.protocol == 1 && (manifest.interfaces.dynamic_capabilities @@ -772,18 +829,16 @@ fn sandbox_spec_windows( staged: &Path, helper: &Path, roots: &[FilesystemGrant], + sealed_tools: &[SealedTool], ) -> Result { use semwright_platform_api::launch::{ Mount, MountClass, ResourceLimits, SandboxKind, SandboxSpec, }; - if !manifest.system_config.is_empty() - || !manifest.secrets.is_empty() - || !manifest.tools.is_empty() - { + if !manifest.system_config.is_empty() || !manifest.secrets.is_empty() { return Err(Error::new( ErrorCode::SandboxDenied, - "Windows system-config, secret and tool grants remain fail-closed until their AppContainer contracts are proven", + "Windows system-config and secret grants remain fail-closed until their AppContainer contracts are proven", )); } if manifest.loopback_port.is_some() { @@ -819,7 +874,7 @@ fn sandbox_spec_windows( mounts, args: vec![], environment: vec![], - sealed_tools: vec![], + sealed_tools: sealed_tools.iter().map(SealedTool::sandbox_mount).collect(), network: manifest.network, limits: Some(ResourceLimits { open_files: manifest.resources.open_files, @@ -847,7 +902,7 @@ pub struct DriverProvider { _staged: Arc, #[cfg(unix)] _loopback: Option>, - #[cfg(unix)] + #[cfg(any(unix, target_os = "windows"))] _tools: Vec, } @@ -877,7 +932,20 @@ impl DriverProvider { // Bind trust checks to the exact staged file that will execute. let _ = verify_owned_executable(&staged_path, &manifest.sha256)?; - let spec = sandbox_spec_windows(&manifest, &staged_path, helper, roots)?; + let sealed_tools = manifest + .tools + .iter() + .map(|tool| { + let grant = roots + .iter() + .find(|grant| grant.name == tool.root) + .ok_or_else(|| { + Error::new(ErrorCode::PolicyDenied, "Driver tool grant disappeared") + })?; + seal_verified_tool(&grant.path, &tool.sha256, &tool.name, state) + }) + .collect::>>()?; + let spec = sandbox_spec_windows(&manifest, &staged_path, helper, roots, &sealed_tools)?; let mut child = semwright_platform_services::sandbox_spawn(&spec)?; let process_id = child .id() @@ -1074,6 +1142,7 @@ impl DriverProvider { cpu_accounting, operation_cpu_gate: Mutex::new(()), _staged: staged, + _tools: sealed_tools, })) } #[cfg(unix)] diff --git a/crates/driver-host/tests/windows_secure_host.rs b/crates/driver-host/tests/windows_secure_host.rs index 6b93d8bb5..b0ae48881 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -3,7 +3,8 @@ use semwright_backend_api::{Context, Provider}; use semwright_driver_host::DriverProvider; use semwright_driver_sdk::{ - ApplicationMatch, DriverInterfaces, DriverMount, DriverResources, Manifest, Transport, + ApplicationMatch, DriverInterfaces, DriverMount, DriverResources, DriverToolMount, Manifest, + Transport, }; use semwright_policy::FilesystemGrant; use sha2::{Digest, Sha256}; @@ -123,6 +124,72 @@ async fn secure_windows_driver_host_roundtrips_protocol_v2() { .expect("secure Windows Driver Host shutdown"); } +#[tokio::test] +async fn secure_windows_driver_sealed_tool_is_staged_immutable_and_executable() { + let driver_source = PathBuf::from(env!("CARGO_BIN_EXE_semwright-driver-fixture")); + let tool_source = PathBuf::from(env!("CARGO_BIN_EXE_semwright-tool-fixture")); + + let binary_dir = tempfile::tempdir().expect("fixture directory"); + let executable = binary_dir.path().join("driver.exe"); + let owner_tool = binary_dir.path().join("owner-tool.exe"); + std::fs::copy(&driver_source, &executable).expect("copy driver fixture"); + std::fs::copy(&tool_source, &owner_tool).expect("copy tool fixture"); + harden_fixture(&executable); + harden_fixture(&owner_tool); + + let mut candidate = manifest(executable); + candidate.tools = vec![DriverToolMount { + root: "fixture-tool-root".into(), + name: "probe".into(), + sha256: digest(&owner_tool), + }]; + let roots = vec![FilesystemGrant { + name: "fixture-tool-root".into(), + path: owner_tool.clone(), + read: true, + write: false, + }]; + + let state = tempfile::tempdir().expect("driver state"); + let helper = std::env::current_exe().expect("current test executable"); + let provider = DriverProvider::connect(candidate, state.path(), &helper, &roots, false) + .await + .expect("Windows Driver Host sealed tool"); + + // The owner source is not the executable granted to the LPAC child. Mutating it after + // connect must not change the Host-staged, re-attested tool. + std::fs::write(&owner_tool, b"tampered-after-connect").expect("mutate owner tool source"); + + let capabilities = Provider::capabilities(provider.as_ref()) + .await + .expect("driver capabilities"); + let probe = capabilities + .iter() + .find(|capability| capability.descriptor.name == "driver.fixture.tool_probe") + .expect("fixture tool capability") + .descriptor + .clone(); + let output = Provider::execute( + provider.as_ref(), + &Context { + session: "windows-sealed-tool".into(), + request_id: "windows-sealed-tool-probe".into(), + cancellation: CancellationToken::new(), + }, + &probe, + &serde_json::json!({}), + ) + .await + .expect("sealed tool probe through LPAC"); + + assert_eq!(output["stdout"], "tool-ok"); + assert_eq!(output["write_ok"], false); + + Provider::shutdown(provider.as_ref()) + .await + .expect("sealed tool Driver Host shutdown"); +} + fn grant_all_application_packages_modify(path: &Path) { // S-1-15-2-1 is ALL APPLICATION PACKAGES. Granting Modify here creates the // adversarial broad-group allow that the per-AppContainer deny ACE must override. diff --git a/crates/driver-sdk/src/lib.rs b/crates/driver-sdk/src/lib.rs index 459e8cca1..4e3c208b4 100644 --- a/crates/driver-sdk/src/lib.rs +++ b/crates/driver-sdk/src/lib.rs @@ -2,7 +2,10 @@ pub mod continuity; use async_trait::async_trait; -use semwright_platform_api::launch::{MountClass, SANDBOX_MOUNTS_ENV, decode_materialized_mounts}; +use semwright_platform_api::launch::{ + MountClass, SANDBOX_MOUNTS_ENV, SANDBOX_TOOLS_ENV, decode_materialized_mounts, + decode_materialized_tools, +}; use semwright_protocol::{read_frame, write_frame}; use semwright_types::provider::canonical_slug; use semwright_types::{ @@ -75,6 +78,41 @@ pub fn system_config_mount(logical_name: &str) -> Result { runtime_mount(MountClass::SystemConfig, logical_name) } +/// Resolve one Host-verified executable tool as materialized by the current platform sandbox. +pub fn tool_path(name: &str) -> Result { + if name.is_empty() + || name.len() > 64 + || name.starts_with("semwright-internal-") + || !name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) + { + return Err(Error::invalid("Invalid sandbox tool name")); + } + match std::env::var(SANDBOX_TOOLS_ENV) { + Ok(encoded) => decode_materialized_tools(&encoded)? + .into_iter() + .find(|tool| tool.name == name) + .map(|tool| PathBuf::from(tool.path)) + .ok_or_else(|| Error::unavailable("Requested sandbox tool was not materialized")), + Err(std::env::VarError::NotPresent) => { + #[cfg(unix)] + { + Ok(Path::new("/plugin/tools").join(name)) + } + #[cfg(not(unix))] + { + Err(Error::unavailable( + "Sandbox tool table is required on this platform", + )) + } + } + Err(std::env::VarError::NotUnicode(_)) => Err(Error::invalid( + "Sandbox tool table must be valid UTF-8 JSON", + )), + } +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum Transport { diff --git a/crates/platform-api/src/launch.rs b/crates/platform-api/src/launch.rs index ccb5a967f..7fb9b02d1 100644 --- a/crates/platform-api/src/launch.rs +++ b/crates/platform-api/src/launch.rs @@ -146,28 +146,112 @@ pub fn decode_materialized_mounts(encoded: &str) -> Result bool { + !name.is_empty() + && name.len() <= 64 + && !name.starts_with("semwright-internal-") + && name + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) +} + +#[derive(Clone, Debug)] +pub enum SealedToolSource { + /// Linux/Bubblewrap immutable descriptor materialization. + UnixFd(i32), + /// Host-staged executable that the platform must re-verify before materializing. + VerifiedFile { path: PathBuf, sha256: String }, +} +impl SealedToolSource { + fn validate(&self) -> Result<()> { + match self { + Self::UnixFd(fd) if *fd >= 3 => Ok(()), + Self::VerifiedFile { path, sha256 } + if path.is_absolute() + && sha256.len() == 64 + && sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) => + { + Ok(()) + } + _ => Err(Error::invalid("Invalid sealed sandbox tool source")), + } + } +} + #[derive(Clone, Debug)] pub struct SealedToolMount { - pub fd: i32, + pub source: SealedToolSource, pub name: String, } impl SealedToolMount { pub fn validate(&self) -> Result<()> { - if self.fd < 3 - || self.name.is_empty() - || self.name.len() > 64 - || self.name.starts_with("semwright-internal-") - || !self - .name - .bytes() - .all(|b| b.is_ascii_alphanumeric() || matches!(b, b'_' | b'-')) - { + self.source.validate()?; + if !valid_tool_name(&self.name) { return Err(Error::invalid("Invalid sealed sandbox tool mount")); } Ok(()) } } +pub const SANDBOX_TOOLS_ENV: &str = "SEMWRIGHT_SANDBOX_TOOLS_V1"; +const MAX_TOOL_ENV_BYTES: usize = 8 * 1024; + +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct MaterializedTool { + pub name: String, + pub path: String, +} +impl MaterializedTool { + pub fn validate(&self) -> Result<()> { + if !valid_tool_name(&self.name) + || self.path.is_empty() + || self.path.len() > 4096 + || self.path.contains('\0') + || !Path::new(&self.path).is_absolute() + { + return Err(Error::invalid("Invalid materialized sandbox tool")); + } + Ok(()) + } +} + +pub fn encode_materialized_tools(tools: &[MaterializedTool]) -> Result { + if tools.len() > 8 { + return Err(Error::new( + ErrorCode::ResourceExhausted, + "Materialized sandbox tool count exceeds budget", + )); + } + let mut names = std::collections::BTreeSet::new(); + for tool in tools { + tool.validate()?; + if !names.insert(&tool.name) { + return Err(Error::invalid("Duplicate materialized sandbox tool")); + } + } + let encoded = serde_json::to_string(tools)?; + if encoded.len() > MAX_TOOL_ENV_BYTES { + return Err(Error::new( + ErrorCode::ResourceExhausted, + "Materialized sandbox tool table exceeds environment budget", + )); + } + Ok(encoded) +} + +pub fn decode_materialized_tools(encoded: &str) -> Result> { + if encoded.len() > MAX_TOOL_ENV_BYTES { + return Err(Error::new( + ErrorCode::ResourceExhausted, + "Materialized sandbox tool table exceeds environment budget", + )); + } + let tools: Vec = serde_json::from_str(encoded)?; + encode_materialized_tools(&tools)?; + Ok(tools) +} + #[derive(Clone, Debug)] pub struct ResourceLimits { pub open_files: u64, @@ -227,11 +311,19 @@ impl SandboxSpec { } let mut tool_names = std::collections::BTreeSet::new(); let mut tool_fds = std::collections::BTreeSet::new(); + let mut tool_files = std::collections::BTreeSet::new(); for tool in &self.sealed_tools { tool.validate()?; - if !tool_names.insert(&tool.name) || !tool_fds.insert(tool.fd) { + if !tool_names.insert(&tool.name) { return Err(Error::invalid("Duplicate sealed sandbox tool mount")); } + let unique = match &tool.source { + SealedToolSource::UnixFd(fd) => tool_fds.insert(*fd), + SealedToolSource::VerifiedFile { path, .. } => tool_files.insert(path.clone()), + }; + if !unique { + return Err(Error::invalid("Duplicate sealed sandbox tool source")); + } } let mut environment_names = std::collections::BTreeSet::new(); for (name, value) in &self.environment { @@ -469,4 +561,57 @@ mod tests { .collect::>(); assert!(encode_materialized_mounts(&mounts).is_err()); } + + #[test] + fn materialized_tool_table_roundtrips_and_rejects_duplicates() { + #[cfg(unix)] + let path = "/plugin/tools/probe"; + #[cfg(windows)] + let path = r"C:\Users\owner\AppData\Local\Semwright\tools\probe.exe"; + let tool = MaterializedTool { + name: "probe".into(), + path: path.into(), + }; + let encoded = encode_materialized_tools(std::slice::from_ref(&tool)).unwrap(); + assert_eq!( + decode_materialized_tools(&encoded).unwrap(), + vec![tool.clone()] + ); + assert!(encode_materialized_tools(&[tool.clone(), tool]).is_err()); + } + + #[test] + fn sealed_tool_sources_are_explicit_and_bounded() { + assert!( + SealedToolMount { + source: SealedToolSource::UnixFd(3), + name: "probe".into(), + } + .validate() + .is_ok() + ); + #[cfg(windows)] + let path = PathBuf::from(r"C:\Semwright\probe.exe"); + #[cfg(not(windows))] + let path = PathBuf::from("/tmp/probe"); + assert!( + SealedToolMount { + source: SealedToolSource::VerifiedFile { + path, + sha256: "a".repeat(64), + }, + name: "probe".into(), + } + .validate() + .is_ok() + ); + assert!( + SealedToolMount { + source: SealedToolSource::UnixFd(2), + name: "probe".into(), + } + .validate() + .is_err() + ); + } } diff --git a/crates/platform-linux-sys/src/launch.rs b/crates/platform-linux-sys/src/launch.rs index bcf4cfcca..3751f01e9 100644 --- a/crates/platform-linux-sys/src/launch.rs +++ b/crates/platform-linux-sys/src/launch.rs @@ -1,6 +1,7 @@ use semwright_platform_api::launch::{ - ExecutableVerifier, MaterializedMount, Mount, MountClass, SANDBOX_MOUNTS_ENV, SandboxKind, - SandboxLauncher, SandboxSpec, encode_materialized_mounts, + ExecutableVerifier, MaterializedMount, MaterializedTool, Mount, MountClass, SANDBOX_MOUNTS_ENV, + SANDBOX_TOOLS_ENV, SandboxKind, SandboxLauncher, SandboxSpec, SealedToolSource, + encode_materialized_mounts, encode_materialized_tools, }; use semwright_types::{Error, ErrorCode, Result}; use sha2::{Digest, Sha256}; @@ -145,6 +146,15 @@ impl SandboxLauncher for LinuxSandbox { p.arg("--ro-bind").arg(&m.source).arg(destination); } for tool in &s.sealed_tools { + let fd = match &tool.source { + SealedToolSource::UnixFd(fd) => *fd, + SealedToolSource::VerifiedFile { .. } => { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Linux sealed tools require Host-owned immutable file descriptors", + )); + } + }; // Materialize the Host-verified sealed bytes directly into the private // sandbox root. The child receives no write/remove/create Landlock rights // for this path, so the executable remains immutable after policy install. @@ -152,7 +162,7 @@ impl SandboxLauncher for LinuxSandbox { // bind-mounting it, which can make later execve() resolve as ENOENT under // deleted-file mediation on Ubuntu/AppArmor. p.args(["--perms", "0500", "--file"]) - .arg(tool.fd.to_string()) + .arg(fd.to_string()) .arg(format!("/plugin/tools/{}", tool.name)); } p.arg("--ro-bind") @@ -194,6 +204,19 @@ impl SandboxLauncher for LinuxSandbox { .collect::>>()?, )?; p.arg("--setenv").arg(SANDBOX_MOUNTS_ENV).arg(mount_table); + + if !s.sealed_tools.is_empty() { + let tool_table = encode_materialized_tools( + &s.sealed_tools + .iter() + .map(|tool| MaterializedTool { + name: tool.name.clone(), + path: format!("/plugin/tools/{}", tool.name), + }) + .collect::>(), + )?; + p.arg("--setenv").arg(SANDBOX_TOOLS_ENV).arg(tool_table); + } } for (name, value) in &s.environment { p.arg("--setenv").arg(name).arg(value); diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 9858238ef..9457aa1a5 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -1,10 +1,9 @@ use crate::{identity::current_user_sid_bytes, job::ProcessJob, pe::require_native_architecture}; use async_trait::async_trait; -#[cfg(test)] -use semwright_platform_api::launch::MountClass; use semwright_platform_api::launch::{ - ExecutableVerifier, MaterializedMount, Mount, SANDBOX_MOUNTS_ENV, SandboxChildControl, - SandboxCpuAccounting, SandboxLauncher, SandboxProcess, SandboxSpec, encode_materialized_mounts, + ExecutableVerifier, MaterializedMount, MaterializedTool, Mount, MountClass, SANDBOX_MOUNTS_ENV, + SANDBOX_TOOLS_ENV, SandboxChildControl, SandboxCpuAccounting, SandboxLauncher, SandboxProcess, + SandboxSpec, SealedToolSource, encode_materialized_mounts, encode_materialized_tools, }; use semwright_types::{Error, ErrorCode, Result, unique_id}; use sha2::{Digest, Sha256}; @@ -1269,6 +1268,84 @@ fn prepare_windows_mounts( Ok((grants, Some(encoded))) } +fn prepare_windows_tools( + spec: &SandboxSpec, + profile: &AppContainerProfile, +) -> Result<(Vec, Option)> { + if spec.sealed_tools.is_empty() { + return Ok((Vec::new(), None)); + } + if spec.kind != semwright_platform_api::launch::SandboxKind::Driver { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows sealed tools are currently limited to Driver children", + )); + } + let staging_root = spec.staged_executable.parent().ok_or_else(|| { + Error::new( + ErrorCode::SandboxDenied, + "Windows Driver staging root is unavailable", + ) + })?; + let mut identities = BTreeSet::new(); + let mut prepared = Vec::with_capacity(spec.sealed_tools.len()); + let mut materialized = Vec::with_capacity(spec.sealed_tools.len()); + for tool in &spec.sealed_tools { + let (path, sha256) = match &tool.source { + SealedToolSource::VerifiedFile { path, sha256 } => (path, sha256), + SealedToolSource::UnixFd(_) => { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows sealed tools require Host-staged verified files", + )); + } + }; + if path.parent() != Some(staging_root) { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows sealed tools must come from the private Driver staging root", + )); + } + // Re-attest the exact staged PE immediately before granting LPAC authority. + let _ = WindowsVerifier.verify(path, sha256)?; + let plan = prepare_mount_grant(&Mount { + source: path.clone(), + class: MountClass::Workspace, + logical_name: tool.name.clone(), + read_only: true, + execute: true, + })?; + if !identities.insert(plan.identity) { + return Err(Error::invalid( + "Windows sealed tools must refer to unique staged executables", + )); + } + materialized.push(MaterializedTool { + name: tool.name.clone(), + path: plan.materialized.path.clone(), + }); + prepared.push(plan); + } + + let encoded = encode_materialized_tools(&materialized)?; + let mut grants = Vec::with_capacity(prepared.len()); + for plan in &prepared { + match WindowsMountGrant::grant(plan, profile.sid) { + Ok(grant) => grants.push(grant), + Err(error) => { + if revoke_mount_grants(&mut grants).is_err() { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool transaction failed and prior grants could not be fully revoked", + )); + } + return Err(error); + } + } + } + Ok((grants, Some(encoded))) +} + struct AppContainerProfile { name: Vec, sid: PSID, @@ -1410,16 +1487,23 @@ fn environment_block( spec: &SandboxSpec, profile: &AppContainerProfile, mount_table: Option<&str>, + tool_table: Option<&str>, ) -> Result> { let mut entries = spec.environment.clone(); - if entries.iter().any(|(name, _)| name == SANDBOX_MOUNTS_ENV) { + if entries + .iter() + .any(|(name, _)| name == SANDBOX_MOUNTS_ENV || name == SANDBOX_TOOLS_ENV) + { return Err(Error::invalid( - "Sandbox mount table environment is reserved to the platform host", + "Sandbox mount/tool table environment is reserved to the platform host", )); } if let Some(table) = mount_table { entries.push((SANDBOX_MOUNTS_ENV.into(), table.to_owned())); } + if let Some(table) = tool_table { + entries.push((SANDBOX_TOOLS_ENV.into(), table.to_owned())); + } if let Some(system_root) = std::env::var_os("SystemRoot") { entries.push(( "SystemRoot".into(), @@ -1657,14 +1741,10 @@ impl SandboxLauncher for WindowsSandbox { fn spawn(&self, spec: &SandboxSpec) -> Result { spec.validate()?; - if !spec.sealed_tools.is_empty() { - return Err(Error::new( - ErrorCode::SandboxDenied, - "Windows sealed-tool mounts remain fail-closed until immutable executable grants are transactional", - )); - } let profile = AppContainerProfile::create()?; - let (mount_grants, mount_table) = prepare_windows_mounts(spec, &profile)?; + let (mut mount_grants, mount_table) = prepare_windows_mounts(spec, &profile)?; + let (tool_grants, tool_table) = prepare_windows_tools(spec, &profile)?; + mount_grants.extend(tool_grants); let (child_stdin, parent_stdin) = inheritable_pipe()?; let (parent_stdout, child_stdout) = inheritable_pipe()?; clear_inheritance(parent_stdin.raw())?; @@ -1730,7 +1810,12 @@ impl SandboxLauncher for WindowsSandbox { let application = wide_null(spec.staged_executable.as_os_str())?; let mut command = command_line(spec)?; - let environment = environment_block(spec, &profile, mount_table.as_deref())?; + let environment = environment_block( + spec, + &profile, + mount_table.as_deref(), + tool_table.as_deref(), + )?; let mut process_info = PROCESS_INFORMATION::default(); let flags = CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT From a0fbe5db6579265352df7e7f3a7bc0b4188baac1 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 01:00:53 -0600 Subject: [PATCH 02/37] test(drivers): account for portable sealed tool table --- crates/driver-host/tests/adversarial_sandbox.rs | 1 + scripts/dev/driver-conformance.sh | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/driver-host/tests/adversarial_sandbox.rs b/crates/driver-host/tests/adversarial_sandbox.rs index cb3a51cb9..841fcb741 100644 --- a/crates/driver-host/tests/adversarial_sandbox.rs +++ b/crates/driver-host/tests/adversarial_sandbox.rs @@ -232,6 +232,7 @@ async fn hostile_driver_is_confined_and_descendants_die_with_provider() { "PWD", "SEMWRIGHT_DRIVER_SANDBOX", "SEMWRIGHT_SANDBOX_MOUNTS_V1", + "SEMWRIGHT_SANDBOX_TOOLS_V1", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", diff --git a/scripts/dev/driver-conformance.sh b/scripts/dev/driver-conformance.sh index 0426498f6..5ebec8e41 100755 --- a/scripts/dev/driver-conformance.sh +++ b/scripts/dev/driver-conformance.sh @@ -66,7 +66,7 @@ assert inspect["identity"]["namespace"] == "driver.fixture." assert inspect["policy_grants_changed"] is False assert conformance["provider"] == "driver:fixture" assert conformance["namespace"] == "driver.fixture." -assert conformance["capabilities"] == 4 # ping + mount_probe + long + continuity disconnect fixture +assert conformance["capabilities"] == 5 # ping + mount_probe + tool_probe + long + continuity disconnect fixture assert conformance["sandboxed"] is True assert conformance["persistent_process"] is True assert conformance["health"] is True From 13fd6e76f27bddc211477647331507b3ad7edaa9 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 01:03:38 -0600 Subject: [PATCH 03/37] test(windows): reject sealed tool digest substitution --- .../driver-host/tests/windows_secure_host.rs | 39 +++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/crates/driver-host/tests/windows_secure_host.rs b/crates/driver-host/tests/windows_secure_host.rs index b0ae48881..6263bdfbd 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -190,6 +190,45 @@ async fn secure_windows_driver_sealed_tool_is_staged_immutable_and_executable() .expect("sealed tool Driver Host shutdown"); } +#[tokio::test] +async fn secure_windows_driver_sealed_tool_rejects_digest_mismatch() { + let driver_source = PathBuf::from(env!("CARGO_BIN_EXE_semwright-driver-fixture")); + let tool_source = PathBuf::from(env!("CARGO_BIN_EXE_semwright-tool-fixture")); + + let binary_dir = tempfile::tempdir().expect("fixture directory"); + let executable = binary_dir.path().join("driver.exe"); + let owner_tool = binary_dir.path().join("owner-tool.exe"); + std::fs::copy(&driver_source, &executable).expect("copy driver fixture"); + std::fs::copy(&tool_source, &owner_tool).expect("copy tool fixture"); + harden_fixture(&executable); + harden_fixture(&owner_tool); + + let mut candidate = manifest(executable); + candidate.tools = vec![DriverToolMount { + root: "fixture-tool-root".into(), + name: "probe".into(), + sha256: "0".repeat(64), + }]; + let roots = vec![FilesystemGrant { + name: "fixture-tool-root".into(), + path: owner_tool, + read: true, + write: false, + }]; + + let state = tempfile::tempdir().expect("driver state"); + let helper = std::env::current_exe().expect("current test executable"); + let error = match DriverProvider::connect(candidate, state.path(), &helper, &roots, false).await + { + Ok(provider) => { + let _ = Provider::shutdown(provider.as_ref()).await; + panic!("sealed tool digest mismatch must fail before child launch"); + } + Err(error) => error, + }; + assert_eq!(error.code, semwright_types::ErrorCode::PermissionDenied); +} + fn grant_all_application_packages_modify(path: &Path) { // S-1-15-2-1 is ALL APPLICATION PACKAGES. Granting Modify here creates the // adversarial broad-group allow that the per-AppContainer deny ACE must override. From 1d51ef695a76a011e1b39306693902548465681f Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 02:17:33 -0600 Subject: [PATCH 04/37] test(windows): expose sealed tool spawn diagnostics --- crates/driver-host/src/bin/fixture.rs | 40 +++++++++++++++---- .../driver-host/tests/windows_secure_host.rs | 10 ++++- 2 files changed, 41 insertions(+), 9 deletions(-) diff --git a/crates/driver-host/src/bin/fixture.rs b/crates/driver-host/src/bin/fixture.rs index 1141379d4..09c2a08e8 100644 --- a/crates/driver-host/src/bin/fixture.rs +++ b/crates/driver-host/src/bin/fixture.rs @@ -87,9 +87,12 @@ fn tool_capability() -> Capability { "type":"object", "properties":{ "stdout":{"type":"string"}, - "write_ok":{"type":"boolean"} + "write_ok":{"type":"boolean"}, + "spawn_error_kind":{"type":"string"}, + "spawn_errno":{"type":"integer"}, + "exit_code":{"type":"integer"} }, - "required":["stdout","write_ok"], + "required":["stdout","write_ok","spawn_error_kind","spawn_errno","exit_code"], "additionalProperties":false }), requires: vec!["driver:fixture".into()], @@ -260,12 +263,27 @@ impl Driver for Fixture { } let tool = tool_path("probe")?; let write_ok = std::fs::OpenOptions::new().write(true).open(&tool).is_ok(); - let output = std::process::Command::new(&tool).output()?; + let output = match std::process::Command::new(&tool).output() { + Ok(output) => output, + Err(error) => { + return Ok(json!({ + "stdout":"", + "write_ok":write_ok, + "spawn_error_kind":format!("{:?}", error.kind()), + "spawn_errno":error.raw_os_error().unwrap_or(-1), + "exit_code":-1 + })); + } + }; + let exit_code = output.status.code().unwrap_or(-1); if !output.status.success() { - return Err(Error::new( - ErrorCode::BackendFailed, - "fixture sealed tool execution failed", - )); + return Ok(json!({ + "stdout":"", + "write_ok":write_ok, + "spawn_error_kind":"", + "spawn_errno":-1, + "exit_code":exit_code + })); } let stdout = String::from_utf8(output.stdout).map_err(|_| { Error::new( @@ -273,7 +291,13 @@ impl Driver for Fixture { "fixture tool output was not UTF-8", ) })?; - return Ok(json!({"stdout":stdout,"write_ok":write_ok})); + return Ok(json!({ + "stdout":stdout, + "write_ok":write_ok, + "spawn_error_kind":"", + "spawn_errno":-1, + "exit_code":exit_code + })); } if command == "driver.fixture.config_probe" { if args.as_object().is_none_or(|args| !args.is_empty()) { diff --git a/crates/driver-host/tests/windows_secure_host.rs b/crates/driver-host/tests/windows_secure_host.rs index f2ffaa0f1..cdcdcb990 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -182,7 +182,15 @@ async fn secure_windows_driver_sealed_tool_is_staged_immutable_and_executable() .await .expect("sealed tool probe through LPAC"); - assert_eq!(output["stdout"], "tool-ok"); + assert_eq!( + output["spawn_error_kind"], "", + "sealed tool spawn diagnostics: {output}" + ); + assert_eq!( + output["exit_code"], 0, + "sealed tool exit diagnostics: {output}" + ); + assert_eq!(output["stdout"], "tool-ok", "sealed tool output: {output}"); assert_eq!(output["write_ok"], false); Provider::shutdown(provider.as_ref()) From 3c467371fafb5beab8dfe75e50aeb0df5085b542 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 12:28:11 -0600 Subject: [PATCH 05/37] fix(windows): grant sealed tool staging traversal --- crates/platform-windows-sys/src/launch.rs | 54 ++++++++++++++++++++--- scripts/dev/driver-conformance.sh | 2 +- 2 files changed, 50 insertions(+), 6 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 9457aa1a5..9e6ddcfe5 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -56,10 +56,10 @@ use windows::Win32::{ BY_HANDLE_FILE_INFORMATION, CreateFileW, DELETE, FILE_APPEND_DATA, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_DELETE_CHILD, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, - FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_READ_ATTRIBUTES, - FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, - FILE_WRITE_DATA, FILE_WRITE_EA, GetFileInformationByHandle, OPEN_EXISTING, WRITE_DAC, - WRITE_OWNER, + FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_LIST_DIRECTORY, + FILE_READ_ATTRIBUTES, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_TRAVERSE, + FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, GetFileInformationByHandle, + OPEN_EXISTING, WRITE_DAC, WRITE_OWNER, }, System::{ Com::CoTaskMemFree, @@ -1115,6 +1115,48 @@ fn prepare_mount_grant(mount: &Mount) -> Result { }) } +fn prepare_tool_staging_traverse(staging_root: &Path) -> Result { + if !staging_root.is_absolute() { + return Err(Error::invalid( + "Windows sealed-tool staging root must be absolute", + )); + } + let (identity, is_directory) = validate_mount_tree(staging_root)?; + if !is_directory { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool staging root must be a directory", + )); + } + let materialized_path = staging_root + .to_str() + .ok_or_else(|| Error::invalid("Windows sealed-tool staging root must be Unicode"))? + .to_owned(); + Ok(PreparedMountGrant { + path: wide_null(staging_root.as_os_str())?, + identity, + materialized: MaterializedMount { + class: MountClass::Workspace, + logical_name: "__sealed-tool-staging__".into(), + path: materialized_path, + read_only: true, + }, + // The child needs path traversal to open a specifically granted tool, but must not + // gain directory listing or mutation authority over the private Driver staging root. + permissions: FILE_TRAVERSE.0, + denied_permissions: FILE_LIST_DIRECTORY.0 + | FILE_WRITE_DATA.0 + | FILE_APPEND_DATA.0 + | FILE_WRITE_EA.0 + | FILE_WRITE_ATTRIBUTES.0 + | FILE_DELETE_CHILD.0 + | DELETE.0 + | WRITE_DAC.0 + | WRITE_OWNER.0, + inheritance: NO_INHERITANCE, + }) +} + struct WindowsMountGrant { path: Vec, sid: Vec, @@ -1287,6 +1329,7 @@ fn prepare_windows_tools( "Windows Driver staging root is unavailable", ) })?; + let staging_traverse = prepare_tool_staging_traverse(staging_root)?; let mut identities = BTreeSet::new(); let mut prepared = Vec::with_capacity(spec.sealed_tools.len()); let mut materialized = Vec::with_capacity(spec.sealed_tools.len()); @@ -1328,7 +1371,8 @@ fn prepare_windows_tools( } let encoded = encode_materialized_tools(&materialized)?; - let mut grants = Vec::with_capacity(prepared.len()); + let mut grants = Vec::with_capacity(prepared.len() + 1); + grants.push(WindowsMountGrant::grant(&staging_traverse, profile.sid)?); for plan in &prepared { match WindowsMountGrant::grant(plan, profile.sid) { Ok(grant) => grants.push(grant), diff --git a/scripts/dev/driver-conformance.sh b/scripts/dev/driver-conformance.sh index 5ebec8e41..09d9b959e 100755 --- a/scripts/dev/driver-conformance.sh +++ b/scripts/dev/driver-conformance.sh @@ -66,7 +66,7 @@ assert inspect["identity"]["namespace"] == "driver.fixture." assert inspect["policy_grants_changed"] is False assert conformance["provider"] == "driver:fixture" assert conformance["namespace"] == "driver.fixture." -assert conformance["capabilities"] == 5 # ping + mount_probe + tool_probe + long + continuity disconnect fixture +assert conformance["capabilities"] == 6 # ping + mount_probe + tool_probe + config_probe + long + continuity disconnect fixture assert conformance["sandboxed"] is True assert conformance["persistent_process"] is True assert conformance["health"] is True From 4db33917cb07ffb0e44a15cb7fb4f14e57df5308 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 12:39:59 -0600 Subject: [PATCH 06/37] fix(windows): allow sealed tool child processes explicitly --- crates/platform-windows-sys/src/launch.rs | 19 ++++++++++++++----- 1 file changed, 14 insertions(+), 5 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 9e6ddcfe5..73e1d963a 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -76,11 +76,15 @@ use windows::Win32::{ DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, INFINITE, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, - PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, - PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, - TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, + PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, ResumeThread, + STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, + WaitForSingleObject, + }, + WindowsProgramming::{ + PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, + PROCESS_CREATION_CHILD_PROCESS_OVERRIDE, }, - WindowsProgramming::PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, }, }; use windows::core::{BOOL, PCWSTR, PWSTR}; @@ -1796,8 +1800,13 @@ impl SandboxLauncher for WindowsSandbox { let child_stderr = inherited_null()?; let handles = [child_stdin.raw(), child_stdout.raw(), child_stderr.raw()]; - let mut attributes = ProcAttributes::new(3)?; + let child_process_override = !spec.sealed_tools.is_empty(); + let mut attributes = ProcAttributes::new(if child_process_override { 4 } else { 3 })?; attributes.set_slice(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; + if child_process_override { + let child_policy = PROCESS_CREATION_CHILD_PROCESS_OVERRIDE; + attributes.set_value(PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &child_policy)?; + } let all_application_packages_policy = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; attributes.set_value( PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, From 40bdfa31cf73179cae46aee08f796a684bee99a2 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 12:47:55 -0600 Subject: [PATCH 07/37] fix(windows): anchor LPAC cwd in private profile --- crates/platform-windows-sys/src/launch.rs | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 73e1d963a..1c1d24532 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -1862,6 +1862,7 @@ impl SandboxLauncher for WindowsSandbox { let job = Arc::new(ProcessJob::new(process_limit, memory_limit, cpu_seconds)?); let application = wide_null(spec.staged_executable.as_os_str())?; + let current_directory = wide_null(OsStr::new(&profile.local_app_data))?; let mut command = command_line(spec)?; let environment = environment_block( spec, @@ -1885,7 +1886,7 @@ impl SandboxLauncher for WindowsSandbox { true, flags, Some(environment.as_ptr().cast()), - PCWSTR::null(), + PCWSTR(current_directory.as_ptr()), (&startup as *const STARTUPINFOEXW).cast(), &mut process_info, ) From ae7efc470c2a313596f43cc44760073c172ea393 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 12:57:00 -0600 Subject: [PATCH 08/37] fix(windows): materialize sealed tools inside LPAC profile --- crates/platform-windows-sys/src/launch.rs | 94 +++++++++++++++++++++-- 1 file changed, 88 insertions(+), 6 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 1c1d24532..cf5944b12 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -410,6 +410,62 @@ fn verify_executable_acl(file: &File) -> Result<()> { Ok(()) } +fn verify_materialized_sealed_tool(path: &Path, digest: &str) -> Result<()> { + if !path.is_absolute() { + return Err(Error::invalid( + "Materialized Windows sealed tool path must be absolute", + )); + } + let mut options = std::fs::OpenOptions::new(); + options + .read(true) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_DELETE.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0); + let mut file = options.open(path)?; + let before = info(&file)?; + if before.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 + || before.nNumberOfLinks != 1 + || before.nFileSizeHigh != 0 + || before.nFileSizeLow as u64 > MAX_EXECUTABLE + { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Unsafe materialized Windows sealed tool type, link count or size", + )); + } + let mut bytes = Vec::with_capacity(before.nFileSizeLow as usize); + file.by_ref() + .take(MAX_EXECUTABLE + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_EXECUTABLE { + return Err(Error::new( + ErrorCode::ResourceExhausted, + "Materialized Windows sealed tool exceeds size budget", + )); + } + let after = info(&file)?; + if !same_identity(&before, &after) + || before.nFileSizeHigh != after.nFileSizeHigh + || before.nFileSizeLow != after.nFileSizeLow + || before.ftLastWriteTime != after.ftLastWriteTime + { + return Err(Error::new( + ErrorCode::Conflict, + "Materialized Windows sealed tool changed while being verified", + )); + } + let expected = digest.trim().to_ascii_lowercase(); + if expected.len() != 64 || format!("{:x}", Sha256::digest(&bytes)) != expected { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Materialized Windows sealed tool digest mismatch", + )); + } + require_native_architecture(&bytes)?; + require_authenticode_policy(authenticode_status(&file, path)?)?; + Ok(()) +} + pub struct WindowsVerifier; impl ExecutableVerifier for WindowsVerifier { fn verify(&self, path: &Path, digest: &str) -> Result> { @@ -1333,7 +1389,14 @@ fn prepare_windows_tools( "Windows Driver staging root is unavailable", ) })?; - let staging_traverse = prepare_tool_staging_traverse(staging_root)?; + let profile_tool_root = Path::new(&profile.local_app_data).join("SemwrightTools"); + std::fs::create_dir(&profile_tool_root).map_err(|_| { + Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool profile directory could not be created", + ) + })?; + let profile_traverse = prepare_tool_staging_traverse(&profile_tool_root)?; let mut identities = BTreeSet::new(); let mut prepared = Vec::with_capacity(spec.sealed_tools.len()); let mut materialized = Vec::with_capacity(spec.sealed_tools.len()); @@ -1353,10 +1416,29 @@ fn prepare_windows_tools( "Windows sealed tools must come from the private Driver staging root", )); } - // Re-attest the exact staged PE immediately before granting LPAC authority. - let _ = WindowsVerifier.verify(path, sha256)?; + + // Re-attest the Host-staged source, copy only verified bytes into the unique + // AppContainer profile, sync them, and then re-attest the exact executable that the + // LPAC child will receive. The owner source and Host staging directory stay inaccessible. + let bytes = WindowsVerifier.verify(path, sha256)?; + let materialized_path = profile_tool_root.join(format!("{}.exe", tool.name)); + let mut output = std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&materialized_path) + .map_err(|_| { + Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool profile copy could not be created", + ) + })?; + output.write_all(&bytes)?; + output.sync_all()?; + drop(output); + verify_materialized_sealed_tool(&materialized_path, sha256)?; + let plan = prepare_mount_grant(&Mount { - source: path.clone(), + source: materialized_path, class: MountClass::Workspace, logical_name: tool.name.clone(), read_only: true, @@ -1364,7 +1446,7 @@ fn prepare_windows_tools( })?; if !identities.insert(plan.identity) { return Err(Error::invalid( - "Windows sealed tools must refer to unique staged executables", + "Windows sealed tools must refer to unique materialized executables", )); } materialized.push(MaterializedTool { @@ -1376,7 +1458,7 @@ fn prepare_windows_tools( let encoded = encode_materialized_tools(&materialized)?; let mut grants = Vec::with_capacity(prepared.len() + 1); - grants.push(WindowsMountGrant::grant(&staging_traverse, profile.sid)?); + grants.push(WindowsMountGrant::grant(&profile_traverse, profile.sid)?); for plan in &prepared { match WindowsMountGrant::grant(plan, profile.sid) { Ok(grant) => grants.push(grant), From 6ebe82a1e3e150332b161766be1b46183311397b Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 13:27:43 -0600 Subject: [PATCH 09/37] fix(windows): import Write for sealed tool staging --- crates/platform-windows-sys/src/launch.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index b115f691b..f467c58df 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -11,7 +11,7 @@ use std::{ collections::BTreeSet, ffi::OsStr, fs::File, - io::Read, + io::{Read, Write}, os::windows::{ ffi::OsStrExt, fs::OpenOptionsExt, From 5cffdd74fd119f21811a7b50f14481aad8cdfe65 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 13:32:04 -0600 Subject: [PATCH 10/37] fix(windows): scope sealed tool write trait --- crates/platform-windows-sys/src/launch.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index f467c58df..71b8fbafb 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -11,7 +11,7 @@ use std::{ collections::BTreeSet, ffi::OsStr, fs::File, - io::{Read, Write}, + io::Read, os::windows::{ ffi::OsStrExt, fs::OpenOptionsExt, @@ -1529,7 +1529,7 @@ fn prepare_windows_tools( "Windows sealed-tool profile copy could not be created", ) })?; - output.write_all(&bytes)?; + std::io::Write::write_all(&mut output, &bytes)?; output.sync_all()?; drop(output); verify_materialized_sealed_tool(&materialized_path, sha256)?; From 1fbc90ba3011b73ffab5d2c39e72b72c5c11f1c9 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 13:39:52 -0600 Subject: [PATCH 11/37] security(windows): narrow inherited sealed tool authority --- crates/platform-windows-sys/src/launch.rs | 58 +++++++++++++++++++++-- 1 file changed, 55 insertions(+), 3 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 71b8fbafb..54cfc4996 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -1318,6 +1318,7 @@ struct WindowsMountGrant { path: Vec, sid: Vec, active: bool, + require_sid_absence_after_revoke: bool, } impl WindowsMountGrant { @@ -1387,6 +1388,46 @@ impl WindowsMountGrant { path: prepared.path.clone(), sid: sid_bytes, active: true, + require_sid_absence_after_revoke: true, + }) + } + + fn narrow_profile_authority(prepared: &PreparedMountGrant, sid: PSID) -> Result { + let sid_bytes = copy_sid_bytes(sid)?; + let owned_sid = PSID(sid_bytes.as_ptr().cast_mut().cast()); + if !dacl_has_sid(&prepared.path, owned_sid)? { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows LPAC profile object did not inherit its AppContainer SID", + )); + } + if let Err(error) = set_mount_ace( + &prepared.path, + owned_sid, + DENY_ACCESS, + prepared.denied_permissions, + prepared.inheritance, + ) { + let _ = set_mount_ace(&prepared.path, owned_sid, REVOKE_ACCESS, 0, NO_INHERITANCE); + return Err(error); + } + if !dacl_has_deny( + &prepared.path, + owned_sid, + prepared.denied_permissions, + prepared.inheritance, + )? { + let _ = set_mount_ace(&prepared.path, owned_sid, REVOKE_ACCESS, 0, NO_INHERITANCE); + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool profile deny ACE verification failed", + )); + } + Ok(Self { + path: prepared.path.clone(), + sid: sid_bytes, + active: true, + require_sid_absence_after_revoke: false, }) } @@ -1394,7 +1435,15 @@ impl WindowsMountGrant { if !self.active { return Ok(()); } - revoke_mount_sid(&self.path, PSID(self.sid.as_ptr().cast_mut().cast()))?; + let sid = PSID(self.sid.as_ptr().cast_mut().cast()); + if self.require_sid_absence_after_revoke { + revoke_mount_sid(&self.path, sid)?; + } else { + // Profile-local objects inherit the AppContainer SID from the unique LPAC profile. + // Remove only Semwright's explicit narrowing ACEs; inherited profile authority is + // intentionally left for DeleteAppContainerProfile to dispose with the profile. + set_mount_ace(&self.path, sid, REVOKE_ACCESS, 0, NO_INHERITANCE)?; + } self.active = false; Ok(()) } @@ -1555,9 +1604,12 @@ fn prepare_windows_tools( let encoded = encode_materialized_tools(&materialized)?; let mut grants = Vec::with_capacity(prepared.len() + 1); - grants.push(WindowsMountGrant::grant(&profile_traverse, profile.sid)?); + grants.push(WindowsMountGrant::narrow_profile_authority( + &profile_traverse, + profile.sid, + )?); for plan in &prepared { - match WindowsMountGrant::grant(plan, profile.sid) { + match WindowsMountGrant::narrow_profile_authority(plan, profile.sid) { Ok(grant) => grants.push(grant), Err(error) => { if revoke_mount_grants(&mut grants).is_err() { From 28b809968106e11e3cb70fd8298f3aade5035495 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 13:55:46 -0600 Subject: [PATCH 12/37] security(windows): protect sealed tool profile ACLs --- crates/platform-windows-sys/src/launch.rs | 223 +++++++++++++++++++--- 1 file changed, 192 insertions(+), 31 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 54cfc4996..a01a4f336 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -37,14 +37,14 @@ use windows::Win32::{ TRUSTEE_IS_USER, TRUSTEE_W, }, CopySid, CreateWellKnownSid, DACL_SECURITY_INFORMATION, EqualSid, FreeSid, GetAce, - GetAclInformation, GetLengthSid, + GetAclInformation, GetLengthSid, GetSecurityDescriptorControl, Isolation::{ CreateAppContainerProfile, DeleteAppContainerProfile, GetAppContainerFolderPath, }, - NO_INHERITANCE, OWNER_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, - SECURITY_ATTRIBUTES, SECURITY_CAPABILITIES, SECURITY_MAX_SID_SIZE, SID_AND_ATTRIBUTES, - SUB_CONTAINERS_AND_OBJECTS_INHERIT, WinBuiltinAdministratorsSid, - WinCapabilityInternetClientSid, WinLocalSystemSid, + NO_INHERITANCE, OWNER_SECURITY_INFORMATION, PROTECTED_DACL_SECURITY_INFORMATION, + PSECURITY_DESCRIPTOR, PSID, SE_DACL_PROTECTED, SECURITY_ATTRIBUTES, SECURITY_CAPABILITIES, + SECURITY_MAX_SID_SIZE, SID_AND_ATTRIBUTES, SUB_CONTAINERS_AND_OBJECTS_INHERIT, + WinBuiltinAdministratorsSid, WinCapabilityInternetClientSid, WinLocalSystemSid, WinTrust::{ WINTRUST_ACTION_GENERIC_VERIFY_V2, WINTRUST_DATA, WINTRUST_DATA_0, WINTRUST_FILE_INFO, WTD_CACHE_ONLY_URL_RETRIEVAL, WTD_CHOICE_FILE, WTD_REVOCATION_CHECK_NONE, @@ -861,6 +861,185 @@ fn named_dacl(path: &[u16]) -> Result<(*mut ACL, SecurityDescriptor)> { Ok((dacl, SecurityDescriptor(descriptor))) } +fn explicit_sid_grant( + sid: PSID, + permissions: u32, + inheritance: windows::Win32::Security::ACE_FLAGS, +) -> EXPLICIT_ACCESS_W { + EXPLICIT_ACCESS_W { + grfAccessPermissions: permissions, + grfAccessMode: GRANT_ACCESS, + grfInheritance: inheritance, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + TrusteeType: TRUSTEE_IS_USER, + ptstrName: PWSTR(sid.0.cast()), + }, + } +} + +fn protected_profile_acl_matches( + path: &[u16], + app_sid: PSID, + permissions: u32, + forbidden_permissions: u32, + inheritance: windows::Win32::Security::ACE_FLAGS, +) -> Result { + let (dacl, descriptor) = named_dacl(path)?; + let mut control = 0u16; + let mut revision = 0u32; + // SAFETY: descriptor is live for this call and both outputs are writable locals. + unsafe { GetSecurityDescriptorControl(descriptor.0, &mut control, &mut revision) }.map_err( + |_| { + Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool protected DACL state could not be read", + ) + }, + )?; + if control & SE_DACL_PROTECTED.0 == 0 { + return Ok(false); + } + + let current = current_user_sid_bytes()?; + let system = well_known_sid(WinLocalSystemSid)?; + let admins = well_known_sid(WinBuiltinAdministratorsSid)?; + let mut acl_info = ACL_SIZE_INFORMATION::default(); + // SAFETY: dacl belongs to the live descriptor guard and acl_info is a sized output buffer. + unsafe { + GetAclInformation( + dacl, + (&mut acl_info as *mut ACL_SIZE_INFORMATION).cast(), + std::mem::size_of::() as u32, + AclSizeInformation, + ) + } + .map_err(|_| { + Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool protected DACL is invalid", + ) + })?; + if acl_info.AceCount > 16 { + return Ok(false); + } + + let inheritance_mask = SUB_CONTAINERS_AND_OBJECTS_INHERIT.0; + let mut app_seen = false; + for index in 0..acl_info.AceCount { + let mut raw: *mut core::ffi::c_void = std::ptr::null_mut(); + // SAFETY: index is bounded by AceCount and raw is a writable ACE out-pointer. + unsafe { GetAce(dacl, index, &mut raw) }.map_err(|_| { + Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool protected DACL entry could not be inspected", + ) + })?; + if raw.is_null() { + return Ok(false); + } + // SAFETY: GetAce returned storage owned by the live DACL/security descriptor. + let header = unsafe { &*(raw.cast::()) }; + if header.AceType as u32 != ACCESS_ALLOWED_ACE_TYPE + || usize::from(header.AceSize) < std::mem::size_of::() + { + return Ok(false); + } + // SAFETY: a simple access-allowed ACE is at least ACCESS_ALLOWED_ACE bytes. + let ace = unsafe { &*(raw.cast::()) }; + let sid = PSID((&ace.SidStart as *const u32).cast_mut().cast()); + // SAFETY: both SIDs are live for this comparison. + if unsafe { EqualSid(sid, app_sid).is_ok() } { + if ace.Mask & permissions != permissions + || ace.Mask & forbidden_permissions != 0 + || u32::from(header.AceFlags) & inheritance_mask != inheritance.0 + { + return Ok(false); + } + app_seen = true; + continue; + } + if sid_matches(sid, ¤t) || sid_matches(sid, &system) || sid_matches(sid, &admins) { + continue; + } + return Ok(false); + } + Ok(app_seen) +} + +fn set_protected_profile_acl( + path: &[u16], + app_sid: PSID, + permissions: u32, + forbidden_permissions: u32, + inheritance: windows::Win32::Security::ACE_FLAGS, +) -> Result<()> { + let current = current_user_sid_bytes()?; + let system = well_known_sid(WinLocalSystemSid)?; + let admins = well_known_sid(WinBuiltinAdministratorsSid)?; + let entries = [ + explicit_sid_grant( + PSID(current.as_ptr().cast_mut().cast()), + GENERIC_ALL.0, + NO_INHERITANCE, + ), + explicit_sid_grant( + PSID(system.as_ptr().cast_mut().cast()), + GENERIC_ALL.0, + NO_INHERITANCE, + ), + explicit_sid_grant( + PSID(admins.as_ptr().cast_mut().cast()), + GENERIC_ALL.0, + NO_INHERITANCE, + ), + explicit_sid_grant(app_sid, permissions, inheritance), + ]; + let mut updated: *mut ACL = std::ptr::null_mut(); + // SAFETY: all SID buffers and entries remain live through this synchronous call. + let status = unsafe { SetEntriesInAclW(Some(&entries), None, &mut updated) }; + if status.0 != 0 || updated.is_null() { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool protected DACL could not be constructed", + )); + } + let updated = LocalAcl(updated); + // SAFETY: path is NUL-terminated and updated contains a valid ACL allocated above. + let status = unsafe { + SetNamedSecurityInfoW( + PCWSTR(path.as_ptr()), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION | PROTECTED_DACL_SECURITY_INFORMATION, + None, + None, + Some(updated.0), + None, + ) + }; + if status.0 != 0 { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool protected DACL could not be applied", + )); + } + if !protected_profile_acl_matches( + path, + app_sid, + permissions, + forbidden_permissions, + inheritance, + )? { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool protected DACL verification failed", + )); + } + Ok(()) +} + fn dacl_has_sid(path: &[u16], sid: PSID) -> Result { let (dacl, _descriptor) = named_dacl(path)?; let mut acl_info = ACL_SIZE_INFORMATION::default(); @@ -1300,7 +1479,7 @@ fn prepare_tool_staging_traverse(staging_root: &Path) -> Result Result { let sid_bytes = copy_sid_bytes(sid)?; let owned_sid = PSID(sid_bytes.as_ptr().cast_mut().cast()); - if !dacl_has_sid(&prepared.path, owned_sid)? { - return Err(Error::new( - ErrorCode::SandboxDenied, - "Windows LPAC profile object did not inherit its AppContainer SID", - )); - } - if let Err(error) = set_mount_ace( - &prepared.path, - owned_sid, - DENY_ACCESS, - prepared.denied_permissions, - prepared.inheritance, - ) { - let _ = set_mount_ace(&prepared.path, owned_sid, REVOKE_ACCESS, 0, NO_INHERITANCE); - return Err(error); - } - if !dacl_has_deny( + // These objects are created inside the unique, disposable AppContainer profile. + // Replace inherited profile ACLs entirely so broad package-group authority cannot + // widen a sealed tool beyond the exact read/execute (or traverse) grant. + set_protected_profile_acl( &prepared.path, owned_sid, + prepared.permissions, prepared.denied_permissions, prepared.inheritance, - )? { - let _ = set_mount_ace(&prepared.path, owned_sid, REVOKE_ACCESS, 0, NO_INHERITANCE); - return Err(Error::new( - ErrorCode::SandboxDenied, - "Windows sealed-tool profile deny ACE verification failed", - )); - } + )?; Ok(Self { path: prepared.path.clone(), sid: sid_bytes, active: true, - require_sid_absence_after_revoke: false, + require_sid_absence_after_revoke: true, }) } From f03b769eda4e471318d1b9491eb960314fee4c55 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 14:02:14 -0600 Subject: [PATCH 13/37] fix(windows): clean sealed tool ACL imports --- crates/platform-windows-sys/src/launch.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index a01a4f336..313a8b50e 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -58,8 +58,8 @@ use windows::Win32::{ FILE_DELETE_CHILD, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_LIST_DIRECTORY, FILE_READ_ATTRIBUTES, FILE_READ_DATA, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, - FILE_TRAVERSE, FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, - GetFileInformationByHandle, OPEN_EXISTING, WRITE_DAC, WRITE_OWNER, + FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, GetFileInformationByHandle, + OPEN_EXISTING, WRITE_DAC, WRITE_OWNER, }, System::{ Com::CoTaskMemFree, From 17505041413eef45f2ee7682cdc5c493f53697f7 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 14:26:46 -0600 Subject: [PATCH 14/37] fix(windows): allow sealed tool directory reads --- crates/driver-host/src/bin/fixture.rs | 7 ++++++- crates/driver-host/tests/windows_secure_host.rs | 4 ++++ crates/platform-windows-sys/src/launch.rs | 10 +++++----- 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/crates/driver-host/src/bin/fixture.rs b/crates/driver-host/src/bin/fixture.rs index b9ba6f346..2d0f00079 100644 --- a/crates/driver-host/src/bin/fixture.rs +++ b/crates/driver-host/src/bin/fixture.rs @@ -87,12 +87,13 @@ fn tool_capability() -> Capability { "type":"object", "properties":{ "stdout":{"type":"string"}, + "read_ok":{"type":"boolean"}, "write_ok":{"type":"boolean"}, "spawn_error_kind":{"type":"string"}, "spawn_errno":{"type":"integer"}, "exit_code":{"type":"integer"} }, - "required":["stdout","write_ok","spawn_error_kind","spawn_errno","exit_code"], + "required":["stdout","read_ok","write_ok","spawn_error_kind","spawn_errno","exit_code"], "additionalProperties":false }), requires: vec!["driver:fixture".into()], @@ -294,12 +295,14 @@ impl Driver for Fixture { return Err(Error::invalid("fixture tool probe accepts an empty object")); } let tool = tool_path("probe")?; + let read_ok = std::fs::File::open(&tool).is_ok(); let write_ok = std::fs::OpenOptions::new().write(true).open(&tool).is_ok(); let output = match std::process::Command::new(&tool).output() { Ok(output) => output, Err(error) => { return Ok(json!({ "stdout":"", + "read_ok":read_ok, "write_ok":write_ok, "spawn_error_kind":format!("{:?}", error.kind()), "spawn_errno":error.raw_os_error().unwrap_or(-1), @@ -311,6 +314,7 @@ impl Driver for Fixture { if !output.status.success() { return Ok(json!({ "stdout":"", + "read_ok":read_ok, "write_ok":write_ok, "spawn_error_kind":"", "spawn_errno":-1, @@ -325,6 +329,7 @@ impl Driver for Fixture { })?; return Ok(json!({ "stdout":stdout, + "read_ok":read_ok, "write_ok":write_ok, "spawn_error_kind":"", "spawn_errno":-1, diff --git a/crates/driver-host/tests/windows_secure_host.rs b/crates/driver-host/tests/windows_secure_host.rs index a5b955856..1d737f51d 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -191,6 +191,10 @@ async fn secure_windows_driver_sealed_tool_is_staged_immutable_and_executable() "sealed tool exit diagnostics: {output}" ); assert_eq!(output["stdout"], "tool-ok", "sealed tool output: {output}"); + assert_eq!( + output["read_ok"], true, + "sealed tool must remain readable: {output}" + ); assert_eq!(output["write_ok"], false); Provider::shutdown(provider.as_ref()) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 313a8b50e..5ee11d749 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -1477,11 +1477,11 @@ fn prepare_tool_staging_traverse(staging_root: &Path) -> Result Date: Sun, 27 Sep 2026 14:36:18 -0600 Subject: [PATCH 15/37] fix(windows): drop obsolete sealed-tool ACL import --- crates/platform-windows-sys/src/launch.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 5ee11d749..00a2150b3 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -56,8 +56,8 @@ use windows::Win32::{ BY_HANDLE_FILE_INFORMATION, CreateFileW, DELETE, FILE_APPEND_DATA, FILE_ATTRIBUTE_DIRECTORY, FILE_ATTRIBUTE_NORMAL, FILE_ATTRIBUTE_REPARSE_POINT, FILE_DELETE_CHILD, FILE_FLAG_BACKUP_SEMANTICS, FILE_FLAG_OPEN_REPARSE_POINT, - FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_LIST_DIRECTORY, - FILE_READ_ATTRIBUTES, FILE_READ_DATA, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, + FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE, FILE_READ_ATTRIBUTES, + FILE_READ_DATA, FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE, FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, GetFileInformationByHandle, OPEN_EXISTING, WRITE_DAC, WRITE_OWNER, }, From f184844a0ddf5a57a9a5391e047a585d4824b7ca Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 14:47:07 -0600 Subject: [PATCH 16/37] test(windows): diagnose sealed tool child creation --- crates/driver-host/src/bin/fixture.rs | 45 ++++++++++++++++++++++++++- 1 file changed, 44 insertions(+), 1 deletion(-) diff --git a/crates/driver-host/src/bin/fixture.rs b/crates/driver-host/src/bin/fixture.rs index 984061877..ea7e2332d 100644 --- a/crates/driver-host/src/bin/fixture.rs +++ b/crates/driver-host/src/bin/fixture.rs @@ -93,12 +93,15 @@ fn tool_capability() -> Capability { "properties":{ "stdout":{"type":"string"}, "read_ok":{"type":"boolean"}, + "execute_open_ok":{"type":"boolean"}, + "self_spawn_ok":{"type":"boolean"}, + "self_spawn_errno":{"type":"integer"}, "write_ok":{"type":"boolean"}, "spawn_error_kind":{"type":"string"}, "spawn_errno":{"type":"integer"}, "exit_code":{"type":"integer"} }, - "required":["stdout","read_ok","write_ok","spawn_error_kind","spawn_errno","exit_code"], + "required":["stdout","read_ok","execute_open_ok","self_spawn_ok","self_spawn_errno","write_ok","spawn_error_kind","spawn_errno","exit_code"], "additionalProperties":false }), requires: vec!["driver:fixture".into()], @@ -301,6 +304,33 @@ impl Driver for Fixture { } let tool = tool_path("probe")?; let read_ok = std::fs::File::open(&tool).is_ok(); + #[cfg(windows)] + let execute_open_ok = { + use std::os::windows::fs::OpenOptionsExt; + std::fs::OpenOptions::new() + .access_mode(0x0012_00A0) + .share_mode(0x7) + .open(&tool) + .is_ok() + }; + #[cfg(not(windows))] + let execute_open_ok = read_ok; + #[cfg(windows)] + let (self_spawn_ok, self_spawn_errno) = match std::env::current_exe() { + Ok(executable) => match std::process::Command::new(executable) + .env("SEMWRIGHT_FIXTURE_SELF_PROBE", "1") + .output() + { + Ok(output) => ( + output.status.success() && output.stdout.starts_with(b"self-ok"), + -1, + ), + Err(error) => (false, error.raw_os_error().unwrap_or(-1)), + }, + Err(error) => (false, error.raw_os_error().unwrap_or(-1)), + }; + #[cfg(not(windows))] + let (self_spawn_ok, self_spawn_errno) = (true, -1); let write_ok = std::fs::OpenOptions::new().write(true).open(&tool).is_ok(); let output = match std::process::Command::new(&tool).output() { Ok(output) => output, @@ -308,6 +338,9 @@ impl Driver for Fixture { return Ok(json!({ "stdout":"", "read_ok":read_ok, + "execute_open_ok":execute_open_ok, + "self_spawn_ok":self_spawn_ok, + "self_spawn_errno":self_spawn_errno, "write_ok":write_ok, "spawn_error_kind":format!("{:?}", error.kind()), "spawn_errno":error.raw_os_error().unwrap_or(-1), @@ -320,6 +353,9 @@ impl Driver for Fixture { return Ok(json!({ "stdout":"", "read_ok":read_ok, + "execute_open_ok":execute_open_ok, + "self_spawn_ok":self_spawn_ok, + "self_spawn_errno":self_spawn_errno, "write_ok":write_ok, "spawn_error_kind":"", "spawn_errno":-1, @@ -335,6 +371,9 @@ impl Driver for Fixture { return Ok(json!({ "stdout":stdout, "read_ok":read_ok, + "execute_open_ok":execute_open_ok, + "self_spawn_ok":self_spawn_ok, + "self_spawn_errno":self_spawn_errno, "write_ok":write_ok, "spawn_error_kind":"", "spawn_errno":-1, @@ -503,6 +542,10 @@ async fn loopback_echo_task(path: String) { #[tokio::main(flavor = "current_thread")] async fn main() { + if std::env::var_os("SEMWRIGHT_FIXTURE_SELF_PROBE").is_some() { + println!("self-ok"); + return; + } #[cfg(windows)] if let Ok(path) = std::env::var("SEMWRIGHT_DRIVER_LOOPBACK_PIPE") { tokio::spawn(loopback_echo_task(path)); From 32bfb4d08973de71eb63a0004cfeaf7bf38bcd2d Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 15:03:06 -0600 Subject: [PATCH 17/37] security(windows): keep sealed tool children inside LPAC tree --- crates/platform-windows-sys/src/launch.rs | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 00a2150b3..77a127153 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -76,14 +76,15 @@ use windows::Win32::{ DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, INFINITE, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, - PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, - PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, ResumeThread, - STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, - WaitForSingleObject, + PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, PROC_THREAD_ATTRIBUTE_DESKTOP_APP_POLICY, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, + TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }, WindowsProgramming::{ PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, PROCESS_CREATION_CHILD_PROCESS_OVERRIDE, + PROCESS_CREATION_DESKTOP_APP_BREAKAWAY_DISABLE_PROCESS_TREE, }, }, }; @@ -2193,11 +2194,13 @@ impl SandboxLauncher for WindowsSandbox { let handles = [child_stdin.raw(), child_stdout.raw(), child_stderr.raw()]; let child_process_override = !spec.sealed_tools.is_empty(); - let mut attributes = ProcAttributes::new(if child_process_override { 4 } else { 3 })?; + let mut attributes = ProcAttributes::new(if child_process_override { 5 } else { 3 })?; attributes.set_slice(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; if child_process_override { let child_policy = PROCESS_CREATION_CHILD_PROCESS_OVERRIDE; attributes.set_value(PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &child_policy)?; + let desktop_policy = PROCESS_CREATION_DESKTOP_APP_BREAKAWAY_DISABLE_PROCESS_TREE; + attributes.set_value(PROC_THREAD_ATTRIBUTE_DESKTOP_APP_POLICY, &desktop_policy)?; } let all_application_packages_policy = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; attributes.set_value( From 4fbaa967b3c262e0d9184280a0a29da5401e058d Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 15:10:40 -0600 Subject: [PATCH 18/37] security(windows): verify sealed-tool child process policy --- crates/platform-windows-sys/src/launch.rs | 49 ++++++++++++++++++++--- 1 file changed, 44 insertions(+), 5 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 77a127153..ab4f1e240 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -69,17 +69,17 @@ use windows::Win32::{ ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE, ACCESS_ALLOWED_OBJECT_ACE_TYPE, ACCESS_DENIED_ACE_TYPE, ACCESS_DENIED_CALLBACK_ACE_TYPE, ACCESS_DENIED_CALLBACK_OBJECT_ACE_TYPE, ACCESS_DENIED_OBJECT_ACE_TYPE, - SE_GROUP_ENABLED, + PROCESS_MITIGATION_CHILD_PROCESS_POLICY, SE_GROUP_ENABLED, }, Threading::{ CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, - INFINITE, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, - PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + GetProcessMitigationPolicy, INFINITE, InitializeProcThreadAttributeList, + LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, PROC_THREAD_ATTRIBUTE_DESKTOP_APP_POLICY, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, - PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, - TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, + PROCESS_INFORMATION, ProcessChildProcessPolicy, ResumeThread, STARTF_USESTDHANDLES, + STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }, WindowsProgramming::{ PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, @@ -2073,6 +2073,36 @@ fn inherited_null() -> Result { Ok(NativeHandle(handle)) } +fn verify_child_process_creation_allowed(process: HANDLE) -> Result<()> { + let mut policy = PROCESS_MITIGATION_CHILD_PROCESS_POLICY::default(); + // SAFETY: process is a live suspended child HANDLE and policy is a correctly-sized output. + unsafe { + GetProcessMitigationPolicy( + process, + ProcessChildProcessPolicy, + (&mut policy as *mut PROCESS_MITIGATION_CHILD_PROCESS_POLICY).cast(), + std::mem::size_of::(), + ) + } + .map_err(|_| { + Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool child-process mitigation policy could not be queried", + ) + })?; + // SAFETY: Flags is the active union member for PROCESS_MITIGATION_CHILD_PROCESS_POLICY. + let flags = unsafe { policy.Anonymous.Flags }; + if flags & 0x1 != 0 { + return Err(Error::new( + ErrorCode::SandboxDenied, + format!( + "Windows sealed-tool child-process policy remained restricted after override (flags={flags:#x})" + ), + )); + } + Ok(()) +} + struct NativeSandboxChild { process: NativeHandle, _job: Arc, @@ -2298,6 +2328,15 @@ impl SandboxLauncher for WindowsSandbox { let process = NativeHandle(process_info.hProcess); let thread = NativeHandle(process_info.hThread); + if child_process_override + && let Err(error) = verify_child_process_creation_allowed(process.raw()) + { + // SAFETY: the child is still suspended and no untrusted instruction has run. + unsafe { + let _ = TerminateProcess(process.raw(), 1); + } + return Err(error); + } if let Err(error) = job.assign_suspended_process(process.raw()) { // SAFETY: child is still suspended and must not survive a failed containment step. unsafe { From e8186fdbce8165252bb6c0985fa980be7732ac33 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 15:19:42 -0600 Subject: [PATCH 19/37] test(windows): isolate sealed tool stdio spawn failure --- crates/driver-host/src/bin/fixture.rs | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/crates/driver-host/src/bin/fixture.rs b/crates/driver-host/src/bin/fixture.rs index ea7e2332d..505eaf08c 100644 --- a/crates/driver-host/src/bin/fixture.rs +++ b/crates/driver-host/src/bin/fixture.rs @@ -96,12 +96,14 @@ fn tool_capability() -> Capability { "execute_open_ok":{"type":"boolean"}, "self_spawn_ok":{"type":"boolean"}, "self_spawn_errno":{"type":"integer"}, + "null_spawn_ok":{"type":"boolean"}, + "null_spawn_errno":{"type":"integer"}, "write_ok":{"type":"boolean"}, "spawn_error_kind":{"type":"string"}, "spawn_errno":{"type":"integer"}, "exit_code":{"type":"integer"} }, - "required":["stdout","read_ok","execute_open_ok","self_spawn_ok","self_spawn_errno","write_ok","spawn_error_kind","spawn_errno","exit_code"], + "required":["stdout","read_ok","execute_open_ok","self_spawn_ok","self_spawn_errno","null_spawn_ok","null_spawn_errno","write_ok","spawn_error_kind","spawn_errno","exit_code"], "additionalProperties":false }), requires: vec!["driver:fixture".into()], @@ -331,6 +333,21 @@ impl Driver for Fixture { }; #[cfg(not(windows))] let (self_spawn_ok, self_spawn_errno) = (true, -1); + #[cfg(windows)] + let (null_spawn_ok, null_spawn_errno) = { + use std::process::Stdio; + match std::process::Command::new(&tool) + .stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + { + Ok(status) => (status.success(), -1), + Err(error) => (false, error.raw_os_error().unwrap_or(-1)), + } + }; + #[cfg(not(windows))] + let (null_spawn_ok, null_spawn_errno) = (true, -1); let write_ok = std::fs::OpenOptions::new().write(true).open(&tool).is_ok(); let output = match std::process::Command::new(&tool).output() { Ok(output) => output, @@ -341,6 +358,8 @@ impl Driver for Fixture { "execute_open_ok":execute_open_ok, "self_spawn_ok":self_spawn_ok, "self_spawn_errno":self_spawn_errno, + "null_spawn_ok":null_spawn_ok, + "null_spawn_errno":null_spawn_errno, "write_ok":write_ok, "spawn_error_kind":format!("{:?}", error.kind()), "spawn_errno":error.raw_os_error().unwrap_or(-1), @@ -356,6 +375,8 @@ impl Driver for Fixture { "execute_open_ok":execute_open_ok, "self_spawn_ok":self_spawn_ok, "self_spawn_errno":self_spawn_errno, + "null_spawn_ok":null_spawn_ok, + "null_spawn_errno":null_spawn_errno, "write_ok":write_ok, "spawn_error_kind":"", "spawn_errno":-1, @@ -374,6 +395,8 @@ impl Driver for Fixture { "execute_open_ok":execute_open_ok, "self_spawn_ok":self_spawn_ok, "self_spawn_errno":self_spawn_errno, + "null_spawn_ok":null_spawn_ok, + "null_spawn_errno":null_spawn_errno, "write_ok":write_ok, "spawn_error_kind":"", "spawn_errno":-1, From 378dd4f7f5c315fb49ae7bb2583b89e177f10940 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 15:29:13 -0600 Subject: [PATCH 20/37] ci(windows): isolate sealed tool host compatibility --- .github/workflows/windows-platform.yml | 39 ++++++++++++++++++++++++++ 1 file changed, 39 insertions(+) diff --git a/.github/workflows/windows-platform.yml b/.github/workflows/windows-platform.yml index 13caa5c71..fe17199e1 100644 --- a/.github/workflows/windows-platform.yml +++ b/.github/workflows/windows-platform.yml @@ -169,3 +169,42 @@ jobs: name: windows-${{ matrix.arch }}-evidence path: verification/platform-ci/ retention-days: 14 + + sealed-tool-compat: + name: sealed-tool compat ${{ matrix.name }} + strategy: + fail-fast: false + matrix: + include: + - name: server2022-x64 + runs_on: windows-2022 + toolchain: "1.98.1" + - name: win11-arm-x64emu + runs_on: windows-11-arm + toolchain: "1.98.1-x86_64-pc-windows-msvc" + runs-on: ${{ matrix.runs_on }} + timeout-minutes: 30 + defaults: + run: + shell: bash + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 + with: + persist-credentials: false + - name: Install diagnostic Rust toolchain + run: | + set -euo pipefail + rustup toolchain install "${{ matrix.toolchain }}" --profile minimal + - name: Record compatibility environment + run: | + set -euo pipefail + echo "RUNNER_ARCH=$RUNNER_ARCH" + echo "PROCESSOR_ARCHITECTURE=${PROCESSOR_ARCHITECTURE:-unknown}" + rustc +${{ matrix.toolchain }} -vV + - name: Sealed tool nested execution probe + run: >- + cargo +${{ matrix.toolchain }} test --locked + -p semwright-driver-host + --test windows_secure_host + -- secure_windows_driver_sealed_tool_is_staged_immutable_and_executable + --nocapture From 9010163261ae233a14154e11c71a092d367d80f6 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 15:42:59 -0600 Subject: [PATCH 21/37] fix(windows): keep sealed tool children in AppContainer --- .github/workflows/windows-platform.yml | 2 +- Cargo.lock | 1 + crates/driver-host/Cargo.toml | 7 ++++ crates/driver-host/src/bin/tool_fixture.rs | 41 ++++++++++++++++++- .../driver-host/tests/windows_secure_host.rs | 5 ++- crates/platform-windows-sys/src/launch.rs | 13 +++--- 6 files changed, 58 insertions(+), 11 deletions(-) diff --git a/.github/workflows/windows-platform.yml b/.github/workflows/windows-platform.yml index fe17199e1..5db26abe8 100644 --- a/.github/workflows/windows-platform.yml +++ b/.github/workflows/windows-platform.yml @@ -194,7 +194,7 @@ jobs: - name: Install diagnostic Rust toolchain run: | set -euo pipefail - rustup toolchain install "${{ matrix.toolchain }}" --profile minimal + rustup toolchain install "${{ matrix.toolchain }}" --profile minimal --force-non-host - name: Record compatibility environment run: | set -euo pipefail diff --git a/Cargo.lock b/Cargo.lock index 7f4afce69..79762018c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3055,6 +3055,7 @@ dependencies = [ "tempfile", "tokio", "tokio-util", + "windows", ] [[package]] diff --git a/crates/driver-host/Cargo.toml b/crates/driver-host/Cargo.toml index b8718b8e5..ca27f4248 100644 --- a/crates/driver-host/Cargo.toml +++ b/crates/driver-host/Cargo.toml @@ -25,6 +25,13 @@ tokio-util.workspace = true async-trait.workspace = true libc.workspace = true +[target.'cfg(windows)'.dependencies] +windows = { version = "0.62.2", features = [ + "Win32_Foundation", + "Win32_Security", + "Win32_System_Threading", +] } + [dev-dependencies] tempfile.workspace = true diff --git a/crates/driver-host/src/bin/tool_fixture.rs b/crates/driver-host/src/bin/tool_fixture.rs index 325361e9e..81f9c422c 100644 --- a/crates/driver-host/src/bin/tool_fixture.rs +++ b/crates/driver-host/src/bin/tool_fixture.rs @@ -1,3 +1,42 @@ +#[cfg(windows)] +fn is_appcontainer() -> bool { + use windows::Win32::{ + Foundation::{CloseHandle, HANDLE}, + Security::{GetTokenInformation, TOKEN_QUERY, TokenIsAppContainer}, + System::Threading::{GetCurrentProcess, OpenProcessToken}, + }; + + let mut token = HANDLE::default(); + // SAFETY: GetCurrentProcess returns the current pseudo-handle and token is a writable out handle. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) }.is_err() { + return false; + } + let mut value = 0u32; + let mut returned = 0u32; + // SAFETY: token is live, value is a correctly-sized DWORD buffer, and returned is writable. + let result = unsafe { + GetTokenInformation( + token, + TokenIsAppContainer, + Some((&mut value as *mut u32).cast()), + std::mem::size_of::() as u32, + &mut returned, + ) + }; + // SAFETY: token was opened successfully above and is owned by this function. + unsafe { + let _ = CloseHandle(token); + } + result.is_ok() && returned == std::mem::size_of::() as u32 && value != 0 +} + fn main() { - print!("tool-ok"); + #[cfg(windows)] + { + print!("tool-ok|appcontainer={}", u8::from(is_appcontainer())); + } + #[cfg(not(windows))] + { + print!("tool-ok"); + } } diff --git a/crates/driver-host/tests/windows_secure_host.rs b/crates/driver-host/tests/windows_secure_host.rs index 7cf712dc6..6ff96ba95 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -194,7 +194,10 @@ async fn secure_windows_driver_sealed_tool_is_staged_immutable_and_executable() output["exit_code"], 0, "sealed tool exit diagnostics: {output}" ); - assert_eq!(output["stdout"], "tool-ok", "sealed tool output: {output}"); + assert_eq!( + output["stdout"], "tool-ok|appcontainer=1", + "sealed tool must execute without breaking out of AppContainer: {output}" + ); assert_eq!( output["read_ok"], true, "sealed tool must remain readable: {output}" diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index ab4f1e240..997b04ecd 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -76,15 +76,14 @@ use windows::Win32::{ DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, GetProcessMitigationPolicy, INFINITE, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, - PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, PROC_THREAD_ATTRIBUTE_DESKTOP_APP_POLICY, - PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, - PROCESS_INFORMATION, ProcessChildProcessPolicy, ResumeThread, STARTF_USESTDHANDLES, - STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, + PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, + ProcessChildProcessPolicy, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, + TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }, WindowsProgramming::{ PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, PROCESS_CREATION_CHILD_PROCESS_OVERRIDE, - PROCESS_CREATION_DESKTOP_APP_BREAKAWAY_DISABLE_PROCESS_TREE, }, }, }; @@ -2224,13 +2223,11 @@ impl SandboxLauncher for WindowsSandbox { let handles = [child_stdin.raw(), child_stdout.raw(), child_stderr.raw()]; let child_process_override = !spec.sealed_tools.is_empty(); - let mut attributes = ProcAttributes::new(if child_process_override { 5 } else { 3 })?; + let mut attributes = ProcAttributes::new(if child_process_override { 4 } else { 3 })?; attributes.set_slice(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; if child_process_override { let child_policy = PROCESS_CREATION_CHILD_PROCESS_OVERRIDE; attributes.set_value(PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &child_policy)?; - let desktop_policy = PROCESS_CREATION_DESKTOP_APP_BREAKAWAY_DISABLE_PROCESS_TREE; - attributes.set_value(PROC_THREAD_ATTRIBUTE_DESKTOP_APP_POLICY, &desktop_policy)?; } let all_application_packages_policy = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; attributes.set_value( From bd1a578570dd98409115e96d2771a78b4cca3264 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 16:54:09 -0600 Subject: [PATCH 22/37] feat(windows): mediate sealed tools through Driver Host --- crates/driver-host/src/bin/fixture.rs | 62 ++ crates/driver-host/src/lib.rs | 546 ++++++++++++++++-- crates/driver-host/tests/protocol_v2.rs | 1 + .../driver-host/tests/windows_secure_host.rs | 12 +- crates/driver-sdk/src/lib.rs | 241 +++++++- crates/platform-api/src/launch.rs | 22 +- crates/platform-windows-sys/src/launch.rs | 89 ++- .../tests/secure_spawn.rs | 21 +- docs/blender/SDK_GAPS.md | 2 +- docs/drivers.md | 16 +- docs/motion-canvas/INTEGRATION.md | 4 +- docs/motion-canvas/SDK_GAPS.md | 2 +- docs/motion-canvas/SECURITY.md | 2 +- 13 files changed, 914 insertions(+), 106 deletions(-) diff --git a/crates/driver-host/src/bin/fixture.rs b/crates/driver-host/src/bin/fixture.rs index 505eaf08c..acc018f90 100644 --- a/crates/driver-host/src/bin/fixture.rs +++ b/crates/driver-host/src/bin/fixture.rs @@ -255,6 +255,7 @@ impl Driver for Fixture { artifacts: true, health: true, native_refs: false, + host_tools: std::env::var_os("SEMWRIGHT_DRIVER_HOST_TOOLS").is_some(), } } async fn capabilities(&mut self) -> Result> { @@ -465,6 +466,67 @@ impl Driver for Fixture { args: Value, context: DriverExecutionContext, ) -> Result { + if command == "driver.fixture.tool_probe" + && std::env::var_os("SEMWRIGHT_DRIVER_HOST_TOOLS").is_some() + { + let capability = tool_capability(); + if descriptor_digest(&capability.descriptor)? != pinned_digest { + return Err(Error::new( + ErrorCode::StaleReference, + "Driver descriptor is not the pinned capability", + )); + } + if args.as_object().is_none_or(|args| !args.is_empty()) { + return Err(Error::invalid("fixture tool probe accepts an empty object")); + } + + let direct_path_visible = tool_path("probe").is_ok(); + #[cfg(windows)] + let (self_spawn_ok, self_spawn_errno) = match std::env::current_exe() { + Ok(executable) => match std::process::Command::new(executable) + .env("SEMWRIGHT_FIXTURE_SELF_PROBE", "1") + .output() + { + Ok(output) => ( + output.status.success() && output.stdout.starts_with(b"self-ok"), + -1, + ), + Err(error) => (false, error.raw_os_error().unwrap_or(-1)), + }, + Err(error) => (false, error.raw_os_error().unwrap_or(-1)), + }; + #[cfg(not(windows))] + let (self_spawn_ok, self_spawn_errno) = (false, -1); + + let output = context + .execute_tool( + "probe", + Vec::new(), + Vec::new(), + std::time::Duration::from_millis(1_500), + ) + .await?; + let stdout = String::from_utf8(output.stdout).map_err(|_| { + Error::new( + ErrorCode::BackendFailed, + "fixture Host-tool output was not UTF-8", + ) + })?; + return Ok(json!({ + "stdout":stdout, + "read_ok":direct_path_visible, + "execute_open_ok":false, + "self_spawn_ok":self_spawn_ok, + "self_spawn_errno":self_spawn_errno, + "null_spawn_ok":false, + "null_spawn_errno":-1, + "write_ok":false, + "spawn_error_kind":"", + "spawn_errno":-1, + "exit_code":output.exit_code + })); + } + if command != "driver.fixture.long" { return self.execute(command, pinned_digest, args).await; } diff --git a/crates/driver-host/src/lib.rs b/crates/driver-host/src/lib.rs index a61d05b01..93389197a 100644 --- a/crates/driver-host/src/lib.rs +++ b/crates/driver-host/src/lib.rs @@ -7,9 +7,11 @@ use semwright_backend_api::{ Context, ProvidedCapability, Provider, ProviderInterfaces, ProviderSignal, }; use semwright_driver_sdk::{ - DriverInterfaces, DriverRequestContext, Manifest, Request, Response, capabilities_digest, - descriptor_digest, + DriverInterfaces, DriverRequestContext, Manifest, Request, Response, ToolExecutionOutput, + capabilities_digest, descriptor_digest, validate_tool_execute_request, }; +#[cfg(target_os = "windows")] +use semwright_platform_api::launch::{ResourceLimits, SandboxSpec}; use semwright_platform_api::launch::{SandboxCpuAccounting, SandboxStdin, SandboxStdout}; use semwright_policy::FilesystemGrant; use semwright_protocol::{read_frame, write_frame}; @@ -27,7 +29,7 @@ use std::os::{ unix::fs::{MetadataExt, OpenOptionsExt, PermissionsExt}, }; use std::{ - collections::BTreeMap, + collections::{BTreeMap, BTreeSet}, io::Write, path::{Path, PathBuf}, sync::{Arc, RwLock as StdRwLock}, @@ -37,7 +39,10 @@ use std::{ use std::{ffi::CString, os::fd::FromRawFd}; #[cfg(all(test, unix))] use tokio::process::Command; -use tokio::sync::{Mutex, broadcast, oneshot}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + sync::{Mutex, broadcast, oneshot}, +}; use tokio_util::sync::CancellationToken; struct StagedFile(PathBuf); @@ -71,6 +76,7 @@ impl SealedTool { } #[cfg(target_os = "windows")] +#[derive(Clone)] struct SealedTool { name: String, staged: Arc, @@ -89,6 +95,203 @@ impl SealedTool { } } +#[async_trait] +trait HostToolExecutor: Send + Sync { + async fn execute( + &self, + name: &str, + args: Vec, + stdin: Vec, + timeout_ms: u64, + charged_cpu_seconds: Arc>, + ) -> Result; +} + +#[cfg(target_os = "windows")] +const MAX_HOST_TOOL_OUTPUT_BYTES: usize = 256 * 1024; +const MAX_HOST_TOOL_CALLS: usize = 8; + +#[cfg(target_os = "windows")] +struct HostToolBroker { + tools: BTreeMap, + template: SandboxSpec, + operation_cpu_seconds: u64, +} + +#[cfg(target_os = "windows")] +impl HostToolBroker { + fn new( + root_spec: &SandboxSpec, + tools: &[SealedTool], + operation_cpu_seconds: u64, + ) -> Result { + let mut by_name = BTreeMap::new(); + for tool in tools { + if by_name.insert(tool.name.clone(), tool.clone()).is_some() { + return Err(Error::invalid("Duplicate Host-mediated sealed tool")); + } + } + let mut template = root_spec.clone(); + template.mounts.clear(); + template.environment.clear(); + template.sealed_tools.clear(); + // Tool subprocesses receive no ambient filesystem, secret, system-config, network or + // loopback authority. A future per-tool grant contract may opt into narrower authority + // explicitly, but the driver root spec is never inherited wholesale. + template.network = false; + Ok(Self { + tools: by_name, + template, + operation_cpu_seconds, + }) + } +} + +#[cfg(target_os = "windows")] +#[async_trait] +impl HostToolExecutor for HostToolBroker { + async fn execute( + &self, + name: &str, + args: Vec, + stdin_bytes: Vec, + timeout_ms: u64, + charged_cpu_seconds: Arc>, + ) -> Result { + validate_tool_execute_request(name, &args, &stdin_bytes, timeout_ms)?; + let tool = self.tools.get(name).ok_or_else(|| { + Error::new( + ErrorCode::PolicyDenied, + "Driver requested an ungranted sealed tool", + ) + })?; + // Bind each invocation to the exact Host-staged immutable bytes. + let _ = verify_owned_executable(&tool.staged.0, &tool.sha256)?; + + let remaining_operation_cpu = if self.operation_cpu_seconds == 0 { + None + } else { + let charged = *charged_cpu_seconds.lock().await; + Some( + self.operation_cpu_seconds + .checked_sub(charged) + .filter(|remaining| *remaining > 0) + .ok_or_else(|| { + Error::new( + ErrorCode::ResourceExhausted, + "Host-mediated sealed tool exhausted the parent operation CPU budget", + ) + })?, + ) + }; + + let mut spec = self.template.clone(); + spec.staged_executable = tool.staged.0.clone(); + spec.args = args; + spec.environment.clear(); + spec.sealed_tools.clear(); + let timeout = Duration::from_millis(timeout_ms); + let timeout_cpu_seconds = timeout + .as_secs() + .saturating_add(u64::from(timeout.subsec_nanos() != 0)) + .max(1); + spec.limits = Some(match spec.limits.take() { + Some(limit) => ResourceLimits { + open_files: limit.open_files, + processes: 1, + cpu_seconds: limit + .cpu_seconds + .min(timeout_cpu_seconds) + .min(remaining_operation_cpu.unwrap_or(u64::MAX)), + address_space_bytes: limit.address_space_bytes, + file_size_bytes: limit.file_size_bytes, + }, + None => ResourceLimits { + open_files: 64, + processes: 1, + cpu_seconds: timeout_cpu_seconds.min(remaining_operation_cpu.unwrap_or(u64::MAX)), + address_space_bytes: 512 * 1024 * 1024, + file_size_bytes: 16 * 1024 * 1024, + }, + }); + + let mut child = semwright_platform_services::sandbox_spawn(&spec)?; + let cpu_accounting = child.cpu_accounting(); + let mut child_stdin = child.take_stdin()?; + let child_stdout = child.take_stdout()?; + + let execution = async { + if !stdin_bytes.is_empty() { + child_stdin.write_all(&stdin_bytes).await?; + } + child_stdin.shutdown().await?; + drop(child_stdin); + + let mut stdout = Vec::new(); + let mut bounded = child_stdout.take((MAX_HOST_TOOL_OUTPUT_BYTES + 1) as u64); + bounded.read_to_end(&mut stdout).await?; + if stdout.len() > MAX_HOST_TOOL_OUTPUT_BYTES { + return Err(Error::new( + ErrorCode::ResourceExhausted, + "Host-mediated sealed tool output exceeded its bound", + )); + } + + let exit_code = child.wait_exit_code().await?.ok_or_else(|| { + Error::new( + ErrorCode::BackendFailed, + "Host-mediated sealed tool did not expose an exit code", + ) + })?; + let output = ToolExecutionOutput { + exit_code, + stdout, + stderr: Vec::new(), + }; + output.validate()?; + Ok(output) + }; + + let result = match tokio::time::timeout(timeout, execution).await { + Ok(Ok(output)) => Ok(output), + Ok(Err(error)) => { + let _ = child.kill().await; + Err(error) + } + Err(_) => { + let _ = child.kill().await; + Err(Error::new( + ErrorCode::Timeout, + "Host-mediated sealed tool timed out", + )) + } + }; + + if self.operation_cpu_seconds != 0 { + let accounting = cpu_accounting.ok_or_else(|| { + Error::new( + ErrorCode::Unsupported, + "Host-mediated sealed tool lacks Windows Job CPU accounting", + ) + })?; + let consumed = accounting.total_cpu_time()?; + let charge = consumed + .as_secs() + .saturating_add(u64::from(consumed.subsec_nanos() != 0)); + let mut charged = charged_cpu_seconds.lock().await; + *charged = charged.saturating_add(charge); + if *charged > self.operation_cpu_seconds { + return Err(Error::new( + ErrorCode::ResourceExhausted, + "Host-mediated sealed tools exceeded the parent operation CPU budget", + )); + } + } + + result + } +} + #[cfg(target_os = "windows")] fn seal_verified_tool(path: &Path, digest: &str, name: &str, state: &Path) -> Result { let bytes = verify_owned_executable(path, digest)?; @@ -501,6 +704,13 @@ fn validate_owner_permissions( } validate_secret_source(&grant.path)?; } + #[cfg(target_os = "windows")] + if !manifest.tools.is_empty() && (manifest.protocol < 4 || !manifest.interfaces.host_tools) { + return Err(Error::new( + ErrorCode::Unsupported, + "Windows sealed tools require Host-mediated driver protocol v4", + )); + } for tool in &manifest.tools { let grant = roots .iter() @@ -552,9 +762,16 @@ struct Io { output: SandboxStdout, } +struct PendingResponse { + sender: oneshot::Sender, + allows_host_tools: bool, + cancellation: CancellationToken, + charged_tool_cpu_seconds: Arc>, +} + struct V2Io { - input: Mutex, - pending: Arc>>>, + input: Arc>, + pending: Arc>>, } #[cfg_attr(target_os = "windows", allow(dead_code))] @@ -566,9 +783,28 @@ enum ProtocolIo { impl V2Io { async fn begin(&self, request: &Request, id: &str) -> Result> { let (sender, receiver) = oneshot::channel(); + let cancellation = CancellationToken::new(); + let allows_host_tools = matches!(request, Request::Execute { .. }); { let mut pending = self.pending.lock().await; - if pending.insert(id.to_owned(), sender).is_some() { + if pending.len() >= 256 { + return Err(Error::new( + ErrorCode::ResourceExhausted, + "Driver protocol has too many pending requests", + )); + } + if pending + .insert( + id.to_owned(), + PendingResponse { + sender, + allows_host_tools, + cancellation, + charged_tool_cpu_seconds: Arc::new(Mutex::new(0)), + }, + ) + .is_some() + { return Err(Error::new( ErrorCode::Conflict, "Driver protocol request ID is already pending", @@ -580,19 +816,25 @@ impl V2Io { write_frame(&mut *input, request).await }; if let Err(error) = result { - self.pending.lock().await.remove(id); + self.cancel_pending(id).await; return Err(error); } Ok(receiver) } + async fn cancel_pending(&self, id: &str) { + if let Some(pending) = self.pending.lock().await.remove(id) { + pending.cancellation.cancel(); + } + } + async fn request(&self, request: &Request, id: &str, timeout: Duration) -> Result { let receiver = self.begin(request, id).await?; match tokio::time::timeout(timeout, receiver).await { Ok(Ok(response)) => Ok(response), Ok(Err(_)) => Err(Error::unavailable("Driver response channel closed")), Err(_) => { - self.pending.lock().await.remove(id); + self.cancel_pending(id).await; Err(Error::new( ErrorCode::Timeout, "Driver protocol request timed out", @@ -627,18 +869,56 @@ fn response_id(response: &Response) -> Option<&str> { Response::Ready { .. } | Response::Event { .. } | Response::CapabilitiesChanged - | Response::Progress { .. } => None, + | Response::Progress { .. } + | Response::ToolExecute { .. } => None, } } +fn host_tool_parent( + pending: &BTreeMap, + tool_id: &str, + parent: &str, +) -> Option<(CancellationToken, Arc>)> { + if pending.contains_key(tool_id) { + return None; + } + pending.get(parent).and_then(|entry| { + entry.allows_host_tools.then(|| { + ( + entry.cancellation.clone(), + entry.charged_tool_cpu_seconds.clone(), + ) + }) + }) +} + +fn register_host_tool_call( + calls: &mut BTreeSet, + parents: &mut BTreeSet, + id: &str, + parent: &str, +) -> bool { + if calls.len() >= MAX_HOST_TOOL_CALLS || calls.contains(id) || parents.contains(parent) { + return false; + } + calls.insert(id.to_owned()); + parents.insert(parent.to_owned()); + true +} + fn spawn_v2_reader( mut output: SandboxStdout, - pending: Arc>>>, + input: Arc>, + pending: Arc>>, signals: broadcast::Sender, interfaces: DriverInterfaces, + protocol: u32, + tool_broker: Option>, closed: CancellationToken, terminate: CancellationToken, ) { + let tool_calls = Arc::new(Mutex::new(BTreeSet::::new())); + let tool_parents = Arc::new(Mutex::new(BTreeSet::::new())); tokio::spawn(async move { loop { let response = match read_frame::<_, Response>(&mut output).await { @@ -687,25 +967,127 @@ fn spawn_v2_reader( artifacts, }); } + Response::ToolExecute { + id, + parent, + name, + args, + stdin, + timeout_ms, + } => { + let parent_state = { + let pending = pending.lock().await; + host_tool_parent(&pending, &id, &parent) + }; + let valid = protocol >= 4 + && interfaces.host_tools + && validate_tool_execute_request(&name, &args, &stdin, timeout_ms).is_ok() + && parent_state.is_some(); + let registered = if valid { + let mut calls = tool_calls.lock().await; + let mut parents = tool_parents.lock().await; + register_host_tool_call(&mut calls, &mut parents, &id, &parent) + } else { + false + }; + if !registered { + terminate.cancel(); + closed.cancel(); + break; + } + + let (parent_cancellation, charged_cpu_seconds) = + parent_state.expect("validated Host-tool parent"); + let input = input.clone(); + let pending = pending.clone(); + let broker = tool_broker.clone(); + let tool_calls = tool_calls.clone(); + let tool_parents = tool_parents.clone(); + let terminate_call = terminate.clone(); + let closed_call = closed.clone(); + tokio::spawn(async move { + let execution = async { + match broker { + Some(broker) => { + broker + .execute( + &name, + args, + stdin, + timeout_ms, + charged_cpu_seconds, + ) + .await + } + None => Err(Error::new( + ErrorCode::Unsupported, + "Host-mediated sealed tools are unavailable on this platform", + )), + } + }; + let result = tokio::select! { + _ = parent_cancellation.cancelled() => Err(Error::new( + ErrorCode::Cancelled, + "Host-mediated sealed tool parent request ended", + )), + _ = closed_call.cancelled() => Err(Error::unavailable( + "Driver connection closed during Host-mediated tool execution", + )), + result = execution => result, + }; + tool_calls.lock().await.remove(&id); + tool_parents.lock().await.remove(&parent); + + let parent_active = { + let pending = pending.lock().await; + pending.get(&parent).is_some_and(|entry| { + entry.allows_host_tools && !entry.cancellation.is_cancelled() + }) + }; + if !parent_active { + return; + } + + let request = match result { + Ok(output) => Request::ToolResult { id, output }, + Err(error) => Request::ToolFailure { + id, + error: Error::new(error.code, "Host-mediated sealed tool failed"), + }, + }; + let mut input = input.lock().await; + if write_frame(&mut **input, &request).await.is_err() { + terminate_call.cancel(); + closed_call.cancel(); + } + }); + } other => { let Some(id) = response_id(&other).map(str::to_owned) else { terminate.cancel(); closed.cancel(); break; }; - let sender = pending.lock().await.remove(&id); - let Some(sender) = sender else { + let pending_response = pending.lock().await.remove(&id); + let Some(pending_response) = pending_response else { terminate.cancel(); closed.cancel(); break; }; - let _ = sender.send(other); + pending_response.cancellation.cancel(); + let _ = pending_response.sender.send(other); } } } terminate.cancel(); closed.cancel(); - pending.lock().await.clear(); + let pending_responses = { + let mut pending = pending.lock().await; + std::mem::take(&mut *pending) + }; + for (_, pending_response) in pending_responses { + pending_response.cancellation.cancel(); + } let _ = signals.send(ProviderSignal::Disconnected); }); } @@ -851,7 +1233,6 @@ fn sandbox_spec_windows( staged: &Path, helper: &Path, roots: &[FilesystemGrant], - sealed_tools: &[SealedTool], loopback_pipe: Option<&Path>, ) -> Result { use semwright_platform_api::launch::{ @@ -923,7 +1304,7 @@ fn sandbox_spec_windows( }) .collect::>>()?, ); - let environment = loopback_pipe + let mut environment = loopback_pipe .map(|path| { path.to_str() .ok_or_else(|| Error::invalid("Windows loopback pipe path must be Unicode")) @@ -931,6 +1312,9 @@ fn sandbox_spec_windows( }) .transpose()? .unwrap_or_default(); + if manifest.interfaces.host_tools { + environment.push(("SEMWRIGHT_DRIVER_HOST_TOOLS".into(), "1".into())); + } Ok(SandboxSpec { kind: SandboxKind::Driver, staged_executable: staged.into(), @@ -938,7 +1322,7 @@ fn sandbox_spec_windows( mounts, args: vec![], environment, - sealed_tools: sealed_tools.iter().map(SealedTool::sandbox_mount).collect(), + sealed_tools: Vec::new(), network: manifest.network, limits: Some(ResourceLimits { open_files: manifest.resources.open_files, @@ -996,6 +1380,14 @@ impl DriverProvider { // Bind trust checks to the exact staged file that will execute. let _ = verify_owned_executable(&staged_path, &manifest.sha256)?; + if !manifest.tools.is_empty() + && (manifest.protocol < 4 || !manifest.interfaces.host_tools) + { + return Err(Error::new( + ErrorCode::Unsupported, + "Windows sealed tools require protocol v4 Host-mediated execution", + )); + } let sealed_tools = manifest .tools .iter() @@ -1018,9 +1410,17 @@ impl DriverProvider { &staged_path, helper, roots, - &sealed_tools, loopback.as_deref().map(loopback::LoopbackProxy::pipe_path), )?; + let tool_broker: Option> = if manifest.interfaces.host_tools { + Some(Arc::new(HostToolBroker::new( + &spec, + &sealed_tools, + manifest.resources.operation_cpu_seconds, + )?)) + } else { + None + }; let mut child = semwright_platform_services::sandbox_spawn(&spec)?; let process_id = child .id() @@ -1177,21 +1577,19 @@ impl DriverProvider { let (signals, _) = broadcast::channel(128); let pending = Arc::new(Mutex::new(BTreeMap::new())); let Io { input, output } = io; + let input = Arc::new(Mutex::new(input)); spawn_v2_reader( output, + input.clone(), pending.clone(), signals.clone(), manifest.interfaces, + manifest.protocol, + tool_broker, closed.clone(), terminate.clone(), ); - ( - ProtocolIo::V2(V2Io { - input: Mutex::new(input), - pending, - }), - Some(signals), - ) + (ProtocolIo::V2(V2Io { input, pending }), Some(signals)) } else { (ProtocolIo::V1(Mutex::new(io)), None) }; @@ -1273,6 +1671,7 @@ impl DriverProvider { loopback.as_deref().map(loopback::LoopbackProxy::directory), &sealed_tools, )?; + let tool_broker: Option> = None; let mut child = semwright_platform_services::sandbox_spawn(&spec)?; let process_id = child .id() @@ -1422,21 +1821,19 @@ impl DriverProvider { let (signals, _) = broadcast::channel(128); let pending = Arc::new(Mutex::new(BTreeMap::new())); let Io { input, output } = io; + let input = Arc::new(Mutex::new(input)); spawn_v2_reader( output, + input.clone(), pending.clone(), signals.clone(), manifest.interfaces, + manifest.protocol, + tool_broker, closed.clone(), terminate.clone(), ); - ( - ProtocolIo::V2(V2Io { - input: Mutex::new(input), - pending, - }), - Some(signals), - ) + (ProtocolIo::V2(V2Io { input, pending }), Some(signals)) } else { (ProtocolIo::V1(Mutex::new(io)), None) }; @@ -1754,7 +2151,7 @@ impl Provider for DriverProvider { Ok(Ok(response)) => Ok(response), Ok(Err(_)) => Err(Error::unavailable("Driver response channel closed")), Err(_) => { - io.pending.lock().await.remove(&id); + io.cancel_pending(&id).await; Err(Error::new(ErrorCode::Timeout, "Driver execution timed out")) } } @@ -1822,7 +2219,7 @@ impl Provider for DriverProvider { biased; budget = &mut cpu_watch => { if let ProtocolIo::V2(io) = &self.io { - io.pending.lock().await.remove(&id); + io.cancel_pending(&id).await; } self.terminate.cancel(); let terminated = tokio::time::timeout( @@ -2011,6 +2408,85 @@ pub async fn conformance( }) } +#[cfg(test)] +mod host_tool_protocol_tests { + use super::*; + + fn pending_entry(allows_host_tools: bool) -> PendingResponse { + let (sender, _receiver) = oneshot::channel(); + PendingResponse { + sender, + allows_host_tools, + cancellation: CancellationToken::new(), + charged_tool_cpu_seconds: Arc::new(Mutex::new(0)), + } + } + + #[test] + fn host_tool_parent_must_be_execute_and_ids_must_not_collide() { + let mut pending = BTreeMap::new(); + pending.insert("execute-parent".into(), pending_entry(true)); + pending.insert("health-parent".into(), pending_entry(false)); + + assert!( + host_tool_parent(&pending, "tool-1", "execute-parent").is_some(), + "an active Execute request may own a Host-mediated tool call" + ); + assert!( + host_tool_parent(&pending, "tool-2", "health-parent").is_none(), + "non-Execute requests may not gain Host-tool authority" + ); + assert!( + host_tool_parent(&pending, "execute-parent", "execute-parent").is_none(), + "tool IDs may not collide with pending protocol request IDs" + ); + assert!( + host_tool_parent(&pending, "tool-3", "missing-parent").is_none(), + "tool calls may not outlive or invent their parent request" + ); + } + + #[test] + fn host_tool_calls_are_unique_parent_scoped_and_bounded() { + let mut calls = BTreeSet::new(); + let mut parents = BTreeSet::new(); + for index in 0..MAX_HOST_TOOL_CALLS { + assert!(register_host_tool_call( + &mut calls, + &mut parents, + &format!("tool-{index}"), + &format!("parent-{index}"), + )); + } + assert!(!register_host_tool_call( + &mut calls, + &mut parents, + "tool-overflow", + "parent-overflow", + )); + calls.remove("tool-0"); + parents.remove("parent-0"); + assert!(!register_host_tool_call( + &mut calls, + &mut parents, + "tool-1", + "parent-replacement", + )); + assert!(!register_host_tool_call( + &mut calls, + &mut parents, + "tool-replacement", + "parent-1", + )); + assert!(register_host_tool_call( + &mut calls, + &mut parents, + "tool-replacement", + "parent-replacement", + )); + } +} + #[cfg(all(test, unix))] mod tests { use super::*; diff --git a/crates/driver-host/tests/protocol_v2.rs b/crates/driver-host/tests/protocol_v2.rs index 8a6c12d54..f40d7f61e 100644 --- a/crates/driver-host/tests/protocol_v2.rs +++ b/crates/driver-host/tests/protocol_v2.rs @@ -63,6 +63,7 @@ fn manifest(executable: PathBuf) -> Manifest { artifacts: true, health: true, native_refs: false, + host_tools: false, }, } } diff --git a/crates/driver-host/tests/windows_secure_host.rs b/crates/driver-host/tests/windows_secure_host.rs index 6ff96ba95..840178eb4 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -76,6 +76,7 @@ fn manifest(executable: PathBuf) -> Manifest { artifacts: true, health: true, native_refs: false, + host_tools: false, }, } } @@ -142,6 +143,8 @@ async fn secure_windows_driver_sealed_tool_is_staged_immutable_and_executable() harden_fixture(&owner_tool); let mut candidate = manifest(executable); + candidate.protocol = 4; + candidate.interfaces.host_tools = true; candidate.tools = vec![DriverToolMount { root: "fixture-tool-root".into(), name: "probe".into(), @@ -199,9 +202,12 @@ async fn secure_windows_driver_sealed_tool_is_staged_immutable_and_executable() "sealed tool must execute without breaking out of AppContainer: {output}" ); assert_eq!( - output["read_ok"], true, - "sealed tool must remain readable: {output}" + output["read_ok"], false, + "Host-mediated sealed tools must not expose a direct executable path to the driver: {output}" ); + assert_eq!(output["execute_open_ok"], false); + assert_eq!(output["self_spawn_ok"], false); + assert_eq!(output["null_spawn_ok"], false); assert_eq!(output["write_ok"], false); Provider::shutdown(provider.as_ref()) @@ -223,6 +229,8 @@ async fn secure_windows_driver_sealed_tool_rejects_digest_mismatch() { harden_fixture(&owner_tool); let mut candidate = manifest(executable); + candidate.protocol = 4; + candidate.interfaces.host_tools = true; candidate.tools = vec![DriverToolMount { root: "fixture-tool-root".into(), name: "probe".into(), diff --git a/crates/driver-sdk/src/lib.rs b/crates/driver-sdk/src/lib.rs index a45be25d7..a4ce1c348 100644 --- a/crates/driver-sdk/src/lib.rs +++ b/crates/driver-sdk/src/lib.rs @@ -10,7 +10,7 @@ use semwright_protocol::{read_frame, write_frame}; use semwright_types::provider::canonical_slug; use semwright_types::{ CommandDescriptor, Error, ErrorCode, JobArtifact, JobProgress, NativeTarget, ProviderIdentity, - Result, SourceKind, + Result, SourceKind, unique_id, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -20,12 +20,18 @@ use std::{ path::{Component, Path, PathBuf}, sync::Arc, }; -use tokio::sync::{Mutex, mpsc}; +use tokio::sync::{Mutex, mpsc, oneshot}; use tokio_util::sync::CancellationToken; pub const DRIVER_MANIFEST_VERSION: u32 = 1; pub const DRIVER_PROTOCOL_MIN_VERSION: u32 = 1; -pub const DRIVER_PROTOCOL_VERSION: u32 = 3; +pub const DRIVER_PROTOCOL_VERSION: u32 = 4; + +const MAX_TOOL_ARGS: usize = 32; +const MAX_TOOL_ARG_BYTES: usize = 4 * 1024; +const MAX_TOOL_STDIN_BYTES: usize = 64 * 1024; +const MAX_TOOL_OUTPUT_BYTES: usize = 256 * 1024; +const MAX_TOOL_TIMEOUT_MS: u64 = 30_000; fn runtime_mount(class: MountClass, logical_name: &str) -> Result { if logical_name.is_empty() @@ -142,6 +148,9 @@ pub struct DriverInterfaces { /// Protocol v3: driver can emit and validate provider-owned native references. #[serde(default)] pub native_refs: bool, + /// Protocol v4: driver may request Host-mediated execution of owner-pinned sealed tools. + #[serde(default)] + pub host_tools: bool, } fn default_health() -> bool { true @@ -424,6 +433,17 @@ impl Manifest { "Driver native-reference validation requires protocol v3", )); } + if self.protocol < 4 && self.interfaces.host_tools { + return Err(Error::new( + ErrorCode::Unsupported, + "Host-mediated sealed tools require driver protocol v4", + )); + } + if self.interfaces.host_tools && self.tools.is_empty() { + return Err(Error::invalid( + "Host-mediated sealed tools require at least one owner-pinned tool", + )); + } if self.publisher.is_empty() || self.publisher.len() > 128 || self.publisher.chars().any(char::is_control) @@ -634,6 +654,50 @@ fn validate_native_target(target: &NativeTarget) -> Result<()> { Ok(()) } +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[serde(deny_unknown_fields)] +pub struct ToolExecutionOutput { + pub exit_code: i32, + pub stdout: Vec, + pub stderr: Vec, +} + +impl ToolExecutionOutput { + pub fn validate(&self) -> Result<()> { + if self.stdout.len() > MAX_TOOL_OUTPUT_BYTES || self.stderr.len() > MAX_TOOL_OUTPUT_BYTES { + return Err(Error::new( + ErrorCode::ResourceExhausted, + "Host-mediated tool output exceeds protocol bounds", + )); + } + Ok(()) + } +} + +pub fn validate_tool_execute_request( + name: &str, + args: &[String], + stdin: &[u8], + timeout_ms: u64, +) -> Result<()> { + if !valid_tool_name(name) + || args.len() > MAX_TOOL_ARGS + || args + .iter() + .any(|arg| arg.len() > MAX_TOOL_ARG_BYTES || arg.contains('\0')) + || stdin.len() > MAX_TOOL_STDIN_BYTES + || timeout_ms == 0 + || timeout_ms > MAX_TOOL_TIMEOUT_MS + { + return Err(Error::invalid( + "Host-mediated tool request exceeds bounded contract", + )); + } + Ok(()) +} + +type ToolCallWaiters = Arc>>>>; + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case", deny_unknown_fields)] pub enum Request { @@ -656,6 +720,14 @@ pub enum Request { #[serde(default, skip_serializing_if = "Option::is_none")] context: Option, }, + ToolResult { + id: String, + output: ToolExecutionOutput, + }, + ToolFailure { + id: String, + error: Error, + }, Validate { id: String, target: NativeTarget, @@ -693,6 +765,14 @@ pub enum Response { id: String, value: Value, }, + ToolExecute { + id: String, + parent: String, + name: String, + args: Vec, + stdin: Vec, + timeout_ms: u64, + }, Failure { id: String, error: Error, @@ -739,6 +819,8 @@ pub struct DriverExecutionContext { cancellation: CancellationToken, output: mpsc::UnboundedSender, interfaces: DriverInterfaces, + protocol: u32, + tool_calls: ToolCallWaiters, } impl DriverExecutionContext { pub fn request_id(&self) -> &str { @@ -763,6 +845,81 @@ impl DriverExecutionContext { Ok(()) } } + + pub async fn execute_tool( + &self, + name: &str, + args: Vec, + stdin: Vec, + timeout: std::time::Duration, + ) -> Result { + if self.protocol < 4 || !self.interfaces.host_tools { + return Err(Error::new( + ErrorCode::Unsupported, + "Driver did not negotiate Host-mediated sealed tools", + )); + } + let timeout_ms = u64::try_from(timeout.as_millis()).map_err(|_| { + Error::new( + ErrorCode::ResourceExhausted, + "Host-mediated tool timeout exceeds protocol bounds", + ) + })?; + validate_tool_execute_request(name, &args, &stdin, timeout_ms)?; + self.check_cancelled()?; + + let id = unique_id(); + let (sender, receiver) = oneshot::channel(); + { + let mut pending = self.tool_calls.lock().await; + if pending.len() >= 64 || pending.insert(id.clone(), sender).is_some() { + return Err(Error::new( + ErrorCode::ResourceExhausted, + "Driver has too many pending Host-mediated tool calls", + )); + } + } + if self + .output + .send(Response::ToolExecute { + id: id.clone(), + parent: self.request_id.clone(), + name: name.to_owned(), + args, + stdin, + timeout_ms, + }) + .is_err() + { + self.tool_calls.lock().await.remove(&id); + return Err(Error::unavailable("Driver protocol writer is closed")); + } + + tokio::select! { + biased; + _ = self.cancellation.cancelled() => { + self.tool_calls.lock().await.remove(&id); + Err(Error::new( + ErrorCode::Cancelled, + "Host-mediated tool execution cancelled", + )) + } + result = tokio::time::timeout(timeout + std::time::Duration::from_secs(2), receiver) => { + match result { + Ok(Ok(result)) => result, + Ok(Err(_)) => Err(Error::unavailable("Host-mediated tool response channel closed")), + Err(_) => { + self.tool_calls.lock().await.remove(&id); + Err(Error::new( + ErrorCode::Timeout, + "Host-mediated tool execution timed out", + )) + } + } + } + } + } + pub fn report_progress( &self, progress: JobProgress, @@ -936,7 +1093,9 @@ async fn serve_v1( Request::Hello { .. } | Request::Interfaces { .. } | Request::Cancel { .. } - | Request::Validate { .. } => { + | Request::Validate { .. } + | Request::ToolResult { .. } + | Request::ToolFailure { .. } => { return Err(Error::new( ErrorCode::ProtocolMismatch, "Driver protocol v1 received a v2-only or duplicate request", @@ -957,6 +1116,7 @@ async fn serve_v2( let mut child_events = driver.take_events(); let driver = Arc::new(Mutex::new(driver)); let active = Arc::new(Mutex::new(BTreeMap::::new())); + let tool_calls: ToolCallWaiters = Arc::new(Mutex::new(BTreeMap::new())); let (responses, mut response_rx) = mpsc::unbounded_channel::(); let writer = tokio::spawn(async move { @@ -1113,6 +1273,7 @@ async fn serve_v2( let driver = driver.clone(); let active = active.clone(); let responses = responses.clone(); + let tool_calls = tool_calls.clone(); tasks.spawn(async move { let context = DriverExecutionContext { request_id: id.clone(), @@ -1121,6 +1282,8 @@ async fn serve_v2( cancellation: token, output: responses.clone(), interfaces, + protocol, + tool_calls: tool_calls.clone(), }; let result = { let mut driver = driver.lock().await; @@ -1136,6 +1299,32 @@ async fn serve_v2( let _ = responses.send(response); }); } + Request::ToolResult { id, output } => { + if protocol < 4 || !interfaces.host_tools { + return Err(Error::new( + ErrorCode::ProtocolMismatch, + "Driver received an unnegotiated Host-mediated tool result", + )); + } + output.validate()?; + if let Some(sender) = tool_calls.lock().await.remove(&id) { + let _ = sender.send(Ok(output)); + } + } + Request::ToolFailure { id, error } => { + if protocol < 4 || !interfaces.host_tools { + return Err(Error::new( + ErrorCode::ProtocolMismatch, + "Driver received an unnegotiated Host-mediated tool failure", + )); + } + if let Some(sender) = tool_calls.lock().await.remove(&id) { + let _ = sender.send(Err(Error::new( + error.code, + "Host-mediated sealed tool failed", + ))); + } + } Request::Validate { id, target } => { if protocol < 3 || !interfaces.native_refs { responses @@ -1461,6 +1650,50 @@ mod tests { assert!(bad_digest.validate().is_err()); } + #[test] + fn host_tool_protocol_requires_v4_tools_and_bounded_requests() { + let mut candidate = manifest(); + candidate.protocol = 3; + candidate.interfaces.host_tools = true; + assert!(candidate.validate().is_err()); + + candidate.protocol = 4; + assert!(candidate.validate().is_err()); + + candidate.tools = vec![DriverToolMount { + root: "tool-root".into(), + name: "probe".into(), + sha256: "a".repeat(64), + }]; + candidate.validate().unwrap(); + + assert!( + validate_tool_execute_request("probe", &[], &[], 1_000).is_ok(), + "a bounded Host-tool request should validate" + ); + assert!(validate_tool_execute_request("../probe", &[], &[], 1_000).is_err()); + assert!( + validate_tool_execute_request( + "probe", + &vec!["x".into(); MAX_TOOL_ARGS + 1], + &[], + 1_000 + ) + .is_err() + ); + assert!( + validate_tool_execute_request( + "probe", + &[], + &vec![0u8; MAX_TOOL_STDIN_BYTES + 1], + 1_000 + ) + .is_err() + ); + assert!(validate_tool_execute_request("probe", &[], &[], 0).is_err()); + assert!(validate_tool_execute_request("probe", &[], &[], MAX_TOOL_TIMEOUT_MS + 1).is_err()); + } + #[test] fn executable_mounts_must_be_read_only_and_wire_compatible() { let mut candidate = manifest(); diff --git a/crates/platform-api/src/launch.rs b/crates/platform-api/src/launch.rs index 738ce66d9..cade19394 100644 --- a/crates/platform-api/src/launch.rs +++ b/crates/platform-api/src/launch.rs @@ -374,10 +374,14 @@ pub trait SandboxChildControl: Send { fn id(&self) -> Option; async fn kill(&mut self) -> Result<()>; async fn wait(&mut self) -> Result<()>; + fn exit_code(&self) -> Option { + None + } } struct TokioSandboxChild { child: Child, + exit_code: Option, } #[async_trait] @@ -391,7 +395,13 @@ impl SandboxChildControl for TokioSandboxChild { } async fn wait(&mut self) -> Result<()> { - self.child.wait().await.map(|_| ()).map_err(Into::into) + let status = self.child.wait().await?; + self.exit_code = status.code(); + Ok(()) + } + + fn exit_code(&self) -> Option { + self.exit_code } } @@ -457,7 +467,10 @@ impl SandboxProcess { Ok(Self { stdin: Some(stdin), stdout: Some(stdout), - control: Box::new(TokioSandboxChild { child }), + control: Box::new(TokioSandboxChild { + child, + exit_code: None, + }), cpu_accounting: None, }) } @@ -495,6 +508,11 @@ impl SandboxProcess { pub async fn wait(&mut self) -> Result<()> { self.control.wait().await } + + pub async fn wait_exit_code(&mut self) -> Result> { + self.control.wait().await?; + Ok(self.control.exit_code()) + } } pub trait SandboxLauncher: Send + Sync { diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 997b04ecd..10cff128e 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -69,22 +69,18 @@ use windows::Win32::{ ACCESS_ALLOWED_CALLBACK_OBJECT_ACE_TYPE, ACCESS_ALLOWED_OBJECT_ACE_TYPE, ACCESS_DENIED_ACE_TYPE, ACCESS_DENIED_CALLBACK_ACE_TYPE, ACCESS_DENIED_CALLBACK_OBJECT_ACE_TYPE, ACCESS_DENIED_OBJECT_ACE_TYPE, - PROCESS_MITIGATION_CHILD_PROCESS_POLICY, SE_GROUP_ENABLED, + SE_GROUP_ENABLED, }, Threading::{ CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, - GetProcessMitigationPolicy, INFINITE, InitializeProcThreadAttributeList, + GetExitCodeProcess, INFINITE, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, - PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, - PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, - ProcessChildProcessPolicy, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }, - WindowsProgramming::{ - PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, - PROCESS_CREATION_CHILD_PROCESS_OVERRIDE, - }, + WindowsProgramming::PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, }, }; use windows::core::{BOOL, PCWSTR, PWSTR}; @@ -2072,36 +2068,6 @@ fn inherited_null() -> Result { Ok(NativeHandle(handle)) } -fn verify_child_process_creation_allowed(process: HANDLE) -> Result<()> { - let mut policy = PROCESS_MITIGATION_CHILD_PROCESS_POLICY::default(); - // SAFETY: process is a live suspended child HANDLE and policy is a correctly-sized output. - unsafe { - GetProcessMitigationPolicy( - process, - ProcessChildProcessPolicy, - (&mut policy as *mut PROCESS_MITIGATION_CHILD_PROCESS_POLICY).cast(), - std::mem::size_of::(), - ) - } - .map_err(|_| { - Error::new( - ErrorCode::SandboxDenied, - "Windows sealed-tool child-process mitigation policy could not be queried", - ) - })?; - // SAFETY: Flags is the active union member for PROCESS_MITIGATION_CHILD_PROCESS_POLICY. - let flags = unsafe { policy.Anonymous.Flags }; - if flags & 0x1 != 0 { - return Err(Error::new( - ErrorCode::SandboxDenied, - format!( - "Windows sealed-tool child-process policy remained restricted after override (flags={flags:#x})" - ), - )); - } - Ok(()) -} - struct NativeSandboxChild { process: NativeHandle, _job: Arc, @@ -2109,6 +2075,7 @@ struct NativeSandboxChild { mount_grants: Vec, profile_name: Option>, exited: bool, + exit_code: Option, } impl NativeSandboxChild { @@ -2128,10 +2095,24 @@ impl NativeSandboxChild { result } + fn capture_exit_code(&mut self) -> Result<()> { + let mut code = 0u32; + // SAFETY: process is a live owned process HANDLE and code is a writable DWORD. + unsafe { GetExitCodeProcess(self.process.raw(), &mut code) }.map_err(|_| { + Error::new( + ErrorCode::BackendFailed, + "Windows sandbox exit code query failed", + ) + })?; + self.exit_code = Some(i32::from_ne_bytes(code.to_ne_bytes())); + Ok(()) + } + fn observed_exit(&mut self) -> Result { // SAFETY: process is a live owned process HANDLE. let wait = unsafe { WaitForSingleObject(self.process.raw(), 0) }; if wait == WAIT_OBJECT_0 { + self.capture_exit_code()?; self._job.terminate(0)?; self.exited = true; self.cleanup_authority()?; @@ -2173,10 +2154,15 @@ impl SandboxChildControl for NativeSandboxChild { "Windows sandbox wait returned an unexpected status", )); } + self.capture_exit_code()?; self._job.terminate(0)?; self.exited = true; self.cleanup_authority() } + + fn exit_code(&self) -> Option { + self.exit_code + } } impl Drop for NativeSandboxChild { @@ -2211,6 +2197,12 @@ impl SandboxLauncher for WindowsSandbox { fn spawn(&self, spec: &SandboxSpec) -> Result { spec.validate()?; + if !spec.sealed_tools.is_empty() { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows sealed tools require Host-mediated driver protocol v4 execution", + )); + } let profile = AppContainerProfile::create()?; let (mut mount_grants, mount_table) = prepare_windows_mounts(spec, &profile)?; let (tool_grants, tool_table) = prepare_windows_tools(spec, &profile)?; @@ -2222,13 +2214,8 @@ impl SandboxLauncher for WindowsSandbox { let child_stderr = inherited_null()?; let handles = [child_stdin.raw(), child_stdout.raw(), child_stderr.raw()]; - let child_process_override = !spec.sealed_tools.is_empty(); - let mut attributes = ProcAttributes::new(if child_process_override { 4 } else { 3 })?; + let mut attributes = ProcAttributes::new(3)?; attributes.set_slice(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; - if child_process_override { - let child_policy = PROCESS_CREATION_CHILD_PROCESS_OVERRIDE; - attributes.set_value(PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, &child_policy)?; - } let all_application_packages_policy = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; attributes.set_value( PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, @@ -2325,15 +2312,6 @@ impl SandboxLauncher for WindowsSandbox { let process = NativeHandle(process_info.hProcess); let thread = NativeHandle(process_info.hThread); - if child_process_override - && let Err(error) = verify_child_process_creation_allowed(process.raw()) - { - // SAFETY: the child is still suspended and no untrusted instruction has run. - unsafe { - let _ = TerminateProcess(process.raw(), 1); - } - return Err(error); - } if let Err(error) = job.assign_suspended_process(process.raw()) { // SAFETY: child is still suspended and must not survive a failed containment step. unsafe { @@ -2372,6 +2350,7 @@ impl SandboxLauncher for WindowsSandbox { mount_grants, profile_name: Some(profile_name), exited: false, + exit_code: None, }), cpu_accounting, )) @@ -2393,6 +2372,8 @@ impl SandboxLauncher for WindowsSandbox { "filesystem_mounts": "driver_appcontainer_sid_acl_v1", "plugin_mcp_mounts": "fail_closed_pending_portable_mount_lookup", "network": "internetClient_capability_only_when_requested", + "sealed_tools": "host_mediated_driver_protocol_v4_only", + "driver_child_process_creation": "denied", "resource_limits": ["processes", "cpu_seconds", "process_memory"], }) } diff --git a/crates/platform-windows-sys/tests/secure_spawn.rs b/crates/platform-windows-sys/tests/secure_spawn.rs index e81a15308..22c3759cd 100644 --- a/crates/platform-windows-sys/tests/secure_spawn.rs +++ b/crates/platform-windows-sys/tests/secure_spawn.rs @@ -1,6 +1,8 @@ #![cfg(target_os = "windows")] -use semwright_platform_api::launch::{ResourceLimits, SandboxKind, SandboxLauncher, SandboxSpec}; +use semwright_platform_api::launch::{ + ResourceLimits, SandboxKind, SandboxLauncher, SandboxSpec, SealedToolMount, SealedToolSource, +}; use semwright_platform_windows_sys::launch::WindowsSandbox; use std::time::Duration; use tokio::io::{AsyncReadExt, AsyncWriteExt}; @@ -25,6 +27,23 @@ fn spec(network: bool) -> SandboxSpec { } } +#[test] +fn direct_sealed_tool_authority_is_fail_closed_on_windows() { + let mut candidate = spec(false); + candidate.sealed_tools = vec![SealedToolMount { + source: SealedToolSource::VerifiedFile { + path: std::env::current_exe().expect("current test executable"), + sha256: "a".repeat(64), + }, + name: "probe".into(), + }]; + let error = match WindowsSandbox.spawn(&candidate) { + Ok(_) => panic!("direct Windows sealed tools must use Host-mediated protocol v4"), + Err(error) => error, + }; + assert_eq!(error.code, semwright_types::ErrorCode::SandboxDenied); +} + #[tokio::test] async fn appcontainer_spawn_roundtrips_only_allowlisted_stdio() { let mut process = WindowsSandbox diff --git a/docs/blender/SDK_GAPS.md b/docs/blender/SDK_GAPS.md index 8bcec43fa..569052819 100644 --- a/docs/blender/SDK_GAPS.md +++ b/docs/blender/SDK_GAPS.md @@ -4,7 +4,7 @@ These are transport/distribution or deliberate authority-boundary gaps; they do ## Driver Protocol -The current Blender manifest negotiates Driver Protocol v1. The SDK supports v3, but some Blender native calls execute synchronously on Blender's main thread. Advertising cooperative cancellation for an in-flight native call would be misleading until the owned Blender process can be safely interrupted or recycled with truthful outcome reporting. +The current Blender manifest negotiates Driver Protocol v1. The SDK supports through v4, but some Blender native calls execute synchronously on Blender's main thread. Advertising cooperative cancellation for an in-flight native call would be misleading until the owned Blender process can be safely interrupted or recycled with truthful outcome reporting. A future transport pass may add v3 progress/artifact/cancellation semantics behind a supervised process-restart boundary. Do not advertise those interfaces before that behavior exists. diff --git a/docs/drivers.md b/docs/drivers.md index 87be323bd..eda9f3b73 100644 --- a/docs/drivers.md +++ b/docs/drivers.md @@ -30,8 +30,12 @@ provider ID. External metadata is treated as untrusted data by the Provider Runt Protocol v1 provides the baseline request/response lifecycle. Protocol v2 additionally negotiates interfaces for cooperative cancellation, child events, progress, artifacts, -health and dynamic capability changes. Each interface remains fail-closed unless both the -child and owner manifest negotiate the same value. +health and dynamic capability changes. Protocol v3 adds request context plus provider-owned +native-reference validation. Protocol v4 adds bounded Host-mediated execution of owner-pinned, +digest-attested sealed tools without granting child-process authority to the driver itself. +Each interface remains fail-closed unless both the child and owner manifest negotiate the same +value; drivers may continue to negotiate an older protocol when they do not need newer +interfaces. ## Manifest @@ -171,8 +175,12 @@ Current semantic ports include: Protocol v1 intentionally rejects dynamic-capability changes, child events, progress, artifacts and cooperative cancellation. Protocol v2 transports those interfaces explicitly, -including bounded event/progress frames and cancellation acknowledgements. Drivers that do not -negotiate an interface remain fail-closed rather than advertising semantics the host cannot enforce. +including bounded event/progress frames and cancellation acknowledgements. Protocol v3 carries +bounded request context and native-reference validation. Protocol v4 adds Host-mediated sealed +tools: the driver names an owner-pinned tool in an active Execute request, while the Host verifies +the immutable staged bytes and launches a separate bounded sandbox child with no inherited +filesystem, secret, loopback or network authority. Drivers that do not negotiate an interface +remain fail-closed rather than advertising semantics the host cannot enforce. ## Developer workflow diff --git a/docs/motion-canvas/INTEGRATION.md b/docs/motion-canvas/INTEGRATION.md index 70329442c..87903b420 100644 --- a/docs/motion-canvas/INTEGRATION.md +++ b/docs/motion-canvas/INTEGRATION.md @@ -6,7 +6,9 @@ That baseline remains the historical snapshot recorded in `SEMWRIGHT_SNAPSHOT.md During PR integration the branch incorporated already-merged shared work without changing the frozen implementation baseline. The final reconciliation before closeout is merge commit `f25bd3db67c488cf76419927e2aa5977df1abaca` with `origin/main` parent `f2f3ec470f95c2010df89a61d4835afe5c4926a1`. The only merge conflict was additive in `fuzz/Cargo.toml`: the resolution retains all six Motion Canvas fuzz targets and also keeps main's `godot_substrate_schema` target. This is an integration merge, not a moving-baseline change. The integrated Driver SDK keeps manifest version 1 and accepts Driver Protocol -versions 1 through 3. Motion Canvas now requests protocol 3. The synchronous +versions 1 through 4. Motion Canvas continues to request protocol 3 because it uses +request context, progress, artifacts and cooperative cancellation but does not need +Protocol v4 Host-mediated sealed tools. The synchronous `render.execute` capability maps the existing renderer/job registry onto one protocol-owned request lifecycle with cooperative cancellation, observed progress and validated artifact reporting. The legacy `render.start/status/cancel/result` diff --git a/docs/motion-canvas/SDK_GAPS.md b/docs/motion-canvas/SDK_GAPS.md index 5506c3021..3a67dd0b9 100644 --- a/docs/motion-canvas/SDK_GAPS.md +++ b/docs/motion-canvas/SDK_GAPS.md @@ -10,7 +10,7 @@ A generic future tool-dependency/package primitive could remove this manual runt ## 2. Protocol-v3 adoption for long-running child jobs -The current Driver SDK exposes Protocol v3 progress, artifacts and request cancellation. Motion Canvas negotiates Driver Protocol v3 for cooperative cancellation, progress and artifact reporting. The asynchronous `render.start/status/cancel/result` API remains available, while `render.execute` maps the same renderer onto one protocol-owned request lifecycle. Native refs remain disabled because Motion Canvas refs are managed semantic refs rather than broker-native application references. +The current Driver SDK supports through Protocol v4. Motion Canvas deliberately negotiates Driver Protocol v3 for cooperative cancellation, progress and artifact reporting because it does not require the v4 Host-mediated sealed-tool interface. The asynchronous `render.start/status/cancel/result` API remains available, while `render.execute` maps the same renderer onto one protocol-owned request lifecycle. Native refs remain disabled because Motion Canvas refs are managed semantic refs rather than broker-native application references. The protocol-v3 path reuses the existing bounded job registry; it does not introduce a second render authority or duplicate renderer implementation. diff --git a/docs/motion-canvas/SECURITY.md b/docs/motion-canvas/SECURITY.md index 58353b734..74eb919cd 100644 --- a/docs/motion-canvas/SECURITY.md +++ b/docs/motion-canvas/SECURITY.md @@ -36,4 +36,4 @@ PNG artifacts must have exact expected sequential names/count, bounded byte size ## Known boundaries -The integrated SDK supports Driver Protocol v3 and the Motion Canvas manifest negotiates v3. `render.execute` uses protocol-owned cooperative request cancellation, observed progress and validated artifact reporting through `DriverExecutionContext`; the legacy `render.start/status/cancel/result` capabilities remain backed by the same driver-local job registry. Dynamic capabilities, child events and broker-native application refs remain disabled because this driver does not emit or require them. Registry packages do not yet provide a generic multi-tool runtime distribution primitive. External arbitrary Motion Canvas projects are not safely mutable and are therefore not accepted as managed semantic data. +The integrated SDK supports through Driver Protocol v4, while the Motion Canvas manifest deliberately negotiates v3 because it does not request Host-mediated sealed tools. `render.execute` uses protocol-owned cooperative request cancellation, observed progress and validated artifact reporting through `DriverExecutionContext`; the legacy `render.start/status/cancel/result` capabilities remain backed by the same driver-local job registry. Dynamic capabilities, child events and broker-native application refs remain disabled because this driver does not emit or require them. Registry packages do not yet provide a generic multi-tool runtime distribution primitive. External arbitrary Motion Canvas projects are not safely mutable and are therefore not accepted as managed semantic data. From b17a994181c11fb92795ae7f304021152ad71ac0 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 16:59:34 -0600 Subject: [PATCH 23/37] fix(driver-sdk): centralize sealed tool name validation --- crates/driver-sdk/src/lib.rs | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/crates/driver-sdk/src/lib.rs b/crates/driver-sdk/src/lib.rs index a4ce1c348..c9ac65cda 100644 --- a/crates/driver-sdk/src/lib.rs +++ b/crates/driver-sdk/src/lib.rs @@ -84,15 +84,13 @@ pub fn system_config_mount(logical_name: &str) -> Result { runtime_mount(MountClass::SystemConfig, logical_name) } +fn valid_tool_name(name: &str) -> bool { + canonical_slug(name) && name.len() <= 64 && !name.starts_with("semwright-internal-") +} + /// Resolve one Host-verified executable tool as materialized by the current platform sandbox. pub fn tool_path(name: &str) -> Result { - if name.is_empty() - || name.len() > 64 - || name.starts_with("semwright-internal-") - || !name - .bytes() - .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'_' | b'-')) - { + if !valid_tool_name(name) { return Err(Error::invalid("Invalid sandbox tool name")); } match std::env::var(SANDBOX_TOOLS_ENV) { @@ -242,9 +240,7 @@ impl DriverToolMount { fn validate(&self) -> Result<()> { if !canonical_slug(&self.root) || self.root.starts_with("semwright-internal-") - || !canonical_slug(&self.name) - || self.name.len() > 64 - || self.name.starts_with("semwright-internal-") + || !valid_tool_name(&self.name) || self.sha256.len() != 64 || !self.sha256.bytes().all(|b| b.is_ascii_hexdigit()) { From 1e8238a579a1a83b0d8943226884c72bd784a681 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 17:02:27 -0600 Subject: [PATCH 24/37] fix(driver-host): write Host tool replies through boxed transport --- crates/driver-host/src/lib.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/driver-host/src/lib.rs b/crates/driver-host/src/lib.rs index 93389197a..8cf9bc03f 100644 --- a/crates/driver-host/src/lib.rs +++ b/crates/driver-host/src/lib.rs @@ -1056,7 +1056,7 @@ fn spawn_v2_reader( }, }; let mut input = input.lock().await; - if write_frame(&mut **input, &request).await.is_err() { + if write_frame(&mut *input, &request).await.is_err() { terminate_call.cancel(); closed_call.cancel(); } From 9e9943569c8a2879de1f3a5c588cd1ab58689770 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 17:10:21 -0600 Subject: [PATCH 25/37] fix(driver-host): scope Host-tool code to active platforms --- crates/driver-host/src/lib.rs | 20 +++----------------- 1 file changed, 3 insertions(+), 17 deletions(-) diff --git a/crates/driver-host/src/lib.rs b/crates/driver-host/src/lib.rs index 8cf9bc03f..2ef267308 100644 --- a/crates/driver-host/src/lib.rs +++ b/crates/driver-host/src/lib.rs @@ -37,12 +37,11 @@ use std::{ }; #[cfg(target_os = "linux")] use std::{ffi::CString, os::fd::FromRawFd}; +#[cfg(target_os = "windows")] +use tokio::io::{AsyncReadExt, AsyncWriteExt}; #[cfg(all(test, unix))] use tokio::process::Command; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - sync::{Mutex, broadcast, oneshot}, -}; +use tokio::sync::{Mutex, broadcast, oneshot}; use tokio_util::sync::CancellationToken; struct StagedFile(PathBuf); @@ -82,19 +81,6 @@ struct SealedTool { staged: Arc, sha256: String, } -#[cfg(target_os = "windows")] -impl SealedTool { - fn sandbox_mount(&self) -> semwright_platform_api::launch::SealedToolMount { - semwright_platform_api::launch::SealedToolMount { - source: semwright_platform_api::launch::SealedToolSource::VerifiedFile { - path: self.staged.0.clone(), - sha256: self.sha256.clone(), - }, - name: self.name.clone(), - } - } -} - #[async_trait] trait HostToolExecutor: Send + Sync { async fn execute( From 5a38a03ca3519f6187eb0565e8ce69143687f59e Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 17:19:40 -0600 Subject: [PATCH 26/37] diag(driver-host): report pre-Hello child exit code --- crates/driver-host/src/lib.rs | 58 ++++++++++++++++++++++++++++++++--- 1 file changed, 54 insertions(+), 4 deletions(-) diff --git a/crates/driver-host/src/lib.rs b/crates/driver-host/src/lib.rs index 2ef267308..879a0dadb 100644 --- a/crates/driver-host/src/lib.rs +++ b/crates/driver-host/src/lib.rs @@ -1431,7 +1431,7 @@ impl DriverProvider { let mut io = Io { input, output }; let timeout = Duration::from_millis(manifest.request_timeout_ms.min(30_000)); - let hello = request( + let hello = match request( &mut io, &Request::Hello { protocol: manifest.protocol, @@ -1440,7 +1440,32 @@ impl DriverProvider { }, timeout, ) - .await?; + .await + { + Ok(response) => response, + Err(error) => { + let exit_code = + match tokio::time::timeout(Duration::from_secs(2), child.wait_exit_code()) + .await + { + Ok(Ok(code)) => code, + _ => { + let _ = child.kill().await; + None + } + }; + let message = match exit_code { + Some(code) => format!( + "Driver exited before protocol Hello completed (exit={:#010x})", + code as u32 + ), + None => { + "Driver transport failed before protocol Hello completed".to_owned() + } + }; + return Err(Error::new(error.code, message)); + } + }; match hello { Response::Ready { protocol, @@ -1675,7 +1700,7 @@ impl DriverProvider { let output = child.take_stdout()?; let mut io = Io { input, output }; let timeout = Duration::from_millis(manifest.request_timeout_ms.min(30_000)); - let hello = request( + let hello = match request( &mut io, &Request::Hello { protocol: manifest.protocol, @@ -1684,7 +1709,32 @@ impl DriverProvider { }, timeout, ) - .await?; + .await + { + Ok(response) => response, + Err(error) => { + let exit_code = + match tokio::time::timeout(Duration::from_secs(2), child.wait_exit_code()) + .await + { + Ok(Ok(code)) => code, + _ => { + let _ = child.kill().await; + None + } + }; + let message = match exit_code { + Some(code) => format!( + "Driver exited before protocol Hello completed (exit={:#010x})", + code as u32 + ), + None => { + "Driver transport failed before protocol Hello completed".to_owned() + } + }; + return Err(Error::new(error.code, message)); + } + }; match hello { Response::Ready { protocol, From 3c5ec0eba00cc1287b5a25e0421364de6f06a42c Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 17:35:18 -0600 Subject: [PATCH 27/37] fix(windows): budget x64-on-arm64 emulation overhead --- crates/platform-windows-sys/Cargo.toml | 1 + crates/platform-windows-sys/src/job.rs | 90 +++++++++++++++++++++++--- 2 files changed, 83 insertions(+), 8 deletions(-) diff --git a/crates/platform-windows-sys/Cargo.toml b/crates/platform-windows-sys/Cargo.toml index f3a8e6c4b..a8ea238ad 100644 --- a/crates/platform-windows-sys/Cargo.toml +++ b/crates/platform-windows-sys/Cargo.toml @@ -39,6 +39,7 @@ windows = { version = "0.62.2", features = [ "Win32_System_Pipes", "Win32_System_RemoteDesktop", "Win32_System_StationsAndDesktops", + "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", diff --git a/crates/platform-windows-sys/src/job.rs b/crates/platform-windows-sys/src/job.rs index 04407447c..320d19645 100644 --- a/crates/platform-windows-sys/src/job.rs +++ b/crates/platform-windows-sys/src/job.rs @@ -3,16 +3,64 @@ use semwright_types::{Error, ErrorCode, Result}; use std::time::Duration; use windows::Win32::{ Foundation::{CloseHandle, HANDLE}, - System::JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY, - JOB_OBJECT_LIMIT_PROCESS_TIME, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, - JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectBasicAccountingInformation, - JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, - TerminateJobObject, + System::{ + JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY, + JOB_OBJECT_LIMIT_PROCESS_TIME, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectBasicAccountingInformation, + JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, + TerminateJobObject, + }, + SystemInformation::{IMAGE_FILE_MACHINE_ARM64, IMAGE_FILE_MACHINE_UNKNOWN}, + Threading::{GetCurrentProcess, IsWow64Process2}, }, }; +const X64_ON_ARM64_EMULATION_MEMORY_HEADROOM: usize = 512 * 1024 * 1024; + +fn memory_limit_with_platform_headroom(limit: usize, x64_on_arm64: bool) -> Result { + if !x64_on_arm64 { + return Ok(limit); + } + limit + .checked_add(X64_ON_ARM64_EMULATION_MEMORY_HEADROOM) + .ok_or_else(|| { + Error::new( + ErrorCode::ResourceExhausted, + "Windows x64-on-ARM64 memory headroom overflowed the Job limit", + ) + }) +} + +fn current_process_is_x64_on_arm64() -> Result { + if std::env::consts::ARCH != "x86_64" { + return Ok(false); + } + let mut process_machine = IMAGE_FILE_MACHINE_UNKNOWN; + let mut native_machine = IMAGE_FILE_MACHINE_UNKNOWN; + // SAFETY: GetCurrentProcess returns a pseudo-handle valid in this process and both outputs + // are writable IMAGE_FILE_MACHINE values. + unsafe { + IsWow64Process2( + GetCurrentProcess(), + &mut process_machine, + Some(&mut native_machine), + ) + } + .map_err(|_| { + Error::new( + ErrorCode::BackendFailed, + "Windows native architecture detection failed", + ) + })?; + Ok(native_machine == IMAGE_FILE_MACHINE_ARM64) +} + +fn effective_process_memory_limit(limit: usize) -> Result { + memory_limit_with_platform_headroom(limit, current_process_is_x64_on_arm64()?) +} + /// OS Job Object used for child-process containment. This is unrelated to Semwright protocol Jobs. pub struct ProcessJob(HANDLE); // SAFETY: a Windows Job Object HANDLE is process-wide rather than thread-affine; this wrapper @@ -52,7 +100,7 @@ impl ProcessJob { } if let Some(limit) = memory_limit { info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_PROCESS_MEMORY; - info.ProcessMemoryLimit = limit; + info.ProcessMemoryLimit = effective_process_memory_limit(limit)?; } if let Some(seconds) = cpu_seconds { let ticks = seconds.checked_mul(10_000_000).ok_or_else(|| { @@ -151,3 +199,29 @@ impl SandboxCpuAccounting for ProcessJob { Ok(Duration::from_nanos(nanos)) } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn native_process_memory_limit_is_exact() { + assert_eq!( + memory_limit_with_platform_headroom(512 * 1024 * 1024, false).unwrap(), + 512 * 1024 * 1024 + ); + } + + #[test] + fn x64_on_arm64_gets_bounded_emulation_headroom() { + assert_eq!( + memory_limit_with_platform_headroom(512 * 1024 * 1024, true).unwrap(), + 1024 * 1024 * 1024 + ); + } + + #[test] + fn emulation_headroom_overflow_fails_closed() { + assert!(memory_limit_with_platform_headroom(usize::MAX, true).is_err()); + } +} From ebce71ad5c5152596699c3f526a4c7ada8dd4b9a Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 17:43:36 -0600 Subject: [PATCH 28/37] fix(windows): make emulation memory budget explicit --- .github/workflows/windows-platform.yml | 4 + crates/driver-host/src/lib.rs | 27 ++++-- .../driver-host/tests/windows_secure_host.rs | 5 ++ crates/platform-windows-sys/Cargo.toml | 1 - crates/platform-windows-sys/src/job.rs | 90 ++----------------- 5 files changed, 36 insertions(+), 91 deletions(-) diff --git a/.github/workflows/windows-platform.yml b/.github/workflows/windows-platform.yml index 5db26abe8..36fa5e48d 100644 --- a/.github/workflows/windows-platform.yml +++ b/.github/workflows/windows-platform.yml @@ -179,9 +179,11 @@ jobs: - name: server2022-x64 runs_on: windows-2022 toolchain: "1.98.1" + address_space_bytes: "536870912" - name: win11-arm-x64emu runs_on: windows-11-arm toolchain: "1.98.1-x86_64-pc-windows-msvc" + address_space_bytes: "2147483648" runs-on: ${{ matrix.runs_on }} timeout-minutes: 30 defaults: @@ -202,6 +204,8 @@ jobs: echo "PROCESSOR_ARCHITECTURE=${PROCESSOR_ARCHITECTURE:-unknown}" rustc +${{ matrix.toolchain }} -vV - name: Sealed tool nested execution probe + env: + SEMWRIGHT_TEST_ADDRESS_SPACE_BYTES: ${{ matrix.address_space_bytes }} run: >- cargo +${{ matrix.toolchain }} test --locked -p semwright-driver-host diff --git a/crates/driver-host/src/lib.rs b/crates/driver-host/src/lib.rs index 879a0dadb..a6852d561 100644 --- a/crates/driver-host/src/lib.rs +++ b/crates/driver-host/src/lib.rs @@ -1454,16 +1454,27 @@ impl DriverProvider { None } }; - let message = match exit_code { - Some(code) => format!( - "Driver exited before protocol Hello completed (exit={:#010x})", - code as u32 + let (error_code, message) = match exit_code { + Some(code) if code as u32 == 0xC000_0017 => ( + ErrorCode::ResourceExhausted, + format!( + "Driver exhausted Windows process memory before protocol Hello completed (exit=0xc0000017, address_space_bytes={}); x64 emulation on ARM64 may require a larger declared budget", + manifest.resources.address_space_bytes + ), + ), + Some(code) => ( + error.code, + format!( + "Driver exited before protocol Hello completed (exit={:#010x})", + code as u32 + ), + ), + None => ( + error.code, + "Driver transport failed before protocol Hello completed".to_owned(), ), - None => { - "Driver transport failed before protocol Hello completed".to_owned() - } }; - return Err(Error::new(error.code, message)); + return Err(Error::new(error_code, message)); } }; match hello { diff --git a/crates/driver-host/tests/windows_secure_host.rs b/crates/driver-host/tests/windows_secure_host.rs index 840178eb4..586c62613 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -143,6 +143,11 @@ async fn secure_windows_driver_sealed_tool_is_staged_immutable_and_executable() harden_fixture(&owner_tool); let mut candidate = manifest(executable); + if let Ok(value) = std::env::var("SEMWRIGHT_TEST_ADDRESS_SPACE_BYTES") { + candidate.resources.address_space_bytes = value + .parse() + .expect("valid sealed-tool compatibility memory budget"); + } candidate.protocol = 4; candidate.interfaces.host_tools = true; candidate.tools = vec![DriverToolMount { diff --git a/crates/platform-windows-sys/Cargo.toml b/crates/platform-windows-sys/Cargo.toml index a8ea238ad..f3a8e6c4b 100644 --- a/crates/platform-windows-sys/Cargo.toml +++ b/crates/platform-windows-sys/Cargo.toml @@ -39,7 +39,6 @@ windows = { version = "0.62.2", features = [ "Win32_System_Pipes", "Win32_System_RemoteDesktop", "Win32_System_StationsAndDesktops", - "Win32_System_SystemInformation", "Win32_System_SystemServices", "Win32_System_Threading", "Win32_System_WindowsProgramming", diff --git a/crates/platform-windows-sys/src/job.rs b/crates/platform-windows-sys/src/job.rs index 320d19645..04407447c 100644 --- a/crates/platform-windows-sys/src/job.rs +++ b/crates/platform-windows-sys/src/job.rs @@ -3,64 +3,16 @@ use semwright_types::{Error, ErrorCode, Result}; use std::time::Duration; use windows::Win32::{ Foundation::{CloseHandle, HANDLE}, - System::{ - JobObjects::{ - AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, - JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY, - JOB_OBJECT_LIMIT_PROCESS_TIME, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, - JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectBasicAccountingInformation, - JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, - TerminateJobObject, - }, - SystemInformation::{IMAGE_FILE_MACHINE_ARM64, IMAGE_FILE_MACHINE_UNKNOWN}, - Threading::{GetCurrentProcess, IsWow64Process2}, + System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, + JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, JOB_OBJECT_LIMIT_PROCESS_MEMORY, + JOB_OBJECT_LIMIT_PROCESS_TIME, JOBOBJECT_BASIC_ACCOUNTING_INFORMATION, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectBasicAccountingInformation, + JobObjectExtendedLimitInformation, QueryInformationJobObject, SetInformationJobObject, + TerminateJobObject, }, }; -const X64_ON_ARM64_EMULATION_MEMORY_HEADROOM: usize = 512 * 1024 * 1024; - -fn memory_limit_with_platform_headroom(limit: usize, x64_on_arm64: bool) -> Result { - if !x64_on_arm64 { - return Ok(limit); - } - limit - .checked_add(X64_ON_ARM64_EMULATION_MEMORY_HEADROOM) - .ok_or_else(|| { - Error::new( - ErrorCode::ResourceExhausted, - "Windows x64-on-ARM64 memory headroom overflowed the Job limit", - ) - }) -} - -fn current_process_is_x64_on_arm64() -> Result { - if std::env::consts::ARCH != "x86_64" { - return Ok(false); - } - let mut process_machine = IMAGE_FILE_MACHINE_UNKNOWN; - let mut native_machine = IMAGE_FILE_MACHINE_UNKNOWN; - // SAFETY: GetCurrentProcess returns a pseudo-handle valid in this process and both outputs - // are writable IMAGE_FILE_MACHINE values. - unsafe { - IsWow64Process2( - GetCurrentProcess(), - &mut process_machine, - Some(&mut native_machine), - ) - } - .map_err(|_| { - Error::new( - ErrorCode::BackendFailed, - "Windows native architecture detection failed", - ) - })?; - Ok(native_machine == IMAGE_FILE_MACHINE_ARM64) -} - -fn effective_process_memory_limit(limit: usize) -> Result { - memory_limit_with_platform_headroom(limit, current_process_is_x64_on_arm64()?) -} - /// OS Job Object used for child-process containment. This is unrelated to Semwright protocol Jobs. pub struct ProcessJob(HANDLE); // SAFETY: a Windows Job Object HANDLE is process-wide rather than thread-affine; this wrapper @@ -100,7 +52,7 @@ impl ProcessJob { } if let Some(limit) = memory_limit { info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_PROCESS_MEMORY; - info.ProcessMemoryLimit = effective_process_memory_limit(limit)?; + info.ProcessMemoryLimit = limit; } if let Some(seconds) = cpu_seconds { let ticks = seconds.checked_mul(10_000_000).ok_or_else(|| { @@ -199,29 +151,3 @@ impl SandboxCpuAccounting for ProcessJob { Ok(Duration::from_nanos(nanos)) } } - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn native_process_memory_limit_is_exact() { - assert_eq!( - memory_limit_with_platform_headroom(512 * 1024 * 1024, false).unwrap(), - 512 * 1024 * 1024 - ); - } - - #[test] - fn x64_on_arm64_gets_bounded_emulation_headroom() { - assert_eq!( - memory_limit_with_platform_headroom(512 * 1024 * 1024, true).unwrap(), - 1024 * 1024 * 1024 - ); - } - - #[test] - fn emulation_headroom_overflow_fails_closed() { - assert!(memory_limit_with_platform_headroom(usize::MAX, true).is_err()); - } -} From 7c509d250990fc8f4a9f70540ab371e446a14332 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 17:57:13 -0600 Subject: [PATCH 29/37] fix(windows): run sealed x64 tools from native ARM host --- .github/workflows/windows-platform.yml | 20 ++- crates/driver-host/src/lib.rs | 12 +- .../driver-host/tests/windows_secure_host.rs | 9 +- crates/platform-services/src/lib.rs | 5 + crates/platform-windows-sys/src/launch.rs | 146 ++++++++++-------- crates/platform-windows-sys/src/pe.rs | 47 ++++++ 6 files changed, 157 insertions(+), 82 deletions(-) diff --git a/.github/workflows/windows-platform.yml b/.github/workflows/windows-platform.yml index 36fa5e48d..f4f612dd7 100644 --- a/.github/workflows/windows-platform.yml +++ b/.github/workflows/windows-platform.yml @@ -179,11 +179,11 @@ jobs: - name: server2022-x64 runs_on: windows-2022 toolchain: "1.98.1" - address_space_bytes: "536870912" - - name: win11-arm-x64emu + tool_target: "" + - name: win11-arm-native-host-x64tool runs_on: windows-11-arm - toolchain: "1.98.1-x86_64-pc-windows-msvc" - address_space_bytes: "2147483648" + toolchain: "1.98.1" + tool_target: "x86_64-pc-windows-msvc" runs-on: ${{ matrix.runs_on }} timeout-minutes: 30 defaults: @@ -203,9 +203,17 @@ jobs: echo "RUNNER_ARCH=$RUNNER_ARCH" echo "PROCESSOR_ARCHITECTURE=${PROCESSOR_ARCHITECTURE:-unknown}" rustc +${{ matrix.toolchain }} -vV + - name: Build emulated sealed tool fixture + if: matrix.tool_target != '' + run: | + set -euo pipefail + rustup target add --toolchain "${{ matrix.toolchain }}" "${{ matrix.tool_target }}" + cargo +${{ matrix.toolchain }} build --locked \ + -p semwright-driver-host \ + --bin semwright-tool-fixture \ + --target "${{ matrix.tool_target }}" + echo "SEMWRIGHT_TEST_TOOL_FIXTURE=$PWD/target/${{ matrix.tool_target }}/debug/semwright-tool-fixture.exe" >> "$GITHUB_ENV" - name: Sealed tool nested execution probe - env: - SEMWRIGHT_TEST_ADDRESS_SPACE_BYTES: ${{ matrix.address_space_bytes }} run: >- cargo +${{ matrix.toolchain }} test --locked -p semwright-driver-host diff --git a/crates/driver-host/src/lib.rs b/crates/driver-host/src/lib.rs index a6852d561..a113aa89f 100644 --- a/crates/driver-host/src/lib.rs +++ b/crates/driver-host/src/lib.rs @@ -151,8 +151,12 @@ impl HostToolExecutor for HostToolBroker { "Driver requested an ungranted sealed tool", ) })?; - // Bind each invocation to the exact Host-staged immutable bytes. - let _ = verify_owned_executable(&tool.staged.0, &tool.sha256)?; + // Bind each invocation to the exact Host-staged immutable bytes while + // allowing Windows' supported x64 user-mode emulation for sealed tools on ARM64. + let _ = semwright_platform_services::verify_sealed_tool_executable( + &tool.staged.0, + &tool.sha256, + )?; let remaining_operation_cpu = if self.operation_cpu_seconds == 0 { None @@ -280,7 +284,7 @@ impl HostToolExecutor for HostToolBroker { #[cfg(target_os = "windows")] fn seal_verified_tool(path: &Path, digest: &str, name: &str, state: &Path) -> Result { - let bytes = verify_owned_executable(path, digest)?; + let bytes = semwright_platform_services::verify_sealed_tool_executable(path, digest)?; let staged_path = state.join(format!("driver-tool-{name}-{}.exe", unique_id())); let staged = Arc::new(StagedFile(staged_path.clone())); let mut file = std::fs::OpenOptions::new() @@ -292,7 +296,7 @@ fn seal_verified_tool(path: &Path, digest: &str, name: &str, state: &Path) -> Re drop(file); // Re-attest the exact private copy that will become visible to the LPAC child. - let _ = verify_owned_executable(&staged_path, digest)?; + let _ = semwright_platform_services::verify_sealed_tool_executable(&staged_path, digest)?; Ok(SealedTool { name: name.to_owned(), staged, diff --git a/crates/driver-host/tests/windows_secure_host.rs b/crates/driver-host/tests/windows_secure_host.rs index 586c62613..481c7b21e 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -132,7 +132,9 @@ async fn secure_windows_driver_host_roundtrips_protocol_v2() { #[tokio::test] async fn secure_windows_driver_sealed_tool_is_staged_immutable_and_executable() { let driver_source = PathBuf::from(env!("CARGO_BIN_EXE_semwright-driver-fixture")); - let tool_source = PathBuf::from(env!("CARGO_BIN_EXE_semwright-tool-fixture")); + let tool_source = std::env::var_os("SEMWRIGHT_TEST_TOOL_FIXTURE") + .map(PathBuf::from) + .unwrap_or_else(|| PathBuf::from(env!("CARGO_BIN_EXE_semwright-tool-fixture"))); let binary_dir = tempfile::tempdir().expect("fixture directory"); let executable = binary_dir.path().join("driver.exe"); @@ -143,11 +145,6 @@ async fn secure_windows_driver_sealed_tool_is_staged_immutable_and_executable() harden_fixture(&owner_tool); let mut candidate = manifest(executable); - if let Ok(value) = std::env::var("SEMWRIGHT_TEST_ADDRESS_SPACE_BYTES") { - candidate.resources.address_space_bytes = value - .parse() - .expect("valid sealed-tool compatibility memory budget"); - } candidate.protocol = 4; candidate.interfaces.host_tools = true; candidate.tools = vec![DriverToolMount { diff --git a/crates/platform-services/src/lib.rs b/crates/platform-services/src/lib.rs index df465b0e4..cc1aa2af9 100644 --- a/crates/platform-services/src/lib.rs +++ b/crates/platform-services/src/lib.rs @@ -118,6 +118,11 @@ pub fn verify_executable(p: &Path, d: &str) -> Result> { verifier().verify(p, d) } +#[cfg(target_os = "windows")] +pub fn verify_sealed_tool_executable(path: &Path, digest: &str) -> Result> { + semwright_platform_windows_sys::launch::verify_sealed_tool_executable(path, digest) +} + #[cfg(target_os = "windows")] pub fn verify_private_data_file(path: &Path, max_bytes: u64) -> Result<()> { semwright_platform_windows_sys::launch::verify_private_data_file(path, max_bytes) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 10cff128e..b4401502a 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -1,4 +1,8 @@ -use crate::{identity::current_user_sid_bytes, job::ProcessJob, pe::require_native_architecture}; +use crate::{ + identity::current_user_sid_bytes, + job::ProcessJob, + pe::{require_native_architecture, require_sealed_tool_architecture}, +}; use async_trait::async_trait; use semwright_platform_api::launch::{ ExecutableVerifier, MaterializedMount, MaterializedTool, Mount, MountClass, SANDBOX_MOUNTS_ENV, @@ -465,7 +469,7 @@ fn verify_materialized_sealed_tool(path: &Path, digest: &str) -> Result<()> { "Materialized Windows sealed tool digest mismatch", )); } - require_native_architecture(&bytes)?; + require_sealed_tool_architecture(&bytes)?; require_authenticode_policy(authenticode_status(&file, path)?)?; Ok(()) } @@ -556,73 +560,83 @@ pub fn verify_private_data_file(path: &Path, max_bytes: u64) -> Result<()> { Ok(()) } +fn verify_windows_executable(path: &Path, digest: &str, sealed_tool: bool) -> Result> { + if !path.is_absolute() { + return Err(Error::invalid( + "Pinned Windows executable path must be absolute", + )); + } + let spelling = path.as_os_str().to_string_lossy().to_ascii_lowercase(); + if spelling.starts_with(r"\\") || spelling.starts_with(r"\\?\") || spelling.starts_with(r"\\.\") + { + return Err(Error::new( + ErrorCode::PolicyDenied, + "UNC, extended and device executable paths are not accepted", + )); + } + let mut options = std::fs::OpenOptions::new(); + options + .read(true) + .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_DELETE.0) + .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0); + let mut file = options.open(path)?; + let before = info(&file)?; + verify_trusted_file_acl(&file, "executable", false)?; + if before.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 + || before.nNumberOfLinks != 1 + || before.nFileSizeHigh != 0 + || before.nFileSizeLow as u64 > MAX_EXECUTABLE + { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Unsafe Windows executable type, link count or size", + )); + } + let mut bytes = Vec::with_capacity(before.nFileSizeLow as usize); + file.by_ref() + .take(MAX_EXECUTABLE + 1) + .read_to_end(&mut bytes)?; + if bytes.len() as u64 > MAX_EXECUTABLE { + return Err(Error::new( + ErrorCode::ResourceExhausted, + "Windows executable exceeds size budget", + )); + } + let after = info(&file)?; + if !same_identity(&before, &after) + || before.nFileSizeHigh != after.nFileSizeHigh + || before.nFileSizeLow != after.nFileSizeLow + || before.ftLastWriteTime != after.ftLastWriteTime + { + return Err(Error::new( + ErrorCode::Conflict, + "Windows executable changed while it was being verified", + )); + } + let expected = digest.trim().to_ascii_lowercase(); + if expected.len() != 64 || format!("{:x}", Sha256::digest(&bytes)) != expected { + return Err(Error::new( + ErrorCode::PermissionDenied, + "Executable digest mismatch", + )); + } + if sealed_tool { + require_sealed_tool_architecture(&bytes)?; + } else { + require_native_architecture(&bytes)?; + } + require_authenticode_policy(authenticode_status(&file, path)?)?; + Ok(bytes) +} + +pub fn verify_sealed_tool_executable(path: &Path, digest: &str) -> Result> { + verify_windows_executable(path, digest, true) +} + pub struct WindowsVerifier; impl ExecutableVerifier for WindowsVerifier { fn verify(&self, path: &Path, digest: &str) -> Result> { - if !path.is_absolute() { - return Err(Error::invalid( - "Pinned Windows executable path must be absolute", - )); - } - let spelling = path.as_os_str().to_string_lossy().to_ascii_lowercase(); - if spelling.starts_with(r"\\") - || spelling.starts_with(r"\\?\") - || spelling.starts_with(r"\\.\") - { - return Err(Error::new( - ErrorCode::PolicyDenied, - "UNC, extended and device executable paths are not accepted", - )); - } - let mut options = std::fs::OpenOptions::new(); - options - .read(true) - .share_mode(FILE_SHARE_READ.0 | FILE_SHARE_DELETE.0) - .custom_flags(FILE_FLAG_OPEN_REPARSE_POINT.0); - let mut file = options.open(path)?; - let before = info(&file)?; - verify_trusted_file_acl(&file, "executable", false)?; - if before.dwFileAttributes & FILE_ATTRIBUTE_REPARSE_POINT.0 != 0 - || before.nNumberOfLinks != 1 - || before.nFileSizeHigh != 0 - || before.nFileSizeLow as u64 > MAX_EXECUTABLE - { - return Err(Error::new( - ErrorCode::PermissionDenied, - "Unsafe Windows executable type, link count or size", - )); - } - let mut bytes = Vec::with_capacity(before.nFileSizeLow as usize); - file.by_ref() - .take(MAX_EXECUTABLE + 1) - .read_to_end(&mut bytes)?; - if bytes.len() as u64 > MAX_EXECUTABLE { - return Err(Error::new( - ErrorCode::ResourceExhausted, - "Windows executable exceeds size budget", - )); - } - let after = info(&file)?; - if !same_identity(&before, &after) - || before.nFileSizeHigh != after.nFileSizeHigh - || before.nFileSizeLow != after.nFileSizeLow - || before.ftLastWriteTime != after.ftLastWriteTime - { - return Err(Error::new( - ErrorCode::Conflict, - "Windows executable changed while it was being verified", - )); - } - let expected = digest.trim().to_ascii_lowercase(); - if expected.len() != 64 || format!("{:x}", Sha256::digest(&bytes)) != expected { - return Err(Error::new( - ErrorCode::PermissionDenied, - "Executable digest mismatch", - )); - } - require_native_architecture(&bytes)?; - require_authenticode_policy(authenticode_status(&file, path)?)?; - Ok(bytes) + verify_windows_executable(path, digest, false) } } diff --git a/crates/platform-windows-sys/src/pe.rs b/crates/platform-windows-sys/src/pe.rs index 2e361d3b2..104035d02 100644 --- a/crates/platform-windows-sys/src/pe.rs +++ b/crates/platform-windows-sys/src/pe.rs @@ -52,6 +52,29 @@ pub fn require_native_architecture(bytes: &[u8]) -> Result { Ok(found) } +/// Host-mediated user-mode tools may use Windows' supported x64 emulation on an ARM64 host. +/// Driver processes remain native-only because they define the long-lived sandbox/runtime +/// boundary; this compatibility rule is deliberately scoped to short-lived sealed tools. +fn sealed_tool_architecture_supported(host_arch: &str, found: PeArchitecture) -> bool { + match host_arch { + "x86_64" => found == PeArchitecture::Amd64, + "aarch64" => matches!(found, PeArchitecture::Arm64 | PeArchitecture::Amd64), + _ => false, + } +} + +pub fn require_sealed_tool_architecture(bytes: &[u8]) -> Result { + let found = architecture(bytes)?; + let supported = sealed_tool_architecture_supported(std::env::consts::ARCH, found); + if !supported { + return Err(Error::new( + ErrorCode::Unsupported, + "PE architecture is not supported for a sealed tool on this Windows host", + )); + } + Ok(found) +} + #[cfg(test)] mod tests { use super::*; @@ -76,4 +99,28 @@ mod tests { assert!(architecture(b"MZ").is_err()); assert!(architecture(&image(0x014c)).is_err()); } + + #[test] + fn sealed_tool_architecture_support_is_host_specific() { + assert!(sealed_tool_architecture_supported( + "x86_64", + PeArchitecture::Amd64 + )); + assert!(!sealed_tool_architecture_supported( + "x86_64", + PeArchitecture::Arm64 + )); + assert!(sealed_tool_architecture_supported( + "aarch64", + PeArchitecture::Arm64 + )); + assert!(sealed_tool_architecture_supported( + "aarch64", + PeArchitecture::Amd64 + )); + assert!(!sealed_tool_architecture_supported( + "riscv64", + PeArchitecture::Amd64 + )); + } } From a1802ba05c37e4ce1e6e828f0228c35cac898519 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 18:17:37 -0600 Subject: [PATCH 30/37] test(windows): restore x64 emulation memory budget --- .github/workflows/windows-platform.yml | 4 ++++ crates/driver-host/tests/windows_secure_host.rs | 5 +++++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/windows-platform.yml b/.github/workflows/windows-platform.yml index f4f612dd7..3032c5c68 100644 --- a/.github/workflows/windows-platform.yml +++ b/.github/workflows/windows-platform.yml @@ -180,10 +180,12 @@ jobs: runs_on: windows-2022 toolchain: "1.98.1" tool_target: "" + address_space_bytes: "536870912" - name: win11-arm-native-host-x64tool runs_on: windows-11-arm toolchain: "1.98.1" tool_target: "x86_64-pc-windows-msvc" + address_space_bytes: "2147483648" runs-on: ${{ matrix.runs_on }} timeout-minutes: 30 defaults: @@ -214,6 +216,8 @@ jobs: --target "${{ matrix.tool_target }}" echo "SEMWRIGHT_TEST_TOOL_FIXTURE=$PWD/target/${{ matrix.tool_target }}/debug/semwright-tool-fixture.exe" >> "$GITHUB_ENV" - name: Sealed tool nested execution probe + env: + SEMWRIGHT_TEST_ADDRESS_SPACE_BYTES: ${{ matrix.address_space_bytes }} run: >- cargo +${{ matrix.toolchain }} test --locked -p semwright-driver-host diff --git a/crates/driver-host/tests/windows_secure_host.rs b/crates/driver-host/tests/windows_secure_host.rs index 481c7b21e..0fea955ec 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -145,6 +145,11 @@ async fn secure_windows_driver_sealed_tool_is_staged_immutable_and_executable() harden_fixture(&owner_tool); let mut candidate = manifest(executable); + if let Ok(value) = std::env::var("SEMWRIGHT_TEST_ADDRESS_SPACE_BYTES") { + candidate.resources.address_space_bytes = value + .parse() + .expect("valid sealed-tool compatibility memory budget"); + } candidate.protocol = 4; candidate.interfaces.host_tools = true; candidate.tools = vec![DriverToolMount { From d1ca6dbf248660546b90a5d128d1e7cb01799b03 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 18:32:56 -0600 Subject: [PATCH 31/37] fix(windows): pin x64 sealed tool machine type on ARM64 --- crates/driver-host/src/lib.rs | 4 ++ crates/platform-api/src/launch.rs | 3 ++ crates/platform-windows-sys/src/launch.rs | 65 +++++++++++++++++++---- crates/platform-windows-sys/src/pe.rs | 38 ++++++++++++- 4 files changed, 99 insertions(+), 11 deletions(-) diff --git a/crates/driver-host/src/lib.rs b/crates/driver-host/src/lib.rs index a113aa89f..089127181 100644 --- a/crates/driver-host/src/lib.rs +++ b/crates/driver-host/src/lib.rs @@ -179,6 +179,10 @@ impl HostToolExecutor for HostToolBroker { spec.staged_executable = tool.staged.0.clone(); spec.args = args; spec.environment.clear(); + spec.environment.push(( + semwright_platform_api::launch::SANDBOX_HOST_TOOL_CHILD_ENV.into(), + "1".into(), + )); spec.sealed_tools.clear(); let timeout = Duration::from_millis(timeout_ms); let timeout_cpu_seconds = timeout diff --git a/crates/platform-api/src/launch.rs b/crates/platform-api/src/launch.rs index cade19394..8c411008b 100644 --- a/crates/platform-api/src/launch.rs +++ b/crates/platform-api/src/launch.rs @@ -77,6 +77,9 @@ impl Mount { } pub const SANDBOX_MOUNTS_ENV: &str = "SEMWRIGHT_SANDBOX_MOUNTS_V1"; +/// Internal host-only marker for a short-lived sealed tool child. Platform launchers may +/// consume this for compatibility policy, but must not forward it into the child environment. +pub const SANDBOX_HOST_TOOL_CHILD_ENV: &str = "SEMWRIGHT_HOST_TOOL_CHILD"; const MAX_MATERIALIZED_MOUNTS: usize = 32; const MAX_MOUNT_ENV_BYTES: usize = 16 * 1024; diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index b4401502a..229bb2f8c 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -1,13 +1,17 @@ use crate::{ identity::current_user_sid_bytes, job::ProcessJob, - pe::{require_native_architecture, require_sealed_tool_architecture}, + pe::{ + IMAGE_FILE_MACHINE_AMD64, PeArchitecture, architecture_file, require_native_architecture, + require_sealed_tool_architecture, + }, }; use async_trait::async_trait; use semwright_platform_api::launch::{ - ExecutableVerifier, MaterializedMount, MaterializedTool, Mount, MountClass, SANDBOX_MOUNTS_ENV, - SANDBOX_TOOLS_ENV, SandboxChildControl, SandboxCpuAccounting, SandboxLauncher, SandboxProcess, - SandboxSpec, SealedToolSource, encode_materialized_mounts, encode_materialized_tools, + ExecutableVerifier, MaterializedMount, MaterializedTool, Mount, MountClass, + SANDBOX_HOST_TOOL_CHILD_ENV, SANDBOX_MOUNTS_ENV, SANDBOX_TOOLS_ENV, SandboxChildControl, + SandboxCpuAccounting, SandboxLauncher, SandboxProcess, SandboxSpec, SealedToolSource, + encode_materialized_mounts, encode_materialized_tools, }; use semwright_types::{Error, ErrorCode, Result, unique_id}; use sha2::{Digest, Sha256}; @@ -80,9 +84,10 @@ use windows::Win32::{ DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, GetExitCodeProcess, INFINITE, InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, - PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, - PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, - TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_MACHINE_TYPE, + PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, ResumeThread, + STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, + WaitForSingleObject, }, WindowsProgramming::PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, }, @@ -1939,7 +1944,12 @@ fn environment_block( mount_table: Option<&str>, tool_table: Option<&str>, ) -> Result> { - let mut entries = spec.environment.clone(); + let mut entries = spec + .environment + .iter() + .filter(|(name, _)| name != SANDBOX_HOST_TOOL_CHILD_ENV) + .cloned() + .collect::>(); if entries .iter() .any(|(name, _)| name == SANDBOX_MOUNTS_ENV || name == SANDBOX_TOOLS_ENV) @@ -2196,6 +2206,39 @@ impl Drop for NativeSandboxChild { } } +fn host_tool_machine_type(spec: &SandboxSpec) -> Result> { + let markers = spec + .environment + .iter() + .filter(|(name, _)| name == SANDBOX_HOST_TOOL_CHILD_ENV) + .collect::>(); + if markers.is_empty() { + return Ok(None); + } + if markers.len() != 1 + || markers[0].1 != "1" + || spec.kind != semwright_platform_api::launch::SandboxKind::Driver + || !spec.mounts.is_empty() + || !spec.sealed_tools.is_empty() + || spec.network + || spec.environment.len() != 1 + { + return Err(Error::new( + ErrorCode::SandboxDenied, + "Windows sealed-tool compatibility marker is only valid for isolated Host-mediated tool children", + )); + } + let architecture = architecture_file(&spec.staged_executable)?; + match (std::env::consts::ARCH, architecture) { + ("aarch64", PeArchitecture::Amd64) => Ok(Some(IMAGE_FILE_MACHINE_AMD64)), + ("aarch64", PeArchitecture::Arm64) | ("x86_64", PeArchitecture::Amd64) => Ok(None), + _ => Err(Error::new( + ErrorCode::Unsupported, + "Windows sealed-tool machine type is not supported by this host", + )), + } +} + /// Windows arbitrary-child launch is platform-owned: an AppContainer identity and explicit /// inherited-handle list are attached at creation, the process starts suspended, enters a /// kill-on-close Job Object, and is resumed only after that boundary exists. @@ -2211,6 +2254,7 @@ impl SandboxLauncher for WindowsSandbox { fn spawn(&self, spec: &SandboxSpec) -> Result { spec.validate()?; + let machine_type = host_tool_machine_type(spec)?; if !spec.sealed_tools.is_empty() { return Err(Error::new( ErrorCode::SandboxDenied, @@ -2228,8 +2272,11 @@ impl SandboxLauncher for WindowsSandbox { let child_stderr = inherited_null()?; let handles = [child_stdin.raw(), child_stdout.raw(), child_stderr.raw()]; - let mut attributes = ProcAttributes::new(3)?; + let mut attributes = ProcAttributes::new(3 + u32::from(machine_type.is_some()))?; attributes.set_slice(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; + if let Some(machine_type) = machine_type { + attributes.set_value(PROC_THREAD_ATTRIBUTE_MACHINE_TYPE, &machine_type)?; + } let all_application_packages_policy = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; attributes.set_value( PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, diff --git a/crates/platform-windows-sys/src/pe.rs b/crates/platform-windows-sys/src/pe.rs index 104035d02..827281b3c 100644 --- a/crates/platform-windows-sys/src/pe.rs +++ b/crates/platform-windows-sys/src/pe.rs @@ -1,7 +1,12 @@ use semwright_types::{Error, ErrorCode, Result}; +use std::{ + fs::File, + io::{Read, Seek, SeekFrom}, + path::Path, +}; -const IMAGE_FILE_MACHINE_AMD64: u16 = 0x8664; -const IMAGE_FILE_MACHINE_ARM64: u16 = 0xAA64; +pub const IMAGE_FILE_MACHINE_AMD64: u16 = 0x8664; +pub const IMAGE_FILE_MACHINE_ARM64: u16 = 0xAA64; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum PeArchitecture { @@ -31,6 +36,35 @@ pub fn architecture(bytes: &[u8]) -> Result { } } +pub fn architecture_file(path: &Path) -> Result { + let mut file = File::open(path)?; + let mut dos = [0u8; 0x40]; + file.read_exact(&mut dos)?; + if &dos[..2] != b"MZ" { + return Err(Error::invalid("Executable is not a PE image")); + } + let offset = u32::from_le_bytes(dos[0x3c..0x40].try_into().expect("bounded slice")) as u64; + if offset > 16 * 1024 * 1024 { + return Err(Error::invalid( + "PE header offset exceeds bounded inspection window", + )); + } + file.seek(SeekFrom::Start(offset))?; + let mut header = [0u8; 6]; + file.read_exact(&mut header)?; + if &header[..4] != b"PE\0\0" { + return Err(Error::invalid("Malformed PE signature")); + } + match u16::from_le_bytes([header[4], header[5]]) { + IMAGE_FILE_MACHINE_AMD64 => Ok(PeArchitecture::Amd64), + IMAGE_FILE_MACHINE_ARM64 => Ok(PeArchitecture::Arm64), + _ => Err(Error::new( + ErrorCode::Unsupported, + "PE machine architecture is not supported by Semwright", + )), + } +} + pub fn require_native_architecture(bytes: &[u8]) -> Result { let found = architecture(bytes)?; let wanted = match std::env::consts::ARCH { From 304869980a571d9ab596d920376e17ffe0d11d08 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 18:42:10 -0600 Subject: [PATCH 32/37] fix(windows): budget one x64 emulation helper process --- crates/platform-windows-sys/src/launch.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 229bb2f8c..52036e049 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -2272,7 +2272,8 @@ impl SandboxLauncher for WindowsSandbox { let child_stderr = inherited_null()?; let handles = [child_stdin.raw(), child_stdout.raw(), child_stderr.raw()]; - let mut attributes = ProcAttributes::new(3 + u32::from(machine_type.is_some()))?; + let cross_arch_host_tool = machine_type.is_some(); + let mut attributes = ProcAttributes::new(3 + u32::from(cross_arch_host_tool))?; attributes.set_slice(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; if let Some(machine_type) = machine_type { attributes.set_value(PROC_THREAD_ATTRIBUTE_MACHINE_TYPE, &machine_type)?; @@ -2318,7 +2319,18 @@ impl SandboxLauncher for WindowsSandbox { ErrorCode::ResourceExhausted, "Windows process limit exceeds Job budget", ) - })?; + })? + .map(|limit| { + if cross_arch_host_tool { + // x64-on-ARM64 emulation may require one runtime-support process. This + // extra Job slot is platform overhead, not delegated driver authority: + // the LPAC child remains without mounts/network and the compatibility + // probe still requires ordinary descendant spawn attempts to fail. + limit.saturating_add(1) + } else { + limit + } + }); let memory_limit = limits .map(|limit| usize::try_from(limit.address_space_bytes)) .transpose() From d8d2d158a284d69606ef96bc7395482ac9ed84c3 Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 18:58:48 -0600 Subject: [PATCH 33/37] fix(windows): let Prism select x64 tool emulation --- crates/platform-windows-sys/src/launch.rs | 48 +++++++++++++++-------- 1 file changed, 31 insertions(+), 17 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 52036e049..74c92cdc2 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -82,12 +82,12 @@ use windows::Win32::{ Threading::{ CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, - GetExitCodeProcess, INFINITE, InitializeProcThreadAttributeList, - LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, - PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_MACHINE_TYPE, - PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, ResumeThread, - STARTF_USESTDHANDLES, STARTUPINFOEXW, TerminateProcess, UpdateProcThreadAttribute, - WaitForSingleObject, + GetExitCodeProcess, GetMachineTypeAttributes, INFINITE, + InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, + PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, + TerminateProcess, UpdateProcThreadAttribute, UserEnabled, WaitForSingleObject, }, WindowsProgramming::PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, }, @@ -2206,14 +2206,14 @@ impl Drop for NativeSandboxChild { } } -fn host_tool_machine_type(spec: &SandboxSpec) -> Result> { +fn host_tool_cross_arch(spec: &SandboxSpec) -> Result { let markers = spec .environment .iter() .filter(|(name, _)| name == SANDBOX_HOST_TOOL_CHILD_ENV) .collect::>(); if markers.is_empty() { - return Ok(None); + return Ok(false); } if markers.len() != 1 || markers[0].1 != "1" @@ -2230,11 +2230,29 @@ fn host_tool_machine_type(spec: &SandboxSpec) -> Result> { } let architecture = architecture_file(&spec.staged_executable)?; match (std::env::consts::ARCH, architecture) { - ("aarch64", PeArchitecture::Amd64) => Ok(Some(IMAGE_FILE_MACHINE_AMD64)), - ("aarch64", PeArchitecture::Arm64) | ("x86_64", PeArchitecture::Amd64) => Ok(None), + ("aarch64", PeArchitecture::Amd64) => { + // Windows 11 ARM64 transparently emulates x64 user-mode binaries. Verify that + // the OS advertises x64 user-mode support, but let CreateProcess select Prism from + // the PE architecture instead of forcing PROC_THREAD_ATTRIBUTE_MACHINE_TYPE. + let attributes = unsafe { GetMachineTypeAttributes(IMAGE_FILE_MACHINE_AMD64) } + .map_err(|_| { + Error::new( + ErrorCode::Unsupported, + "Windows x64 emulation capability query failed", + ) + })?; + if attributes.0 & UserEnabled.0 == 0 { + return Err(Error::new( + ErrorCode::Unsupported, + "Windows host does not enable x64 user-mode execution", + )); + } + Ok(true) + } + ("aarch64", PeArchitecture::Arm64) | ("x86_64", PeArchitecture::Amd64) => Ok(false), _ => Err(Error::new( ErrorCode::Unsupported, - "Windows sealed-tool machine type is not supported by this host", + "Windows sealed-tool architecture is not supported by this host", )), } } @@ -2254,7 +2272,7 @@ impl SandboxLauncher for WindowsSandbox { fn spawn(&self, spec: &SandboxSpec) -> Result { spec.validate()?; - let machine_type = host_tool_machine_type(spec)?; + let cross_arch_host_tool = host_tool_cross_arch(spec)?; if !spec.sealed_tools.is_empty() { return Err(Error::new( ErrorCode::SandboxDenied, @@ -2272,12 +2290,8 @@ impl SandboxLauncher for WindowsSandbox { let child_stderr = inherited_null()?; let handles = [child_stdin.raw(), child_stdout.raw(), child_stderr.raw()]; - let cross_arch_host_tool = machine_type.is_some(); - let mut attributes = ProcAttributes::new(3 + u32::from(cross_arch_host_tool))?; + let mut attributes = ProcAttributes::new(3)?; attributes.set_slice(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; - if let Some(machine_type) = machine_type { - attributes.set_value(PROC_THREAD_ATTRIBUTE_MACHINE_TYPE, &machine_type)?; - } let all_application_packages_policy = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; attributes.set_value( PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, From fb40463d96ee3857da0d261cc9ffc10384f1466e Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 19:03:11 -0600 Subject: [PATCH 34/37] fix(windows): scope x64 emulation query to ARM64 --- crates/platform-windows-sys/src/launch.rs | 51 ++++++++++++++--------- 1 file changed, 31 insertions(+), 20 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 74c92cdc2..d66fb2c7a 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -29,6 +29,8 @@ use std::{ sync::Arc, }; use tokio::{fs::File as TokioFile, process::Command}; +#[cfg(target_arch = "aarch64")] +use windows::Win32::System::Threading::{GetMachineTypeAttributes, UserEnabled}; use windows::Win32::{ Foundation::{ CloseHandle, DUPLICATE_SAME_ACCESS, DuplicateHandle, GENERIC_ALL, GENERIC_READ, @@ -82,12 +84,11 @@ use windows::Win32::{ Threading::{ CREATE_NO_WINDOW, CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, DeleteProcThreadAttributeList, EXTENDED_STARTUPINFO_PRESENT, GetCurrentProcess, - GetExitCodeProcess, GetMachineTypeAttributes, INFINITE, - InitializeProcThreadAttributeList, LPPROC_THREAD_ATTRIBUTE_LIST, - PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + GetExitCodeProcess, INFINITE, InitializeProcThreadAttributeList, + LPPROC_THREAD_ATTRIBUTE_LIST, PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, STARTUPINFOEXW, - TerminateProcess, UpdateProcThreadAttribute, UserEnabled, WaitForSingleObject, + TerminateProcess, UpdateProcThreadAttribute, WaitForSingleObject, }, WindowsProgramming::PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT, }, @@ -2206,6 +2207,29 @@ impl Drop for NativeSandboxChild { } } +#[cfg(target_arch = "aarch64")] +fn require_x64_user_mode_support() -> Result<()> { + let attributes = + unsafe { GetMachineTypeAttributes(IMAGE_FILE_MACHINE_AMD64) }.map_err(|_| { + Error::new( + ErrorCode::Unsupported, + "Windows x64 emulation capability query failed", + ) + })?; + if attributes.0 & UserEnabled.0 == 0 { + return Err(Error::new( + ErrorCode::Unsupported, + "Windows host does not enable x64 user-mode execution", + )); + } + Ok(()) +} + +#[cfg(not(target_arch = "aarch64"))] +fn require_x64_user_mode_support() -> Result<()> { + Ok(()) +} + fn host_tool_cross_arch(spec: &SandboxSpec) -> Result { let markers = spec .environment @@ -2231,22 +2255,9 @@ fn host_tool_cross_arch(spec: &SandboxSpec) -> Result { let architecture = architecture_file(&spec.staged_executable)?; match (std::env::consts::ARCH, architecture) { ("aarch64", PeArchitecture::Amd64) => { - // Windows 11 ARM64 transparently emulates x64 user-mode binaries. Verify that - // the OS advertises x64 user-mode support, but let CreateProcess select Prism from - // the PE architecture instead of forcing PROC_THREAD_ATTRIBUTE_MACHINE_TYPE. - let attributes = unsafe { GetMachineTypeAttributes(IMAGE_FILE_MACHINE_AMD64) } - .map_err(|_| { - Error::new( - ErrorCode::Unsupported, - "Windows x64 emulation capability query failed", - ) - })?; - if attributes.0 & UserEnabled.0 == 0 { - return Err(Error::new( - ErrorCode::Unsupported, - "Windows host does not enable x64 user-mode execution", - )); - } + // Windows 11 ARM64 transparently emulates x64 user-mode binaries. Verify support + // only in ARM64 builds so older x64 hosts never import this Windows 11 API. + require_x64_user_mode_support()?; Ok(true) } ("aarch64", PeArchitecture::Arm64) | ("x86_64", PeArchitecture::Amd64) => Ok(false), From 1d70d3c5be46c7835f43c9422a11173f6c36284f Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 19:12:35 -0600 Subject: [PATCH 35/37] test(windows): isolate Prism memory-cap diagnosis --- .github/workflows/windows-platform.yml | 4 +++- crates/platform-windows-sys/src/launch.rs | 6 +++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/.github/workflows/windows-platform.yml b/.github/workflows/windows-platform.yml index 3032c5c68..8793c1d0c 100644 --- a/.github/workflows/windows-platform.yml +++ b/.github/workflows/windows-platform.yml @@ -185,7 +185,9 @@ jobs: runs_on: windows-11-arm toolchain: "1.98.1" tool_target: "x86_64-pc-windows-msvc" - address_space_bytes: "2147483648" + # Diagnostic-only probe budget: if Prism still fails with STATUS_NO_MEMORY at 4 GiB, + # do not expand production authority; investigate LPAC/emulation compatibility instead. + address_space_bytes: "4294967296" runs-on: ${{ matrix.runs_on }} timeout-minutes: 30 defaults: diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index d66fb2c7a..536488145 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -1,8 +1,10 @@ +#[cfg(target_arch = "aarch64")] +use crate::pe::IMAGE_FILE_MACHINE_AMD64; use crate::{ identity::current_user_sid_bytes, job::ProcessJob, pe::{ - IMAGE_FILE_MACHINE_AMD64, PeArchitecture, architecture_file, require_native_architecture, + PeArchitecture, architecture_file, require_native_architecture, require_sealed_tool_architecture, }, }; @@ -2209,6 +2211,8 @@ impl Drop for NativeSandboxChild { #[cfg(target_arch = "aarch64")] fn require_x64_user_mode_support() -> Result<()> { + // SAFETY: this is a read-only capability query for a constant machine type and returns + // a value by copy; no caller-owned pointers or handles are involved. let attributes = unsafe { GetMachineTypeAttributes(IMAGE_FILE_MACHINE_AMD64) }.map_err(|_| { Error::new( From 960bb65e194510432477275ef1438a023cbbda1b Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 19:20:02 -0600 Subject: [PATCH 36/37] test(windows): isolate LPAC impact on Prism --- crates/platform-windows-sys/src/launch.rs | 18 ++++++++++++------ 1 file changed, 12 insertions(+), 6 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 536488145..4bedcfe67 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -2305,13 +2305,19 @@ impl SandboxLauncher for WindowsSandbox { let child_stderr = inherited_null()?; let handles = [child_stdin.raw(), child_stdout.raw(), child_stderr.raw()]; - let mut attributes = ProcAttributes::new(3)?; + let mut attributes = ProcAttributes::new(3 - u32::from(cross_arch_host_tool))?; attributes.set_slice(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; - let all_application_packages_policy = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; - attributes.set_value( - PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, - &all_application_packages_policy, - )?; + if !cross_arch_host_tool { + let all_application_packages_policy = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; + attributes.set_value( + PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &all_application_packages_policy, + )?; + } + // Diagnostic only: x64-on-ARM64 sealed-tool children use regular AppContainer here to + // isolate whether LPAC itself blocks Prism initialization. This branch must not merge + // with this relaxation; the result decides between a targeted capability fix and + // fail-closed cross-architecture support. let network_sid = spec .network From b1a969870b1470edb1f5be38e6a8b0bf18a202ff Mon Sep 17 00:00:00 2001 From: Semwright integration Date: Sun, 27 Sep 2026 19:32:31 -0600 Subject: [PATCH 37/37] fix(windows): keep Prism sealed tools inside LPAC --- crates/platform-windows-sys/src/launch.rs | 114 +++++++++++++++++----- 1 file changed, 92 insertions(+), 22 deletions(-) diff --git a/crates/platform-windows-sys/src/launch.rs b/crates/platform-windows-sys/src/launch.rs index 4bedcfe67..22a4373be 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -48,8 +48,8 @@ use windows::Win32::{ SE_FILE_OBJECT, SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, }, - CopySid, CreateWellKnownSid, DACL_SECURITY_INFORMATION, EqualSid, FreeSid, GetAce, - GetAclInformation, GetLengthSid, GetSecurityDescriptorControl, + CopySid, CreateWellKnownSid, DACL_SECURITY_INFORMATION, DeriveCapabilitySidsFromName, + EqualSid, FreeSid, GetAce, GetAclInformation, GetLengthSid, GetSecurityDescriptorControl, Isolation::{ CreateAppContainerProfile, DeleteAppContainerProfile, GetAppContainerFolderPath, }, @@ -262,6 +262,60 @@ fn well_known_sid(kind: windows::Win32::Security::WELL_KNOWN_SID_TYPE) -> Result Ok(bytes) } +fn free_derived_sid_array(array: *mut PSID, count: u32) { + if array.is_null() { + return; + } + // Capability derivation is expected to return a tiny list. Bound traversal defensively; + // leaking an impossible oversized OS allocation is preferable to trusting an absurd count. + let bounded = count.min(64) as usize; + // SAFETY: DeriveCapabilitySidsFromName returns at least count LocalAlloc-owned SID + // pointers followed by a LocalAlloc-owned pointer array on success. + unsafe { + for sid in std::slice::from_raw_parts(array, bounded) { + if !sid.is_invalid() { + let _ = LocalFree(Some(HLOCAL(sid.0))); + } + } + let _ = LocalFree(Some(HLOCAL(array.cast()))); + } +} + +fn named_capability_sid(name: &str) -> Result> { + let wide = wide_null(OsStr::new(name))?; + let mut group_sids: *mut PSID = std::ptr::null_mut(); + let mut group_count = 0u32; + let mut capability_sids: *mut PSID = std::ptr::null_mut(); + let mut capability_count = 0u32; + // SAFETY: all out-pointers reference live locals and wide is NUL-terminated. + let derived = unsafe { + DeriveCapabilitySidsFromName( + PCWSTR(wide.as_ptr()), + &mut group_sids, + &mut group_count, + &mut capability_sids, + &mut capability_count, + ) + }; + let result = match derived { + Ok(()) if capability_count == 1 && !capability_sids.is_null() => { + // SAFETY: the API reported exactly one capability SID. + copy_sid_bytes(unsafe { *capability_sids }) + } + Ok(()) => Err(Error::new( + ErrorCode::SandboxDenied, + format!("Windows capability {name} did not resolve to exactly one SID"), + )), + Err(_) => Err(Error::new( + ErrorCode::SandboxDenied, + format!("Windows capability {name} could not be resolved"), + )), + }; + free_derived_sid_array(group_sids, group_count); + free_derived_sid_array(capability_sids, capability_count); + result +} + fn sid_matches(sid: PSID, expected: &[u8]) -> bool { if sid.is_invalid() || expected.is_empty() { return false; @@ -2305,34 +2359,50 @@ impl SandboxLauncher for WindowsSandbox { let child_stderr = inherited_null()?; let handles = [child_stdin.raw(), child_stdout.raw(), child_stderr.raw()]; - let mut attributes = ProcAttributes::new(3 - u32::from(cross_arch_host_tool))?; + let mut attributes = ProcAttributes::new(3)?; attributes.set_slice(PROC_THREAD_ATTRIBUTE_HANDLE_LIST, &handles)?; - if !cross_arch_host_tool { - let all_application_packages_policy = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; - attributes.set_value( - PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, - &all_application_packages_policy, - )?; - } - // Diagnostic only: x64-on-ARM64 sealed-tool children use regular AppContainer here to - // isolate whether LPAC itself blocks Prism initialization. This branch must not merge - // with this relaxation; the result decides between a targeted capability fix and - // fail-closed cross-architecture support. + let all_application_packages_policy = PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT; + attributes.set_value( + PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY, + &all_application_packages_policy, + )?; let network_sid = spec .network .then(|| well_known_sid(WinCapabilityInternetClientSid)) .transpose()?; - let mut network_capability = network_sid.as_ref().map(|sid| SID_AND_ATTRIBUTES { - Sid: PSID(sid.as_ptr().cast_mut().cast()), - Attributes: SE_GROUP_ENABLED as u32, - }); + // Windows-on-ARM x64 emulation performs compatibility initialization that needs + // read-only registry access. Keep this capability scoped to the cross-architecture + // Host-tool case; native drivers/tools and network-disabled children do not gain it. + let emulation_registry_sid = cross_arch_host_tool + .then(|| named_capability_sid("registryRead")) + .transpose()?; + let mut capability_entries = Vec::with_capacity(2); + if let Some(sid) = network_sid.as_ref() { + capability_entries.push(SID_AND_ATTRIBUTES { + Sid: PSID(sid.as_ptr().cast_mut().cast()), + Attributes: SE_GROUP_ENABLED as u32, + }); + } + if let Some(sid) = emulation_registry_sid.as_ref() { + capability_entries.push(SID_AND_ATTRIBUTES { + Sid: PSID(sid.as_ptr().cast_mut().cast()), + Attributes: SE_GROUP_ENABLED as u32, + }); + } let capabilities = SECURITY_CAPABILITIES { AppContainerSid: profile.sid, - Capabilities: network_capability - .as_mut() - .map_or(std::ptr::null_mut(), |capability| capability), - CapabilityCount: u32::from(network_capability.is_some()), + Capabilities: if capability_entries.is_empty() { + std::ptr::null_mut() + } else { + capability_entries.as_mut_ptr() + }, + CapabilityCount: u32::try_from(capability_entries.len()).map_err(|_| { + Error::new( + ErrorCode::ResourceExhausted, + "Windows sandbox capability count exceeds platform budget", + ) + })?, ..Default::default() }; attributes.set_value(PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, &capabilities)?;