//! Windows Restricted Token sandbox helper binary. //! //! Usage: //! ```bash //! # Capture mode: run command, capture output, print JSON result //! qiming-sandbox-helper run \ //! --mode \ //! --cwd \ //! [--home ] \ //! [--policy-json ] \ //! -- [args...] //! //! # Proxy mode: run command as persistent stdio proxy (stdin/stdout forwarded) //! qiming-sandbox-helper serve \ //! --mode \ //! --cwd \ //! [--home ] \ //! [--policy-json ] \ //! -- [args...] //! ``` #![cfg(target_os = "windows")] mod acl; mod allow; mod audit; mod cap; mod env; mod logging; mod policy; mod token; mod winutil; use acl::{add_allow_ace, add_deny_write_ace, allow_null_device, revoke_ace}; use allow::compute_allow_paths; use audit::apply_world_writable_scan_and_denies; use cap::{cap_sid_file, load_or_create_cap_sids}; use clap::{Parser, ValueEnum}; use env::{apply_no_network_to_env, ensure_non_interactive_pager, normalize_null_device_env}; use logging::{debug_log, log_failure, log_start, log_success}; use policy::SandboxPolicy; use std::collections::HashMap; use std::ffi::c_void; use std::fs; use std::io::{self, Read, Write}; use std::path::Path; use std::path::PathBuf; use std::ptr; use std::thread; use std::sync::mpsc; use std::time::Duration; use token::{convert_string_sid_to_sid, create_restricted_token}; use winutil::{format_last_error, to_wide}; use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, SetHandleInformation, HANDLE, HANDLE_FLAG_INHERIT}; use windows_sys::Win32::System::Pipes::CreatePipe; use windows_sys::Win32::System::Threading::{ CreateProcessAsUserW, GetExitCodeProcess, WaitForSingleObject, CREATE_UNICODE_ENVIRONMENT, INFINITE, PROCESS_INFORMATION, STARTF_USESTDHANDLES, STARTUPINFOW, }; // ============================================================================ // CLI // ============================================================================ #[derive(ValueEnum, Clone, Debug)] enum Mode { ReadOnly, WorkspaceWrite, } #[derive(Parser, Debug)] #[command(name = "qiming-sandbox-helper")] #[command(author = "Qiming Agent")] #[command(version = "0.1.0")] struct Args { #[command(subcommand)] subcommand: Subcommand, } #[derive(Parser, Debug)] enum Subcommand { /// Run a command in the sandbox, capture output, print JSON result. Run(RunArgs), /// Run a command in the sandbox as a persistent stdio proxy. /// Stdin/stdout/stderr are forwarded bidirectionally. /// /// By default uses a token without WRITE_RESTRICTED for maximum /// compatibility. Pass --write-restricted to enable filesystem /// write protection via restricting SIDs + DACL ACEs (needed for /// strict/compat sandbox modes). Serve(ServeArgs), } #[derive(Parser, Debug)] struct ServeArgs { #[command(flatten)] common: CommonArgs, /// Enable WRITE_RESTRICTED on the sandbox token. /// When set, write access is restricted to paths with ALLOW ACEs /// for the restricting SIDs (capability, logon, everyone). /// Required for strict/compat sandbox modes to enforce filesystem /// write protection. #[arg(long, default_value_t = false)] write_restricted: bool, } #[derive(Parser, Debug)] struct RunArgs { #[command(flatten)] common: CommonArgs, /// Skip WRITE_RESTRICTED on the sandbox token. Allows child processes /// (e.g. Git Bash) to create pipes and modify their own token DACL. /// Filesystem write protection is still enforced by DACL ACEs. #[arg(long, default_value_t = false)] no_write_restricted: bool, } #[derive(Parser, Debug)] struct CommonArgs { #[arg(long)] mode: Mode, #[arg(long)] cwd: PathBuf, #[arg(long, env = "QIMING_SANDBOX_HOME")] home: Option, #[arg(long)] policy_json: Option, #[arg(last = true)] command: Vec, } // ============================================================================ // Shared helpers // ============================================================================ type PipeHandles = ((HANDLE, HANDLE), (HANDLE, HANDLE), (HANDLE, HANDLE)); fn should_apply_network_block(policy: &SandboxPolicy) -> bool { !policy.has_full_network_access() } fn ensure_dir(p: &Path) -> anyhow::Result<()> { if let Some(d) = p.parent() { std::fs::create_dir_all(d)?; } Ok(()) } fn ensure_sandbox_home_exists(p: &Path) -> anyhow::Result<()> { std::fs::create_dir_all(p)?; Ok(()) } fn make_env_block(env: &HashMap) -> Vec { let mut items: Vec<(String, String)> = env.iter().map(|(k, v)| (k.clone(), v.clone())).collect(); items.sort_by(|a, b| a.0.to_uppercase().cmp(&b.0.to_uppercase()).then(a.0.cmp(&b.0))); let mut w: Vec = Vec::new(); for (k, v) in items { let mut s = to_wide(format!("{}={}", k, v)); s.pop(); w.extend_from_slice(&s); w.push(0); } w.push(0); w } fn quote_windows_arg(arg: &str) -> String { let needs_quotes = arg.is_empty() || arg.chars().any(|c| matches!(c, ' ' | '\t' | '\n' | '\r' | '"')); if !needs_quotes { return arg.to_string(); } let mut quoted = String::with_capacity(arg.len() + 2); quoted.push('"'); let mut backslashes = 0usize; for ch in arg.chars() { match ch { '\\' => backslashes += 1, '"' => { quoted.push_str(&"\\".repeat(backslashes * 2 + 1)); quoted.push('"'); backslashes = 0; } _ => { if backslashes > 0 { quoted.push_str(&"\\".repeat(backslashes)); backslashes = 0; } quoted.push(ch); } } } if backslashes > 0 { quoted.push_str(&"\\".repeat(backslashes * 2)); } quoted.push('"'); quoted } unsafe fn setup_stdio_pipes() -> io::Result { let mut in_r: HANDLE = 0; let mut in_w: HANDLE = 0; let mut out_r: HANDLE = 0; let mut out_w: HANDLE = 0; let mut err_r: HANDLE = 0; let mut err_w: HANDLE = 0; if CreatePipe(&mut in_r, &mut in_w, ptr::null_mut(), 0) == 0 { return Err(io::Error::from_raw_os_error(GetLastError() as i32)); } if CreatePipe(&mut out_r, &mut out_w, ptr::null_mut(), 0) == 0 { return Err(io::Error::from_raw_os_error(GetLastError() as i32)); } if CreatePipe(&mut err_r, &mut err_w, ptr::null_mut(), 0) == 0 { return Err(io::Error::from_raw_os_error(GetLastError() as i32)); } if SetHandleInformation(in_r, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { return Err(io::Error::from_raw_os_error(GetLastError() as i32)); } if SetHandleInformation(out_w, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { return Err(io::Error::from_raw_os_error(GetLastError() as i32)); } if SetHandleInformation(err_w, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) == 0 { return Err(io::Error::from_raw_os_error(GetLastError() as i32)); } Ok(((in_r, in_w), (out_r, out_w), (err_r, err_w))) } /// Helper to close an array of HANDLEs, ignoring nulls. unsafe fn close_handles(handles: &[HANDLE]) { for &h in handles { if h != 0 { CloseHandle(h); } } } // ============================================================================ // Shared sandbox context (token + ACL + env setup) // ============================================================================ /// Holds the state needed to spawn a sandboxed child process. struct SandboxContext { /// Modified environment block for the child. env_map: HashMap, /// Restricted token for CreateProcessAsUserW. h_token: HANDLE, /// Whether ACEs should persist (workspace-write mode). persist_aces: bool, /// ACE guards to revoke on cleanup (only when !persist_aces). guards: Vec<(PathBuf, *mut c_void)>, /// Logging directory. logs_base_dir: Option, /// Full command line (for logging). command: Vec, } impl SandboxContext { /// Set up sandbox policy, restricted token, ACLs, and environment. fn setup( policy_json_or_preset: &str, sandbox_policy_cwd: &Path, home: &Path, command: Vec, cwd: &Path, args: Vec, write_restricted: bool, ) -> anyhow::Result { let mut env_map: HashMap = std::env::vars().collect(); let mut command = command; command.extend(args); let policy = policy::parse_policy(policy_json_or_preset)?; if should_apply_network_block(&policy) { normalize_null_device_env(&mut env_map); ensure_non_interactive_pager(&mut env_map); apply_no_network_to_env(&mut env_map)?; } else { normalize_null_device_env(&mut env_map); ensure_non_interactive_pager(&mut env_map); } ensure_sandbox_home_exists(home)?; let current_dir = cwd.to_path_buf(); let logs_base_dir = Some(home.to_path_buf()); log_start(&command, logs_base_dir.as_deref()); let cap_sid_path = cap_sid_file(home); let is_workspace_write = matches!(&policy, SandboxPolicy::WorkspaceWrite { .. }); let (h_token, psid): (HANDLE, *mut c_void) = unsafe { let caps = load_or_create_cap_sids(home); ensure_dir(&cap_sid_path)?; fs::write(&cap_sid_path, serde_json::to_string(&caps)?)?; let cap_sid_str = if is_workspace_write { &caps.workspace } else { &caps.readonly }; let psid = convert_string_sid_to_sid(cap_sid_str).unwrap(); create_restricted_token(psid, write_restricted)? }; unsafe { if is_workspace_write { if let Ok(base) = token::get_current_token_for_restriction() { if let Ok(bytes) = token::get_logon_sid_bytes(base) { let mut tmp = bytes.clone(); let psid2 = tmp.as_mut_ptr() as *mut c_void; allow_null_device(psid2); } CloseHandle(base); } } } let persist_aces = is_workspace_write; let allow_deny = compute_allow_paths(&policy, sandbox_policy_cwd, ¤t_dir, &env_map); let mut guards: Vec<(PathBuf, *mut c_void)> = Vec::new(); unsafe { for p in &allow_deny.allow { if let Ok(added) = add_allow_ace(p, psid) { if added && !persist_aces { guards.push((p.clone(), psid)); } } } for p in &allow_deny.deny { if let Ok(added) = add_deny_write_ace(p, psid) { if added && !persist_aces { guards.push((p.clone(), psid)); } } } allow_null_device(psid); } let _ = apply_world_writable_scan_and_denies( home, ¤t_dir, &env_map, &policy, logs_base_dir.as_deref(), ); // Mark the child as running inside a sandbox so it skips nested sandboxing. env_map.insert("QIMING_IN_SANDBOX".to_string(), "1".to_string()); Ok(Self { env_map, h_token, persist_aces, guards, logs_base_dir, command, }) } /// Spawn the child process with restricted token and the given stdio handles. unsafe fn spawn_child( &self, cwd: &Path, child_stdin_r: HANDLE, child_stdout_w: HANDLE, child_stderr_w: HANDLE, all_pipe_handles: &[HANDLE], ) -> anyhow::Result { let mut si: STARTUPINFOW = std::mem::zeroed(); si.cb = std::mem::size_of::() as u32; si.dwFlags |= STARTF_USESTDHANDLES; si.hStdInput = child_stdin_r; si.hStdOutput = child_stdout_w; si.hStdError = child_stderr_w; let cmdline_str = self.command .iter() .map(|a| quote_windows_arg(a)) .collect::>() .join(" "); let mut cmdline: Vec = to_wide(&cmdline_str); let env_block = make_env_block(&self.env_map); let desktop = to_wide("Winsta0\\Default"); si.lpDesktop = desktop.as_ptr() as *mut u16; let mut pi: PROCESS_INFORMATION = std::mem::zeroed(); let spawn_res = CreateProcessAsUserW( self.h_token, ptr::null(), cmdline.as_mut_ptr(), ptr::null_mut(), ptr::null_mut(), 1, CREATE_UNICODE_ENVIRONMENT, env_block.as_ptr() as *mut c_void, to_wide(cwd).as_ptr(), &si, &mut pi, ); if spawn_res == 0 { let err = GetLastError() as i32; let msg = format!( "CreateProcessAsUserW failed: {} ({}) | cwd={} | cmd={} | env_u16_len={}", err, format_last_error(err), cwd.display(), cmdline_str, env_block.len(), ); debug_log(&msg, self.logs_base_dir.as_deref()); close_handles(all_pipe_handles); CloseHandle(self.h_token); return Err(anyhow::anyhow!("CreateProcessAsUserW failed: {}", err)); } Ok(pi) } /// Log the result and revoke temporary ACEs. fn cleanup(&mut self, exit_code: i32) { if exit_code == 0 { log_success(&self.command, self.logs_base_dir.as_deref()); } else { log_failure(&self.command, &format!("exit code {}", exit_code), self.logs_base_dir.as_deref()); } if !self.persist_aces { unsafe { for (p, sid) in &self.guards { revoke_ace(p, *sid); } } } } } // ============================================================================ // Resolve common args // ============================================================================ fn resolve_home(args: &CommonArgs) -> PathBuf { args.home.clone().unwrap_or_else(|| { dirs_next::home_dir() .unwrap_or_else(|| PathBuf::from("C:\\Users\\Default")) .join(".qiming-sandbox") }) } fn resolve_policy(args: &CommonArgs) -> String { if let Some(json) = &args.policy_json { json.clone() } else { match args.mode { Mode::ReadOnly => "read-only".to_string(), Mode::WorkspaceWrite => "workspace-write".to_string(), } } } fn split_command(args: &CommonArgs) -> anyhow::Result<(String, Vec)> { if args.command.is_empty() { anyhow::bail!("no command provided; use `-- -- [args...]`"); } Ok((args.command[0].clone(), args.command[1..].to_vec())) } // ============================================================================ // Capture mode (run subcommand) // ============================================================================ #[derive(Debug)] pub struct CaptureResult { pub exit_code: i32, pub stdout: Vec, pub stderr: Vec, pub timed_out: bool, } pub fn run_sandbox_capture( policy_json_or_preset: &str, sandbox_policy_cwd: &Path, home: &Path, command: Vec, cwd: &Path, args: Vec, timeout_ms: Option, write_restricted: bool, ) -> anyhow::Result { let mut ctx = SandboxContext::setup(policy_json_or_preset, sandbox_policy_cwd, home, command, cwd, args, write_restricted)?; let (stdin_pair, stdout_pair, stderr_pair) = unsafe { setup_stdio_pipes()? }; let ((in_r, in_w), (out_r, out_w), (err_r, err_w)) = (stdin_pair, stdout_pair, stderr_pair); let all_pipes = [in_r, in_w, out_r, out_w, err_r, err_w]; let pi = unsafe { ctx.spawn_child(cwd, in_r, out_w, err_w, &all_pipes)? }; // Close all child-ends and the stdin write-end (capture mode doesn't forward stdin) unsafe { CloseHandle(in_r); // child's stdin read end CloseHandle(in_w); // parent's stdin write end (not forwarding stdin in capture mode) CloseHandle(out_w); // child's stdout write end CloseHandle(err_w); // child's stderr write end } // Read stdout/stderr in background threads let (tx_out, rx_out) = mpsc::channel::>(); let (tx_err, rx_err) = mpsc::channel::>(); let t_out = thread::spawn(move || { let mut buf = Vec::new(); let mut tmp = [0u8; 8192]; loop { let mut read_bytes: u32 = 0; let ok = unsafe { windows_sys::Win32::Storage::FileSystem::ReadFile( out_r, tmp.as_mut_ptr(), tmp.len() as u32, &mut read_bytes, ptr::null_mut(), ) }; if ok == 0 || read_bytes == 0 { break; } buf.extend_from_slice(&tmp[..read_bytes as usize]); } let _ = tx_out.send(buf); }); let t_err = thread::spawn(move || { let mut buf = Vec::new(); let mut tmp = [0u8; 8192]; loop { let mut read_bytes: u32 = 0; let ok = unsafe { windows_sys::Win32::Storage::FileSystem::ReadFile( err_r, tmp.as_mut_ptr(), tmp.len() as u32, &mut read_bytes, ptr::null_mut(), ) }; if ok == 0 || read_bytes == 0 { break; } buf.extend_from_slice(&tmp[..read_bytes as usize]); } let _ = tx_err.send(buf); }); let timeout = timeout_ms.unwrap_or(u64::MAX).min(u64::from(INFINITE)) as u32; let res = unsafe { WaitForSingleObject(pi.hProcess, timeout) }; let timed_out = res == 0x0000_0102; let mut exit_code_u32: u32 = 1; if !timed_out { unsafe { GetExitCodeProcess(pi.hProcess, &mut exit_code_u32); } } else { unsafe { windows_sys::Win32::System::Threading::TerminateProcess(pi.hProcess, 1); } } unsafe { close_handles(&[pi.hThread, pi.hProcess, ctx.h_token]); } let _ = t_out.join(); let _ = t_err.join(); let stdout = rx_out.recv().unwrap_or_default(); let stderr = rx_err.recv().unwrap_or_default(); let exit_code = if timed_out { 128 + 64 } else { exit_code_u32 as i32 }; ctx.cleanup(exit_code); Ok(CaptureResult { exit_code, stdout, stderr, timed_out }) } // ============================================================================ // Proxy mode (serve subcommand) // ============================================================================ /// Result of a proxy-mode sandbox run. pub struct ProxyResult { pub exit_code: i32, } /// Run a sandboxed process with bidirectional stdio forwarding. pub fn run_sandbox_proxy( policy_json_or_preset: &str, sandbox_policy_cwd: &Path, home: &Path, command: Vec, cwd: &Path, args: Vec, write_restricted: bool, ) -> anyhow::Result { // When write_restricted=true, the restricted token gets WRITE_RESTRICTED // flag + restricting SIDs, so only paths with explicit ALLOW ACEs are // writable. When false, only DACL-level token restrictions apply // (permissive mode — no filesystem write protection). debug_log( &format!( "serve mode: write_restricted={} ({}), policy={}", write_restricted, if write_restricted { "STRICT/COMPAT — filesystem writes restricted to ALLOW ACEs" } else { "PERMISSIVE — no filesystem write protection" }, policy_json_or_preset, ), None, ); let mut ctx = SandboxContext::setup(policy_json_or_preset, sandbox_policy_cwd, home, command, cwd, args, write_restricted)?; let (stdin_pair, stdout_pair, stderr_pair) = unsafe { setup_stdio_pipes()? }; let ((child_stdin_r, child_stdin_w), (child_stdout_r, child_stdout_w), (child_stderr_r, child_stderr_w)) = (stdin_pair, stdout_pair, stderr_pair); let all_pipes = [child_stdin_r, child_stdin_w, child_stdout_r, child_stdout_w, child_stderr_r, child_stderr_w]; let pi = unsafe { ctx.spawn_child(cwd, child_stdin_r, child_stdout_w, child_stderr_w, &all_pipes)? }; // Close child-side handles that the parent doesn't need unsafe { CloseHandle(child_stdin_r); // child's stdin read end CloseHandle(child_stdout_w); // child's stdout write end CloseHandle(child_stderr_w); // child's stderr write end } // --- Bidirectional forwarding threads --- // stdin: parent_stdin → child_stdin_w let stdin_writer_w = child_stdin_w; let (stdin_done_tx, stdin_done_rx) = mpsc::channel::<()>(); let stdin_thread = thread::spawn(move || { let mut buf = [0u8; 8192]; let mut stdin_handle = io::stdin(); loop { match stdin_handle.read(&mut buf) { Ok(0) => break, // EOF Ok(n) => { let mut written = 0usize; while written < n { let mut bytes_written: u32 = 0; let ok = unsafe { windows_sys::Win32::Storage::FileSystem::WriteFile( stdin_writer_w, buf[written..].as_ptr(), (n - written) as u32, &mut bytes_written, ptr::null_mut(), ) }; if ok == 0 || bytes_written == 0 { break; } written += bytes_written as usize; } if written < n { break; // pipe broken } } Err(_) => break, } } unsafe { CloseHandle(stdin_writer_w); } let _ = stdin_done_tx.send(()); }); // stdout: child_stdout_r → parent_stdout let stdout_reader_r = child_stdout_r; let stdout_thread = thread::spawn(move || { let mut buf = [0u8; 8192]; let mut stdout_handle = io::stdout(); loop { let mut read_bytes: u32 = 0; let ok = unsafe { windows_sys::Win32::Storage::FileSystem::ReadFile( stdout_reader_r, buf.as_mut_ptr(), buf.len() as u32, &mut read_bytes, ptr::null_mut(), ) }; if ok == 0 || read_bytes == 0 { break; } if stdout_handle.write_all(&buf[..read_bytes as usize]).is_err() { break; } let _ = stdout_handle.flush(); } unsafe { CloseHandle(stdout_reader_r); } }); // stderr: child_stderr_r → parent_stderr let stderr_reader_r = child_stderr_r; let stderr_thread = thread::spawn(move || { let mut buf = [0u8; 8192]; let mut stderr_handle = io::stderr(); loop { let mut read_bytes: u32 = 0; let ok = unsafe { windows_sys::Win32::Storage::FileSystem::ReadFile( stderr_reader_r, buf.as_mut_ptr(), buf.len() as u32, &mut read_bytes, ptr::null_mut(), ) }; if ok == 0 || read_bytes == 0 { break; } if stderr_handle.write_all(&buf[..read_bytes as usize]).is_err() { break; } let _ = stderr_handle.flush(); } unsafe { CloseHandle(stderr_reader_r); } }); // Wait for child process to exit unsafe { WaitForSingleObject(pi.hProcess, INFINITE) }; let mut exit_code_u32: u32 = 1; unsafe { GetExitCodeProcess(pi.hProcess, &mut exit_code_u32); } // Close child process handles so stdout/stderr forwarding threads can unblock unsafe { close_handles(&[pi.hThread, pi.hProcess, ctx.h_token]); } // Wait for stdout/stderr threads to drain let _ = stdout_thread.join(); let _ = stderr_thread.join(); // stdin thread may block on parent stdin — give it a short window then detach if stdin_done_rx.recv_timeout(Duration::from_secs(2)).is_err() { drop(stdin_thread); } let exit_code = exit_code_u32 as i32; ctx.cleanup(exit_code); Ok(ProxyResult { exit_code }) } // ============================================================================ // main // ============================================================================ fn main() -> anyhow::Result<()> { let args = Args::parse(); match args.subcommand { Subcommand::Run(run) => { let home = resolve_home(&run.common); let policy = resolve_policy(&run.common); let (cmd, cmd_args) = split_command(&run.common)?; let write_restricted = !run.no_write_restricted; let timeout_ms: u64 = 300_000; let result = run_sandbox_capture( &policy, &run.common.cwd, &home, vec![cmd], &run.common.cwd, cmd_args, Some(timeout_ms), write_restricted, )?; println!( "{{\"exit_code\":{},\"stdout\":{},\"stderr\":{},\"timed_out\":{}}}", result.exit_code, serde_json::to_string(&String::from_utf8_lossy(&result.stdout))?, serde_json::to_string(&String::from_utf8_lossy(&result.stderr))?, result.timed_out ); } Subcommand::Serve(serve) => { let home = resolve_home(&serve.common); let policy = resolve_policy(&serve.common); let (cmd, cmd_args) = split_command(&serve.common)?; let result = run_sandbox_proxy( &policy, &serve.common.cwd, &home, vec![cmd], &serve.common.cwd, cmd_args, serve.write_restricted, )?; std::process::exit(result.exit_code); } } Ok(()) }