diff --git a/.github/workflows/windows-platform.yml b/.github/workflows/windows-platform.yml index d09e288d5..8793c1d0c 100644 --- a/.github/workflows/windows-platform.yml +++ b/.github/workflows/windows-platform.yml @@ -131,6 +131,8 @@ jobs: run: cargo test --locked -p semwright-driver-host --test windows_secure_host -- secure_windows_driver_workspace --nocapture - name: Secure Windows Driver secret grants run: cargo test --locked -p semwright-driver-host --test windows_secure_host -- secure_windows_driver_secret --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 Driver loopback bridge run: cargo test --locked -p semwright-driver-host --test windows_secure_host -- secure_windows_driver_loopback --nocapture - name: Secure Windows Plugin Host round-trip @@ -167,3 +169,60 @@ 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" + 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" + # 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: + 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 --force-non-host + - name: Record compatibility environment + run: | + set -euo pipefail + 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 + --test windows_secure_host + -- secure_windows_driver_sealed_tool_is_staged_immutable_and_executable + --nocapture diff --git a/Cargo.lock b/Cargo.lock index ede077f4b..cbb0c31a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3059,6 +3059,7 @@ dependencies = [ "tempfile", "tokio", "tokio-util", + "windows", ] [[package]] diff --git a/crates/driver-host/Cargo.toml b/crates/driver-host/Cargo.toml index b419005c9..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 @@ -35,6 +42,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 f84102754..acc018f90 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, secret_mount, - serve, system_config_mount, workspace_mount, + serve, system_config_mount, tool_path, workspace_mount, }; use semwright_types::{ CommandDescriptor, Error, ErrorCode, Idempotency, JobArtifact, JobProgress, Result, Risk, @@ -81,6 +81,45 @@ 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"}, + "read_ok":{"type":"boolean"}, + "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","null_spawn_ok","null_spawn_errno","write_ok","spawn_error_kind","spawn_errno","exit_code"], + "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 config_capability() -> Capability { Capability { descriptor: CommandDescriptor { @@ -216,12 +255,14 @@ 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> { Ok(vec![ capability(), mount_capability(), + tool_capability(), config_capability(), secret_capability(), long_capability(), @@ -232,6 +273,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.config_probe" => config_capability(), "driver.fixture.secret_probe" => secret_capability(), "driver.fixture.disconnect" => disconnect_capability(), @@ -259,6 +301,109 @@ 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 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); + #[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, + Err(error) => { + 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, + "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), + "exit_code":-1 + })); + } + }; + let exit_code = output.status.code().unwrap_or(-1); + if !output.status.success() { + 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, + "null_spawn_ok":null_spawn_ok, + "null_spawn_errno":null_spawn_errno, + "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( + ErrorCode::BackendFailed, + "fixture tool output was not UTF-8", + ) + })?; + 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, + "null_spawn_ok":null_spawn_ok, + "null_spawn_errno":null_spawn_errno, + "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()) { return Err(Error::invalid( @@ -321,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; } @@ -421,6 +627,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)); 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..81f9c422c --- /dev/null +++ b/crates/driver-host/src/bin/tool_fixture.rs @@ -0,0 +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() { + #[cfg(windows)] + { + print!("tool-ok|appcontainer={}", u8::from(is_appcontainer())); + } + #[cfg(not(windows))] + { + print!("tool-ok"); + } +} diff --git a/crates/driver-host/src/lib.rs b/crates/driver-host/src/lib.rs index 1a05e0950..17d16f0a7 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}, @@ -35,6 +37,8 @@ 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::sync::{Mutex, broadcast, oneshot}; @@ -62,12 +66,248 @@ 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")] +#[derive(Clone)] +struct SealedTool { + name: String, + staged: Arc, + sha256: String, +} +#[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 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 + } 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.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 + .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 = 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() + .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 _ = semwright_platform_services::verify_sealed_tool_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_sealed_tool_executable(path, digest)?; @@ -458,17 +698,38 @@ 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() .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 @@ -495,9 +756,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))] @@ -509,9 +777,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", @@ -523,19 +810,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", @@ -570,18 +863,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 { @@ -630,25 +961,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); }); } @@ -800,12 +1233,6 @@ fn sandbox_spec_windows( Mount, MountClass, ResourceLimits, SandboxKind, SandboxSpec, }; - if !manifest.tools.is_empty() { - return Err(Error::new( - ErrorCode::SandboxDenied, - "Windows tool grants remain fail-closed until their immutable-executable contract is proven", - )); - } let lookup = |name: &str| -> Result<&FilesystemGrant> { roots .iter() @@ -871,7 +1298,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")) @@ -879,6 +1306,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(), @@ -886,7 +1316,7 @@ fn sandbox_spec_windows( mounts, args: vec![], environment, - sealed_tools: vec![], + sealed_tools: Vec::new(), network: manifest.network, limits: Some(ResourceLimits { open_files: manifest.resources.open_files, @@ -914,7 +1344,7 @@ pub struct DriverProvider { _staged: Arc, #[cfg(any(unix, windows))] _loopback: Option>, - #[cfg(unix)] + #[cfg(any(unix, target_os = "windows"))] _tools: Vec, } @@ -944,6 +1374,27 @@ 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() + .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 loopback = match manifest.loopback_port { Some(port) => Some(loopback::start(state, port).await?), None => None, @@ -955,6 +1406,15 @@ impl DriverProvider { roots, 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() @@ -979,7 +1439,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, @@ -988,7 +1448,43 @@ 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 (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(), + ), + }; + return Err(Error::new(error_code, message)); + } + }; match hello { Response::Ready { protocol, @@ -1111,21 +1607,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) }; @@ -1162,6 +1656,7 @@ impl DriverProvider { operation_cpu_gate: Mutex::new(()), _staged: staged, _loopback: loopback, + _tools: sealed_tools, })) } #[cfg(unix)] @@ -1206,6 +1701,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() @@ -1223,7 +1719,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, @@ -1232,7 +1728,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, @@ -1355,21 +1876,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) }; @@ -1687,7 +2206,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")) } } @@ -1755,7 +2274,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( @@ -1944,6 +2463,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/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/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 a75fe179b..0fea955ec 100644 --- a/crates/driver-host/tests/windows_secure_host.rs +++ b/crates/driver-host/tests/windows_secure_host.rs @@ -3,8 +3,8 @@ use semwright_backend_api::{Context, Provider}; use semwright_driver_host::DriverProvider; use semwright_driver_sdk::{ - ApplicationMatch, DriverInterfaces, DriverMount, DriverResources, DriverSecretMount, Manifest, - SystemConfigMount, Transport, + ApplicationMatch, DriverInterfaces, DriverMount, DriverResources, DriverSecretMount, + DriverToolMount, Manifest, SystemConfigMount, Transport, }; use semwright_policy::FilesystemGrant; use sha2::{Digest, Sha256}; @@ -76,6 +76,7 @@ fn manifest(executable: PathBuf) -> Manifest { artifacts: true, health: true, native_refs: false, + host_tools: false, }, } } @@ -128,6 +129,140 @@ 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 = 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"); + 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); + 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 { + 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["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|appcontainer=1", + "sealed tool must execute without breaking out of AppContainer: {output}" + ); + assert_eq!( + 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()) + .await + .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.protocol = 4; + candidate.interfaces.host_tools = true; + 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 free_loopback_port() -> u16 { let listener = std::net::TcpListener::bind(("127.0.0.1", 0)).expect("reserve loopback test port"); diff --git a/crates/driver-sdk/src/lib.rs b/crates/driver-sdk/src/lib.rs index 256b04635..c9ac65cda 100644 --- a/crates/driver-sdk/src/lib.rs +++ b/crates/driver-sdk/src/lib.rs @@ -2,12 +2,15 @@ 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::{ CommandDescriptor, Error, ErrorCode, JobArtifact, JobProgress, NativeTarget, ProviderIdentity, - Result, SourceKind, + Result, SourceKind, unique_id, }; use serde::{Deserialize, Serialize}; use serde_json::Value; @@ -17,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() @@ -75,6 +84,39 @@ 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 !valid_tool_name(name) { + 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", + )), + } +} + /// Resolve an owner-granted secret file as materialized by the current platform sandbox. pub fn secret_mount(logical_name: &str) -> Result { runtime_mount(MountClass::Secret, logical_name) @@ -104,6 +146,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 @@ -195,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()) { @@ -386,6 +429,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) @@ -596,6 +650,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 { @@ -618,6 +716,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, @@ -655,6 +761,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, @@ -701,6 +815,8 @@ pub struct DriverExecutionContext { cancellation: CancellationToken, output: mpsc::UnboundedSender, interfaces: DriverInterfaces, + protocol: u32, + tool_calls: ToolCallWaiters, } impl DriverExecutionContext { pub fn request_id(&self) -> &str { @@ -725,6 +841,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, @@ -898,7 +1089,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", @@ -919,6 +1112,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 { @@ -1075,6 +1269,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(), @@ -1083,6 +1278,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; @@ -1098,6 +1295,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 @@ -1423,6 +1646,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 f93639b51..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; @@ -154,28 +157,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, @@ -235,11 +322,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 { @@ -282,10 +377,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] @@ -299,7 +398,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 } } @@ -365,7 +470,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, }) } @@ -403,6 +511,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 { @@ -477,4 +590,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 8ba0d1001..e6905eb83 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}; @@ -156,6 +157,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. @@ -163,7 +173,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") @@ -205,6 +215,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-services/src/lib.rs b/crates/platform-services/src/lib.rs index 7c84d3113..36fa57461 100644 --- a/crates/platform-services/src/lib.rs +++ b/crates/platform-services/src/lib.rs @@ -123,6 +123,11 @@ pub fn verify_sealed_tool_executable(p: &Path, d: &str) -> Result> { semwright_platform_linux_sys::launch::verify_sealed_tool_executable(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 1d18752d2..22a4373be 100644 --- a/crates/platform-windows-sys/src/launch.rs +++ b/crates/platform-windows-sys/src/launch.rs @@ -1,9 +1,19 @@ -use crate::{identity::current_user_sid_bytes, job::ProcessJob, pe::require_native_architecture}; +#[cfg(target_arch = "aarch64")] +use crate::pe::IMAGE_FILE_MACHINE_AMD64; +use crate::{ + identity::current_user_sid_bytes, + job::ProcessJob, + pe::{ + PeArchitecture, architecture_file, require_native_architecture, + require_sealed_tool_architecture, + }, +}; use async_trait::async_trait; use semwright_platform_api::launch::{ - ExecutableVerifier, MaterializedMount, Mount, MountClass, SANDBOX_MOUNTS_ENV, - SandboxChildControl, SandboxCpuAccounting, SandboxLauncher, SandboxProcess, SandboxSpec, - encode_materialized_mounts, + 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}; @@ -21,6 +31,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, @@ -36,15 +48,15 @@ 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, + CopySid, CreateWellKnownSid, DACL_SECURITY_INFORMATION, DeriveCapabilitySidsFromName, + EqualSid, FreeSid, GetAce, 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, @@ -74,8 +86,8 @@ use windows::Win32::{ 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, + 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, @@ -250,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; @@ -414,6 +480,62 @@ fn verify_trusted_file_acl(file: &File, kind: &str, private_data: bool) -> Resul 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_sealed_tool_architecture(&bytes)?; + require_authenticode_policy(authenticode_status(&file, path)?)?; + Ok(()) +} + pub fn verify_private_data_file(path: &Path, max_bytes: u64) -> Result<()> { if max_bytes == 0 || max_bytes > 16 * 1024 * 1024 { return Err(Error::invalid( @@ -500,73 +622,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) } } @@ -801,6 +933,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(); @@ -1212,10 +1523,53 @@ 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, + }, + // This is a dedicated per-profile directory containing only tools already named in + // the host-controlled tool table. Windows image resolution may read/list this directory + // on some hosts, so grant read + traverse while continuing to deny all mutation. + permissions: FILE_GENERIC_READ.0 | FILE_GENERIC_EXECUTE.0, + denied_permissions: 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, active: bool, + require_sid_absence_after_revoke: bool, } impl WindowsMountGrant { @@ -1285,6 +1639,28 @@ 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()); + // 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, + )?; + Ok(Self { + path: prepared.path.clone(), + sid: sid_bytes, + active: true, + require_sid_absence_after_revoke: true, }) } @@ -1292,7 +1668,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(()) } @@ -1365,6 +1749,115 @@ 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 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()); + 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 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", + ) + })?; + std::io::Write::write_all(&mut output, &bytes)?; + output.sync_all()?; + drop(output); + verify_materialized_sealed_tool(&materialized_path, sha256)?; + + let plan = prepare_mount_grant(&Mount { + source: materialized_path, + 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 materialized 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() + 1); + grants.push(WindowsMountGrant::narrow_profile_authority( + &profile_traverse, + profile.sid, + )?); + for plan in &prepared { + match WindowsMountGrant::narrow_profile_authority(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, @@ -1506,16 +1999,28 @@ 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) { + 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) + { 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(), @@ -1651,6 +2156,7 @@ struct NativeSandboxChild { mount_grants: Vec, profile_name: Option>, exited: bool, + exit_code: Option, } impl NativeSandboxChild { @@ -1670,10 +2176,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()?; @@ -1715,10 +2235,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 { @@ -1738,6 +2263,69 @@ 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( + 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 + .iter() + .filter(|(name, _)| name == SANDBOX_HOST_TOOL_CHILD_ENV) + .collect::>(); + if markers.is_empty() { + return Ok(false); + } + 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) => { + // 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), + _ => Err(Error::new( + ErrorCode::Unsupported, + "Windows sealed-tool architecture 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. @@ -1753,14 +2341,17 @@ impl SandboxLauncher for WindowsSandbox { fn spawn(&self, spec: &SandboxSpec) -> Result { spec.validate()?; + let cross_arch_host_tool = host_tool_cross_arch(spec)?; 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", + "Windows sealed tools require Host-mediated driver protocol v4 execution", )); } 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())?; @@ -1780,16 +2371,38 @@ impl SandboxLauncher for WindowsSandbox { .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)?; @@ -1811,7 +2424,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() @@ -1825,8 +2449,14 @@ 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, &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 @@ -1843,7 +2473,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, ) @@ -1898,6 +2528,7 @@ impl SandboxLauncher for WindowsSandbox { mount_grants, profile_name: Some(profile_name), exited: false, + exit_code: None, }), cpu_accounting, )) @@ -1919,6 +2550,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/src/pe.rs b/crates/platform-windows-sys/src/pe.rs index 2e361d3b2..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 { @@ -52,6 +86,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 +133,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 + )); + } } 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. diff --git a/scripts/dev/driver-conformance.sh b/scripts/dev/driver-conformance.sh index 2ffce7194..c6ced1bd8 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"] == 6 # ping + mount_probe + config_probe + secret_probe + long + continuity disconnect fixture +assert conformance["capabilities"] == 7 # ping + mount_probe + tool_probe + config_probe + secret_probe + long + continuity disconnect fixture assert conformance["sandboxed"] is True assert conformance["persistent_process"] is True assert conformance["health"] is True