chore: initialize qiming workspace repository
This commit is contained in:
259
qimingclaw/crates/windows-sandbox-helper/src/acl.rs
Normal file
259
qimingclaw/crates/windows-sandbox-helper/src/acl.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
//! DACL manipulation (add/deny ACEs).
|
||||
|
||||
use crate::winutil::to_wide;
|
||||
use anyhow::Result;
|
||||
use std::ffi::c_void;
|
||||
use std::path::Path;
|
||||
use windows_sys::Win32::Foundation::{
|
||||
CloseHandle, LocalFree, ERROR_SUCCESS, HLOCAL, INVALID_HANDLE_VALUE,
|
||||
};
|
||||
use windows_sys::Win32::Security::{
|
||||
AclSizeInformation, EqualSid, GetAce, GetAclInformation,
|
||||
Authorization::{
|
||||
GetNamedSecurityInfoW, GetSecurityInfo, SetEntriesInAclW, SetNamedSecurityInfoW,
|
||||
SetSecurityInfo, EXPLICIT_ACCESS_W, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, TRUSTEE_W,
|
||||
},
|
||||
ACL, ACL_SIZE_INFORMATION, ACCESS_ALLOWED_ACE, ACE_HEADER, DACL_SECURITY_INFORMATION,
|
||||
};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_ATTRIBUTE_NORMAL, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ,
|
||||
FILE_GENERIC_WRITE, FILE_APPEND_DATA, FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA,
|
||||
FILE_WRITE_EA, FILE_SHARE_READ, FILE_SHARE_WRITE, OPEN_EXISTING,
|
||||
};
|
||||
|
||||
const SE_KERNEL_OBJECT: u32 = 6;
|
||||
const INHERIT_ONLY_ACE: u8 = 0x08;
|
||||
const GENERIC_WRITE_MASK: u32 = 0x4000_0000;
|
||||
const CONTAINER_INHERIT_ACE: u32 = 0x2;
|
||||
const OBJECT_INHERIT_ACE: u32 = 0x1;
|
||||
|
||||
// ACE access modes
|
||||
const GRANT_ACCESS: i32 = 2;
|
||||
const DENY_ACCESS: i32 = 3;
|
||||
const REVOKE_ACCESS: i32 = 4;
|
||||
|
||||
const DENY_WRITE_MASK: u32 = FILE_GENERIC_WRITE | FILE_WRITE_DATA | FILE_APPEND_DATA
|
||||
| FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES | GENERIC_WRITE_MASK;
|
||||
|
||||
/// Check if a DACL contains an ACE of the given type matching the SID with the specified mask.
|
||||
fn dacl_has_ace_for_sid(
|
||||
p_dacl: *mut ACL,
|
||||
psid: *mut c_void,
|
||||
ace_type: u8, // 0 = ACCESS_ALLOWED, 1 = ACCESS_DENIED
|
||||
mask_check: u32,
|
||||
) -> bool {
|
||||
if p_dacl.is_null() {
|
||||
return false;
|
||||
}
|
||||
let mut info: ACL_SIZE_INFORMATION = unsafe { std::mem::zeroed() };
|
||||
let ok = unsafe {
|
||||
GetAclInformation(
|
||||
p_dacl as *const ACL,
|
||||
&mut info as *mut _ as *mut c_void,
|
||||
std::mem::size_of::<ACL_SIZE_INFORMATION>() as u32,
|
||||
AclSizeInformation,
|
||||
)
|
||||
};
|
||||
if ok == 0 {
|
||||
return false;
|
||||
}
|
||||
for i in 0..info.AceCount {
|
||||
let mut p_ace: *mut c_void = std::ptr::null_mut();
|
||||
if unsafe { GetAce(p_dacl as *const ACL, i, &mut p_ace) } == 0 {
|
||||
continue;
|
||||
}
|
||||
let hdr = unsafe { &*(p_ace as *const ACE_HEADER) };
|
||||
if hdr.AceType != ace_type {
|
||||
continue;
|
||||
}
|
||||
if (hdr.AceFlags & INHERIT_ONLY_ACE) != 0 {
|
||||
continue;
|
||||
}
|
||||
let ace = unsafe { &*(p_ace as *const ACCESS_ALLOWED_ACE) };
|
||||
let base = p_ace as usize;
|
||||
let sid_ptr = (base + std::mem::size_of::<ACE_HEADER>() + std::mem::size_of::<u32>()) as *mut c_void;
|
||||
if unsafe { EqualSid(sid_ptr, psid) } != 0 && (ace.Mask & mask_check) != 0 {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
fn trustee(psid: *mut c_void) -> TRUSTEE_W {
|
||||
TRUSTEE_W {
|
||||
pMultipleTrustee: std::ptr::null_mut(),
|
||||
MultipleTrusteeOperation: 0,
|
||||
TrusteeForm: TRUSTEE_IS_SID,
|
||||
TrusteeType: TRUSTEE_IS_UNKNOWN,
|
||||
ptstrName: psid as *mut u16,
|
||||
}
|
||||
}
|
||||
|
||||
fn get_dacl(path: &Path) -> Result<(*mut c_void, *mut ACL)> {
|
||||
let mut p_sd: *mut c_void = std::ptr::null_mut();
|
||||
let mut p_dacl: *mut ACL = std::ptr::null_mut();
|
||||
let code = unsafe {
|
||||
GetNamedSecurityInfoW(
|
||||
to_wide(path).as_ptr(),
|
||||
1,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
&mut p_dacl,
|
||||
std::ptr::null_mut(),
|
||||
&mut p_sd,
|
||||
)
|
||||
};
|
||||
if code != ERROR_SUCCESS as u32 {
|
||||
return Err(anyhow::anyhow!("GetNamedSecurityInfoW failed: {}", code));
|
||||
}
|
||||
Ok((p_sd, p_dacl))
|
||||
}
|
||||
|
||||
fn free_sd(p_sd: *mut c_void) {
|
||||
if !p_sd.is_null() {
|
||||
unsafe { LocalFree(p_sd as HLOCAL) };
|
||||
}
|
||||
}
|
||||
|
||||
/// Internal: apply an EXPLICIT_ACCESS_W entry to a path's DACL.
|
||||
unsafe fn set_ace_on_path(
|
||||
path: &Path,
|
||||
psid: *mut c_void,
|
||||
access_mode: i32,
|
||||
permissions: u32,
|
||||
skip_if_present: Option<(u8, u32)>, // (ace_type, mask) to check before adding
|
||||
) -> Result<bool> {
|
||||
let (p_sd, p_dacl) = get_dacl(path)?;
|
||||
|
||||
if let Some((ace_type, mask)) = skip_if_present {
|
||||
if dacl_has_ace_for_sid(p_dacl, psid, ace_type, mask) {
|
||||
free_sd(p_sd);
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let mut explicit: EXPLICIT_ACCESS_W = std::mem::zeroed();
|
||||
explicit.grfAccessPermissions = permissions;
|
||||
explicit.grfAccessMode = access_mode;
|
||||
explicit.grfInheritance = CONTAINER_INHERIT_ACE | OBJECT_INHERIT_ACE;
|
||||
explicit.Trustee = trustee(psid);
|
||||
|
||||
let mut p_new_dacl: *mut ACL = std::ptr::null_mut();
|
||||
let code2 = unsafe { SetEntriesInAclW(1, &explicit, p_dacl, &mut p_new_dacl) };
|
||||
let mut added = false;
|
||||
if code2 == ERROR_SUCCESS as u32 {
|
||||
let code3 = unsafe {
|
||||
SetNamedSecurityInfoW(
|
||||
to_wide(path).as_ptr() as *mut u16,
|
||||
1,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
p_new_dacl,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if code3 == ERROR_SUCCESS as u32 {
|
||||
added = true;
|
||||
}
|
||||
if !p_new_dacl.is_null() {
|
||||
unsafe { LocalFree(p_new_dacl as HLOCAL) };
|
||||
}
|
||||
}
|
||||
free_sd(p_sd);
|
||||
Ok(added)
|
||||
}
|
||||
|
||||
pub unsafe fn add_allow_ace(path: &Path, psid: *mut c_void) -> Result<bool> {
|
||||
set_ace_on_path(
|
||||
path,
|
||||
psid,
|
||||
GRANT_ACCESS,
|
||||
FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE,
|
||||
Some((0, FILE_GENERIC_WRITE)), // skip if ALLOW ACE with write exists
|
||||
)
|
||||
}
|
||||
|
||||
pub unsafe fn add_deny_write_ace(path: &Path, psid: *mut c_void) -> Result<bool> {
|
||||
set_ace_on_path(
|
||||
path,
|
||||
psid,
|
||||
DENY_ACCESS,
|
||||
DENY_WRITE_MASK,
|
||||
Some((1, DENY_WRITE_MASK)), // skip if DENY ACE with write mask exists
|
||||
)
|
||||
}
|
||||
|
||||
pub unsafe fn revoke_ace(path: &Path, psid: *mut c_void) {
|
||||
let _ = set_ace_on_path(
|
||||
path,
|
||||
psid,
|
||||
REVOKE_ACCESS,
|
||||
0,
|
||||
None, // always revoke
|
||||
);
|
||||
}
|
||||
|
||||
pub unsafe fn allow_null_device(psid: *mut c_void) {
|
||||
let desired = 0x00020000 | 0x00040000;
|
||||
let h = unsafe {
|
||||
CreateFileW(
|
||||
to_wide(r"\\.\NUL").as_ptr(),
|
||||
desired,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE,
|
||||
std::ptr::null_mut(),
|
||||
OPEN_EXISTING,
|
||||
FILE_ATTRIBUTE_NORMAL,
|
||||
0,
|
||||
)
|
||||
};
|
||||
if h == 0 || h == INVALID_HANDLE_VALUE {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut p_sd: *mut c_void = std::ptr::null_mut();
|
||||
let mut p_dacl: *mut ACL = std::ptr::null_mut();
|
||||
let code = unsafe {
|
||||
GetSecurityInfo(
|
||||
h,
|
||||
SE_KERNEL_OBJECT as i32,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
&mut p_dacl,
|
||||
std::ptr::null_mut(),
|
||||
&mut p_sd,
|
||||
)
|
||||
};
|
||||
if code == ERROR_SUCCESS as u32 {
|
||||
let mut explicit: EXPLICIT_ACCESS_W = unsafe { std::mem::zeroed() };
|
||||
explicit.grfAccessPermissions = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE;
|
||||
explicit.grfAccessMode = GRANT_ACCESS;
|
||||
explicit.grfInheritance = 0;
|
||||
explicit.Trustee = trustee(psid);
|
||||
|
||||
let mut p_new_dacl: *mut ACL = std::ptr::null_mut();
|
||||
let code2 = unsafe { SetEntriesInAclW(1, &explicit, p_dacl, &mut p_new_dacl) };
|
||||
if code2 == ERROR_SUCCESS as u32 {
|
||||
let _ = unsafe {
|
||||
SetSecurityInfo(
|
||||
h,
|
||||
SE_KERNEL_OBJECT as i32,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
p_new_dacl,
|
||||
std::ptr::null_mut(),
|
||||
)
|
||||
};
|
||||
if !p_new_dacl.is_null() {
|
||||
unsafe { LocalFree(p_new_dacl as HLOCAL) };
|
||||
}
|
||||
}
|
||||
}
|
||||
if !p_sd.is_null() {
|
||||
unsafe { LocalFree(p_sd as HLOCAL) };
|
||||
}
|
||||
unsafe { CloseHandle(h) };
|
||||
}
|
||||
97
qimingclaw/crates/windows-sandbox-helper/src/allow.rs
Normal file
97
qimingclaw/crates/windows-sandbox-helper/src/allow.rs
Normal file
@@ -0,0 +1,97 @@
|
||||
//! Compute allow/deny path sets from a sandbox policy.
|
||||
|
||||
use crate::policy::{SandboxMode, SandboxPolicy};
|
||||
use dunce::canonicalize;
|
||||
use std::collections::HashMap;
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Debug, Default, PartialEq, Eq)]
|
||||
pub struct AllowDenyPaths {
|
||||
pub allow: HashSet<PathBuf>,
|
||||
pub deny: HashSet<PathBuf>,
|
||||
}
|
||||
|
||||
pub fn compute_allow_paths(
|
||||
policy: &SandboxPolicy,
|
||||
policy_cwd: &Path,
|
||||
command_cwd: &Path,
|
||||
env_map: &HashMap<String, String>,
|
||||
) -> AllowDenyPaths {
|
||||
let mut allow: HashSet<PathBuf> = HashSet::new();
|
||||
let mut deny: HashSet<PathBuf> = HashSet::new();
|
||||
|
||||
let mut add_allow_path = |p: PathBuf| {
|
||||
if p.exists() {
|
||||
allow.insert(p);
|
||||
}
|
||||
};
|
||||
let mut add_deny_path = |p: PathBuf| {
|
||||
if p.exists() {
|
||||
deny.insert(p);
|
||||
}
|
||||
};
|
||||
|
||||
if matches!(policy, SandboxPolicy::WorkspaceWrite { .. }) {
|
||||
let add_writable_root =
|
||||
|root: PathBuf, policy_cwd: &Path,
|
||||
add_allow: &mut dyn FnMut(PathBuf), add_deny: &mut dyn FnMut(PathBuf)| {
|
||||
let candidate = if root.is_absolute() {
|
||||
root
|
||||
} else {
|
||||
policy_cwd.join(root)
|
||||
};
|
||||
let canonical = canonicalize(&candidate).unwrap_or(candidate);
|
||||
add_allow(canonical.clone());
|
||||
|
||||
let git_dir = canonical.join(".git");
|
||||
if git_dir.is_dir() {
|
||||
add_deny(git_dir);
|
||||
}
|
||||
};
|
||||
|
||||
add_writable_root(
|
||||
command_cwd.to_path_buf(),
|
||||
policy_cwd,
|
||||
&mut add_allow_path,
|
||||
&mut add_deny_path,
|
||||
);
|
||||
|
||||
if let SandboxPolicy::WorkspaceWrite { writable_roots, .. } = policy {
|
||||
for root in writable_roots {
|
||||
add_writable_root(
|
||||
root.clone(),
|
||||
policy_cwd,
|
||||
&mut add_allow_path,
|
||||
&mut add_deny_path,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Always include TEMP/TMP as writable roots
|
||||
for key in ["TEMP", "TMP"] {
|
||||
if let Some(v) = env_map.get(key) {
|
||||
add_allow_path(PathBuf::from(v));
|
||||
} else if let Ok(v) = std::env::var(key) {
|
||||
add_allow_path(PathBuf::from(v));
|
||||
}
|
||||
}
|
||||
|
||||
// Include APPDATA/LOCALAPPDATA as writable roots in compat mode (and permissive).
|
||||
// Strict mode intentionally excludes these — only workspace + TEMP/TMP are writable.
|
||||
// This is the single authority for APPDATA allowance; the TypeScript side does NOT add them.
|
||||
let mode = policy.sandbox_mode();
|
||||
if mode != &SandboxMode::Strict {
|
||||
for key in ["APPDATA", "LOCALAPPDATA"] {
|
||||
if let Some(v) = env_map.get(key) {
|
||||
add_allow_path(PathBuf::from(v));
|
||||
} else if let Ok(v) = std::env::var(key) {
|
||||
add_allow_path(PathBuf::from(v));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AllowDenyPaths { allow, deny }
|
||||
}
|
||||
265
qimingclaw/crates/windows-sandbox-helper/src/audit.rs
Normal file
265
qimingclaw/crates/windows-sandbox-helper/src/audit.rs
Normal file
@@ -0,0 +1,265 @@
|
||||
//! Simplified world-writable directory audit.
|
||||
//!
|
||||
//! Unlike the macOS/Linux sandbox backends (which are declarative and need no audit),
|
||||
//! Windows still benefits from a lightweight scan of CWD children to prevent sandbox
|
||||
//! escape through world-writable paths. The aggressive global scan (PATH, C:\, Windows)
|
||||
//! has been removed to match the simpler approach of macOS/Linux.
|
||||
|
||||
use crate::acl::add_deny_write_ace;
|
||||
use crate::cap::{cap_sid_file, load_or_create_cap_sids};
|
||||
use crate::logging::log_note;
|
||||
use crate::policy::SandboxPolicy;
|
||||
use crate::token::{convert_string_sid_to_sid, world_sid};
|
||||
use crate::winutil::to_wide;
|
||||
use anyhow::Result;
|
||||
use std::collections::HashSet;
|
||||
use std::ffi::c_void;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::time::{Duration, Instant};
|
||||
use windows_sys::Win32::Foundation::{
|
||||
CloseHandle, ERROR_SUCCESS, HLOCAL, INVALID_HANDLE_VALUE, LocalFree,
|
||||
};
|
||||
use windows_sys::Win32::Security::{
|
||||
EqualSid, GetAce, GetAclInformation, MapGenericMask,
|
||||
Authorization::{GetNamedSecurityInfoW, GetSecurityInfo},
|
||||
DACL_SECURITY_INFORMATION, ACL, GENERIC_MAPPING,
|
||||
ACCESS_ALLOWED_ACE, ACE_HEADER,
|
||||
};
|
||||
use windows_sys::Win32::Storage::FileSystem::{
|
||||
CreateFileW, FILE_ALL_ACCESS, FILE_APPEND_DATA, FILE_FLAG_BACKUP_SEMANTICS,
|
||||
FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, FILE_GENERIC_WRITE,
|
||||
FILE_SHARE_DELETE, FILE_SHARE_READ, FILE_SHARE_WRITE,
|
||||
FILE_WRITE_ATTRIBUTES, FILE_WRITE_DATA, FILE_WRITE_EA, OPEN_EXISTING,
|
||||
};
|
||||
|
||||
const MAX_CWD_CHILDREN: usize = 200;
|
||||
const AUDIT_TIME_LIMIT_SECS: u64 = 1;
|
||||
|
||||
fn normalize_path_key(p: &Path) -> String {
|
||||
let n = dunce::canonicalize(p).unwrap_or_else(|_| p.to_path_buf());
|
||||
n.to_string_lossy().replace('\\', "/").to_ascii_lowercase()
|
||||
}
|
||||
|
||||
/// Check if a path has a world-writable ALLOW ACE in its DACL.
|
||||
unsafe fn path_has_world_write_allow(path: &Path) -> Result<bool> {
|
||||
let mut p_sd: *mut c_void = std::ptr::null_mut();
|
||||
let mut p_dacl: *mut ACL = std::ptr::null_mut();
|
||||
let wpath = to_wide(path);
|
||||
|
||||
let h = CreateFileW(
|
||||
wpath.as_ptr(),
|
||||
0x00020000,
|
||||
FILE_SHARE_READ | FILE_SHARE_WRITE | FILE_SHARE_DELETE,
|
||||
std::ptr::null_mut(),
|
||||
OPEN_EXISTING,
|
||||
FILE_FLAG_BACKUP_SEMANTICS,
|
||||
0,
|
||||
);
|
||||
if h == INVALID_HANDLE_VALUE {
|
||||
let code = GetNamedSecurityInfoW(
|
||||
wpath.as_ptr(),
|
||||
1,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
&mut p_dacl,
|
||||
std::ptr::null_mut(),
|
||||
&mut p_sd,
|
||||
);
|
||||
if code != ERROR_SUCCESS as u32 {
|
||||
if !p_sd.is_null() { LocalFree(p_sd as HLOCAL); }
|
||||
return Ok(false);
|
||||
}
|
||||
} else {
|
||||
let code = GetSecurityInfo(
|
||||
h,
|
||||
1,
|
||||
DACL_SECURITY_INFORMATION,
|
||||
std::ptr::null_mut(),
|
||||
std::ptr::null_mut(),
|
||||
&mut p_dacl,
|
||||
std::ptr::null_mut(),
|
||||
&mut p_sd,
|
||||
);
|
||||
CloseHandle(h);
|
||||
if code != ERROR_SUCCESS as u32 {
|
||||
if !p_sd.is_null() { LocalFree(p_sd as HLOCAL); }
|
||||
return Ok(false);
|
||||
}
|
||||
}
|
||||
|
||||
let mut world = world_sid()?;
|
||||
let psid_world = world.as_mut_ptr() as *mut c_void;
|
||||
|
||||
if p_dacl.is_null() {
|
||||
if !p_sd.is_null() { LocalFree(p_sd as HLOCAL); }
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let mut info: windows_sys::Win32::Security::ACL_SIZE_INFORMATION = std::mem::zeroed();
|
||||
let ok = GetAclInformation(
|
||||
p_dacl as *const ACL,
|
||||
&mut info as *mut _ as *mut c_void,
|
||||
std::mem::size_of::<windows_sys::Win32::Security::ACL_SIZE_INFORMATION>() as u32,
|
||||
4, // AclSizeInformation
|
||||
);
|
||||
let has_write = if ok != 0 {
|
||||
let mapping = GENERIC_MAPPING {
|
||||
GenericRead: FILE_GENERIC_READ,
|
||||
GenericWrite: FILE_GENERIC_WRITE,
|
||||
GenericExecute: FILE_GENERIC_EXECUTE,
|
||||
GenericAll: FILE_ALL_ACCESS,
|
||||
};
|
||||
let write_mask = FILE_WRITE_DATA | FILE_APPEND_DATA | FILE_WRITE_EA | FILE_WRITE_ATTRIBUTES;
|
||||
let mut found = false;
|
||||
for i in 0..(info.AceCount as usize) {
|
||||
let mut p_ace: *mut c_void = std::ptr::null_mut();
|
||||
if GetAce(p_dacl as *const ACL, i as u32, &mut p_ace) == 0 { continue; }
|
||||
let hdr = &*(p_ace as *const ACE_HEADER);
|
||||
if hdr.AceType != 0 || (hdr.AceFlags & 0x08) != 0 { continue; }
|
||||
let base = p_ace as usize;
|
||||
let sid_ptr = (base + std::mem::size_of::<ACE_HEADER>() + std::mem::size_of::<u32>()) as *mut c_void;
|
||||
if EqualSid(sid_ptr, psid_world) == 0 { continue; }
|
||||
let ace = &*(p_ace as *const ACCESS_ALLOWED_ACE);
|
||||
let mut mask = ace.Mask;
|
||||
MapGenericMask(&mut mask, &mapping);
|
||||
if (mask & write_mask) != 0 { found = true; break; }
|
||||
}
|
||||
found
|
||||
} else { false };
|
||||
|
||||
if !p_sd.is_null() { LocalFree(p_sd as HLOCAL); }
|
||||
Ok(has_write)
|
||||
}
|
||||
|
||||
/// Audit only CWD direct children for world-writable directories.
|
||||
fn audit_cwd_children(cwd: &Path, logs_base_dir: Option<&Path>) -> Result<Vec<PathBuf>> {
|
||||
let start = Instant::now();
|
||||
let mut flagged: Vec<PathBuf> = Vec::new();
|
||||
let mut seen: HashSet<String> = HashSet::new();
|
||||
|
||||
if let Ok(read) = std::fs::read_dir(cwd) {
|
||||
for ent in read.flatten().take(MAX_CWD_CHILDREN) {
|
||||
if start.elapsed() > Duration::from_secs(AUDIT_TIME_LIMIT_SECS) {
|
||||
break;
|
||||
}
|
||||
let ft = match ent.file_type() {
|
||||
Ok(ft) => ft,
|
||||
Err(_) => continue,
|
||||
};
|
||||
if ft.is_symlink() || !ft.is_dir() {
|
||||
continue;
|
||||
}
|
||||
let p = ent.path();
|
||||
if unsafe { path_has_world_write_allow(&p)? } {
|
||||
let key = normalize_path_key(&p);
|
||||
if seen.insert(key) {
|
||||
flagged.push(p);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let elapsed_ms = start.elapsed().as_millis();
|
||||
if !flagged.is_empty() {
|
||||
let mut list = String::new();
|
||||
for p in &flagged {
|
||||
list.push_str(&format!("\n - {}", p.display()));
|
||||
}
|
||||
log_note(
|
||||
&format!("AUDIT: world-writable scan found; cwd={cwd:?}; duration_ms={elapsed_ms}; flagged:{list}"),
|
||||
logs_base_dir,
|
||||
);
|
||||
} else {
|
||||
log_note(
|
||||
&format!("AUDIT: world-writable scan OK; duration_ms={elapsed_ms}"),
|
||||
logs_base_dir,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(flagged)
|
||||
}
|
||||
|
||||
pub fn apply_world_writable_scan_and_denies(
|
||||
home: &Path,
|
||||
cwd: &Path,
|
||||
_env_map: &std::collections::HashMap<String, String>,
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
logs_base_dir: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
let flagged = audit_cwd_children(cwd, logs_base_dir)?;
|
||||
if flagged.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
if let Err(err) = apply_capability_denies_for_world_writable(
|
||||
home, &flagged, sandbox_policy, cwd, logs_base_dir,
|
||||
) {
|
||||
log_note(
|
||||
&format!("AUDIT: failed to apply capability deny ACEs: {}", err),
|
||||
logs_base_dir,
|
||||
);
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn apply_capability_denies_for_world_writable(
|
||||
home: &Path,
|
||||
flagged: &[PathBuf],
|
||||
sandbox_policy: &SandboxPolicy,
|
||||
cwd: &Path,
|
||||
logs_base_dir: Option<&Path>,
|
||||
) -> Result<()> {
|
||||
if flagged.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
std::fs::create_dir_all(home)?;
|
||||
let cap_path = cap_sid_file(home);
|
||||
let caps = load_or_create_cap_sids(home);
|
||||
std::fs::write(&cap_path, serde_json::to_string(&caps)?)?;
|
||||
|
||||
let (active_sid, workspace_roots): (*mut c_void, Vec<PathBuf>) = match sandbox_policy {
|
||||
SandboxPolicy::WorkspaceWrite { writable_roots, .. } => {
|
||||
let sid = unsafe { convert_string_sid_to_sid(&caps.workspace) }
|
||||
.ok_or_else(|| anyhow::anyhow!("ConvertStringSidToSidW failed for workspace capability"))?;
|
||||
let mut roots: Vec<PathBuf> = vec![dunce::canonicalize(cwd).unwrap_or_else(|_| cwd.to_path_buf())];
|
||||
for root in writable_roots {
|
||||
let candidate = if root.is_absolute() {
|
||||
root.clone()
|
||||
} else {
|
||||
cwd.join(root)
|
||||
};
|
||||
roots.push(dunce::canonicalize(&candidate).unwrap_or(candidate));
|
||||
}
|
||||
(sid, roots)
|
||||
}
|
||||
SandboxPolicy::ReadOnly => (
|
||||
unsafe { convert_string_sid_to_sid(&caps.readonly) }.ok_or_else(|| {
|
||||
anyhow::anyhow!("ConvertStringSidToSidW failed for readonly capability")
|
||||
})?,
|
||||
Vec::new(),
|
||||
),
|
||||
};
|
||||
|
||||
for path in flagged {
|
||||
if workspace_roots.iter().any(|root| path.starts_with(root)) {
|
||||
continue;
|
||||
}
|
||||
let res = unsafe { add_deny_write_ace(path, active_sid) };
|
||||
match res {
|
||||
Ok(true) => log_note(
|
||||
&format!("AUDIT: applied capability deny ACE to {}", path.display()),
|
||||
logs_base_dir,
|
||||
),
|
||||
Ok(false) => {}
|
||||
Err(err) => log_note(
|
||||
&format!("AUDIT: failed to apply capability deny ACE to {}: {}", path.display(), err),
|
||||
logs_base_dir,
|
||||
),
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
51
qimingclaw/crates/windows-sandbox-helper/src/cap.rs
Normal file
51
qimingclaw/crates/windows-sandbox-helper/src/cap.rs
Normal file
@@ -0,0 +1,51 @@
|
||||
//! Capability SID generation and persistence.
|
||||
|
||||
use rand::rngs::SmallRng;
|
||||
use rand::RngCore;
|
||||
use rand::SeedableRng;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fs;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone, Debug)]
|
||||
pub struct CapSids {
|
||||
pub workspace: String,
|
||||
pub readonly: String,
|
||||
}
|
||||
|
||||
pub fn cap_sid_file(home: &Path) -> PathBuf {
|
||||
home.join("cap_sid")
|
||||
}
|
||||
|
||||
fn make_random_cap_sid_string() -> String {
|
||||
let mut rng = SmallRng::from_entropy();
|
||||
let a = rng.next_u32();
|
||||
let b = rng.next_u32();
|
||||
let c = rng.next_u32();
|
||||
let d = rng.next_u32();
|
||||
format!("S-1-5-21-{}-{}-{}-{}", a, b, c, d)
|
||||
}
|
||||
|
||||
pub fn load_or_create_cap_sids(home: &Path) -> CapSids {
|
||||
let path = cap_sid_file(home);
|
||||
if path.exists() {
|
||||
if let Ok(txt) = fs::read_to_string(&path) {
|
||||
let t = txt.trim();
|
||||
if t.starts_with('{') && t.ends_with('}') {
|
||||
if let Ok(obj) = serde_json::from_str::<CapSids>(t) {
|
||||
return obj;
|
||||
}
|
||||
} else if !t.is_empty() {
|
||||
return CapSids {
|
||||
workspace: t.to_string(),
|
||||
readonly: make_random_cap_sid_string(),
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
CapSids {
|
||||
workspace: make_random_cap_sid_string(),
|
||||
readonly: make_random_cap_sid_string(),
|
||||
}
|
||||
}
|
||||
135
qimingclaw/crates/windows-sandbox-helper/src/env.rs
Normal file
135
qimingclaw/crates/windows-sandbox-helper/src/env.rs
Normal file
@@ -0,0 +1,135 @@
|
||||
//! Environment variable manipulation for sandboxed processes.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::env;
|
||||
use std::fs::File;
|
||||
use std::fs;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
pub fn normalize_null_device_env(env_map: &mut HashMap<String, String>) {
|
||||
let keys: Vec<String> = env_map.keys().cloned().collect();
|
||||
for k in keys {
|
||||
if let Some(v) = env_map.get(&k).cloned() {
|
||||
let t = v.trim().to_ascii_lowercase();
|
||||
if t == "/dev/null" || t == "\\\\dev\\\\null" {
|
||||
env_map.insert(k, "NUL".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn ensure_non_interactive_pager(env_map: &mut HashMap<String, String>) {
|
||||
env_map.entry("GIT_PAGER".into()).or_insert_with(|| "more.com".into());
|
||||
env_map.entry("PAGER".into()).or_insert_with(|| "more.com".into());
|
||||
env_map.entry("LESS".into()).or_insert_with(|| "".into());
|
||||
}
|
||||
|
||||
fn prepend_path(env_map: &mut HashMap<String, String>, prefix: &str) {
|
||||
let existing = env_map
|
||||
.get("PATH")
|
||||
.cloned()
|
||||
.or_else(|| env::var("PATH").ok())
|
||||
.unwrap_or_default();
|
||||
let parts: Vec<String> = existing.split(';').map(|s| s.to_string()).collect();
|
||||
if parts.first().map(|p| p.eq_ignore_ascii_case(prefix)).unwrap_or(false) {
|
||||
return;
|
||||
}
|
||||
let mut new_path = String::new();
|
||||
new_path.push_str(prefix);
|
||||
if !existing.is_empty() {
|
||||
new_path.push(';');
|
||||
new_path.push_str(&existing);
|
||||
}
|
||||
env_map.insert("PATH".into(), new_path);
|
||||
}
|
||||
|
||||
fn reorder_pathext_for_stubs(env_map: &mut HashMap<String, String>) {
|
||||
let default = env_map
|
||||
.get("PATHEXT")
|
||||
.cloned()
|
||||
.or_else(|| env::var("PATHEXT").ok())
|
||||
.unwrap_or(".COM;.EXE;.BAT;.CMD".to_string());
|
||||
let exts: Vec<String> = default.split(';').filter(|e| !e.is_empty()).map(|s| s.to_string()).collect();
|
||||
let exts_norm: Vec<String> = exts.iter().map(|e| e.to_ascii_uppercase()).collect();
|
||||
let want = [".BAT", ".CMD"];
|
||||
let mut front: Vec<String> = Vec::new();
|
||||
for w in want {
|
||||
if let Some(idx) = exts_norm.iter().position(|e| e == w) {
|
||||
front.push(exts[idx].clone());
|
||||
}
|
||||
}
|
||||
let rest: Vec<String> = exts
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.filter(|(i, _)| {
|
||||
let up = &exts_norm[*i];
|
||||
up != ".BAT" && up != ".CMD"
|
||||
})
|
||||
.map(|(_, e)| e)
|
||||
.collect();
|
||||
let mut combined = Vec::new();
|
||||
combined.extend(front);
|
||||
combined.extend(rest);
|
||||
env_map.insert("PATHEXT".into(), combined.join(";"));
|
||||
}
|
||||
|
||||
fn ensure_denybin(tools: &[&str], denybin_dir: Option<&Path>) -> Result<PathBuf> {
|
||||
let base = match denybin_dir {
|
||||
Some(p) => p.to_path_buf(),
|
||||
None => {
|
||||
let home = dirs_next::home_dir().ok_or_else(|| anyhow::anyhow!("no home dir"))?;
|
||||
home.join(".sbx-denybin")
|
||||
}
|
||||
};
|
||||
fs::create_dir_all(&base)?;
|
||||
for tool in tools {
|
||||
for ext in [".bat", ".cmd"] {
|
||||
let path = base.join(format!("{}{}", tool, ext));
|
||||
if !path.exists() {
|
||||
let mut f = File::create(&path)?;
|
||||
f.write_all(b"@echo off\r\nexit /b 1\r\n")?;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(base)
|
||||
}
|
||||
|
||||
pub fn apply_no_network_to_env(env_map: &mut HashMap<String, String>) -> Result<()> {
|
||||
env_map.insert("SBX_NONET_ACTIVE".into(), "1".into());
|
||||
env_map
|
||||
.entry("HTTP_PROXY".into())
|
||||
.or_insert_with(|| "http://127.0.0.1:9".into());
|
||||
env_map
|
||||
.entry("HTTPS_PROXY".into())
|
||||
.or_insert_with(|| "http://127.0.0.1:9".into());
|
||||
env_map
|
||||
.entry("ALL_PROXY".into())
|
||||
.or_insert_with(|| "http://127.0.0.1:9".into());
|
||||
env_map
|
||||
.entry("NO_PROXY".into())
|
||||
.or_insert_with(|| "localhost,127.0.0.1,::1".into());
|
||||
env_map.insert("PIP_NO_INDEX".into(), "1".into());
|
||||
env_map.insert("PIP_DISABLE_PIP_VERSION_CHECK".into(), "1".into());
|
||||
env_map.insert("NPM_CONFIG_OFFLINE".into(), "true".into());
|
||||
env_map.insert("CARGO_NET_OFFLINE".into(), "true".into());
|
||||
env_map.insert("GIT_HTTP_PROXY".into(), "http://127.0.0.1:9".into());
|
||||
env_map.insert("GIT_HTTPS_PROXY".into(), "http://127.0.0.1:9".into());
|
||||
env_map.insert("GIT_SSH_COMMAND".into(), "cmd /c exit 1".into());
|
||||
env_map.insert("GIT_ALLOW_PROTOCOLS".into(), "".into());
|
||||
|
||||
let base = ensure_denybin(&["ssh", "scp"], None)?;
|
||||
for tool in ["curl", "wget"] {
|
||||
for ext in [".bat", ".cmd"] {
|
||||
let p = base.join(format!("{}{}", tool, ext));
|
||||
if p.exists() {
|
||||
let _ = std::fs::remove_file(&p);
|
||||
}
|
||||
}
|
||||
}
|
||||
prepend_path(env_map, &base.to_string_lossy());
|
||||
reorder_pathext_for_stubs(env_map);
|
||||
Ok(())
|
||||
}
|
||||
59
qimingclaw/crates/windows-sandbox-helper/src/logging.rs
Normal file
59
qimingclaw/crates/windows-sandbox-helper/src/logging.rs
Normal file
@@ -0,0 +1,59 @@
|
||||
//! Command logging for audit trail.
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Write;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
|
||||
const LOG_COMMAND_PREVIEW_LIMIT: usize = 200;
|
||||
pub const LOG_FILE_NAME: &str = "sandbox_commands.rust.log";
|
||||
|
||||
fn preview(command: &[String]) -> String {
|
||||
let joined = command.join(" ");
|
||||
if joined.len() <= LOG_COMMAND_PREVIEW_LIMIT {
|
||||
joined
|
||||
} else {
|
||||
joined[..LOG_COMMAND_PREVIEW_LIMIT].to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn log_file_path(base_dir: &Path) -> Option<PathBuf> {
|
||||
if base_dir.is_dir() {
|
||||
Some(base_dir.join(LOG_FILE_NAME))
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
fn append_line(line: &str, base_dir: Option<&Path>) {
|
||||
if let Some(dir) = base_dir {
|
||||
if let Some(path) = log_file_path(dir) {
|
||||
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(path) {
|
||||
let _ = writeln!(f, "{}", line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_start(command: &[String], base_dir: Option<&Path>) {
|
||||
append_line(&format!("START: {}", preview(command)), base_dir);
|
||||
}
|
||||
|
||||
pub fn log_success(command: &[String], base_dir: Option<&Path>) {
|
||||
append_line(&format!("SUCCESS: {}", preview(command)), base_dir);
|
||||
}
|
||||
|
||||
pub fn log_failure(command: &[String], detail: &str, base_dir: Option<&Path>) {
|
||||
append_line(&format!("FAILURE: {} ({})", preview(command), detail), base_dir);
|
||||
}
|
||||
|
||||
pub fn debug_log(msg: &str, base_dir: Option<&Path>) {
|
||||
if std::env::var("SBX_DEBUG").ok().as_deref() == Some("1") {
|
||||
append_line(&format!("DEBUG: {}", msg), base_dir);
|
||||
eprintln!("{}", msg);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn log_note(msg: &str, base_dir: Option<&Path>) {
|
||||
append_line(msg, base_dir);
|
||||
}
|
||||
799
qimingclaw/crates/windows-sandbox-helper/src/main.rs
Normal file
799
qimingclaw/crates/windows-sandbox-helper/src/main.rs
Normal file
@@ -0,0 +1,799 @@
|
||||
//! Windows Restricted Token sandbox helper binary.
|
||||
//!
|
||||
//! Usage:
|
||||
//! ```bash
|
||||
//! # Capture mode: run command, capture output, print JSON result
|
||||
//! qiming-sandbox-helper run \
|
||||
//! --mode <read-only|workspace-write> \
|
||||
//! --cwd <path> \
|
||||
//! [--home <path>] \
|
||||
//! [--policy-json <json>] \
|
||||
//! -- <command> [args...]
|
||||
//!
|
||||
//! # Proxy mode: run command as persistent stdio proxy (stdin/stdout forwarded)
|
||||
//! qiming-sandbox-helper serve \
|
||||
//! --mode <read-only|workspace-write> \
|
||||
//! --cwd <path> \
|
||||
//! [--home <path>] \
|
||||
//! [--policy-json <json>] \
|
||||
//! -- <command> [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<PathBuf>,
|
||||
#[arg(long)]
|
||||
policy_json: Option<String>,
|
||||
#[arg(last = true)]
|
||||
command: Vec<String>,
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// 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<String, String>) -> Vec<u16> {
|
||||
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<u16> = 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<PipeHandles> {
|
||||
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<String, String>,
|
||||
/// 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<PathBuf>,
|
||||
/// Full command line (for logging).
|
||||
command: Vec<String>,
|
||||
}
|
||||
|
||||
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<String>,
|
||||
cwd: &Path,
|
||||
args: Vec<String>,
|
||||
write_restricted: bool,
|
||||
) -> anyhow::Result<Self> {
|
||||
let mut env_map: HashMap<String, String> = 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<PROCESS_INFORMATION> {
|
||||
let mut si: STARTUPINFOW = std::mem::zeroed();
|
||||
si.cb = std::mem::size_of::<STARTUPINFOW>() 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::<Vec<_>>()
|
||||
.join(" ");
|
||||
let mut cmdline: Vec<u16> = 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<String>)> {
|
||||
if args.command.is_empty() {
|
||||
anyhow::bail!("no command provided; use `-- -- <command> [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<u8>,
|
||||
pub stderr: Vec<u8>,
|
||||
pub timed_out: bool,
|
||||
}
|
||||
|
||||
pub fn run_sandbox_capture(
|
||||
policy_json_or_preset: &str,
|
||||
sandbox_policy_cwd: &Path,
|
||||
home: &Path,
|
||||
command: Vec<String>,
|
||||
cwd: &Path,
|
||||
args: Vec<String>,
|
||||
timeout_ms: Option<u64>,
|
||||
write_restricted: bool,
|
||||
) -> anyhow::Result<CaptureResult> {
|
||||
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::<Vec<u8>>();
|
||||
let (tx_err, rx_err) = mpsc::channel::<Vec<u8>>();
|
||||
|
||||
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<String>,
|
||||
cwd: &Path,
|
||||
args: Vec<String>,
|
||||
write_restricted: bool,
|
||||
) -> anyhow::Result<ProxyResult> {
|
||||
// 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(())
|
||||
}
|
||||
89
qimingclaw/crates/windows-sandbox-helper/src/policy.rs
Normal file
89
qimingclaw/crates/windows-sandbox-helper/src/policy.rs
Normal file
@@ -0,0 +1,89 @@
|
||||
//! Sandbox policy types and parsing.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Sandbox strictness mode.
|
||||
///
|
||||
/// Using an enum instead of a raw string ensures that invalid values (e.g. typos
|
||||
/// like "strcit") cause a deserialization error rather than silently falling
|
||||
/// through to a less-strict mode (fail-closed).
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SandboxMode {
|
||||
Strict,
|
||||
#[default]
|
||||
Compat,
|
||||
Permissive,
|
||||
}
|
||||
|
||||
/// Windows Restricted Token sandbox policy.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type", rename_all = "kebab-case")]
|
||||
pub enum SandboxPolicy {
|
||||
/// Read-only access to the entire file-system.
|
||||
ReadOnly,
|
||||
|
||||
/// Read-only + write access to workspace directories.
|
||||
WorkspaceWrite {
|
||||
/// Additional writable folders beyond cwd and TMPDIR.
|
||||
#[serde(default, skip_serializing_if = "Vec::is_empty")]
|
||||
writable_roots: Vec<PathBuf>,
|
||||
|
||||
/// Allow outbound network access. Defaults to false.
|
||||
#[serde(default)]
|
||||
network_access: bool,
|
||||
|
||||
/// Sandbox strictness mode — controls which system paths are writable.
|
||||
/// - "strict": only workspace + TEMP/TMP (no APPDATA)
|
||||
/// - "compat": workspace + TEMP/TMP + APPDATA/LOCALAPPDATA
|
||||
/// - "permissive": all user-writable paths
|
||||
/// Defaults to "compat" when absent.
|
||||
#[serde(default)]
|
||||
sandbox_mode: SandboxMode,
|
||||
},
|
||||
}
|
||||
|
||||
impl Default for SandboxPolicy {
|
||||
fn default() -> Self {
|
||||
SandboxPolicy::ReadOnly
|
||||
}
|
||||
}
|
||||
|
||||
impl SandboxPolicy {
|
||||
pub fn new_read_only_policy() -> Self {
|
||||
SandboxPolicy::ReadOnly
|
||||
}
|
||||
|
||||
pub fn new_workspace_write_policy() -> Self {
|
||||
SandboxPolicy::WorkspaceWrite {
|
||||
writable_roots: Vec::new(),
|
||||
network_access: false,
|
||||
sandbox_mode: SandboxMode::Compat,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn has_full_network_access(&self) -> bool {
|
||||
match self {
|
||||
SandboxPolicy::ReadOnly => false,
|
||||
SandboxPolicy::WorkspaceWrite { network_access, .. } => *network_access,
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the sandbox strictness mode.
|
||||
pub fn sandbox_mode(&self) -> &SandboxMode {
|
||||
match self {
|
||||
SandboxPolicy::ReadOnly => &SandboxMode::Strict,
|
||||
SandboxPolicy::WorkspaceWrite { sandbox_mode, .. } => sandbox_mode,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse a policy from a string (preset name or JSON).
|
||||
pub fn parse_policy(value: &str) -> anyhow::Result<SandboxPolicy> {
|
||||
match value {
|
||||
"read-only" => Ok(SandboxPolicy::new_read_only_policy()),
|
||||
"workspace-write" => Ok(SandboxPolicy::new_workspace_write_policy()),
|
||||
other => Ok(serde_json::from_str(other)?),
|
||||
}
|
||||
}
|
||||
225
qimingclaw/crates/windows-sandbox-helper/src/token.rs
Normal file
225
qimingclaw/crates/windows-sandbox-helper/src/token.rs
Normal file
@@ -0,0 +1,225 @@
|
||||
//! Windows Restricted Token creation.
|
||||
|
||||
use crate::winutil::to_wide;
|
||||
use anyhow::Result;
|
||||
use std::ffi::c_void;
|
||||
use windows_sys::Win32::Foundation::{CloseHandle, GetLastError, HANDLE, LUID};
|
||||
use windows_sys::Win32::Security::{
|
||||
AdjustTokenPrivileges, CopySid, CreateRestrictedToken, CreateWellKnownSid,
|
||||
GetLengthSid, GetTokenInformation, LookupPrivilegeValueW, TokenGroups, SID_AND_ATTRIBUTES,
|
||||
TOKEN_ADJUST_DEFAULT, TOKEN_ADJUST_PRIVILEGES, TOKEN_ADJUST_SESSIONID, TOKEN_ASSIGN_PRIMARY,
|
||||
TOKEN_DUPLICATE, TOKEN_PRIVILEGES, TOKEN_QUERY,
|
||||
};
|
||||
use windows_sys::Win32::System::Threading::GetCurrentProcess;
|
||||
|
||||
const DISABLE_MAX_PRIVILEGE: u32 = 0x01;
|
||||
const LUA_TOKEN: u32 = 0x04;
|
||||
const WRITE_RESTRICTED: u32 = 0x08;
|
||||
const WIN_WORLD_SID: i32 = 1;
|
||||
const SE_GROUP_LOGON_ID: u32 = 0xC0000000;
|
||||
|
||||
pub unsafe fn world_sid() -> Result<Vec<u8>> {
|
||||
let mut size: u32 = 0;
|
||||
CreateWellKnownSid(WIN_WORLD_SID, std::ptr::null_mut(), std::ptr::null_mut(), &mut size);
|
||||
let mut buf: Vec<u8> = vec![0u8; size as usize];
|
||||
let ok = CreateWellKnownSid(WIN_WORLD_SID, std::ptr::null_mut(), buf.as_mut_ptr() as *mut c_void, &mut size);
|
||||
if ok == 0 {
|
||||
return Err(anyhow::anyhow!("CreateWellKnownSid failed: {}", GetLastError()));
|
||||
}
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
pub unsafe fn convert_string_sid_to_sid(s: &str) -> Option<*mut c_void> {
|
||||
#[link(name = "advapi32")]
|
||||
extern "system" {
|
||||
fn ConvertStringSidToSidW(StringSid: *const u16, Sid: *mut *mut c_void) -> i32;
|
||||
}
|
||||
let mut psid: *mut c_void = std::ptr::null_mut();
|
||||
let ok = unsafe { ConvertStringSidToSidW(to_wide(s).as_ptr(), &mut psid) };
|
||||
if ok != 0 {
|
||||
Some(psid)
|
||||
} else {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub unsafe fn get_current_token_for_restriction() -> Result<HANDLE> {
|
||||
let desired = TOKEN_DUPLICATE
|
||||
| TOKEN_QUERY
|
||||
| TOKEN_ASSIGN_PRIMARY
|
||||
| TOKEN_ADJUST_DEFAULT
|
||||
| TOKEN_ADJUST_SESSIONID
|
||||
| TOKEN_ADJUST_PRIVILEGES;
|
||||
let mut h: HANDLE = 0;
|
||||
#[link(name = "advapi32")]
|
||||
extern "system" {
|
||||
fn OpenProcessToken(ProcessHandle: HANDLE, DesiredAccess: u32, TokenHandle: *mut HANDLE) -> i32;
|
||||
}
|
||||
let ok = unsafe { OpenProcessToken(GetCurrentProcess(), desired, &mut h) };
|
||||
if ok == 0 {
|
||||
return Err(anyhow::anyhow!("OpenProcessToken failed: {}", GetLastError()));
|
||||
}
|
||||
Ok(h)
|
||||
}
|
||||
|
||||
pub unsafe fn get_logon_sid_bytes(h_token: HANDLE) -> Result<Vec<u8>> {
|
||||
unsafe fn scan_token_groups_for_logon(h: HANDLE) -> Option<Vec<u8>> {
|
||||
let mut needed: u32 = 0;
|
||||
GetTokenInformation(h, TokenGroups, std::ptr::null_mut(), 0, &mut needed);
|
||||
if needed == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut buf: Vec<u8> = vec![0u8; needed as usize];
|
||||
let ok = GetTokenInformation(
|
||||
h, TokenGroups, buf.as_mut_ptr() as *mut c_void, needed, &mut needed,
|
||||
);
|
||||
if ok == 0 || (needed as usize) < std::mem::size_of::<u32>() {
|
||||
return None;
|
||||
}
|
||||
let group_count = std::ptr::read_unaligned(buf.as_ptr() as *const u32) as usize;
|
||||
let after_count =
|
||||
unsafe { buf.as_ptr().add(std::mem::size_of::<u32>()) } as usize;
|
||||
let align = std::mem::align_of::<SID_AND_ATTRIBUTES>();
|
||||
let aligned = (after_count + (align - 1)) & !(align - 1);
|
||||
let groups_ptr = aligned as *const SID_AND_ATTRIBUTES;
|
||||
for i in 0..group_count {
|
||||
let entry: SID_AND_ATTRIBUTES = unsafe { std::ptr::read_unaligned(groups_ptr.add(i)) };
|
||||
if (entry.Attributes & SE_GROUP_LOGON_ID) == SE_GROUP_LOGON_ID {
|
||||
let sid = entry.Sid;
|
||||
let sid_len = unsafe { GetLengthSid(sid) };
|
||||
if sid_len == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut out = vec![0u8; sid_len as usize];
|
||||
if unsafe { CopySid(sid_len, out.as_mut_ptr() as *mut c_void, sid) } == 0 {
|
||||
return None;
|
||||
}
|
||||
return Some(out);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
if let Some(v) = unsafe { scan_token_groups_for_logon(h_token) } {
|
||||
return Ok(v);
|
||||
}
|
||||
|
||||
#[repr(C)]
|
||||
struct TOKEN_LINKED_TOKEN {
|
||||
linked_token: HANDLE,
|
||||
}
|
||||
const TOKEN_LINKED_TOKEN_CLASS: i32 = 19;
|
||||
let mut ln_needed: u32 = 0;
|
||||
unsafe {
|
||||
GetTokenInformation(
|
||||
h_token,
|
||||
TOKEN_LINKED_TOKEN_CLASS,
|
||||
std::ptr::null_mut(),
|
||||
0,
|
||||
&mut ln_needed,
|
||||
)
|
||||
};
|
||||
if ln_needed >= std::mem::size_of::<TOKEN_LINKED_TOKEN>() as u32 {
|
||||
let mut ln_buf: Vec<u8> = vec![0u8; ln_needed as usize];
|
||||
let ok = unsafe {
|
||||
GetTokenInformation(
|
||||
h_token,
|
||||
TOKEN_LINKED_TOKEN_CLASS,
|
||||
ln_buf.as_mut_ptr() as *mut c_void,
|
||||
ln_needed,
|
||||
&mut ln_needed,
|
||||
)
|
||||
};
|
||||
if ok != 0 {
|
||||
let lt: TOKEN_LINKED_TOKEN = unsafe { std::ptr::read_unaligned(ln_buf.as_ptr() as *const TOKEN_LINKED_TOKEN) };
|
||||
if lt.linked_token != 0 {
|
||||
let res = unsafe { scan_token_groups_for_logon(lt.linked_token) };
|
||||
unsafe { CloseHandle(lt.linked_token) };
|
||||
if let Some(v) = res {
|
||||
return Ok(v);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(anyhow::anyhow!("Logon SID not present on token"))
|
||||
}
|
||||
|
||||
unsafe fn enable_single_privilege(h_token: HANDLE, name: &str) -> Result<()> {
|
||||
let mut luid = LUID { LowPart: 0, HighPart: 0 };
|
||||
let ok = unsafe { LookupPrivilegeValueW(std::ptr::null(), to_wide(name).as_ptr(), &mut luid) };
|
||||
if ok == 0 {
|
||||
return Err(anyhow::anyhow!("LookupPrivilegeValueW failed: {}", GetLastError()));
|
||||
}
|
||||
let mut tp: TOKEN_PRIVILEGES = unsafe { std::mem::zeroed() };
|
||||
tp.PrivilegeCount = 1;
|
||||
tp.Privileges[0].Luid = luid;
|
||||
tp.Privileges[0].Attributes = 0x00000002;
|
||||
let ok2 = unsafe {
|
||||
AdjustTokenPrivileges(h_token, 0, &tp, 0, std::ptr::null_mut(), std::ptr::null_mut())
|
||||
};
|
||||
if ok2 == 0 {
|
||||
return Err(anyhow::anyhow!("AdjustTokenPrivileges failed: {}", GetLastError()));
|
||||
}
|
||||
let err = unsafe { GetLastError() };
|
||||
if err != 0 {
|
||||
return Err(anyhow::anyhow!("AdjustTokenPrivileges error {}", err));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create a restricted token for the sandbox.
|
||||
///
|
||||
/// When `write_restricted` is true, the token gets WRITE_RESTRICTED flag and
|
||||
/// restricting SIDs (logon SID, everyone SID, capability SID), which prevents
|
||||
/// write access except to paths explicitly allowed via DACL ACEs.
|
||||
///
|
||||
/// When `write_restricted` is false (serve mode), the token only gets
|
||||
/// DISABLE_MAX_PRIVILEGE | LUA_TOKEN, allowing child processes to spawn
|
||||
/// grandchildren. File-system write protection is handled by DACL ACEs alone.
|
||||
pub unsafe fn create_restricted_token(
|
||||
psid_capability: *mut c_void,
|
||||
write_restricted: bool,
|
||||
) -> Result<(HANDLE, *mut c_void)> {
|
||||
let base = get_current_token_for_restriction()?;
|
||||
|
||||
let (flags, restricting_count, restricting_sids) = if write_restricted {
|
||||
let mut logon_sid_bytes = get_logon_sid_bytes(base)
|
||||
.map_err(|e| { CloseHandle(base); e })?;
|
||||
let psid_logon = logon_sid_bytes.as_mut_ptr() as *mut c_void;
|
||||
let mut everyone = world_sid().map_err(|e| { CloseHandle(base); e })?;
|
||||
let psid_everyone = everyone.as_mut_ptr() as *mut c_void;
|
||||
|
||||
let mut entries: [SID_AND_ATTRIBUTES; 3] = std::mem::zeroed();
|
||||
entries[0].Sid = psid_capability;
|
||||
entries[0].Attributes = 0;
|
||||
entries[1].Sid = psid_logon;
|
||||
entries[1].Attributes = 0;
|
||||
entries[2].Sid = psid_everyone;
|
||||
entries[2].Attributes = 0;
|
||||
|
||||
(DISABLE_MAX_PRIVILEGE | LUA_TOKEN | WRITE_RESTRICTED, 3, entries)
|
||||
} else {
|
||||
(DISABLE_MAX_PRIVILEGE | LUA_TOKEN, 0, [std::mem::zeroed(); 3])
|
||||
};
|
||||
|
||||
let mut new_token: HANDLE = 0;
|
||||
let ok = CreateRestrictedToken(
|
||||
base,
|
||||
flags,
|
||||
0,
|
||||
std::ptr::null(),
|
||||
0,
|
||||
std::ptr::null(),
|
||||
restricting_count,
|
||||
if restricting_count > 0 { restricting_sids.as_ptr() as *mut SID_AND_ATTRIBUTES } else { std::ptr::null() },
|
||||
&mut new_token,
|
||||
);
|
||||
CloseHandle(base);
|
||||
if ok == 0 {
|
||||
return Err(anyhow::anyhow!("CreateRestrictedToken failed: {}", GetLastError()));
|
||||
}
|
||||
enable_single_privilege(new_token, "SeChangeNotifyPrivilege")
|
||||
.map_err(|e| { CloseHandle(new_token); e })?;
|
||||
Ok((new_token, psid_capability))
|
||||
}
|
||||
46
qimingclaw/crates/windows-sandbox-helper/src/winutil.rs
Normal file
46
qimingclaw/crates/windows-sandbox-helper/src/winutil.rs
Normal file
@@ -0,0 +1,46 @@
|
||||
//! Win32 utility functions.
|
||||
|
||||
use std::ffi::OsStr;
|
||||
#[cfg(target_os = "windows")]
|
||||
use std::os::windows::ffi::OsStrExt;
|
||||
use windows_sys::Win32::Foundation::{LocalFree, HLOCAL};
|
||||
use windows_sys::Win32::System::Diagnostics::Debug::{
|
||||
FormatMessageW, FORMAT_MESSAGE_ALLOCATE_BUFFER, FORMAT_MESSAGE_FROM_SYSTEM,
|
||||
FORMAT_MESSAGE_IGNORE_INSERTS,
|
||||
};
|
||||
|
||||
/// Convert an OsStr to a null-terminated wide string (LPWSTR).
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn to_wide<S: AsRef<OsStr>>(s: S) -> Vec<u16> {
|
||||
let mut v: Vec<u16> = s.as_ref().encode_wide().collect();
|
||||
v.push(0);
|
||||
v
|
||||
}
|
||||
|
||||
/// Produce a readable description for a Win32 error code.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn format_last_error(err: i32) -> String {
|
||||
unsafe {
|
||||
let mut buf_ptr: *mut u16 = std::ptr::null_mut();
|
||||
let flags = FORMAT_MESSAGE_ALLOCATE_BUFFER
|
||||
| FORMAT_MESSAGE_FROM_SYSTEM
|
||||
| FORMAT_MESSAGE_IGNORE_INSERTS;
|
||||
let len = FormatMessageW(
|
||||
flags,
|
||||
std::ptr::null(),
|
||||
err as u32,
|
||||
0,
|
||||
(&mut buf_ptr as *mut *mut u16) as *mut u16,
|
||||
0,
|
||||
std::ptr::null_mut(),
|
||||
);
|
||||
if len == 0 || buf_ptr.is_null() {
|
||||
return format!("Win32 error {}", err);
|
||||
}
|
||||
let slice = std::slice::from_raw_parts(buf_ptr, len as usize);
|
||||
let mut s = String::from_utf16_lossy(slice);
|
||||
s = s.trim().to_string();
|
||||
let _ = LocalFree(buf_ptr as HLOCAL);
|
||||
s
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user