diff --git a/sdk/CHANGELOG.md b/sdk/CHANGELOG.md index 21511ead..f2629975 100644 --- a/sdk/CHANGELOG.md +++ b/sdk/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to `@microsoft/mxc-sdk` will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [Unreleased] + +### Added + +- `getPlatformSupport()` now reports `uiCapabilities` on Windows when the + native probe can determine which UI restrictions the host can enforce. + ## [0.3.0] ### ⚠️ Breaking changes diff --git a/sdk/README.md b/sdk/README.md index 585ead75..8c855333 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -79,6 +79,8 @@ The default `processcontainer`, `bubblewrap`, and `lxc` backends work out of the > **Hyperlight** is an opt-in build flavor (Linux x64 and Windows x64) gated by the `--with-hyperlight` cargo feature. Default shipped binaries do not include it; build from source with `build.bat --with-hyperlight` (Windows) or the equivalent cargo invocation on Linux. +`getPlatformSupport()` reports backend availability and, when the native probe can determine it, `uiCapabilities`: a platform-neutral view of which UI restrictions the host can enforce. This is currently populated only by the Windows native probe, where it is derived from `JOB_OBJECT_UILIMIT_*` support; Linux and macOS omit the field until their probes expose equivalent data. + **Node.js:** ≥ 18. --- @@ -364,6 +366,9 @@ getAvailableToolsPolicy(env?, options?) → FilesystemPolicyResult getUserProfilePolicy() → FilesystemPolicyResult getTemporaryFilesPolicy(env?) → FilesystemPolicyResult +// Capability types +UiCapabilitySupport + // Errors (typed wire-format errors from wxc-exec) ErrorCode, MxcError, mxcErrorFromCode(code) ``` diff --git a/sdk/src/index.ts b/sdk/src/index.ts index fef32f93..be02c49a 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -36,6 +36,7 @@ export { ExperimentalBackends, ContainerConfig, PlatformSupport, + UiCapabilitySupport, } from './types.js'; // Export platform detection functions diff --git a/sdk/src/platform.ts b/sdk/src/platform.ts index cee1bc5f..65019caa 100644 --- a/sdk/src/platform.ts +++ b/sdk/src/platform.ts @@ -3,7 +3,7 @@ import * as fs from 'fs'; import * as path from 'path'; import { execSync, execFileSync } from 'child_process'; import { fileURLToPath } from 'node:url'; -import { ContainmentBackend, IsolationTier, PlatformSupport } from './types.js'; +import { ContainmentBackend, IsolationTier, PlatformSupport, UiCapabilitySupport } from './types.js'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -141,11 +141,12 @@ function isWindowsSandboxAvailable(): boolean { /** * Get platform support information. * - * On Windows, when the host build is supported, this also invokes - * `wxc-exec --probe` to populate `isolationTier` and (if any) the - * `isolationWarnings` array. The result is cached for the lifetime of - * the SDK module — the underlying machine state is not expected to - * change at runtime. + * On Windows, this also invokes `wxc-exec --probe` to populate + * `isolationTier`, the `isolationWarnings` array (if any), and portable UI + * capability facts. Linux and macOS currently do not expose native probe data, + * so `uiCapabilities` is omitted on those platforms. The result is cached for + * the lifetime of the SDK module — the underlying machine state is not + * expected to change at runtime. * * @returns Platform support details including available sandboxing methods */ @@ -194,6 +195,27 @@ function isValidTier(s: unknown): s is IsolationTier { return s === 'base-container' || s === 'appcontainer-bfs' || s === 'appcontainer-dacl'; } +const UI_CAPABILITY_FIELDS: readonly (keyof UiCapabilitySupport)[] = [ + 'canBlockClipboardRead', + 'canBlockClipboardWrite', + 'canBlockInputInjection', + 'canBlockInputMethodChanges', + 'canBlockExternalUiObjects', + 'canBlockGlobalUiNamespace', + 'canBlockDesktopSwitching', + 'canBlockLogoffOrShutdown', + 'canBlockSystemParameterChanges', + 'canBlockDisplaySettingsChanges', +]; + +function isUiCapabilitySupport(value: unknown): value is UiCapabilitySupport { + if (!value || typeof value !== 'object') { + return false; + } + const capabilities = value as Record; + return UI_CAPABILITY_FIELDS.every((field) => typeof capabilities[field] === 'boolean'); +} + /** * Run the probe binary and merge its results into `support`. On any * failure (binary missing, timeout, malformed JSON, unknown tier), the @@ -215,6 +237,12 @@ function populateIsolationFromProbe(support: PlatformSupport): void { support.isolationWarnings = warnings; } } + const facts = probe.probes; + if (facts && typeof facts === 'object') { + if (isUiCapabilitySupport(facts.uiCapabilities)) { + support.uiCapabilities = facts.uiCapabilities; + } + } } } catch { // Graceful degradation: leave isolation fields unset. @@ -225,7 +253,8 @@ function computeSupport(): PlatformSupport { const platform = os.platform(); const support: PlatformSupport = { isSupported: false, reason: '', availableMethods: [] }; - // Non-Windows platforms + // Non-Windows platforms do not currently have native probes, so fields that + // depend on probe data (including uiCapabilities) stay omitted. if (platform === 'darwin') { // seatbelt is the only containment backend on macOS. // /usr/bin/sandbox-exec ships with every release of macOS so the check diff --git a/sdk/src/types.ts b/sdk/src/types.ts index d66936ba..98c94961 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -407,6 +407,39 @@ export type IsolationTier = | 'appcontainer-bfs' | 'appcontainer-dacl'; +/** + * Host support for enforcing sandbox UI restrictions. + * + * The fields describe platform-agnostic restriction intents, not the + * OS-specific primitive used to enforce them. For example, Windows derives + * these values from `JOB_OBJECT_UILIMIT_*` support. The SDK currently + * receives this object only from the Windows native probe; other platforms + * omit `PlatformSupport.uiCapabilities` until they expose equivalent probe + * data. + */ +export interface UiCapabilitySupport { + /** Whether the host can block reads from the clipboard. */ + canBlockClipboardRead: boolean; + /** Whether the host can block writes to the clipboard. */ + canBlockClipboardWrite: boolean; + /** Whether the host can block synthetic keyboard/mouse input. */ + canBlockInputInjection: boolean; + /** Whether the host can block input method / IME changes. */ + canBlockInputMethodChanges: boolean; + /** Whether the host can block access to external UI object handles. */ + canBlockExternalUiObjects: boolean; + /** Whether the host can block access to global UI namespaces. */ + canBlockGlobalUiNamespace: boolean; + /** Whether the host can block desktop switching. */ + canBlockDesktopSwitching: boolean; + /** Whether the host can block logoff or shutdown requests. */ + canBlockLogoffOrShutdown: boolean; + /** Whether the host can block system parameter changes. */ + canBlockSystemParameterChanges: boolean; + /** Whether the host can block display settings changes. */ + canBlockDisplaySettingsChanges: boolean; +} + /** * Platform support information */ @@ -427,4 +460,9 @@ export interface PlatformSupport { * Omitted on non-Windows platforms or when the probe fails. */ isolationWarnings?: string[]; -} \ No newline at end of file + /** + * Host UI-restriction capabilities. Omitted when the backend probe cannot + * determine them, including on Linux and macOS today. + */ + uiCapabilities?: UiCapabilitySupport; +} diff --git a/sdk/tests/unit/platform.test.ts b/sdk/tests/unit/platform.test.ts index 869ab439..bcb36c9b 100644 --- a/sdk/tests/unit/platform.test.ts +++ b/sdk/tests/unit/platform.test.ts @@ -15,6 +15,19 @@ import { const isWindows = os.platform() === 'win32'; +const allUiCapabilities = { + canBlockClipboardRead: true, + canBlockClipboardWrite: true, + canBlockInputInjection: true, + canBlockInputMethodChanges: true, + canBlockExternalUiObjects: true, + canBlockGlobalUiNamespace: true, + canBlockDesktopSwitching: true, + canBlockLogoffOrShutdown: true, + canBlockSystemParameterChanges: true, + canBlockDisplaySettingsChanges: true, +}; + describe('getPlatformSupport probe integration', () => { beforeEach(() => { _resetPlatformSupportCache(); @@ -97,6 +110,7 @@ describe('getPlatformSupport probe integration', () => { const support = getPlatformSupport(); assert.strictEqual(support.isolationTier, undefined); assert.strictEqual(support.isolationWarnings, undefined); + assert.strictEqual(support.uiCapabilities, undefined); assert.ok(Array.isArray(support.availableMethods)); }); @@ -169,6 +183,105 @@ describe('getPlatformSupport probe integration', () => { assert.strictEqual(support.isolationWarnings, undefined, `payload=${payload}`); } }); + + it('surfaces portable UI capabilities from probes', { skip: !isWindows }, () => { + _setProbeRunner(() => + JSON.stringify({ + tier: 'appcontainer-dacl', + probes: { + baseContainerApiPresent: false, + bfscfgPresent: false, + uiCapabilities: allUiCapabilities, + }, + }), + ); + const support = getPlatformSupport(); + if (!support.isSupported) return; + assert.deepStrictEqual(support.uiCapabilities, allUiCapabilities); + }); + + it('reports input-injection blocking unsupported from probe capabilities', { skip: !isWindows }, () => { + _setProbeRunner(() => + JSON.stringify({ + tier: 'appcontainer-dacl', + probes: { + baseContainerApiPresent: false, + bfscfgPresent: false, + uiCapabilities: { + ...allUiCapabilities, + canBlockInputInjection: false, + }, + }, + }), + ); + const support = getPlatformSupport(); + if (!support.isSupported) return; + assert.strictEqual(support.uiCapabilities?.canBlockInputInjection, false); + assert.strictEqual(support.uiCapabilities?.canBlockInputMethodChanges, true); + }); + + it('reports input-method and input-injection blocking unsupported from probe capabilities', { skip: !isWindows }, () => { + _setProbeRunner(() => + JSON.stringify({ + tier: 'appcontainer-dacl', + probes: { + baseContainerApiPresent: false, + bfscfgPresent: false, + uiCapabilities: { + ...allUiCapabilities, + canBlockInputInjection: false, + canBlockInputMethodChanges: false, + }, + }, + }), + ); + const support = getPlatformSupport(); + if (!support.isSupported) return; + assert.strictEqual(support.uiCapabilities?.canBlockInputInjection, false); + assert.strictEqual(support.uiCapabilities?.canBlockInputMethodChanges, false); + assert.strictEqual(support.uiCapabilities?.canBlockClipboardRead, true); + assert.strictEqual(support.uiCapabilities?.canBlockDisplaySettingsChanges, true); + }); + + it('omits UI capabilities when probes block is absent', { skip: !isWindows }, () => { + _setProbeRunner(() => JSON.stringify({ tier: 'appcontainer-dacl' })); + const support = getPlatformSupport(); + if (!support.isSupported) return; + assert.strictEqual(support.uiCapabilities, undefined); + }); + + it('omits UI capabilities when probe omits them', { skip: !isWindows }, () => { + _setProbeRunner(() => + JSON.stringify({ + tier: 'appcontainer-dacl', + probes: { + baseContainerApiPresent: false, + bfscfgPresent: false, + }, + }), + ); + const support = getPlatformSupport(); + if (!support.isSupported) return; + assert.strictEqual(support.uiCapabilities, undefined); + }); + + it('omits UI capabilities when probe returns a partial capability object', { skip: !isWindows }, () => { + _setProbeRunner(() => + JSON.stringify({ + tier: 'appcontainer-dacl', + probes: { + baseContainerApiPresent: false, + bfscfgPresent: false, + uiCapabilities: { + canBlockClipboardRead: true, + }, + }, + }), + ); + const support = getPlatformSupport(); + if (!support.isSupported) return; + assert.strictEqual(support.uiCapabilities, undefined); + }); }); // findWxcExecutable failure-mode: the SDK's default probe runner calls diff --git a/src/backends/appcontainer/common/src/job_object.rs b/src/backends/appcontainer/common/src/job_object.rs index bc794f4a..fa83b48f 100644 --- a/src/backends/appcontainer/common/src/job_object.rs +++ b/src/backends/appcontainer/common/src/job_object.rs @@ -141,14 +141,47 @@ fn supported_ui_limit_mask_for_build(build: u32) -> u32 { supported } +#[inline(always)] +fn has_ui_limit(mask: u32, flag: u32) -> bool { + (mask & flag) == flag +} + +fn supported_ui_restrictions_for_build(build: u32) -> EffectiveUiRestrictions { + let supported = supported_ui_limit_mask_for_build(build); + EffectiveUiRestrictions { + block_clipboard_read: has_ui_limit(supported, JOB_OBJECT_UILIMIT_READCLIPBOARD.0), + block_clipboard_write: has_ui_limit(supported, JOB_OBJECT_UILIMIT_WRITECLIPBOARD.0), + block_input_injection: has_ui_limit(supported, JOB_OBJECT_UILIMIT_INJECTION), + block_input_method_changes: has_ui_limit(supported, JOB_OBJECT_UILIMIT_IME), + block_external_ui_objects: has_ui_limit(supported, JOB_OBJECT_UILIMIT_HANDLES.0), + block_global_ui_namespace: has_ui_limit(supported, JOB_OBJECT_UILIMIT_GLOBALATOMS.0), + block_desktop_switching: has_ui_limit(supported, JOB_OBJECT_UILIMIT_DESKTOP.0), + block_logoff_or_shutdown: has_ui_limit(supported, JOB_OBJECT_UILIMIT_EXITWINDOWS.0), + block_system_parameter_changes: has_ui_limit( + supported, + JOB_OBJECT_UILIMIT_SYSTEMPARAMETERS.0, + ), + block_display_settings_changes: has_ui_limit( + supported, + JOB_OBJECT_UILIMIT_DISPLAYSETTINGS.0, + ), + } +} + /// Returns the subset of encoder-defined `JOB_OBJECT_UILIMIT_*` flags the /// current OS build can enforce. The effective restriction mask applied to a /// job is always `requested & supported`, so the kernel is never handed a -/// flag it would reject. Reported to callers via `wxc-exec --probe`. +/// flag it would reject. pub fn supported_ui_limit_mask() -> u32 { supported_ui_limit_mask_for_build(os_build_number()) } +/// Returns the platform-agnostic UI restrictions the current OS build can +/// enforce. Reported to callers via `wxc-exec --probe`. +pub fn supported_ui_restrictions() -> EffectiveUiRestrictions { + supported_ui_restrictions_for_build(os_build_number()) +} + /// Encode platform-agnostic UI restrictions as the `JOB_OBJECT_UILIMIT_*` /// bitmask consumed by `SetInformationJobObject(JobObjectBasicUIRestrictions)` /// and by the BaseContainer SandboxSpec `ui_restrictions` field. @@ -382,6 +415,17 @@ mod tests { assert_eq!(mask, 0x00FF); } + #[test] + fn supported_restrictions_match_downlevel_mask() { + let restrictions = supported_ui_restrictions_for_build(20348); + assert!(restrictions.block_clipboard_read); + assert!(restrictions.block_clipboard_write); + assert!(restrictions.block_external_ui_objects); + assert!(restrictions.block_display_settings_changes); + assert!(!restrictions.block_input_injection); + assert!(!restrictions.block_input_method_changes); + } + #[test] fn supported_mask_keeps_ime_on_22h2() { // Build 22621 (22H2) and later support the IME flag. diff --git a/src/backends/appcontainer/common/src/probe.rs b/src/backends/appcontainer/common/src/probe.rs index 81bb9e2c..d7282b8d 100644 --- a/src/backends/appcontainer/common/src/probe.rs +++ b/src/backends/appcontainer/common/src/probe.rs @@ -15,6 +15,7 @@ use serde::Serialize; use crate::fallback_detector::{self, FallbackError}; use wxc_common::models::ContainerPolicy; +use wxc_common::ui_policy::EffectiveUiRestrictions; /// JSON output emitted by `wxc-exec --probe`. #[derive(Serialize, Debug)] @@ -57,6 +58,51 @@ pub struct ProbeFacts { /// 11 25H2 where `bfscfg.exe` locks `bfs.sys`) should refuse to /// run a binary that reports `true` here. pub bfs_compiled_in: bool, + /// Platform-agnostic UI restrictions this host can enforce. + pub ui_capabilities: UiCapabilitySupport, +} + +/// Host support for enforcing sandbox UI restrictions. +#[derive(Serialize, Debug)] +#[serde(rename_all = "camelCase")] +pub struct UiCapabilitySupport { + /// Whether the host can block reads from the clipboard. + pub can_block_clipboard_read: bool, + /// Whether the host can block writes to the clipboard. + pub can_block_clipboard_write: bool, + /// Whether the host can block synthetic keyboard/mouse input. + pub can_block_input_injection: bool, + /// Whether the host can block input method / IME changes. + pub can_block_input_method_changes: bool, + /// Whether the host can block access to external UI object handles. + pub can_block_external_ui_objects: bool, + /// Whether the host can block access to global UI namespaces. + pub can_block_global_ui_namespace: bool, + /// Whether the host can block desktop switching. + pub can_block_desktop_switching: bool, + /// Whether the host can block logoff or shutdown requests. + pub can_block_logoff_or_shutdown: bool, + /// Whether the host can block system parameter changes. + pub can_block_system_parameter_changes: bool, + /// Whether the host can block display settings changes. + pub can_block_display_settings_changes: bool, +} + +impl From for UiCapabilitySupport { + fn from(value: EffectiveUiRestrictions) -> Self { + Self { + can_block_clipboard_read: value.block_clipboard_read, + can_block_clipboard_write: value.block_clipboard_write, + can_block_input_injection: value.block_input_injection, + can_block_input_method_changes: value.block_input_method_changes, + can_block_external_ui_objects: value.block_external_ui_objects, + can_block_global_ui_namespace: value.block_global_ui_namespace, + can_block_desktop_switching: value.block_desktop_switching, + can_block_logoff_or_shutdown: value.block_logoff_or_shutdown, + can_block_system_parameter_changes: value.block_system_parameter_changes, + can_block_display_settings_changes: value.block_display_settings_changes, + } + } } /// Run the fallback detector against `policy` and return a JSON-shaped @@ -69,6 +115,7 @@ pub fn run_probe(policy: &ContainerPolicy) -> ProbeOutput { .flatten() .is_some(), bfs_compiled_in: cfg!(feature = "tier2_bfs"), + ui_capabilities: crate::job_object::supported_ui_restrictions().into(), }; match fallback_detector::detect(policy, /* prefer_base_container */ true) { Ok(decision) => ProbeOutput { @@ -117,6 +164,21 @@ mod tests { use crate::fallback_detector::IsolationTier; use crate::test_env::ForceTierGuard; + fn all_ui_capabilities() -> UiCapabilitySupport { + UiCapabilitySupport { + can_block_clipboard_read: true, + can_block_clipboard_write: true, + can_block_input_injection: true, + can_block_input_method_changes: true, + can_block_external_ui_objects: true, + can_block_global_ui_namespace: true, + can_block_desktop_switching: true, + can_block_logoff_or_shutdown: true, + can_block_system_parameter_changes: true, + can_block_display_settings_changes: true, + } + } + #[test] fn probe_output_serializes() { let out = ProbeOutput { @@ -127,6 +189,7 @@ mod tests { base_container_api_present: true, bfscfg_present: false, bfs_compiled_in: false, + ui_capabilities: all_ui_capabilities(), }, error: None, }; @@ -138,9 +201,50 @@ mod tests { assert_eq!(v["probes"]["baseContainerApiPresent"], true); assert_eq!(v["probes"]["bfscfgPresent"], false); assert_eq!(v["probes"]["bfsCompiledIn"], false); + assert_eq!(v["probes"]["uiCapabilities"]["canBlockClipboardRead"], true); + assert_eq!( + v["probes"]["uiCapabilities"]["canBlockInputInjection"], + true + ); + assert_eq!( + v["probes"]["uiCapabilities"]["canBlockInputMethodChanges"], + true + ); assert!(v.get("error").is_none()); } + #[test] + fn probe_serializes_partial_ui_capabilities() { + let out = ProbeOutput { + tier: Some("appcontainer-dacl"), + needs_dacl_augmentation: Some(true), + warnings: vec![], + probes: ProbeFacts { + base_container_api_present: false, + bfscfg_present: false, + bfs_compiled_in: false, + ui_capabilities: UiCapabilitySupport { + can_block_input_injection: false, + can_block_input_method_changes: false, + ..all_ui_capabilities() + }, + }, + error: None, + }; + let v = serde_json::to_value(&out).expect("to_value"); + let probes = v["probes"].as_object().expect("probes object"); + assert!(probes.contains_key("uiCapabilities")); + assert_eq!( + v["probes"]["uiCapabilities"]["canBlockInputInjection"], + false + ); + assert_eq!( + v["probes"]["uiCapabilities"]["canBlockInputMethodChanges"], + false + ); + assert_eq!(v["probes"]["uiCapabilities"]["canBlockClipboardRead"], true); + } + #[test] fn tier_strings_stable() { assert_eq!(IsolationTier::BaseContainer.as_str(), "base-container");