diff --git a/docs/schema.md b/docs/schema.md index 44dd6aebb..b142eb2d3 100644 --- a/docs/schema.md +++ b/docs/schema.md @@ -36,7 +36,13 @@ production configs and the dev schema when working on experimental features: "commandLine": "python app.py", // Required: command to execute "cwd": "C:\\workspace", // Working directory "env": ["MY_VAR=value"], // Environment variables as KEY=VALUE - "timeout": 30000 // Timeout in ms (0 = no timeout) + "timeout": 30000, // Timeout in ms (0 = no timeout) + "resourceLimits": { // Optional resource caps (Job Object on Windows) + "memoryMb": 512, // Memory cap in MiB (0 = no limit) + "maxProcesses": 16, // Max active processes (0 = no limit) + "cpuRatePercent": 50, // CPU cap as percentage 0..100 (0 = no limit) + "allowChildProcesses": true // Allow spawning children (default true) + } }, "filesystem": { @@ -91,6 +97,47 @@ The `filesystem` section defines path access policy shared across backends: | `readonlyPaths` | string[] | `[]` | Paths the process can read but not write. | | `deniedPaths` | string[] | `[]` | Paths the process cannot access at all. | +### Resource Limits + +The optional `process.resourceLimits` section caps the resources consumed by +the sandboxed process and **all of its descendants**. On the Windows +processcontainer / AppContainer backends this is enforced via a Windows Job +Object. + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `memoryMb` | integer | `0` | Maximum committed memory in MiB across all processes in the job. `0` = no limit. | +| `maxProcesses` | integer | `0` | Maximum number of concurrent active processes (fork-bomb protection). `0` = no limit. | +| `cpuRatePercent` | integer | `0` | CPU rate cap as a percentage `0..=100`. `0` = no limit. Values outside this range are rejected at parse time. | +| `allowChildProcesses` | boolean | `true` | When `false`, the OS blocks the sandboxed process from spawning child processes (`PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY = PROCESS_CREATION_CHILD_PROCESS_RESTRICTED`). | + +#### Process tree cleanup + +Even when `resourceLimits` is omitted entirely, the AppContainer runner still +creates a Job Object with `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE`. This guarantees +that any descendant processes spawned by the sandboxed command — including +detached children created via `start /b`, `DETACHED_PROCESS`, or +`CREATE_NEW_CONSOLE` — are terminated when execution ends. Without this +behavior, an orphaned child could outlive the sandbox indefinitely while still +constrained by the AppContainer token. + +#### Backend support + +| Backend | `memoryMb` / `maxProcesses` / `cpuRatePercent` | `allowChildProcesses` | Orphan cleanup | +|---------|------------------------------------------------|------------------------|----------------| +| `processcontainer` (AppContainer, legacy) | enforced via Job Object | enforced via `PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY` | always on | +| `processcontainer` (BaseContainer) | not yet enforced (see note below) | not yet enforced | inherits from OS sandbox | +| `lxc` / `wslc` | not yet enforced (future: cgroups) | not yet enforced | not yet enforced | +| `windows_sandbox` / `microvm` / `isolation_session` | governed by the VM/session, not Job Object | n/a | governed by the VM/session | +| `seatbelt` | not yet enforced (future: `RLIMIT_*`) | not yet enforced | not yet enforced | + +**BaseContainer gap.** The BaseContainer `SandboxSpec` FlatBuffer +(`external/windows-sdk/BaseContainerSpecification.fbs`) does not currently +expose resource-limit fields. As a follow-up, the BaseContainer runner can +externally wrap the process handle returned by +`Experimental_CreateProcessInSandbox` in its own Job Object (Windows 8+ +supports nested jobs) to apply the same caps. + ### Fallback Policy The `fallback` section gates the runner's host-impacting fallbacks. Each flag is an explicit operator consent for a specific mechanism the runner may otherwise pick when the preferred primitive is unavailable. Defaults preserve the pre-fallback-section behavior (all permitted). diff --git a/schemas/dev/mxc-config.schema.0.6.0-dev.json b/schemas/dev/mxc-config.schema.0.6.0-dev.json index 48cf180c6..797c7d9dd 100644 --- a/schemas/dev/mxc-config.schema.0.6.0-dev.json +++ b/schemas/dev/mxc-config.schema.0.6.0-dev.json @@ -74,6 +74,36 @@ "minimum": 0, "default": 0, "description": "Execution timeout in milliseconds. 0 = no timeout (infinite)." + }, + "resourceLimits": { + "type": "object", + "description": "Resource caps enforced on the sandboxed process and all of its descendants. Implemented via a Windows Job Object on the AppContainer / processcontainer backends. When this section is omitted the runner still creates a Job Object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE so that descendant processes are terminated when execution ends — preventing orphan processes from outliving the sandbox.", + "properties": { + "memoryMb": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "Maximum committed memory in MiB across all processes in the job. 0 = no limit. Maps to JOB_OBJECT_LIMIT_JOB_MEMORY." + }, + "maxProcesses": { + "type": "integer", + "minimum": 0, + "default": 0, + "description": "Maximum number of concurrent active processes in the job (fork-bomb protection). 0 = no limit. Maps to JOB_OBJECT_LIMIT_ACTIVE_PROCESS." + }, + "cpuRatePercent": { + "type": "integer", + "minimum": 0, + "maximum": 100, + "default": 0, + "description": "CPU rate cap as a percentage of total CPU across all logical processors. 0 = no limit. Maps to JOBOBJECT_CPU_RATE_CONTROL_INFORMATION." + }, + "allowChildProcesses": { + "type": "boolean", + "default": true, + "description": "Whether the sandboxed process may spawn child processes. When false, sets PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY = PROCESS_CREATION_CHILD_PROCESS_RESTRICTED. Note: certain Windows subsystems (e.g. Windows Error Reporting) may still be able to spawn helpers via documented OS overrides; this flag is a hardening control, not a hard boundary." + } + } } } }, diff --git a/sdk/README.md b/sdk/README.md index 62bb52d3e..e4391e26d 100644 --- a/sdk/README.md +++ b/sdk/README.md @@ -210,6 +210,55 @@ Open the schema file matching your `policy.version` (e.g. `mxc-config.schema.0.5 +## Policy + +`SandboxPolicy` describes cross-platform sandbox intent. Key fields include: + +```typescript +type SandboxPolicy = { + version: string; + filesystem?: { + readwritePaths?: string[]; + readonlyPaths?: string[]; + deniedPaths?: string[]; + clearPolicyOnExit?: boolean; + }; + network?: { + allowOutbound?: boolean; + allowLocalNetwork?: boolean; + allowedHosts?: string[]; + blockedHosts?: string[]; + proxy?: { builtinTestServer: true } | { localhost: number } | { url: string }; + }; + ui?: { + allowWindows?: boolean; + clipboard?: 'none' | 'read' | 'write' | 'all'; + allowInputInjection?: boolean; + }; + timeoutMs?: number; + resourceLimits?: { + memoryMb?: number; + maxProcesses?: number; + cpuRatePercent?: number; + allowChildProcesses?: boolean; + }; +}; +``` + +### Resource Limits + +Use `resourceLimits` to cap process-level resources for the sandboxed process and its descendants. Numeric caps use `0` or omission for no limit; `allowChildProcesses` defaults to `true`. + +```typescript +const config = createConfigFromPolicy({ + version: '0.6.0-dev', + resourceLimits: { + memoryMb: 512, + allowChildProcesses: false, + }, +}); +``` + ## State-Aware Sandboxes
diff --git a/sdk/src/index.ts b/sdk/src/index.ts index d195d5df3..3bd98f2f0 100644 --- a/sdk/src/index.ts +++ b/sdk/src/index.ts @@ -35,6 +35,7 @@ export { ExperimentalBackends, ContainerConfig, PlatformSupport, + ResourceLimits, } from './types.js'; // Export platform detection functions diff --git a/sdk/src/sandbox.ts b/sdk/src/sandbox.ts index 2992a7437..05fc882ed 100644 --- a/sdk/src/sandbox.ts +++ b/sdk/src/sandbox.ts @@ -59,6 +59,34 @@ function validatePolicyVersion(version: string): void { } } +function validateResourceLimits(policy: SandboxPolicy): void { + const limits = policy.resourceLimits; + if (!limits) { + return; + } + + const numericCaps: Array<[string, number | undefined]> = [ + ['memoryMb', limits.memoryMb], + ['maxProcesses', limits.maxProcesses], + ['cpuRatePercent', limits.cpuRatePercent], + ]; + + for (const [name, value] of numericCaps) { + if (value === undefined) { + continue; + } + if (!Number.isInteger(value)) { + throw new Error(`resourceLimits.${name} must be an integer`); + } + if (value < 0) { + throw new Error(`resourceLimits.${name} must be greater than or equal to 0`); + } + } + + if (limits.cpuRatePercent !== undefined && limits.cpuRatePercent > 100) { + throw new Error('resourceLimits.cpuRatePercent must be between 0 and 100'); + } +} /** * Builds the WSLC (WSL Container) portion of a ContainerConfig. @@ -228,6 +256,7 @@ export function createConfigFromPolicy( ): ContainerConfig { diagLogVersion(); validatePolicyVersion(policy.version); + validateResourceLimits(policy); const platform = os.platform(); const containerId = containerName ?? generateRandomContainerName(); @@ -243,6 +272,7 @@ export function createConfigFromPolicy( process: { commandLine: '', timeout: policy.timeoutMs ?? 0, + ...(policy.resourceLimits ? { resourceLimits: policy.resourceLimits } : {}), }, }; diff --git a/sdk/src/types.ts b/sdk/src/types.ts index 3de788cf3..745c8518b 100644 --- a/sdk/src/types.ts +++ b/sdk/src/types.ts @@ -19,6 +19,27 @@ export interface ProcessConfig { env?: string[]; /** Execution timeout in milliseconds (default: 0 = no timeout) */ timeout?: number; + /** Process-level resource caps. Enforced via Job Object on Windows backends. */ + resourceLimits?: ResourceLimits; +} + +/** + * Process-level resource caps enforced on the sandboxed process and all of its + * descendants. On Windows backends these are implemented via a Job Object. + * + * A value of 0 (the default for each numeric cap) means "no limit". When + * resourceLimits is omitted entirely, the executor still creates a Job Object + * with KILL_ON_JOB_CLOSE so descendant processes cannot outlive the sandbox. + */ +export interface ResourceLimits { + /** Maximum committed memory in MiB across all processes in the job. 0 = no limit. */ + memoryMb?: number; + /** Maximum concurrent active processes (fork-bomb protection). 0 = no limit. */ + maxProcesses?: number; + /** CPU rate cap as a percentage 0..=100. 0 = no limit. */ + cpuRatePercent?: number; + /** Whether the sandboxed process may spawn child processes. Defaults to true. */ + allowChildProcesses?: boolean; } /** @@ -302,6 +323,8 @@ export type SandboxPolicy = { }; /** Execution timeout in milliseconds. Omitted = no timeout. */ timeoutMs?: number; + /** Process-level resource caps (memory, process count, CPU, child-process policy). */ + resourceLimits?: ResourceLimits; } /** diff --git a/sdk/tests/unit/sandbox.test.ts b/sdk/tests/unit/sandbox.test.ts index b15437b18..176c62645 100644 --- a/sdk/tests/unit/sandbox.test.ts +++ b/sdk/tests/unit/sandbox.test.ts @@ -446,6 +446,73 @@ describe('createConfigFromPolicy', () => { assert.strictEqual(config.process!.timeout, 30000); }); + describe('resourceLimits', () => { + it('should omit process.resourceLimits when policy omits resourceLimits', () => { + const config = createConfigFromPolicy(defaultPolicy); + assert.strictEqual(config.process!.resourceLimits, undefined); + }); + + it('should propagate all resourceLimits fields faithfully', () => { + const resourceLimits = { + memoryMb: 512, + maxProcesses: 16, + cpuRatePercent: 50, + allowChildProcesses: true, + }; + const config = createConfigFromPolicy({ + version: '0.6.0-alpha', + resourceLimits, + }); + assert.deepStrictEqual(config.process!.resourceLimits, resourceLimits); + }); + + it('should reject cpuRatePercent greater than 100', () => { + assert.throws( + () => createConfigFromPolicy({ + version: '0.6.0-alpha', + resourceLimits: { cpuRatePercent: 101 }, + }), + { message: /resourceLimits\.cpuRatePercent must be between 0 and 100/ }, + ); + }); + + it('should reject negative memoryMb and maxProcesses', () => { + assert.throws( + () => createConfigFromPolicy({ + version: '0.6.0-alpha', + resourceLimits: { memoryMb: -1 }, + }), + { message: /resourceLimits\.memoryMb must be greater than or equal to 0/ }, + ); + + assert.throws( + () => createConfigFromPolicy({ + version: '0.6.0-alpha', + resourceLimits: { maxProcesses: -1 }, + }), + { message: /resourceLimits\.maxProcesses must be greater than or equal to 0/ }, + ); + }); + + it('should reject non-integer numeric caps', () => { + assert.throws( + () => createConfigFromPolicy({ + version: '0.6.0-alpha', + resourceLimits: { memoryMb: 1.5 }, + }), + { message: /resourceLimits\.memoryMb must be an integer/ }, + ); + }); + + it('should accept allowChildProcesses false and include it in output', () => { + const config = createConfigFromPolicy({ + version: '0.6.0-alpha', + resourceLimits: { allowChildProcesses: false }, + }); + assert.deepStrictEqual(config.process!.resourceLimits, { allowChildProcesses: false }); + }); + }); + describe('Windows', () => { let originalPlatform: PropertyDescriptor | undefined; diff --git a/src/wxc_common/src/appcontainer_runner.rs b/src/wxc_common/src/appcontainer_runner.rs index 9bde6d9f3..6469993b1 100644 --- a/src/wxc_common/src/appcontainer_runner.rs +++ b/src/wxc_common/src/appcontainer_runner.rs @@ -1,6 +1,8 @@ // Copyright (c) Microsoft Corporation. // Licensed under the MIT License. +use core::ffi::c_void; +use std::mem::size_of; use std::ptr; use windows::Win32::Foundation::{ @@ -10,17 +12,27 @@ use windows::Win32::Security::Isolation::{ CreateAppContainerProfile, DeriveAppContainerSidFromAppContainerName, }; use windows::Win32::Security::PSID; +use windows::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JobObjectCpuRateControlInformation, + JobObjectExtendedLimitInformation, SetInformationJobObject, TerminateJobObject, + JOBOBJECT_CPU_RATE_CONTROL_INFORMATION, JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JOB_OBJECT_CPU_RATE_CONTROL_ENABLE, + JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, JOB_OBJECT_LIMIT_ACTIVE_PROCESS, + JOB_OBJECT_LIMIT_JOB_MEMORY, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, +}; use windows::Win32::System::Threading::{ CreateProcessW, DeleteProcThreadAttributeList, GetExitCodeProcess, - InitializeProcThreadAttributeList, TerminateProcess, UpdateProcThreadAttribute, - WaitForSingleObject, LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_CREATION_FLAGS, PROCESS_INFORMATION, - STARTUPINFOEXW, STARTUPINFOW, + InitializeProcThreadAttributeList, ResumeThread, TerminateProcess, UpdateProcThreadAttribute, + WaitForSingleObject, CREATE_SUSPENDED, LPPROC_THREAD_ATTRIBUTE_LIST, PROCESS_CREATION_FLAGS, + PROCESS_INFORMATION, STARTUPINFOEXW, STARTUPINFOW, }; use windows_core::{PCWSTR, PWSTR}; use crate::error::WxcError; use crate::logger::Logger; -use crate::models::{CodexRequest, NetworkEnforcementMode, NetworkPolicy, ScriptResponse}; +use crate::models::{ + CodexRequest, NetworkEnforcementMode, NetworkPolicy, ResourceLimits, ScriptResponse, +}; use crate::process_util::{get_capability_sid_from_name, OwnedHandle, SidAndAttributes}; use crate::script_runner::{get_timeout_milliseconds, ScriptRunner}; use crate::string_util; @@ -28,7 +40,9 @@ use crate::string_util; // Attribute list constants (not always exported by the windows crate) const PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES: usize = 0x0002_0009; const PROC_THREAD_ATTRIBUTE_ALL_APPLICATION_PACKAGES_POLICY: usize = 0x0002_000F; +const PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY: usize = 0x0002_000E; const PROCESS_CREATION_ALL_APPLICATION_PACKAGES_OPT_OUT: u32 = 1; +const PROCESS_CREATION_CHILD_PROCESS_RESTRICTED: u32 = 0x01; const EXTENDED_STARTUPINFO_PRESENT: PROCESS_CREATION_FLAGS = PROCESS_CREATION_FLAGS(0x0008_0000); const CREATE_UNICODE_ENVIRONMENT: PROCESS_CREATION_FLAGS = PROCESS_CREATION_FLAGS(0x0000_0400); @@ -79,6 +93,77 @@ fn build_proxy_env_block(address: &crate::models::ProxyAddress) -> Vec { block } +fn build_job_extended_limit_information( + resource_limits: &ResourceLimits, +) -> JOBOBJECT_EXTENDED_LIMIT_INFORMATION { + let mut info = JOBOBJECT_EXTENDED_LIMIT_INFORMATION::default(); + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + + if resource_limits.memory_mb > 0 { + info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_JOB_MEMORY; + info.JobMemoryLimit = resource_limits + .memory_mb + .saturating_mul(1024) + .saturating_mul(1024) as usize; + } + + if resource_limits.max_processes > 0 { + info.BasicLimitInformation.LimitFlags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + info.BasicLimitInformation.ActiveProcessLimit = resource_limits.max_processes; + } + + info +} + +fn build_job_cpu_rate_information( + resource_limits: &ResourceLimits, +) -> Option { + if resource_limits.cpu_rate_percent == 0 { + return None; + } + + Some(JOBOBJECT_CPU_RATE_CONTROL_INFORMATION { + ControlFlags: JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP, + Anonymous: JOBOBJECT_CPU_RATE_CONTROL_INFORMATION_0 { + CpuRate: u32::from(resource_limits.cpu_rate_percent) * 100, + }, + }) +} + +fn create_appcontainer_job_object( + resource_limits: &ResourceLimits, +) -> Result { + let job_handle = OwnedHandle::new( + unsafe { CreateJobObjectW(None, PCWSTR::null()) } + .map_err(|e| WxcError::Process(format!("CreateJobObjectW failed: {e}")))?, + ); + + let extended_info = build_job_extended_limit_information(resource_limits); + unsafe { + SetInformationJobObject( + job_handle.get(), + JobObjectExtendedLimitInformation, + &extended_info as *const _ as *const c_void, + size_of::() as u32, + ) + } + .map_err(|e| WxcError::Process(format!("SetInformationJobObject(extended) failed: {e}")))?; + + if let Some(cpu_info) = build_job_cpu_rate_information(resource_limits) { + unsafe { + SetInformationJobObject( + job_handle.get(), + JobObjectCpuRateControlInformation, + &cpu_info as *const _ as *const c_void, + size_of::() as u32, + ) + } + .map_err(|e| WxcError::Process(format!("SetInformationJobObject(cpu) failed: {e}")))?; + } + + Ok(job_handle) +} + /// RAII guard that frees capability SID pointers via `LocalFree` on drop. /// Ensures SIDs are freed regardless of the error return path. struct CapabilitySidGuard(Vec<*mut core::ffi::c_void>); @@ -239,11 +324,13 @@ impl AppContainerScriptRunner { }; // --- Allocate and initialize attribute list --- - let attr_count = if request.policy.least_privilege_mode { - 2u32 - } else { - 1u32 - }; + let mut attr_count = 1u32; + if request.policy.least_privilege_mode { + attr_count += 1; + } + if request.resource_limits.child_processes_blocked() { + attr_count += 1; + } let mut attr_list_size: usize = 0; // First call to get the required buffer size. @@ -318,6 +405,28 @@ impl AppContainerScriptRunner { } } + // 3. CHILD_PROCESS_POLICY (block child process creation) + let child_process_policy = PROCESS_CREATION_CHILD_PROCESS_RESTRICTED; + if request.resource_limits.child_processes_blocked() { + unsafe { + UpdateProcThreadAttribute( + attr_list, + 0, + PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, + Some(&child_process_policy as *const u32 as *const c_void), + size_of::(), + None, + None, + ) + .map_err(|e| { + WxcError::Process(format!( + "UpdateProcThreadAttribute(CHILD_PROCESS_POLICY): {}", + e + )) + })?; + } + } + // --- Setup STARTUPINFOEXW --- let mut desktop_wide = string_util::to_wide("winsta0\\default"); @@ -350,11 +459,15 @@ impl AppContainerScriptRunner { .map(|block| block.as_ptr() as *const core::ffi::c_void); let creation_flags = if env_block.is_some() { - PROCESS_CREATION_FLAGS(EXTENDED_STARTUPINFO_PRESENT.0 | CREATE_UNICODE_ENVIRONMENT.0) + PROCESS_CREATION_FLAGS( + EXTENDED_STARTUPINFO_PRESENT.0 | CREATE_UNICODE_ENVIRONMENT.0 | CREATE_SUSPENDED.0, + ) } else { - EXTENDED_STARTUPINFO_PRESENT + PROCESS_CREATION_FLAGS(EXTENDED_STARTUPINFO_PRESENT.0 | CREATE_SUSPENDED.0) }; + let job_handle = create_appcontainer_job_object(&request.resource_limits)?; + // --- Create process (console inheritance) --- // // Console I/O path — no pipes, no relay threads: @@ -414,7 +527,29 @@ impl AppContainerScriptRunner { )); let process_handle = OwnedHandle::new(pi.hProcess); - let _thread_handle = OwnedHandle::new(pi.hThread); + let thread_handle = OwnedHandle::new(pi.hThread); + + if let Err(err) = + unsafe { AssignProcessToJobObject(job_handle.get(), process_handle.get()) } + { + unsafe { + let _ = TerminateProcess(process_handle.get(), u32::MAX); + let _ = WaitForSingleObject(process_handle.get(), u32::MAX); + } + return Err(WxcError::Process(format!( + "AssignProcessToJobObject failed: {}", + err + ))); + } + + if unsafe { ResumeThread(thread_handle.get()) } == u32::MAX { + let err = unsafe { GetLastError() }; + unsafe { + let _ = TerminateJobObject(job_handle.get(), u32::MAX); + let _ = WaitForSingleObject(process_handle.get(), u32::MAX); + } + return Err(WxcError::Process(format!("ResumeThread failed: {:?}", err))); + } // --- Wait for child process to exit --- // No relay threads needed — child shares our console directly. @@ -425,7 +560,7 @@ impl AppContainerScriptRunner { match wait_result { WAIT_OBJECT_0 => {} WAIT_TIMEOUT => unsafe { - let _ = TerminateProcess(process_handle.get(), u32::MAX); + let _ = TerminateJobObject(job_handle.get(), u32::MAX); let _ = WaitForSingleObject(process_handle.get(), u32::MAX); }, WAIT_FAILED => { @@ -563,3 +698,169 @@ impl Drop for AppContainerScriptRunner { } } } + +#[cfg(all(test, target_os = "windows"))] +mod tests { + use super::*; + use std::thread::sleep; + use std::time::{Duration, Instant}; + + use windows::Win32::System::Diagnostics::ToolHelp::{ + CreateToolhelp32Snapshot, Process32FirstW, Process32NextW, PROCESSENTRY32W, + TH32CS_SNAPPROCESS, + }; + use windows::Win32::System::Threading::{OpenProcess, PROCESS_SYNCHRONIZE, PROCESS_TERMINATE}; + + struct TestProcess { + process: OwnedHandle, + thread: OwnedHandle, + pid: u32, + } + + fn spawn_test_process(command_line: &str, flags: PROCESS_CREATION_FLAGS) -> TestProcess { + let mut command_line_wide = string_util::to_wide(command_line); + let si = STARTUPINFOW { + cb: size_of::() as u32, + ..Default::default() + }; + let mut pi = PROCESS_INFORMATION::default(); + + unsafe { + CreateProcessW( + PCWSTR::null(), + Some(PWSTR(command_line_wide.as_mut_ptr())), + None, + None, + false, + flags, + None, + PCWSTR::null(), + &si, + &mut pi, + ) + } + .expect("CreateProcessW"); + + TestProcess { + process: OwnedHandle::new(pi.hProcess), + thread: OwnedHandle::new(pi.hThread), + pid: pi.dwProcessId, + } + } + + fn assert_process_exits(process_handle: windows::Win32::Foundation::HANDLE) { + let wait_result = unsafe { WaitForSingleObject(process_handle, 5_000) }; + assert_eq!(wait_result, WAIT_OBJECT_0, "process did not exit in time"); + } + + fn open_process_for_wait(pid: u32) -> Option { + unsafe { OpenProcess(PROCESS_SYNCHRONIZE | PROCESS_TERMINATE, false, pid) } + .ok() + .map(OwnedHandle::new) + } + + fn find_child_process(parent_pid: u32) -> Option { + let snapshot = OwnedHandle::new(unsafe { + CreateToolhelp32Snapshot(TH32CS_SNAPPROCESS, 0).expect("process snapshot") + }); + let mut entry = PROCESSENTRY32W { + dwSize: size_of::() as u32, + ..Default::default() + }; + + if unsafe { Process32FirstW(snapshot.get(), &mut entry) }.is_err() { + return None; + } + + loop { + if entry.th32ParentProcessID == parent_pid { + return Some(entry.th32ProcessID); + } + if unsafe { Process32NextW(snapshot.get(), &mut entry) }.is_err() { + return None; + } + } + } + + fn wait_for_child_process(parent_pid: u32) -> Option<(u32, OwnedHandle)> { + let deadline = Instant::now() + Duration::from_secs(5); + while Instant::now() < deadline { + if let Some(pid) = find_child_process(parent_pid) { + if let Some(handle) = open_process_for_wait(pid) { + return Some((pid, handle)); + } + } + sleep(Duration::from_millis(100)); + } + None + } + + #[test] + fn job_limit_info_uses_request_resource_limits() { + let limits = ResourceLimits { + memory_mb: 128, + max_processes: 7, + cpu_rate_percent: 50, + allow_child_processes: false, + }; + + let extended = build_job_extended_limit_information(&limits); + assert!(extended + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)); + assert!(extended + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_JOB_MEMORY)); + assert!(extended + .BasicLimitInformation + .LimitFlags + .contains(JOB_OBJECT_LIMIT_ACTIVE_PROCESS)); + assert_eq!(extended.JobMemoryLimit, 128 * 1024 * 1024); + assert_eq!(extended.BasicLimitInformation.ActiveProcessLimit, 7); + + let cpu = build_job_cpu_rate_information(&limits).expect("cpu cap"); + assert!(cpu + .ControlFlags + .contains(JOB_OBJECT_CPU_RATE_CONTROL_ENABLE)); + assert!(cpu + .ControlFlags + .contains(JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP)); + assert_eq!(unsafe { cpu.Anonymous.CpuRate }, 5_000); + assert!(limits.child_processes_blocked()); + } + + #[test] + fn job_object_kills_orphans_on_drop() { + let job = create_appcontainer_job_object(&ResourceLimits::permissive()).expect("job"); + let child = + spawn_test_process("cmd.exe /c ping -n 30 127.0.0.1", PROCESS_CREATION_FLAGS(0)); + + unsafe { AssignProcessToJobObject(job.get(), child.process.get()) }.expect("assign"); + drop(job); + + assert_process_exits(child.process.get()); + } + + #[test] + fn job_object_terminates_tree() { + let job = create_appcontainer_job_object(&ResourceLimits::permissive()).expect("job"); + let parent = spawn_test_process( + "cmd.exe /c start /b cmd.exe /c ping -n 30 127.0.0.1 & ping -n 30 127.0.0.1", + CREATE_SUSPENDED, + ); + + unsafe { AssignProcessToJobObject(job.get(), parent.process.get()) }.expect("assign"); + assert_ne!(unsafe { ResumeThread(parent.thread.get()) }, u32::MAX); + + let (child_pid, child) = wait_for_child_process(parent.pid).expect("child process"); + let (_grandchild_pid, grandchild) = + wait_for_child_process(child_pid).expect("grandchild process"); + unsafe { TerminateJobObject(job.get(), u32::MAX) }.expect("terminate job"); + + assert_process_exits(parent.process.get()); + assert_process_exits(child.get()); + assert_process_exits(grandchild.get()); + } +} diff --git a/src/wxc_common/src/config_parser.rs b/src/wxc_common/src/config_parser.rs index ba2b78974..89dbac55a 100644 --- a/src/wxc_common/src/config_parser.rs +++ b/src/wxc_common/src/config_parser.rs @@ -12,8 +12,8 @@ use crate::logger::Logger; use crate::models::{ ClipboardPolicy, CodexRequest, ContainerPolicy, ContainmentBackend, ExperimentalConfig, IsolationSessionConfig, IsolationSessionUser, LifecycleConfig, LxcConfig, - NetworkEnforcementMode, NetworkPolicy, PortMapping, ProxyAddress, ProxyConfig, SeatbeltConfig, - TestFeatureConfig, UiPolicy, WindowsSandboxConfig, WslcConfig, + NetworkEnforcementMode, NetworkPolicy, PortMapping, ProxyAddress, ProxyConfig, ResourceLimits, + SeatbeltConfig, TestFeatureConfig, UiPolicy, WindowsSandboxConfig, WslcConfig, }; use crate::mxc_error::MxcError; use crate::state_aware_request::{MxcRequest, ParsedStateAwareRequest, Phase}; @@ -145,6 +145,21 @@ struct RawProcess { cwd: Option, env: Option>, timeout: Option, + #[serde(rename = "resourceLimits")] + resource_limits: Option, +} + +#[derive(Deserialize, Default)] +#[serde(default)] +struct RawResourceLimits { + #[serde(rename = "memoryMb")] + memory_mb: Option, + #[serde(rename = "maxProcesses")] + max_processes: Option, + #[serde(rename = "cpuRatePercent")] + cpu_rate_percent: Option, + #[serde(rename = "allowChildProcesses")] + allow_child_processes: Option, } #[derive(Deserialize, Default)] @@ -515,6 +530,39 @@ fn validate_paths(paths: &[String], logger: &mut Logger) -> Result<(), WxcError> // ---------- Conversion from raw JSON to domain model ---------- +/// Convert a raw `resourceLimits` block into the validated `ResourceLimits` +/// model. Validation: +/// - `cpu_rate_percent` must be in `0..=100`. +/// - Other caps are unbounded (a value of `0` means "no limit"). +/// +/// When `raw` is `None`, returns `ResourceLimits::permissive()` — matching the +/// schema default for an omitted section. +fn convert_resource_limits( + raw: Option, + logger: &mut Logger, +) -> Result { + let Some(raw) = raw else { + return Ok(ResourceLimits::permissive()); + }; + + let cpu_rate_percent = raw.cpu_rate_percent.unwrap_or(0); + if cpu_rate_percent > 100 { + let msg = format!( + "process.resourceLimits.cpuRatePercent must be in 0..=100, got {}", + cpu_rate_percent + ); + logger.log_line(&msg); + return Err(WxcError::ConfigParse(msg)); + } + + Ok(ResourceLimits { + memory_mb: raw.memory_mb.unwrap_or(0), + max_processes: raw.max_processes.unwrap_or(0), + cpu_rate_percent, + allow_child_processes: raw.allow_child_processes.unwrap_or(true), + }) +} + fn convert_raw_config(raw: RawConfig, logger: &mut Logger) -> Result { convert_raw_config_inner(raw, logger, true) } @@ -534,7 +582,7 @@ fn convert_raw_config_inner( // Process section: required for one-shot and for state-aware exec; absent // is allowed for state-aware non-exec phases (require_process == false). - let (script_code, working_directory, script_timeout, env) = match raw.process { + let (script_code, working_directory, script_timeout, env, resource_limits) = match raw.process { Some(process) => { let script_code = match process.command_line { Some(s) if !s.is_empty() => s, @@ -561,11 +609,14 @@ fn convert_raw_config_inner( )); } + let resource_limits = convert_resource_limits(process.resource_limits, logger)?; + ( script_code, process.cwd.unwrap_or_default(), process.timeout.unwrap_or(0), process.env.unwrap_or_default(), + resource_limits, ) } None if require_process => { @@ -573,7 +624,13 @@ fn convert_raw_config_inner( "'process' section is required".into(), )); } - None => (String::new(), String::new(), 0, Vec::new()), + None => ( + String::new(), + String::new(), + 0, + Vec::new(), + ResourceLimits::default(), + ), }; // Containment backend selection. @@ -910,6 +967,7 @@ fn convert_raw_config_inner( script_code, working_directory, script_timeout, + resource_limits, containment, lifecycle, policy, @@ -1955,6 +2013,98 @@ mod tests { assert_eq!(req.script_timeout, 9000); } + #[test] + fn resource_limits_omitted_uses_permissive_defaults() { + let json = r#"{"process": {"commandLine": "echo hi"}}"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert_eq!(req.resource_limits, ResourceLimits::permissive()); + assert!(req.resource_limits.allow_child_processes); + assert!(!req.resource_limits.has_explicit_caps()); + assert!(!req.resource_limits.child_processes_blocked()); + } + + #[test] + fn resource_limits_all_fields_parsed() { + let json = r#"{ + "process": { + "commandLine": "echo hi", + "resourceLimits": { + "memoryMb": 512, + "maxProcesses": 16, + "cpuRatePercent": 50, + "allowChildProcesses": false + } + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert_eq!(req.resource_limits.memory_mb, 512); + assert_eq!(req.resource_limits.max_processes, 16); + assert_eq!(req.resource_limits.cpu_rate_percent, 50); + assert!(!req.resource_limits.allow_child_processes); + assert!(req.resource_limits.has_explicit_caps()); + assert!(req.resource_limits.child_processes_blocked()); + } + + #[test] + fn resource_limits_partial_fields_use_per_field_defaults() { + let json = r#"{ + "process": { + "commandLine": "echo hi", + "resourceLimits": { "memoryMb": 256 } + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert_eq!(req.resource_limits.memory_mb, 256); + assert_eq!(req.resource_limits.max_processes, 0); + assert_eq!(req.resource_limits.cpu_rate_percent, 0); + // Omitted allowChildProcesses must default to true (matches schema). + assert!(req.resource_limits.allow_child_processes); + } + + #[test] + fn resource_limits_cpu_rate_out_of_range_rejected() { + let json = r#"{ + "process": { + "commandLine": "echo hi", + "resourceLimits": { "cpuRatePercent": 150 } + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let result = load_request(&encoded, &mut logger, true); + assert!(result.is_err(), "cpuRatePercent > 100 must be rejected"); + } + + #[test] + fn resource_limits_zero_values_treated_as_unlimited() { + let json = r#"{ + "process": { + "commandLine": "echo hi", + "resourceLimits": { + "memoryMb": 0, + "maxProcesses": 0, + "cpuRatePercent": 0 + } + } + }"#; + let encoded = base64_encode(json.as_bytes()); + let mut logger = test_logger(); + + let req = load_request(&encoded, &mut logger, true).unwrap(); + assert!(!req.resource_limits.has_explicit_caps()); + assert!(req.resource_limits.allow_child_processes); + } + #[test] fn containment_microvm_accepted() { let json = r#"{"process": {"commandLine": "echo hi"}, "containment": "microvm"}"#; diff --git a/src/wxc_common/src/models.rs b/src/wxc_common/src/models.rs index b7ab689ce..21482d3a4 100644 --- a/src/wxc_common/src/models.rs +++ b/src/wxc_common/src/models.rs @@ -390,6 +390,66 @@ impl Default for LifecycleConfig { } } +/// Process-level resource caps applied to the sandboxed process and all of +/// its descendants. On Windows these are enforced via a Job Object created by +/// the runner; on other platforms they are accepted by the parser and +/// currently ignored (future backends may map them to cgroups or rlimits). +/// +/// A value of `0` for any cap means "no limit". Even when every field is `0`, +/// the AppContainer runner still creates a Job Object with +/// `JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE` so descendant processes cannot outlive +/// the sandbox. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] +pub struct ResourceLimits { + /// Maximum committed memory in MiB across all processes in the job. + /// 0 = no limit. Maps to `JOBOBJECT_EXTENDED_LIMIT_INFORMATION::JobMemoryLimit`. + pub memory_mb: u64, + /// Maximum number of concurrent active processes in the job. 0 = no limit. + /// Fork-bomb protection. Maps to `JOB_OBJECT_LIMIT_ACTIVE_PROCESS`. + pub max_processes: u32, + /// CPU rate cap as a percentage (0-100) of total CPU across all logical + /// processors. 0 = no limit. Maps to + /// `JOBOBJECT_CPU_RATE_CONTROL_INFORMATION` with weight-based hard cap. + pub cpu_rate_percent: u8, + /// Whether the sandboxed process may spawn child processes. + /// `false` sets `PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY` to + /// `PROCESS_CREATION_CHILD_PROCESS_RESTRICTED` at process-creation time. + /// Defaults to `true` to preserve existing behaviour. + pub allow_child_processes: bool, +} + +impl Default for ResourceLimits { + fn default() -> Self { + Self::permissive() + } +} + +impl ResourceLimits { + /// Permissive defaults: no caps, child processes allowed. + /// Matches the schema default for an omitted `resourceLimits` section. + pub fn permissive() -> Self { + Self { + memory_mb: 0, + max_processes: 0, + cpu_rate_percent: 0, + allow_child_processes: true, + } + } + + /// True if any explicit cap is configured (memory, processes, or CPU). + /// The child-process policy is tracked separately via + /// `child_processes_blocked`. + pub fn has_explicit_caps(&self) -> bool { + self.memory_mb > 0 || self.max_processes > 0 || self.cpu_rate_percent > 0 + } + + /// True if `allow_child_processes` is explicitly false. + pub fn child_processes_blocked(&self) -> bool { + !self.allow_child_processes + } +} + /// Placeholder experimental feature for testing the experimental infrastructure. #[derive(Debug, Clone, Default, Serialize, Deserialize)] #[serde(default)] @@ -438,6 +498,9 @@ pub struct CodexRequest { pub script_code: String, pub working_directory: String, pub script_timeout: u32, + /// Process-level resource caps (memory, process count, CPU, child-process policy). + /// Enforced by a Windows Job Object on the AppContainer/processcontainer backends. + pub resource_limits: ResourceLimits, /// Which containment backend to use. Default: ProcessContainer. pub containment: ContainmentBackend, /// Shared lifecycle settings. @@ -465,6 +528,7 @@ impl Default for CodexRequest { script_code: String::new(), working_directory: String::new(), script_timeout: 0, + resource_limits: ResourceLimits::default(), containment: ContainmentBackend::default(), lifecycle: LifecycleConfig::default(), policy: ContainerPolicy::default(),