From c844540b1a3bf6dca9fb6bb8109ff15f18b0b6f7 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:19:23 -0700 Subject: [PATCH 1/7] sandbox(windows): dedicated-account + WFP backend skeleton (agent-sandbox) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a second Windows backend alongside the per-run AppContainer one. The AppContainer path is a pure allowlist and stays exactly as it was — it is build-jail's mechanism and remains admin-free. Agent-sandbox routes instead to a dedicated local account fenced by SID-keyed WFP filters, which is the only shape on Windows that expresses a generous-read base, a deny carved inside a grant, and per-host egress. The privilege split is the point: one elevated setup per machine installs the account and four persistent WFP filters over a pre-authorized loopback port window; every run after that is unelevated, because the egress proxy binds into that window rather than a filter chasing an ephemeral port. This commit lands the WFP fence, the plan derivation and mode selection, the durable marker/ledger, the CreateProcessWithLogonW launcher, and the setup/teardown/status/clean surface. The account and ACL modules land next. Refs .fray/sandbox-decisions-current.md --- crates/nub-sandbox/Cargo.toml | 13 + crates/nub-sandbox/src/backend/mod.rs | 30 +- crates/nub-sandbox/src/backend/windows.rs | 65 +- .../src/backend/windows_account/account.rs | 967 ++++++++++++++++++ .../src/backend/windows_account/acl.rs | 674 ++++++++++++ .../src/backend/windows_account/launch.rs | 309 ++++++ .../src/backend/windows_account/mod.rs | 737 +++++++++++++ .../src/backend/windows_account/state.rs | 297 ++++++ .../src/backend/windows_account/wfp.rs | 615 +++++++++++ crates/nub-sandbox/src/lib.rs | 12 + crates/nub-sandbox/src/proxy/mod.rs | 42 +- 11 files changed, 3742 insertions(+), 19 deletions(-) create mode 100644 crates/nub-sandbox/src/backend/windows_account/account.rs create mode 100644 crates/nub-sandbox/src/backend/windows_account/acl.rs create mode 100644 crates/nub-sandbox/src/backend/windows_account/launch.rs create mode 100644 crates/nub-sandbox/src/backend/windows_account/mod.rs create mode 100644 crates/nub-sandbox/src/backend/windows_account/state.rs create mode 100644 crates/nub-sandbox/src/backend/windows_account/wfp.rs diff --git a/crates/nub-sandbox/Cargo.toml b/crates/nub-sandbox/Cargo.toml index ce38cd997..de8ba3389 100644 --- a/crates/nub-sandbox/Cargo.toml +++ b/crates/nub-sandbox/Cargo.toml @@ -92,6 +92,19 @@ features = [ "Win32_System_JobObjects", "Win32_System_Memory", "Win32_NetworkManagement_WindowsFirewall", + # The dedicated-account + WFP backend (backend/windows_account/): the SID-keyed egress + # fence (WindowsFilteringPlatform; System_Rpc only because FwpmEngineOpen0's signature + # names SEC_WINNT_AUTH_IDENTITY_W), local-account lifecycle (NetManagement), the + # DPAPI-sealed credential (Security_Cryptography), the Winlogon user-picker hide + # (System_Registry), profile deletion (DeleteProfileW lives under UI_Shell), and the FILE_GENERIC_* access + # masks the grant/deny aces are built from (Storage_FileSystem). + "Win32_NetworkManagement_WindowsFilteringPlatform", + "Win32_System_Rpc", + "Win32_NetworkManagement_NetManagement", + "Win32_Security_Cryptography", + "Win32_System_Registry", + "Win32_UI_Shell", + "Win32_Storage_FileSystem", ] [dev-dependencies] diff --git a/crates/nub-sandbox/src/backend/mod.rs b/crates/nub-sandbox/src/backend/mod.rs index 0f5831d1b..25ad1a659 100644 --- a/crates/nub-sandbox/src/backend/mod.rs +++ b/crates/nub-sandbox/src/backend/mod.rs @@ -48,6 +48,11 @@ mod linux_connect_notify; #[cfg(any(target_os = "windows", test))] mod windows; +// The Windows dedicated-account + WFP backend (agent-sandbox). Same cfg as `windows` so its +// OS-agnostic plan derivation and mode-selection are unit-tested on the macOS dev host. +#[cfg(any(target_os = "windows", test))] +pub(crate) mod windows_account; + // The OS-agnostic Landlock grant derivation. Compiled on Linux (its real consumer) // and under `test` on any host — so its security-critical carve logic is unit-tested // on the macOS dev host over tempfile trees, without a kernel. @@ -199,7 +204,30 @@ fn start_proxy_if_needed(policy: &SandboxPolicy) -> Option { } Inspection::Connection => None, }; - EgressProxy::start(decider, mitm).ok() + EgressProxy::start_in_range(decider, mitm, proxy_port_range(policy)).ok() +} + +/// The loopback window the proxy must bind inside, when one applies. +/// +/// Only Windows' dedicated-account backend has one: its WFP permit is installed ONCE, by the +/// elevated setup, over a fixed port window — so the proxy binds into that window rather than +/// a filter chasing an ephemeral port, which would cost a UAC prompt every run. Everywhere +/// else the deny-layer carves the exact port at launch and no range is needed. +#[cfg(target_os = "windows")] +fn proxy_port_range(policy: &SandboxPolicy) -> Option<(u16, u16)> { + if !windows_account::needs_account_backend(policy) { + return None; + } + // An absent or unreadable marker is NOT decided here: `windows_account::apply` fails + // closed with the actionable "run the elevated setup" message, and an ephemeral bind in + // the meantime is harmless because that launch never happens. + let m = windows_account::state::read_marker().ok().flatten()?; + Some((m.port_low, m.port_high)) +} + +#[cfg(not(target_os = "windows"))] +fn proxy_port_range(_policy: &SandboxPolicy) -> Option<(u16, u16)> { + None } /// The CA-trust env keys pointed at the child CA bundle (ephemeral CA + real roots). diff --git a/crates/nub-sandbox/src/backend/windows.rs b/crates/nub-sandbox/src/backend/windows.rs index 3235e4b34..ad67dffb8 100644 --- a/crates/nub-sandbox/src/backend/windows.rs +++ b/crates/nub-sandbox/src/backend/windows.rs @@ -58,7 +58,7 @@ use std::path::{Path, PathBuf}; /// IR→plan derivation is unit-tested on the dev host; [`WindowsLaunch::run`] (the FFI) /// is `#[cfg(windows)]`. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] -pub(crate) struct WindowsLaunch { +pub(crate) struct AppContainerLaunch { program: OsString, args: Vec, cwd: Option, @@ -78,9 +78,30 @@ pub(crate) struct WindowsLaunch { register_loopback_exemption: bool, } +/// Which Windows mechanism owns this launch. Exactly one applies, which is why this is an +/// enum rather than two `Option` fields: build-jail's admin-free AppContainer and +/// agent-sandbox's dedicated account are alternatives, never a combination. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) enum WindowsLaunch { + /// Per-run AppContainer (LowBox) — the pure-allowlist path. No elevation, ever. + AppContainer(AppContainerLaunch), + /// Dedicated local account + WFP — the full-grammar path. Needs a one-time elevated setup. + Account(super::windows_account::AccountLaunch), +} + +#[cfg(target_os = "windows")] +impl WindowsLaunch { + pub(crate) fn run(self) -> std::io::Result { + match self { + WindowsLaunch::AppContainer(l) => l.run(), + WindowsLaunch::Account(l) => l.run(), + } + } +} + /// What the allowlist model could NOT express for a policy, so the caller can be told. #[derive(Debug, Default, PartialEq)] -struct FsDegrade { +pub(super) struct FsDegrade { /// A generous-read base (`default_effect == Allow`, OR a whole-fs `**` Allow entry /// — the shape the compiler actually emits for `"..."`/`sandbox: true`). The /// allowlist can't express read-all-minus-secrets; reads are confined to the @@ -98,7 +119,7 @@ struct FsDegrade { /// and is reported via [`FsDegrade`] (fail-safe: over-confine + name it, never widen). /// (The deny-shadowing check is done by [`deny_shadows_grant`] in `apply`, AFTER the /// program-dir grant is folded into the read set.) -fn derive_grants(fs: &FsPolicy) -> (Vec, Vec, FsDegrade) { +pub(super) fn derive_grants(fs: &FsPolicy) -> (Vec, Vec, FsDegrade) { let mut read = Vec::new(); let mut write = Vec::new(); let mut degrade = FsDegrade { @@ -144,7 +165,7 @@ fn derive_grants(fs: &FsPolicy) -> (Vec, Vec, FsDegrade) { /// shadows it. Matching is case-insensitive (Windows paths are). Run against the /// policy-derived SUBTREE grants only — the caller excludes the program-file grant (a /// single leaf with no subtree, an exec necessity), which cannot host a deny "inside" it. -fn deny_shadows_grant(entries: &[FsRule], read_grants: &[PathBuf]) -> bool { +pub(super) fn deny_shadows_grant(entries: &[FsRule], read_grants: &[PathBuf]) -> bool { if read_grants.is_empty() { return false; } @@ -203,12 +224,12 @@ fn path_prefixes(a: &Path, b: &Path) -> bool { } /// Whether a canonical IR glob contains glob metacharacters. -fn has_glob_meta(glob: &str) -> bool { +pub(super) fn has_glob_meta(glob: &str) -> bool { glob.contains(['*', '?', '[', ']', '{', '}']) } /// Whether a glob addresses the whole filesystem (the generous-read base spellings). -fn is_whole_fs(glob: &str) -> bool { +pub(super) fn is_whole_fs(glob: &str) -> bool { matches!(glob, "**" | "/**" | "/") } @@ -216,7 +237,7 @@ fn is_whole_fs(glob: &str) -> bool { /// as one inheritable ACE. A plain absolute literal, or a literal + trailing `/**` /// subtree twin, yields that directory; anything with embedded globs (or the whole-fs /// spellings) yields `None`. Mirrors the macOS backend's `to_match_term` subpath case. -fn literal_subtree(glob: &str) -> Option { +pub(super) fn literal_subtree(glob: &str) -> Option { if is_whole_fs(glob) { return None; } @@ -238,7 +259,7 @@ fn literal_subtree(glob: &str) -> Option { /// filesystem-wide write hole. The Windows twin of the macOS `is_dangerous_write_root` /// (reads are exempt; a generous read is a legitimate posture, and read is separately /// allowlist-confined here anyway). Matches on the forward-slashed canonical form. -fn is_dangerous_write_root(dir: &Path) -> bool { +pub(super) fn is_dangerous_write_root(dir: &Path) -> bool { let Some(s) = dir.to_str() else { return false }; let s = s.trim_end_matches('/'); // Drive root (`C:`), the Windows dir, and Program Files are the roots a stray `..` @@ -342,6 +363,16 @@ pub(crate) fn apply( let confine_fs = fs_confines(&policy.fs); let sandboxing = confine_fs || policy.net.enforce; + // ── agent-sandbox route (dedicated account + WFP) ──────────────────────────── + // A policy the ALLOWLIST cannot carry — a generous-read base, a deny that must be carved + // inside a grant, or per-host egress — goes to the dedicated-account backend, which + // expresses all three but costs a one-time elevated setup. build-jail's shape (pure + // default-deny allowlist, coarse or absent net) never matches, so `nub install` stays + // admin-free. See `windows_account`'s module doc for why the split falls exactly here. + if sandboxing && super::windows_account::needs_account_backend(policy) { + return super::windows_account::apply(policy, spec, proxy_port, proxy_token, ca_bundle); + } + // ── net posture (strict-Windows tier decision) ────────────────────────────── // Per-host + MITM ride nub's loopback proxy, which an AppContainer child can reach // ONLY through an admin-registered loopback exemption. `is_elevated` is queried lazily @@ -495,7 +526,7 @@ pub(crate) fn apply( // degraded when it isn't. See the module doc.) deg.reason = reason; - let launch = WindowsLaunch { + let launch = AppContainerLaunch { program: spec.program, args: spec.args, cwd: spec.cwd, @@ -516,7 +547,7 @@ pub(crate) fn apply( command: std::process::Command::new(&launch.program), degradation: deg, proxy: None, - launch: Some(launch), + launch: Some(WindowsLaunch::AppContainer(launch)), }) } @@ -598,7 +629,7 @@ fn build_child_env( /// name → PATH search trying the name and common executable extensions. Windows-only /// (its PATHEXT search is Windows semantics; the host build never calls it). #[cfg(target_os = "windows")] -fn resolve_program(program: &std::ffi::OsStr, child_cwd: Option<&Path>) -> Option { +pub(super) fn resolve_program(program: &std::ffi::OsStr, child_cwd: Option<&Path>) -> Option { let p = Path::new(program); if p.is_absolute() { return Some(p.to_path_buf()); @@ -634,8 +665,8 @@ fn resolve_program(program: &std::ffi::OsStr, child_cwd: Option<&Path>) -> Optio // ── the FFI launcher ──────────────────────────────────────────────────────────── #[cfg(target_os = "windows")] -mod launch { - use super::WindowsLaunch; +pub(super) mod launch { + use super::AppContainerLaunch; use std::io; use std::os::windows::ffi::OsStrExt; use std::os::windows::io::AsRawHandle; @@ -841,7 +872,7 @@ mod launch { } } - impl WindowsLaunch { + impl AppContainerLaunch { /// Own the full spawn lifecycle: create a per-run AppContainer profile, grant /// the inheritable allow-ACEs, launch the child under the LowBox token inside a /// kill-on-close Job, wait, then tear everything down (RAII). @@ -1359,7 +1390,7 @@ mod launch { } /// UTF-16, NUL-terminated. - fn to_wide(s: &str) -> Vec { + pub(in crate::backend) fn to_wide(s: &str) -> Vec { s.encode_utf16().chain(std::iter::once(0)).collect() } @@ -1373,7 +1404,7 @@ mod launch { /// Build a mutable UTF-16 command line from program + args, quoting each token per /// the CommandLineToArgvW rules std uses. lpApplicationName is NULL, so the child /// gets a conventional argv. - fn build_command_line(program: &std::ffi::OsStr, args: &[std::ffi::OsString]) -> Vec { + pub(in crate::backend) fn build_command_line(program: &std::ffi::OsStr, args: &[std::ffi::OsString]) -> Vec { let mut line: Vec = Vec::new(); append_quoted(&mut line, program); for a in args { @@ -1424,7 +1455,7 @@ mod launch { /// expects (the source `BTreeMap` is case-sensitive, so a lowercase key like /// `windir` would otherwise sort after all-uppercase keys and violate the /// convention). - fn build_env_block(env: &std::collections::BTreeMap) -> Vec { + pub(in crate::backend) fn build_env_block(env: &std::collections::BTreeMap) -> Vec { let mut pairs: Vec<(&String, &String)> = env.iter().collect(); pairs.sort_by_key(|a| a.0.to_ascii_uppercase()); let mut block: Vec = Vec::new(); diff --git a/crates/nub-sandbox/src/backend/windows_account/account.rs b/crates/nub-sandbox/src/backend/windows_account/account.rs new file mode 100644 index 000000000..2a520391b --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/account.rs @@ -0,0 +1,967 @@ +//! The sandbox account's lifecycle and its credential store. +//! +//! THE PRIVILEGE SPLIT LIVES HERE. [`provision`] and [`deprovision`] are the ELEVATED +//! one-time halves — SAM writes (`NetUserAdd`, `NetLocalGroupAddMembers`) and an `HKLM` value +//! all demand administrator. [`lookup_sid`] and [`load_credential`] are the UNELEVATED +//! per-run halves the broker calls on every launch. Keeping the split visible in this file's +//! signatures is what stops a per-run code path from quietly acquiring an elevation +//! requirement (see [`super`]'s module doc: every run after setup is unelevated). +//! +//! WHY THE CREDENTIAL IS DPAPI **MACHINE** SCOPE. The elevated setup writes the blob and the +//! unelevated broker reads it. Those are the same *user* but DIFFERENT LOGON SESSIONS, and a +//! self-elevated child may not have the user's master key loaded at all — user-scope DPAPI +//! does not round-trip across that split, machine scope does. The honest consequence is that +//! machine scope is **not a security boundary**: any local principal that can READ the +//! ciphertext can decrypt it, including the sandbox account itself. The credential file's +//! DACL is the only gate, and this module never writes a DACL — [`credential_dir`] exists so +//! the setup path can hand the directory to the acl module, which owns every DACL write. +//! +//! Mirrors SRT's `vendor/srt-win-src/src/{user,sam,dpapi}.rs` (read 2026-07-24); Codex's +//! `windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs` is the second reference. + +#![cfg(target_os = "windows")] + +use super::{SANDBOX_ACCOUNT, SANDBOX_GROUP}; +use std::io; +use std::path::PathBuf; +use windows_sys::Win32::Foundation::{ + CloseHandle, ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_INSUFFICIENT_BUFFER, + ERROR_MEMBER_IN_ALIAS, ERROR_NONE_MAPPED, ERROR_SUCCESS, GetLastError, HANDLE, LocalFree, +}; +use windows_sys::Win32::NetworkManagement::NetManagement::{ + LOCALGROUP_INFO_1, LOCALGROUP_MEMBERS_INFO_0, NERR_GroupExists, NERR_GroupNotFound, + NERR_PasswordTooShort, NERR_UserExists, NERR_UserNotFound, NetApiBufferFree, NetLocalGroupAdd, + NetLocalGroupAddMembers, NetLocalGroupDel, NetUserAdd, NetUserDel, NetUserGetInfo, + NetUserModalsGet, NetUserSetInfo, UF_DONT_EXPIRE_PASSWD, UF_SCRIPT, USER_INFO_1, + USER_INFO_1003, USER_INFO_1008, USER_MODALS_INFO_0, USER_PRIV_USER, +}; +use windows_sys::Win32::Security::Authorization::{ConvertSidToStringSidW, ConvertStringSidToSidW}; +use windows_sys::Win32::Security::Cryptography::{ + BCRYPT_USE_SYSTEM_PREFERRED_RNG, BCryptGenRandom, CRYPT_INTEGER_BLOB, + CRYPTPROTECT_LOCAL_MACHINE, CRYPTPROTECT_UI_FORBIDDEN, CryptProtectData, CryptUnprotectData, +}; +use windows_sys::Win32::Security::{ + GetTokenInformation, LookupAccountNameW, LookupAccountSidW, PSID, SID_NAME_USE, + TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, +}; +use windows_sys::Win32::System::Registry::{ + HKEY, HKEY_LOCAL_MACHINE, KEY_SET_VALUE, REG_DWORD, REG_OPTION_NON_VOLATILE, RegCloseKey, + RegCreateKeyExW, RegDeleteValueW, RegOpenKeyExW, RegSetValueExW, +}; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; +use windows_sys::Win32::UI::Shell::DeleteProfileW; + +/// `BUILTIN\Users`. The well-known SID is stable across locales; the *name* is not +/// ("Benutzer" on de-DE, "Utilisateurs" on fr-FR), which is why membership goes through a +/// reverse lookup rather than a literal. +const SID_BUILTIN_USERS: &str = "S-1-5-32-545"; + +const WINLOGON_USERLIST: &str = + r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList"; + +/// Benign-idempotency codes with no `windows-sys` constant. +const ERROR_ALIAS_EXISTS: u32 = 1379; +const ERROR_NO_SUCH_ALIAS: u32 = 1376; + +const MAX_PW_ATTEMPTS: usize = 5; +const PW_LEN: usize = 32; + +// ───────────────────────────── public surface ───────────────────────────── + +/// ELEVATED. Create-or-repair the sandbox account and its group, rotate the credential, and +/// store it. Idempotent — re-running repairs a half-provisioned machine (account present but +/// out of `BUILTIN\Users`, flags cleared by a GPO, credential file deleted). Returns the +/// account's SID, which is the identity every downstream object keys on: WFP's +/// `ALE_USER_ID` descriptor and every granted or denied ACE. +pub(crate) fn provision() -> io::Result { + ensure_group()?; + let password = ensure_user()?; + + let sid = lookup_sid()?.ok_or_else(|| { + io::Error::other(format!( + "the {SANDBOX_ACCOUNT} account was created but does not resolve to a SID" + )) + })?; + let psid = LocalSid::parse(&sid)?; + + // `BUILTIN\Users` is REQUIRED, not cosmetic: it carries the interactive-logon right that + // `CreateProcessWithLogonW` depends on, and without it every launch fails at logon. + let builtin_users = lookup_account_name(SID_BUILTIN_USERS)?; + add_member(&builtin_users, &psid)?; + add_member(SANDBOX_GROUP, &psid)?; + + // Deliberately NOT granting `SeDenyInteractiveLogonRight`: `CreateProcessWithLogonW` goes + // through an INTERACTIVE-type logon, so a deny-interactive right plausibly breaks the + // launch outright. Neither SRT nor Codex sets one. Revisit only against a real Windows + // box that can prove the launch survives it. + set_hidden(true)?; + + store_credential(password.as_str())?; + Ok(sid) +} + +/// ELEVATED. Remove the account, its profile, the group, the Winlogon hide entry, and the +/// credential file. Every step tolerates already-absent state and runs even after an earlier +/// step failed, so a partially-provisioned or crash-interrupted machine still gets cleaned as +/// far as it can; the FIRST failure is what surfaces. +pub(crate) fn deprovision() -> io::Result<()> { + // Resolve BEFORE `NetUserDel` destroys the SAM mapping — `DeleteProfileW` takes the SID + // *string* and there is no route back to it once the account is gone. + let sid = lookup_sid().ok().flatten(); + + let mut failure: Option = None; + let mut record = |r: io::Result<()>| { + if let Err(e) = r { + let _ = failure.get_or_insert(e); + } + }; + + if let Some(sid) = &sid { + let sid_w = to_wide(sid); + // Best-effort by design: Windows only materializes the profile on first logon, and a + // stuck child can hold it open. Neither may block the account delete below. + // SAFETY: NUL-terminated SID string; NULL profile path and NULL computer name select + // the default local profile, which is the documented form. + unsafe { DeleteProfileW(sid_w.as_ptr(), std::ptr::null(), std::ptr::null()) }; + } + + let name_w = to_wide(SANDBOX_ACCOUNT); + // SAFETY: NUL-terminated account name; NULL server means the local SAM. + let rc = unsafe { NetUserDel(std::ptr::null(), name_w.as_ptr()) }; + if rc != 0 && rc != NERR_UserNotFound { + record(Err(net_err("NetUserDel", rc))); + } + + let group_w = to_wide(SANDBOX_GROUP); + // SAFETY: as above. + let rc = unsafe { NetLocalGroupDel(std::ptr::null(), group_w.as_ptr()) }; + if rc != 0 && rc != NERR_GroupNotFound && rc != ERROR_NO_SUCH_ALIAS { + record(Err(net_err("NetLocalGroupDel", rc))); + } + + record(set_hidden(false)); + + match credential_path().and_then(|p| match std::fs::remove_file(&p) { + Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + }) { + Err(e) => record(Err(e)), + Ok(()) => {} + } + + match failure { + Some(e) => Err(e), + None => Ok(()), + } +} + +/// UNELEVATED. The sandbox account's SID, or `None` when it does not exist. An access-denied +/// or transient LSA failure propagates — reporting "absent" for those would make an +/// already-provisioned machine look unprovisioned and trigger a spurious elevation prompt. +pub(crate) fn lookup_sid() -> io::Result> { + lookup_account_sid(SANDBOX_ACCOUNT) +} + +/// UNELEVATED. Decrypt the stored credential. +/// +/// The returned plaintext is the CALLER's to bound — it goes straight into +/// `CreateProcessWithLogonW`, so it is deliberately a plain `String` rather than a scrubbing +/// wrapper that the FFI boundary would defeat anyway. Every intermediate buffer this function +/// owns is zeroed before it returns. +pub(crate) fn load_credential() -> io::Result { + let path = credential_path()?; + let ciphertext = std::fs::read(&path).map_err(|e| { + io::Error::new( + e.kind(), + format!( + "cannot read the sandbox credential at {} ({e}) — the one-time elevated sandbox \ + setup has not run on this machine", + path.display() + ), + ) + })?; + let mut plaintext = dpapi_unprotect(&ciphertext)?; + let out = match std::str::from_utf8(&plaintext) { + Ok(s) => s.to_owned(), + Err(_) => { + scrub_u8(&mut plaintext); + return Err(io::Error::other( + "the stored sandbox credential is corrupt (not valid UTF-8)", + )); + } + }; + scrub_u8(&mut plaintext); + Ok(out) +} + +/// The credential store's directory. Its DACL must DENY [`SANDBOX_GROUP`] — machine-scope +/// DPAPI protects nothing on its own, so that DENY is the whole boundary. It is applied by +/// the setup path via the acl module; this module never writes a DACL. +pub(crate) fn credential_dir() -> io::Result { + let root = std::env::var_os("PROGRAMDATA") + .filter(|v| !v.is_empty()) + .ok_or_else(|| { + io::Error::other( + "PROGRAMDATA is not set, so nub cannot locate the machine-wide sandbox \ + credential store", + ) + })?; + Ok(PathBuf::from(root).join("nub").join("sandbox")) +} + +pub(crate) fn credential_path() -> io::Result { + Ok(credential_dir()?.join("credential.bin")) +} + +/// Whether this process holds an ELEVATED (full-admin) token — the exact condition under +/// which the SAM and `HKLM` writes in [`provision`] succeed. A standard user and an admin's +/// filtered Medium-IL token both report `false`, and both would get `ERROR_ACCESS_DENIED`. +pub(crate) fn is_elevated() -> bool { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: query-only handle into our own process token. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return false; + } + let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 }; + let mut ret_len: u32 = 0; + // SAFETY: `elevation` is a correctly-sized TOKEN_ELEVATION out-buffer. + let ok = unsafe { + GetTokenInformation( + token, + TokenElevation, + std::ptr::from_mut(&mut elevation).cast(), + std::mem::size_of::() as u32, + &mut ret_len, + ) + }; + // SAFETY: `token` came from a successful OpenProcessToken and is closed once. + unsafe { CloseHandle(token) }; + ok != 0 && elevation.TokenIsElevated != 0 +} + +// ───────────────────────────── errors and scratch ───────────────────────────── + +/// Access-denied on any of these operations means "you are not elevated", not a fault — the +/// caller turns `PermissionDenied` into an actionable re-run-elevated message rather than +/// surfacing a bare numeric code. +fn net_err(op: &str, rc: u32) -> io::Error { + if rc == ERROR_ACCESS_DENIED { + return io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "{op} failed: administrator rights are required to manage the \ + {SANDBOX_ACCOUNT} account" + ), + ); + } + io::Error::other(format!("{op} failed (status {rc})")) +} + +fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +fn scrub_u8(buf: &mut [u8]) { + for b in buf { + // SAFETY: `b` is a live unique reference; volatile so the store survives a compiler + // that would otherwise treat writing to a dying buffer as dead code. + unsafe { std::ptr::write_volatile(b, 0) }; + } +} + +fn scrub_u16(buf: &mut [u16]) { + for b in buf { + // SAFETY: as above. + unsafe { std::ptr::write_volatile(b, 0) }; + } +} + +/// A plaintext credential zeroed on drop. NOT a defence against a process-memory attacker +/// (the value was copied at least once on the way in) — it bounds how long the password sits +/// readable in nub's heap, which is the part this module controls. +struct Secret(String); + +impl Secret { + fn as_str(&self) -> &str { + &self.0 + } +} + +impl Drop for Secret { + fn drop(&mut self) { + // SAFETY: zeroing every byte keeps the buffer valid UTF-8 (NUL is ASCII), so the + // `String` invariant holds. + scrub_u8(unsafe { self.0.as_bytes_mut() }); + } +} + +/// The UTF-16 form the `USER_INFO_*` structs point at, zeroed on drop for the same reason. +struct WideSecret(Vec); + +impl Drop for WideSecret { + fn drop(&mut self) { + scrub_u16(&mut self.0); + } +} + +/// A `PSID` minted by `ConvertStringSidToSidW`, released with `LocalFree` — never `FreeSid`, +/// which is only valid for SIDs built by `AllocateAndInitializeSid`. +struct LocalSid(PSID); + +impl LocalSid { + fn parse(sid: &str) -> io::Result { + let w = to_wide(sid); + let mut psid: PSID = std::ptr::null_mut(); + // SAFETY: `w` is NUL-terminated UTF-16; `psid` is a valid out-slot. + let ok = unsafe { ConvertStringSidToSidW(w.as_ptr(), &mut psid) }; + if ok == 0 { + return Err(io::Error::other(format!( + "ConvertStringSidToSidW({sid}) failed (status {})", + // SAFETY: read immediately after the failed call on this thread. + unsafe { GetLastError() } + ))); + } + Ok(LocalSid(psid)) + } +} + +impl Drop for LocalSid { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: `ConvertStringSidToSidW` documents `LocalFree` as the release. + unsafe { LocalFree(self.0) }; + } + } +} + +// ───────────────────────────── SID lookups ───────────────────────────── + +/// Two-call `LookupAccountNameW`. `ERROR_NONE_MAPPED` is the "no such account" answer and +/// becomes `Ok(None)`; everything else is a real failure. +fn lookup_account_sid(name: &str) -> io::Result> { + let name_w = to_wide(name); + let mut cb_sid: u32 = 0; + let mut cch_dom: u32 = 0; + let mut sid_use: SID_NAME_USE = 0; + + // SAFETY: the documented sizing form — NULL buffers with zeroed lengths. + let ok = unsafe { + LookupAccountNameW( + std::ptr::null(), + name_w.as_ptr(), + std::ptr::null_mut(), + &mut cb_sid, + std::ptr::null_mut(), + &mut cch_dom, + &mut sid_use, + ) + }; + if ok == 0 { + // SAFETY: read immediately after the failed call on this thread. + let e = unsafe { GetLastError() }; + if e == ERROR_NONE_MAPPED { + return Ok(None); + } + if e != ERROR_INSUFFICIENT_BUFFER { + return Err(net_err(&format!("LookupAccountNameW({name})"), e)); + } + } + if cb_sid == 0 { + return Ok(None); + } + + // `u32` backing, not `u8`: a SID's `SubAuthority` array is `DWORD`-typed, so the buffer + // must be DWORD-aligned — which `Vec` does not promise. + let mut sid = vec![0u32; cb_sid.div_ceil(4) as usize]; + let mut dom = vec![0u16; cch_dom.max(1) as usize]; + // SAFETY: both buffers are sized by the call above and outlive this one. + let ok = unsafe { + LookupAccountNameW( + std::ptr::null(), + name_w.as_ptr(), + sid.as_mut_ptr().cast(), + &mut cb_sid, + dom.as_mut_ptr(), + &mut cch_dom, + &mut sid_use, + ) + }; + if ok == 0 { + // SAFETY: as above. + let e = unsafe { GetLastError() }; + if e == ERROR_NONE_MAPPED { + return Ok(None); + } + return Err(net_err(&format!("LookupAccountNameW({name})"), e)); + } + // SAFETY: the buffer now holds a valid self-relative SID. + Ok(Some(unsafe { sid_to_string(sid.as_mut_ptr().cast()) }?)) +} + +/// Reverse lookup, used to resolve a well-known SID to its LOCALIZED account name so the +/// name-taking SAM calls work on non-English Windows. +fn lookup_account_name(sid_str: &str) -> io::Result { + let sid = LocalSid::parse(sid_str)?; + let mut cch_name: u32 = 0; + let mut cch_dom: u32 = 0; + let mut sid_use: SID_NAME_USE = 0; + + // SAFETY: sizing call against a live PSID; both out-lengths are valid slots. + unsafe { + LookupAccountSidW( + std::ptr::null(), + sid.0, + std::ptr::null_mut(), + &mut cch_name, + std::ptr::null_mut(), + &mut cch_dom, + &mut sid_use, + ) + }; + if cch_name == 0 { + return Err(io::Error::other(format!( + "LookupAccountSidW({sid_str}) sizing returned 0" + ))); + } + let mut name = vec![0u16; cch_name as usize]; + let mut dom = vec![0u16; cch_dom.max(1) as usize]; + // SAFETY: both buffers are sized by the call above and outlive this one. + let ok = unsafe { + LookupAccountSidW( + std::ptr::null(), + sid.0, + name.as_mut_ptr(), + &mut cch_name, + dom.as_mut_ptr(), + &mut cch_dom, + &mut sid_use, + ) + }; + if ok == 0 { + // SAFETY: read immediately after the failed call on this thread. + return Err(net_err(&format!("LookupAccountSidW({sid_str})"), unsafe { + GetLastError() + })); + } + Ok(String::from_utf16_lossy(&name[..cch_name as usize])) +} + +/// # Safety +/// `sid` must point at a valid self-relative SID for the duration of the call. +unsafe fn sid_to_string(sid: PSID) -> io::Result { + let mut out: *mut u16 = std::ptr::null_mut(); + // SAFETY: caller guarantees `sid`; `out` is a valid slot. + let ok = unsafe { ConvertSidToStringSidW(sid, &mut out) }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + let mut len = 0usize; + // SAFETY: on success the buffer is NUL-terminated UTF-16 allocated by LocalAlloc. + while unsafe { *out.add(len) } != 0 { + len += 1; + } + // SAFETY: `len` units precede the terminator. + let s = String::from_utf16_lossy(unsafe { std::slice::from_raw_parts(out, len) }); + // SAFETY: `ConvertSidToStringSidW` documents `LocalFree` as the release. + unsafe { LocalFree(out.cast()) }; + Ok(s) +} + +// ───────────────────────────── account + group ───────────────────────────── + +/// The 85-symbol alphabet. It EXCLUDES `"`, `\`, backtick, whitespace and the shell-special +/// `& | < > ^` set, so the credential survives any cmd / PowerShell / argv relay between here +/// and `CreateProcessWithLogonW`. 32 chars ≈ 205 bits; Windows caps a local account at 127. +const ALPHA: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ + abcdefghijklmnopqrstuvwxyz\ + 0123456789!#$%()*+,-./:;=?@[]_{}~"; + +const CLASSES: [&[u8]; 4] = [ + b"ABCDEFGHIJKLMNOPQRSTUVWXYZ", + b"abcdefghijklmnopqrstuvwxyz", + b"0123456789", + b"!#$%()*+,-./:;=?@[]_{}~", +]; + +fn fill_random(buf: &mut [u8]) -> io::Result<()> { + // SAFETY: `buf` is a live writable slice of exactly `len` bytes; a NULL algorithm handle + // is what BCRYPT_USE_SYSTEM_PREFERRED_RNG requires. + let st = unsafe { + BCryptGenRandom( + std::ptr::null_mut(), + buf.as_mut_ptr(), + buf.len() as u32, + BCRYPT_USE_SYSTEM_PREFERRED_RNG, + ) + }; + if st != 0 { + return Err(io::Error::other(format!( + "BCryptGenRandom failed (NTSTATUS {st:#010x})" + ))); + } + Ok(()) +} + +/// 32 chars rejection-sampled from [`ALPHA`] via the system CSPRNG. Rejection sampling is +/// what makes each pick UNIFORM — 85 does not divide 256, so a bare `% 85` would bias the +/// first 86 symbols. +fn gen_password() -> io::Result { + let mut raw = [0u8; PW_LEN]; + fill_random(&mut raw)?; + let bound = (u8::MAX - (u8::MAX % ALPHA.len() as u8)) as usize; + let mut out = Vec::with_capacity(PW_LEN); + let mut i = 0usize; + while out.len() < PW_LEN { + if i == raw.len() { + fill_random(&mut raw)?; + i = 0; + } + let b = raw[i] as usize; + i += 1; + if b < bound { + out.push(ALPHA[b % ALPHA.len()]); + } + } + scrub_u8(&mut raw); + + let mut extra = [0u8; 5]; + fill_random(&mut extra)?; + apply_class_floor(&mut out, &extra); + scrub_u8(&mut extra); + Ok(Secret(String::from_utf8(out).expect("ALPHA is ASCII"))) +} + +/// Force one character from each complexity class when the uniform draw missed one. The +/// 2245 retry loop in [`ensure_user`] is the primary defence against a tightened local +/// policy; this makes the FIRST attempt pass almost always. +fn apply_class_floor(out: &mut [u8], extra: &[u8; 5]) { + if CLASSES.iter().all(|c| out.iter().any(|b| c.contains(b))) { + return; + } + let base = extra[0] as usize; + for (k, class) in CLASSES.iter().enumerate() { + let slot = (base + k) % out.len(); + out[slot] = class[extra[1 + k] as usize % class.len()]; + } +} + +/// The rejected draw's CLASS COMPOSITION — never the password itself. This is what makes a +/// 2245 on a user's machine diagnosable from logs alone. +fn class_summary(password: &str) -> String { + let (mut u, mut l, mut d, mut s) = (0u32, 0u32, 0u32, 0u32); + for b in password.bytes() { + match b { + b'A'..=b'Z' => u += 1, + b'a'..=b'z' => l += 1, + b'0'..=b'9' => d += 1, + _ => s += 1, + } + } + format!("len={} U={u} L={l} D={d} S={s}", password.len()) +} + +/// `(min_password_len, password_history_len)` from the local SAM, or `(-1, -1)` when the +/// query itself fails. +fn local_password_policy() -> (i64, i64) { + let mut buf: *mut u8 = std::ptr::null_mut(); + // SAFETY: NULL server = local SAM; `buf` is a valid out-slot. + if unsafe { NetUserModalsGet(std::ptr::null(), 0, &mut buf) } != 0 || buf.is_null() { + return (-1, -1); + } + // SAFETY: on success netapi returns one USER_MODALS_INFO_0 in its own buffer. + let m = unsafe { *buf.cast::() }; + // SAFETY: the buffer came from NetUserModalsGet and is freed exactly once. + unsafe { NetApiBufferFree(buf.cast()) }; + ( + i64::from(m.usrmod0_min_passwd_len), + i64::from(m.usrmod0_password_hist_len), + ) +} + +fn warn_password_rejected(op: &str, attempt: usize, password: &str) { + let (min_len, hist) = local_password_policy(); + tracing::warn!( + "nub sandbox: {op} rejected a generated password (NERR_PasswordTooShort, attempt {}/{MAX_PW_ATTEMPTS}); \ + local policy min_len={min_len} history={hist}; rejected draw {}; retrying", + attempt + 1, + class_summary(password) + ); +} + +/// Create the account, or rotate the credential of the one already present. +/// +/// Retries on `NERR_PasswordTooShort` (2245) because SAM returns that code for ANY local +/// password-policy rejection — length, history, or a third-party password-filter DLL — not +/// literally "too short". A policy tightened since the last provision will bounce an +/// otherwise-valid 32-char draw, and a fresh draw usually clears it. +fn ensure_user() -> io::Result { + let mut name_w = to_wide(SANDBOX_ACCOUNT); + let mut comment_w = to_wide("nub: dedicated account for OS-enforced sandboxing"); + + for attempt in 0..MAX_PW_ATTEMPTS { + let password = gen_password()?; + let mut pw_w = WideSecret(to_wide(password.as_str())); + + let info = USER_INFO_1 { + usri1_name: name_w.as_mut_ptr(), + usri1_password: pw_w.0.as_mut_ptr(), + usri1_password_age: 0, + usri1_priv: USER_PRIV_USER, + usri1_home_dir: std::ptr::null_mut(), + usri1_comment: comment_w.as_mut_ptr(), + // UF_SCRIPT is MANDATORY on workstation SKUs — a vestigial LAN-Manager flag SAM + // still insists on. Omit it and NetUserAdd fails with NERR_BadUsername / + // ERROR_INVALID_PARAMETER, neither of which hints at the real cause. + usri1_flags: UF_SCRIPT | UF_DONT_EXPIRE_PASSWD, + usri1_script_path: std::ptr::null_mut(), + }; + // SAFETY: every PWSTR field points into a buffer that outlives this call. + let rc = unsafe { + NetUserAdd( + std::ptr::null(), + 1, + std::ptr::from_ref(&info).cast(), + std::ptr::null_mut(), + ) + }; + if rc == 0 { + return Ok(password); + } + if rc == NERR_PasswordTooShort && attempt + 1 < MAX_PW_ATTEMPTS { + warn_password_rejected("NetUserAdd", attempt, password.as_str()); + continue; + } + if rc != NERR_UserExists { + return Err(net_err("NetUserAdd", rc)); + } + + // Already present — rotate, so the credential file about to be rewritten matches the + // live account. Levels 1003 (password) and 1008 (flags) rather than another level-1 + // SetInfo, which would clobber priv / home_dir / comment. + let info = USER_INFO_1003 { + usri1003_password: pw_w.0.as_mut_ptr(), + }; + // SAFETY: `name_w` and the password buffer outlive this call. + let rc = unsafe { + NetUserSetInfo( + std::ptr::null(), + name_w.as_ptr(), + 1003, + std::ptr::from_ref(&info).cast(), + std::ptr::null_mut(), + ) + }; + if rc == NERR_PasswordTooShort && attempt + 1 < MAX_PW_ATTEMPTS { + warn_password_rejected("NetUserSetInfo(1003)", attempt, password.as_str()); + continue; + } + if rc != 0 { + return Err(net_err("NetUserSetInfo(1003, password rotate)", rc)); + } + reassert_flags(&name_w)?; + return Ok(password); + } + Err(io::Error::other(format!( + "the local password policy rejected {MAX_PW_ATTEMPTS} generated passwords \ + (NERR_PasswordTooShort)" + ))) +} + +/// Re-OR `UF_DONT_EXPIRE_PASSWD` into an existing account's flags. An older nub or a GPO may +/// have cleared it since the last provision, and an expiring password silently breaks every +/// future launch with an opaque logon failure. +fn reassert_flags(name_w: &[u16]) -> io::Result<()> { + let mut buf: *mut u8 = std::ptr::null_mut(); + // SAFETY: NUL-terminated name; `buf` is a valid out-slot. + let rc = unsafe { NetUserGetInfo(std::ptr::null(), name_w.as_ptr(), 1, &mut buf) }; + if rc != 0 || buf.is_null() { + return Err(net_err("NetUserGetInfo(1)", rc)); + } + // SAFETY: on success netapi returns one USER_INFO_1 in its own buffer. + let flags = unsafe { (*buf.cast::()).usri1_flags }; + // SAFETY: the buffer came from NetUserGetInfo and is freed exactly once. + unsafe { NetApiBufferFree(buf.cast()) }; + + let info = USER_INFO_1008 { + usri1008_flags: flags | UF_DONT_EXPIRE_PASSWD, + }; + // SAFETY: `info` and `name_w` outlive the call. + let rc = unsafe { + NetUserSetInfo( + std::ptr::null(), + name_w.as_ptr(), + 1008, + std::ptr::from_ref(&info).cast(), + std::ptr::null_mut(), + ) + }; + if rc != 0 { + return Err(net_err("NetUserSetInfo(1008, flags)", rc)); + } + Ok(()) +} + +fn ensure_group() -> io::Result<()> { + let mut name_w = to_wide(SANDBOX_GROUP); + let mut comment_w = to_wide("nub: holds the sandbox account; DENY trustee for nub state"); + let info = LOCALGROUP_INFO_1 { + lgrpi1_name: name_w.as_mut_ptr(), + lgrpi1_comment: comment_w.as_mut_ptr(), + }; + // SAFETY: both PWSTR fields point into buffers that outlive this call. + let rc = unsafe { + NetLocalGroupAdd( + std::ptr::null(), + 1, + std::ptr::from_ref(&info).cast(), + std::ptr::null_mut(), + ) + }; + if rc != 0 && rc != NERR_GroupExists && rc != ERROR_ALIAS_EXISTS { + return Err(net_err("NetLocalGroupAdd", rc)); + } + Ok(()) +} + +/// Add `member` to the local group NAMED `group`, by PSID at level 0. Level 3 takes a name +/// and wants the literal `\` form — it rejects `.\name` with +/// `ERROR_NO_SUCH_MEMBER`, so the SID form is the only portable one. +fn add_member(group: &str, member: &LocalSid) -> io::Result<()> { + let group_w = to_wide(group); + let info = LOCALGROUP_MEMBERS_INFO_0 { + lgrmi0_sid: member.0, + }; + // SAFETY: `group_w` and the member's PSID both outlive this call. + let rc = unsafe { + NetLocalGroupAddMembers( + std::ptr::null(), + group_w.as_ptr(), + 0, + std::ptr::from_ref(&info).cast(), + 1, + ) + }; + if rc != 0 && rc != ERROR_MEMBER_IN_ALIAS { + return Err(net_err(&format!("NetLocalGroupAddMembers({group})"), rc)); + } + Ok(()) +} + +/// Add or remove the Winlogon `SpecialAccounts\UserList` entry. COSMETIC ONLY — it keeps the +/// account off the sign-in picker and changes nothing about its rights; the account stays +/// fully usable through `CreateProcessWithLogonW`. +fn set_hidden(hide: bool) -> io::Result<()> { + let sub_w = to_wide(WINLOGON_USERLIST); + let val_w = to_wide(SANDBOX_ACCOUNT); + let mut key: HKEY = std::ptr::null_mut(); + + if hide { + // SAFETY: NUL-terminated subkey; `key` is a valid out-slot. Create, because the + // SpecialAccounts subtree does not exist on a stock install. + let rc = unsafe { + RegCreateKeyExW( + HKEY_LOCAL_MACHINE, + sub_w.as_ptr(), + 0, + std::ptr::null(), + REG_OPTION_NON_VOLATILE, + KEY_SET_VALUE, + std::ptr::null(), + &mut key, + std::ptr::null_mut(), + ) + }; + if rc != ERROR_SUCCESS { + return Err(net_err("RegCreateKeyExW(Winlogon SpecialAccounts)", rc)); + } + let data = 0u32.to_ne_bytes(); + // SAFETY: `key` is open for KEY_SET_VALUE; `data` is exactly 4 bytes as REG_DWORD + // requires. + let rc = unsafe { + RegSetValueExW( + key, + val_w.as_ptr(), + 0, + REG_DWORD, + data.as_ptr(), + data.len() as u32, + ) + }; + // SAFETY: `key` came from a successful create and is closed once. + unsafe { RegCloseKey(key) }; + if rc != ERROR_SUCCESS { + return Err(net_err("RegSetValueExW(SpecialAccounts UserList)", rc)); + } + } else { + // Open, not create: if the key was never written there is nothing to remove. + // SAFETY: NUL-terminated subkey; `key` is a valid out-slot. + let rc = unsafe { + RegOpenKeyExW( + HKEY_LOCAL_MACHINE, + sub_w.as_ptr(), + 0, + KEY_SET_VALUE, + &mut key, + ) + }; + if rc != ERROR_SUCCESS { + return Ok(()); + } + // SAFETY: `key` is open for KEY_SET_VALUE. + let rc = unsafe { RegDeleteValueW(key, val_w.as_ptr()) }; + // SAFETY: `key` came from a successful open and is closed once. + unsafe { RegCloseKey(key) }; + if rc != ERROR_SUCCESS && rc != ERROR_FILE_NOT_FOUND { + return Err(net_err("RegDeleteValueW(SpecialAccounts UserList)", rc)); + } + } + Ok(()) +} + +// ───────────────────────────── credential store ───────────────────────────── + +fn store_credential(password: &str) -> io::Result<()> { + let dir = credential_dir()?; + std::fs::create_dir_all(&dir)?; + let ciphertext = dpapi_protect(password.as_bytes())?; + std::fs::write(credential_path()?, &ciphertext) +} + +fn dpapi_protect(plaintext: &[u8]) -> io::Result> { + let input = CRYPT_INTEGER_BLOB { + cbData: plaintext.len() as u32, + pbData: plaintext.as_ptr().cast_mut(), + }; + let mut out = CRYPT_INTEGER_BLOB::default(); + // SAFETY: `input` borrows a live slice for the call; `out` is a valid out-slot. All + // optional parameters are NULL, which the API documents as "unused". + let ok = unsafe { + CryptProtectData( + &input, + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_LOCAL_MACHINE | CRYPTPROTECT_UI_FORBIDDEN, + &mut out, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: on success `out` describes a LocalAlloc'd buffer this call now owns. + Ok(unsafe { take_blob(out) }) +} + +fn dpapi_unprotect(ciphertext: &[u8]) -> io::Result> { + let input = CRYPT_INTEGER_BLOB { + cbData: ciphertext.len() as u32, + pbData: ciphertext.as_ptr().cast_mut(), + }; + let mut out = CRYPT_INTEGER_BLOB::default(); + // SAFETY: as above. The scope flag is read back out of the blob header, so machine-scope + // ciphertext needs no flag here. + let ok = unsafe { + CryptUnprotectData( + &input, + std::ptr::null_mut(), + std::ptr::null(), + std::ptr::null(), + std::ptr::null(), + CRYPTPROTECT_UI_FORBIDDEN, + &mut out, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + // SAFETY: as above. + Ok(unsafe { take_blob(out) }) +} + +/// # Safety +/// `out` must be a blob DPAPI filled in on a successful call; ownership transfers here. +unsafe fn take_blob(out: CRYPT_INTEGER_BLOB) -> Vec { + if out.pbData.is_null() { + return Vec::new(); + } + // SAFETY: DPAPI guarantees `cbData` readable bytes at `pbData`. + let v = unsafe { std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec() }; + // SAFETY: freed exactly once; `LocalFree` is the documented release. Freed even when + // `cbData` is 0, which the API can return for an empty plaintext. + unsafe { LocalFree(out.pbData.cast()) }; + v +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The alphabet is the whole reason the credential survives a cmd / PowerShell / argv + /// relay into `CreateProcessWithLogonW`. One stray metacharacter here turns into an + /// intermittent, machine-specific logon failure. + #[test] + fn alphabet_excludes_every_shell_hostile_character() { + assert_eq!(ALPHA.len(), 85, "the rejection bound assumes this size"); + for c in [b'"', b'\\', b'`', b'\'', b' ', b'&', b'|', b'<', b'>', b'^'] { + assert!(!ALPHA.contains(&c), "ALPHA contains {}", c as char); + } + assert!(ALPHA.iter().all(|b| b.is_ascii_graphic())); + // Every class must be drawable from ALPHA, or the floor below writes a character the + // uniform draw could never produce. + for class in CLASSES { + assert!(class.iter().all(|b| ALPHA.contains(b))); + } + } + + /// A tightened local complexity policy rejects a draw that happens to miss a class, and + /// the retry loop then burns attempts on identically-shaped draws. The floor is what + /// makes the first attempt pass. + #[test] + fn class_floor_repairs_a_draw_missing_every_class() { + let mut out = [b'a'; PW_LEN]; + apply_class_floor(&mut out, &[7, 0, 0, 0, 0]); + assert!(out.iter().any(|b| b.is_ascii_uppercase())); + assert!(out.iter().any(|b| b.is_ascii_lowercase())); + assert!(out.iter().any(|b| b.is_ascii_digit())); + assert!(out.iter().any(|b| !b.is_ascii_alphanumeric())); + } + + /// A complete draw must be left ALONE — rewriting four slots with a deterministic pick + /// derived from five bytes would shed entropy on every generated password. + #[test] + fn class_floor_leaves_a_complete_draw_untouched() { + let mut out = *b"Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!"; + let before = out; + apply_class_floor(&mut out, &[3, 9, 9, 9, 9]); + assert_eq!(out, before); + } + + /// The 2245 diagnostic is logged on a user's machine; leaking the rejected password into + /// it would be a credential disclosure in plain logs. + #[test] + fn class_summary_reports_composition_without_the_password() { + let pw = "Aa1!Aa1!"; + let s = class_summary(pw); + assert_eq!(s, "len=8 U=2 L=2 D=2 S=2"); + assert!(!s.contains(pw)); + assert!(!s.contains("Aa1")); + } + + /// End-to-end shape of the generator against the real system CSPRNG. Needs Windows but + /// no elevation, so it runs on the ordinary CI leg. + #[test] + fn generated_password_is_ascii_complex_and_unique() { + let p = gen_password().expect("gen_password"); + assert_eq!(p.as_str().len(), PW_LEN); + assert!(p.as_str().bytes().all(|b| ALPHA.contains(&b))); + for class in CLASSES { + assert!( + p.as_str().bytes().any(|b| class.contains(&b)), + "missing a complexity class: {}", + class_summary(p.as_str()) + ); + } + assert_ne!(p.as_str(), gen_password().unwrap().as_str()); + } +} diff --git a/crates/nub-sandbox/src/backend/windows_account/acl.rs b/crates/nub-sandbox/src/backend/windows_account/acl.rs new file mode 100644 index 000000000..568c87371 --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/acl.rs @@ -0,0 +1,674 @@ +//! Filesystem confinement for the dedicated sandbox account: explicit ACEs keyed on its SID. +//! +//! THE MODEL IS PURELY ADDITIVE. The sandbox account is a DIFFERENT local principal, so every +//! path it was never granted is already unreachable — the invoking user's profile needs no ACE +//! authored at all. This module therefore only ever ADDS ACEs for the sandbox SID and later +//! removes exactly those. It never rewrites, protects, or snapshots a user path's descriptor, +//! which is why there is no crash journal here and nothing to restore after a hard kill beyond +//! [`strip`]. (The abandoned deny-strip design — `SE_DACL_PROTECTED` plus a DACL restore +//! journal — is why that distinction is worth stating; see [`super`].) +//! +//! CANONICAL DACL ORDER IS THE WHOLE MECHANISM. Windows resolves an access check first-match +//! over the DACL and orders ACEs explicit-DENY → explicit-ALLOW → explicit-other → inherited +//! (any type). Because *explicit* always precedes *inherited*, a DENY written directly onto +//! `/.env` outranks the ALLOW that ``'s `(OI)(CI)` grant PROPAGATED onto it +//! as an inherited ACE. That is exactly the deny-inside-allow the AppContainer backend cannot +//! express. The hazard: a non-canonical order is ACCEPTED by Windows and still resolves +//! first-match, so a misplaced DENY silently resolves as ALLOW — a security failure with no +//! error anywhere. `SetEntriesInAclW` canonicalizes on insert and [`strip`] canonicalizes by +//! hand, and `deny_inside_a_grant_lands_before_the_inherited_allow` pins the result rather +//! than trusting either. +//! +//! INHERITANCE IS DELIBERATELY LEFT UNPROTECTED. Every write passes +//! `DACL_SECURITY_INFORMATION` alone, never `PROTECTED_DACL_SECURITY_INFORMATION`: the +//! invoking user's own inherited access must survive so they can still read what the sandbox +//! child creates inside a granted tree. + +#![cfg(target_os = "windows")] + +use std::io; +use std::path::{Path, PathBuf}; +use windows_sys::Win32::Foundation::{ + ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, LocalFree, +}; +use windows_sys::Win32::Security::Authorization::{ + ACCESS_MODE, ConvertStringSidToSidW, DENY_ACCESS, EXPLICIT_ACCESS_W, GRANT_ACCESS, + GetNamedSecurityInfoW, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SetEntriesInAclW, + SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, TRUSTEE_W, +}; +use windows_sys::Win32::Security::{ + ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, AclSizeInformation, AddAce, + CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetAclInformation, + InitializeAcl, OBJECT_INHERIT_ACE, PSECURITY_DESCRIPTOR, PSID, +}; + +// Win32 file access rights, spelled numerically so this module needs no +// `Win32_Storage_FileSystem` feature for nine frozen constants — the same call +// `backend::windows` already makes. Every value confirmed against windows-sys 0.61.2 +// `src/Windows/Win32/Storage/FileSystem/mod.rs` at the cited line. +const FILE_GENERIC_READ: u32 = 0x0012_0089; // :1535 (1179785) +const FILE_GENERIC_WRITE: u32 = 0x0012_0116; // :1536 (1179926) +const FILE_GENERIC_EXECUTE: u32 = 0x0012_00A0; // :1534 (1179808) +const FILE_ALL_ACCESS: u32 = 0x001F_01FF; // :1396 (2032127) +const FILE_DELETE_CHILD: u32 = 0x0000_0040; // :1459 (64) +/// The same bit as `FILE_EXECUTE` (:1488) — the kernel reads it as traverse on a directory +/// and as execute on a file. There is no primitive that separates them. +const FILE_TRAVERSE: u32 = 0x0000_0020; // :1859 (32) +const DELETE: u32 = 0x0001_0000; // :1119 (65536) +const WRITE_DAC: u32 = 0x0004_0000; // :4363 (262144) +const WRITE_OWNER: u32 = 0x0008_0000; // :4364 (524288) + +/// Read + write + execute + delete. What a policy's write grant stamps. +const RW: u32 = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE; + +/// Read + execute. What a policy's read grant stamps. +const RO: u32 = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; + +// The EXCLUSIONS are the security property, so they are asserted at compile time rather than +// left to a reader to re-derive from two hex literals. +const _: () = { + // `FILE_DELETE_CHILD` on a granted PARENT is checked INSTEAD OF `DELETE` on the child, so + // including it would let the account delete a file carrying a full deny ACE. That single + // bit voids every deny-inside-allow rule the policy can express. + assert!(RW & FILE_DELETE_CHILD == 0); + // Either bit lets the confined account rewrite its own confinement. + assert!(RW & (WRITE_DAC | WRITE_OWNER) == 0); + assert!(RO & (WRITE_DAC | WRITE_OWNER) == 0); + // The ladder must nest, or a read grant could reach something a write grant cannot. + assert!(RW & RO == RO); + // Without traverse, `SetCurrentDirectoryW` into a granted directory fails + // ERROR_ACCESS_DENIED — which is why `FILE_GENERIC_EXECUTE` is in BOTH masks. + assert!(RO & FILE_TRAVERSE != 0); +}; + +const ACCESS_ALLOWED_ACE_TYPE: u8 = 0x00; +const ACCESS_DENIED_ACE_TYPE: u8 = 0x01; +const INHERITED_ACE_FLAG: u8 = 0x10; + +/// What a grant hands the sandbox account on a subtree. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum Access { + Read, + ReadWrite, +} + +impl Access { + fn mask(self) -> u32 { + match self { + Access::Read => RO, + Access::ReadWrite => RW, + } + } +} + +/// Add an inheritable ALLOW ace for `sid` on `path`. +/// +/// Inheritance is applied only on a directory: `(OI)(CI)` on a leaf file is meaningless and +/// Windows would reject or silently strip it. +pub(crate) fn grant(path: &Path, sid: &str, access: Access) -> io::Result<()> { + let (target, is_dir) = resolve(path)?; + let sid = OwnedSid::parse(sid)?; + add_ace(&target, &sid, access.mask(), GRANT_ACCESS, is_dir) +} + +/// Add an inheritable DENY ace for `sid` on `path`, plus the parent-side carve that stops the +/// account deleting or renaming `path` through its parent directory. +pub(crate) fn deny(path: &Path, sid: &str) -> io::Result<()> { + let (target, is_dir) = resolve(path)?; + let sid = OwnedSid::parse(sid)?; + add_ace(&target, &sid, FILE_ALL_ACCESS, DENY_ACCESS, is_dir)?; + + // The counterpart to excluding `FILE_DELETE_CHILD` from the grant masks: that exclusion + // only covers rights WE stamp, while the account may hold the bit on the parent through + // an inherited `BUILTIN\Users` ACE it picks up as a local user. Denying it explicitly on + // the parent closes `del`/`ren` of the denied target. NOT inheritable — the check that + // matters is against the parent directory object itself, so propagating it down would + // confine sibling subtrees for no gain. (SRT applies the same carve with `(OI)(CI)`.) + let Some(parent) = target.parent() else { + // A volume root has nothing above it to delete through. + return Ok(()); + }; + add_ace(parent, &sid, FILE_DELETE_CHILD, DENY_ACCESS, false) +} + +/// Deny `sid` all access to `dir` and everything under it — used to lock the credential store +/// against the sandbox account. Same mechanism as [`deny`], named for its call site. +pub(crate) fn lock_out(dir: &Path, sid: &str) -> io::Result<()> { + deny(dir, sid) +} + +/// Remove EVERY explicit ace whose trustee is `sid`, preserving all other explicit aces +/// verbatim and leaving inherited aces alone. Idempotent; safe on a path with no such ace. +/// +/// This deliberately does NOT use `SetEntriesInAclW(REVOKE_ACCESS)`: on Windows 11 25H2 that +/// fails to remove explicit `ACCESS_DENIED` aces (field-observed, and regression-tested as +/// MXC's `deny_round_trip_leaves_no_residue`). The documented behavior and the observed +/// behavior disagree, so the DACL is rebuilt by hand instead. +pub(crate) fn strip(path: &Path, sid: &str) -> io::Result<()> { + let (target, _) = resolve(path)?; + let sid = OwnedSid::parse(sid)?; + + let dacl = ReadDacl::open(&target)?; + let Some(rebuilt) = rebuild_without_sid(&target, dacl.acl, &sid)? else { + // Nothing matched. Returning early is not just an optimization: writing a rebuilt + // DACL onto a path whose DACL is NULL would replace "no DACL, everyone allowed" with + // an EMPTY DACL, which denies everyone — a catastrophic silent lockout on a path we + // were only asked to clean up. + return Ok(()); + }; + + let wpath = wide(&target); + // SAFETY: `rebuilt` is a live, DWORD-aligned, InitializeAcl'd buffer that outlives the + // call; `wpath` is NUL-terminated UTF-16 and likewise outlives it. DACL only — + // inheritance stays unprotected so the invoking user keeps their inherited access. + let rc = unsafe { + SetNamedSecurityInfoW( + wpath.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + rebuilt.as_ptr().cast::(), + std::ptr::null(), + ) + }; + if rc != 0 { + return Err(win32_err("SetNamedSecurityInfoW", &target, rc)); + } + Ok(()) +} + +/// One `SetEntriesInAclW` read-modify-write. `SetEntriesInAclW` merges the new entry into the +/// path's existing DACL and canonicalizes on insert, so this is additive: no pre-existing ace, +/// explicit or inherited, is disturbed. +fn add_ace( + path: &Path, + sid: &OwnedSid, + mask: u32, + mode: ACCESS_MODE, + inherit: bool, +) -> io::Result<()> { + let wpath = wide(path); + let dacl = ReadDacl::open(path)?; + + let ea = EXPLICIT_ACCESS_W { + grfAccessPermissions: mask, + grfAccessMode: mode, + grfInheritance: if inherit { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + 0 + }, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + // UNKNOWN, not `TRUSTEE_IS_USER`: the same helper stamps the sandbox GROUP's SID + // for the credential-store lock-out, and the field is advisory anyway. + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: sid.0.cast(), + }, + }; + + let mut new_dacl: *mut ACL = std::ptr::null_mut(); + // SAFETY: `ea` and the SID it points at outlive the call; `dacl.acl` came from + // GetNamedSecurityInfoW and may legitimately be NULL. + let rc = unsafe { SetEntriesInAclW(1, &ea, dacl.acl, &mut new_dacl) }; + if rc != 0 { + return Err(win32_err("SetEntriesInAclW", path, rc)); + } + let _new_guard = LocalFreeGuard(new_dacl.cast()); + + // SAFETY: `new_dacl` is a live ACL from SetEntriesInAclW; the path buffer is + // NUL-terminated. `DACL_SECURITY_INFORMATION` only — never PROTECTED (see module doc). + let rc = unsafe { + SetNamedSecurityInfoW( + wpath.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + new_dacl, + std::ptr::null(), + ) + }; + if rc != 0 { + return Err(win32_err("SetNamedSecurityInfoW", path, rc)); + } + Ok(()) +} + +/// Rebuild `existing`'s ace list without any explicit ace for `sid`, in canonical order. +/// `Ok(None)` means nothing matched and the caller must not write anything back. +/// +/// The rebuilt buffer is a `Vec` because `InitializeAcl` requires DWORD alignment. +fn rebuild_without_sid( + path: &Path, + existing: *mut ACL, + sid: &OwnedSid, +) -> io::Result>> { + // (bucket, original index, ace pointer, ace size). The stable sort on (bucket, index) + // preserves each bucket's original ordering so unrelated aces never shuffle. + let mut kept: Vec<(u8, u32, *const std::ffi::c_void, u32)> = Vec::new(); + let mut kept_bytes: u32 = 0; + let mut dropped = 0usize; + + walk_aces(existing, path, |i, header, ace| { + let inherited = header.AceFlags & INHERITED_ACE_FLAG != 0; + // Only the allow/deny types share the `{ header, mask, SidStart }` layout the SID is + // read out of, and they are the only types this module ever writes. An ace of any + // other type — object, callback, audit — is copied through untouched rather than + // guessed at: keeping a foreign ace is safe, dropping one corrupts someone else's ACL. + let is_ours = !inherited + && matches!( + header.AceType, + ACCESS_ALLOWED_ACE_TYPE | ACCESS_DENIED_ACE_TYPE + ) + // SAFETY: layout checked above; `SidStart` is the first DWORD of the inline SID. + && unsafe { EqualSid(sid_of(ace), sid.0) } != 0; + + if is_ours { + dropped += 1; + return; + } + kept.push(( + canonical_bucket(header.AceType, inherited), + i, + ace.cast_const(), + u32::from(header.AceSize), + )); + kept_bytes += u32::from(header.AceSize); + })?; + + if dropped == 0 { + return Ok(None); + } + kept.sort_by_key(|&(bucket, index, _, _)| (bucket, index)); + + let acl_bytes = (std::mem::size_of::() as u32 + kept_bytes).next_multiple_of(4); + let mut buf: Vec = vec![0; (acl_bytes as usize).div_ceil(4)]; + let acl = buf.as_mut_ptr().cast::(); + + // Preserve the source revision rather than assuming ACL_REVISION: an ACL carrying object + // aces is revision 4, and AddAce rejects a revision-4 ace into a revision-2 ACL. + // SAFETY: `existing` is live; `acl` points at `acl_bytes` of zeroed, DWORD-aligned space. + let revision = u32::from(unsafe { (*existing).AclRevision }); + // SAFETY: as above. + if unsafe { InitializeAcl(acl, acl_bytes, revision) } == 0 { + return Err(win32_last_err("InitializeAcl", path)); + } + + // `AddAce` and the `AddAccess{Allowed,Denied}AceEx` family both APPEND at the tail and do + // NOT canonicalize despite the latter's name, so the bucket order above is what actually + // produces a canonical DACL. Emitting out of order would still be accepted by Windows and + // would silently resolve a deny as an allow. + for &(_, _, ace, size) in &kept { + // SAFETY: `acl` was sized to hold exactly these aces; each `ace` is a live + // `size`-byte ace inside the descriptor `ReadDacl` keeps alive across this call. + if unsafe { AddAce(acl, revision, u32::MAX, ace, size) } == 0 { + return Err(win32_last_err("AddAce", path)); + } + } + Ok(Some(buf)) +} + +/// Walk every ace in `acl` in DACL order. A NULL `acl` means "no DACL, everything allowed" +/// and yields nothing. +fn walk_aces( + acl: *mut ACL, + path: &Path, + mut f: impl FnMut(u32, ACE_HEADER, *mut std::ffi::c_void), +) -> io::Result<()> { + if acl.is_null() { + return Ok(()); + } + let mut info = ACL_SIZE_INFORMATION { + AceCount: 0, + AclBytesInUse: 0, + AclBytesFree: 0, + }; + // SAFETY: `acl` is a live ACL from GetNamedSecurityInfoW; `info` is a correctly sized + // out-slot for the AclSizeInformation class. + let ok = unsafe { + GetAclInformation( + acl, + std::ptr::from_mut(&mut info).cast(), + std::mem::size_of::() as u32, + AclSizeInformation, + ) + }; + if ok == 0 { + return Err(win32_last_err("GetAclInformation", path)); + } + for i in 0..info.AceCount { + let mut ace: *mut std::ffi::c_void = std::ptr::null_mut(); + // SAFETY: `i` is below the reported AceCount of a live ACL. + if unsafe { GetAce(acl, i, &mut ace) } == 0 { + return Err(win32_last_err("GetAce", path)); + } + // SAFETY: every ace GetAce yields begins with an ACE_HEADER. + f(i, unsafe { *ace.cast::() }, ace); + } + Ok(()) +} + +/// The trustee SID of an allow/deny ace. Caller must have checked the type — the inline +/// `SidStart` field is only at this offset for the types sharing `ACCESS_ALLOWED_ACE`'s +/// layout. +fn sid_of(ace: *mut std::ffi::c_void) -> PSID { + // SAFETY: `addr_of!` takes the field address without forming a reference to the + // variable-length SID that follows it. + unsafe { std::ptr::addr_of!((*ace.cast::()).SidStart) } + .cast_mut() + .cast() +} + +/// Canonical-order bucket: explicit DENY, explicit ALLOW, explicit other, then inherited. +/// Smaller sorts earlier. +fn canonical_bucket(ace_type: u8, inherited: bool) -> u8 { + if inherited { + return 3; + } + match ace_type { + ACCESS_DENIED_ACE_TYPE => 0, + ACCESS_ALLOWED_ACE_TYPE => 1, + _ => 2, + } +} + +/// A path's DACL plus the security descriptor that owns its storage. The descriptor MUST +/// outlive every read of `acl` — the ACL points INTO it. +struct ReadDacl { + acl: *mut ACL, + _sd: LocalFreeGuard, +} + +impl ReadDacl { + fn open(path: &Path) -> io::Result { + let wpath = wide(path); + let mut acl: *mut ACL = std::ptr::null_mut(); + let mut sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: `wpath` is NUL-terminated UTF-16 and outlives the call; every out-param is + // a valid slot and the unwanted ones are NULL. + let rc = unsafe { + GetNamedSecurityInfoW( + wpath.as_ptr(), + SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut acl, + std::ptr::null_mut(), + &mut sd, + ) + }; + if rc != 0 { + return Err(win32_err("GetNamedSecurityInfoW", path, rc)); + } + Ok(ReadDacl { + acl, + _sd: LocalFreeGuard(sd), + }) + } +} + +/// A SID parsed from its string form, `LocalFree`d on drop. +struct OwnedSid(PSID); + +impl OwnedSid { + fn parse(s: &str) -> io::Result { + let wide: Vec = s.encode_utf16().chain(std::iter::once(0)).collect(); + let mut sid: PSID = std::ptr::null_mut(); + // SAFETY: `wide` is NUL-terminated and outlives the call; `sid` is a valid out-slot. + if unsafe { ConvertStringSidToSidW(wide.as_ptr(), &mut sid) } == 0 { + return Err(io::Error::new( + io::ErrorKind::InvalidInput, + format!("not a valid SID string: {s}"), + )); + } + Ok(OwnedSid(sid)) + } +} + +impl Drop for OwnedSid { + fn drop(&mut self) { + // SAFETY: `self.0` came from ConvertStringSidToSidW, which documents LocalFree. + unsafe { LocalFree(self.0) }; + } +} + +struct LocalFreeGuard(*mut std::ffi::c_void); + +impl Drop for LocalFreeGuard { + fn drop(&mut self) { + if !self.0.is_null() { + // SAFETY: every pointer wrapped here came from an API documenting LocalFree. + unsafe { LocalFree(self.0) }; + } + } +} + +/// Canonicalize for the Win32 security APIs, returning the path and whether it is a directory. +/// +/// `fs::canonicalize` emits `\\?\C:\…` and `\\?\UNC\server\share\…` — the extended-length +/// spellings both `Get`/`SetNamedSecurityInfoW` accept, and the only forms that are correct +/// for BOTH drive and UNC inputs (a naive `\\?\` prefix produces a malformed UNC path). It +/// also supplies the not-found check for free. Deliberate consequence: a symlink or junction +/// resolves to its TARGET, so the ACE lands on the object an open actually reaches rather than +/// on a reparse point, which does not gate content access. +fn resolve(path: &Path) -> io::Result<(PathBuf, bool)> { + let canonical = std::fs::canonicalize(path).map_err(|e| { + if e.kind() == io::ErrorKind::NotFound { + io::Error::new( + io::ErrorKind::NotFound, + format!("sandbox ACL target does not exist: {}", path.display()), + ) + } else { + io::Error::other(format!( + "resolving sandbox ACL target {}: {e}", + path.display() + )) + } + })?; + let is_dir = std::fs::metadata(&canonical)?.is_dir(); + Ok((canonical, is_dir)) +} + +fn wide(p: &Path) -> Vec { + use std::os::windows::ffi::OsStrExt; + p.as_os_str() + .encode_wide() + .chain(std::iter::once(0)) + .collect() +} + +fn win32_err(op: &str, path: &Path, rc: u32) -> io::Error { + match rc { + ERROR_ACCESS_DENIED => io::Error::new( + io::ErrorKind::PermissionDenied, + format!( + "{op} on {}: access denied — nub holds no WRITE_DAC on this path, so the \ + sandbox account's access cannot be set", + path.display() + ), + ), + ERROR_FILE_NOT_FOUND | ERROR_PATH_NOT_FOUND => io::Error::new( + io::ErrorKind::NotFound, + format!("{op} on {}: path no longer exists", path.display()), + ), + _ => io::Error::other(format!( + "{op} on {} failed (Win32 error {rc})", + path.display() + )), + } +} + +/// For the ACL-surgery calls, which report failure through `GetLastError` rather than a +/// returned status. +fn win32_last_err(op: &str, path: &Path) -> io::Error { + let rc = io::Error::last_os_error().raw_os_error().unwrap_or(0) as u32; + win32_err(op, path, rc) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// One ace, flattened enough to assert ordering and byte-level preservation without + /// holding a pointer into a freed descriptor. + struct Ace { + ace_type: u8, + inherited: bool, + bytes: Vec, + } + + /// Every ace on `path`'s DACL, in DACL order. Shares [`walk_aces`] with the production + /// rebuild so the assertion can never drift from what `strip` actually sees. + fn read_aces(path: &Path) -> Vec { + let (canonical, _) = resolve(path).expect("resolve"); + let dacl = ReadDacl::open(&canonical).expect("GetNamedSecurityInfoW"); + let mut out = Vec::new(); + walk_aces(dacl.acl, &canonical, |_, header, ace| { + // SAFETY: `AceSize` is the ace's own length, inside the descriptor `dacl` holds. + let bytes = + unsafe { std::slice::from_raw_parts(ace.cast::(), header.AceSize as usize) }; + out.push(Ace { + ace_type: header.AceType, + inherited: header.AceFlags & INHERITED_ACE_FLAG != 0, + bytes: bytes.to_vec(), + }); + }) + .expect("walk DACL"); + out + } + + /// The raw SID bytes a `S-1-…` string parses to, for matching aces by trustee. + fn sid_bytes(sid: &str) -> Vec { + let parsed = OwnedSid::parse(sid).expect("parse SID"); + // SubAuthorityCount is byte 1; a SID is 8 + 4 * count bytes. + let head = unsafe { std::slice::from_raw_parts(parsed.0.cast::(), 2) }; + let len = 8 + 4 * head[1] as usize; + unsafe { std::slice::from_raw_parts(parsed.0.cast::(), len) }.to_vec() + } + + /// Explicit allow/deny aces on `aces` whose trustee is `sid` — the SID sits at offset 8, + /// immediately after the 4-byte header and the 4-byte mask. + fn explicit_for(aces: &[Ace], sid: &[u8]) -> Vec> { + aces.iter() + .filter(|a| { + !a.inherited + && matches!(a.ace_type, ACCESS_ALLOWED_ACE_TYPE | ACCESS_DENIED_ACE_TYPE) + && a.bytes.len() >= 8 + sid.len() + && &a.bytes[8..8 + sid.len()] == sid + }) + .map(|a| a.bytes.clone()) + .collect() + } + + /// No explicit ace after any inherited one; no explicit DENY after any explicit ALLOW. + fn assert_canonical(aces: &[Ace], label: &str) { + let order: Vec<(u8, bool)> = aces.iter().map(|a| (a.ace_type, a.inherited)).collect(); + let mut saw_inherited = false; + let mut saw_allow = false; + for (i, a) in aces.iter().enumerate() { + if a.inherited { + saw_inherited = true; + continue; + } + assert!( + !saw_inherited, + "{label}: explicit ace at {i} follows an inherited ace; order={order:?}" + ); + match a.ace_type { + ACCESS_DENIED_ACE_TYPE => assert!( + !saw_allow, + "{label}: explicit DENY at {i} follows an explicit ALLOW — Windows accepts \ + this and resolves it first-match, so the deny would silently read as \ + allow; order={order:?}" + ), + ACCESS_ALLOWED_ACE_TYPE => saw_allow = true, + _ => {} + } + } + } + + /// BUILTIN\Users. Always resolves, harmless to ace on a temp dir the test owns, and does + /// not appear as an *explicit* ace under a user-profile `%TEMP%`. + const SID: &str = "S-1-5-32-545"; + /// Everyone — a second, distinct trustee for the preservation test. + const OTHER_SID: &str = "S-1-1-0"; + + /// The property the whole agent-sandbox fs axis rests on: a deny written onto a file + /// inside a granted tree must land AHEAD of the allow the tree's `(OI)(CI)` grant + /// propagated onto that file. Windows accepts the wrong order silently, so nothing but an + /// assertion catches a regression here. + #[test] + fn deny_inside_a_grant_lands_before_the_inherited_allow() { + let td = tempfile::tempdir().unwrap(); + grant(td.path(), SID, Access::ReadWrite).expect("grant"); + + // Created AFTER the grant, so the inheritable allow propagates onto it as an + // INHERITED ace — the exact shape the deny has to outrank. + let secret = td.path().join(".env"); + std::fs::write(&secret, b"TOKEN=x").unwrap(); + deny(&secret, SID).expect("deny"); + + let aces = read_aces(&secret); + assert_canonical(&aces, "deny inside grant"); + assert!( + aces.iter().any(|a| a.inherited), + "the grant should have propagated an inherited ace onto the file" + ); + assert!( + aces.iter() + .any(|a| !a.inherited && a.ace_type == ACCESS_DENIED_ACE_TYPE), + "the deny should be present as an EXPLICIT ace" + ); + } + + /// The regression `SetEntriesInAclW(REVOKE_ACCESS)` fails: on Windows 11 25H2 an explicit + /// ACCESS_DENIED ace survives a REVOKE. Baseline-relative so a pre-existing ace for the + /// same SID cannot make it pass or fail spuriously. + #[test] + fn strip_removes_a_deny_ace() { + let td = tempfile::tempdir().unwrap(); + let sid = sid_bytes(SID); + let baseline = explicit_for(&read_aces(td.path()), &sid).len(); + + grant(td.path(), SID, Access::ReadWrite).expect("grant"); + deny(td.path(), SID).expect("deny"); + assert!( + explicit_for(&read_aces(td.path()), &sid).len() > baseline, + "setup did not add any explicit ace to strip" + ); + + strip(td.path(), SID).expect("strip"); + assert_eq!( + explicit_for(&read_aces(td.path()), &sid).len(), + baseline, + "strip left residue — the REVOKE_ACCESS deny-survival defect is back" + ); + assert_canonical(&read_aces(td.path()), "after strip"); + } + + /// Teardown must not disturb aces nub did not author — including ones written by other + /// tools. Byte-identical, not merely present: a rebuild that re-encoded a kept ace would + /// silently rewrite a third party's rights. + #[test] + fn strip_preserves_foreign_aces() { + let td = tempfile::tempdir().unwrap(); + grant(td.path(), OTHER_SID, Access::Read).expect("seed foreign ace"); + grant(td.path(), SID, Access::ReadWrite).expect("grant"); + + let foreign = sid_bytes(OTHER_SID); + let before = explicit_for(&read_aces(td.path()), &foreign); + assert!(!before.is_empty(), "foreign ace was not seeded"); + + strip(td.path(), SID).expect("strip"); + assert_eq!( + explicit_for(&read_aces(td.path()), &foreign), + before, + "stripping our SID altered another trustee's aces" + ); + } +} diff --git a/crates/nub-sandbox/src/backend/windows_account/launch.rs b/crates/nub-sandbox/src/backend/windows_account/launch.rs new file mode 100644 index 000000000..1d552335a --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/launch.rs @@ -0,0 +1,309 @@ +//! The unelevated per-run launch: ACL the policy's paths, then start the child AS the +//! sandbox account through the Secondary Logon service. +//! +//! WHY `CreateProcessWithLogonW` AND NOT `LogonUser` + `CreateProcessAsUserW`: the latter +//! makes the caller hold a FOREIGN primary token, which requires +//! `SE_ASSIGNPRIMARYTOKEN_NAME` + `SE_INCREASE_QUOTA_NAME` — i.e. administrator, on every +//! run. `CreateProcessWithLogonW` hands the credential to the seclogon service, which does +//! the token work out of process, so an ordinary unelevated token suffices. That single API +//! choice is what makes the "one elevated setup, then never again" promise hold. (SRT and +//! Codex independently converged on it.) +//! +//! CONSEQUENCES OF THAT API, all load-bearing: +//! - It has NO `bInheritHandles` parameter and no `STARTUPINFOEX` overload. seclogon +//! duplicates exactly the `STARTF_USESTDHANDLES` handles into the new logon and nothing +//! else — so stdio must ride those three fields, and there is no handle-list attribute to +//! scope inheritance with (nor any need for one: nothing else crosses). +//! - `lpDesktop` is left NULL deliberately. A non-NULL desktop makes seclogon SKIP its +//! window-station auto-grant, at which point the child needs an explicit `WinSta0` ace +//! (including `READ_CONTROL`, without which it HANGS in loader init) plus a session +//! `BaseNamedObjects` ace. NULL avoids all of it; the cost is no desktop isolation, which +//! is a hardening follow-up, not a confinement hole. +//! - `AssignProcessToJobObject` on the resulting child commonly fails `ERROR_NOT_SUPPORTED`: +//! seclogon already placed it in its own job, and current Windows refuses that nesting +//! cross-session. The assignment is attempted and its failure reported, never silently +//! swallowed — whole-tree reap is genuinely weaker here than on the AppContainer path. + +use super::{AccountLaunch, AccountNet, acl, account, state}; +use crate::backend::windows::launch::{build_command_line, build_env_block, to_wide}; +use std::io; +use std::os::windows::io::AsRawHandle; +use std::os::windows::process::ExitStatusExt; +use std::process::ExitStatus; +use windows_sys::Win32::Foundation::{ + CloseHandle, HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, SetHandleInformation, + WAIT_OBJECT_0, +}; +use windows_sys::Win32::System::JobObjects::{ + AssignProcessToJobObject, CreateJobObjectW, JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE, + JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, + SetInformationJobObject, +}; +use windows_sys::Win32::System::Threading::{ + CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessWithLogonW, GetExitCodeProcess, + INFINITE, LOGON_WITH_PROFILE, PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, + STARTUPINFOW, TerminateProcess, WaitForSingleObject, +}; + +const ERROR_NOT_SUPPORTED: i32 = 50; +const ERROR_LOGON_FAILURE: i32 = 1326; +const ERROR_SERVICE_DISABLED: i32 = 1058; + +/// Strips every ace this run applied, on drop. Ordering matters: declared before the child is +/// spawned but dropped after the wait returns, so a granted path is never revoked out from +/// under a live child. Best-effort — a failed strip leaves an over-permissive ace for a +/// confined account, which the ledger sweep (`nub run --sandbox-clean`) collects later. +struct AceGuard { + paths: Vec, + sid: String, +} + +impl Drop for AceGuard { + fn drop(&mut self) { + for p in &self.paths { + if let Err(e) = acl::strip(p, &self.sid) { + tracing::debug!(path = %p.display(), error = %e, "sandbox: ace strip failed — left for the ledger sweep"); + } + } + } +} + +impl AccountLaunch { + pub(crate) fn run(self) -> io::Result { + let marker = state::read_marker()?.ok_or_else(not_provisioned)?; + + // The account can be deleted out from under a stale marker; catching that here turns + // an inscrutable `ERROR_LOGON_FAILURE` into an actionable message. + match account::lookup_sid()? { + Some(live) if live == marker.sid => {} + Some(_) => { + return Err(io::Error::other( + "the nub sandbox account exists but its SID no longer matches the recorded \ + setup — re-run the elevated sandbox setup", + )); + } + None => return Err(not_provisioned()), + } + + // Ledger BEFORE apply: a crash between the two leaves a recorded path whose ace was + // never written, and stripping an absent ace is a no-op. The reverse order would + // leave an ace nothing knows about. + let mut applied = Vec::new(); + let mut guard = AceGuard { + paths: Vec::new(), + sid: marker.sid.clone(), + }; + for (path, access) in self + .read_grants + .iter() + .map(|p| (p, acl::Access::Read)) + .chain(self.write_grants.iter().map(|p| (p, acl::Access::ReadWrite))) + { + state::record_acl_path(path)?; + guard.paths.push(path.clone()); + acl::grant(path, &marker.sid, access)?; + applied.push(path.clone()); + } + // Denies go on AFTER the grants so the deny ace is inserted into a DACL that already + // carries the grant it must outrank — the canonical-order insert has to see both. + for path in &self.denies { + state::record_acl_path(path)?; + guard.paths.push(path.clone()); + acl::deny(path, &marker.sid)?; + } + + let password = account::load_credential()?; + let status = self.spawn_and_wait(&marker.account, &password); + drop(guard); + status + } + + fn spawn_and_wait(&self, account_name: &str, password: &str) -> io::Result { + let user_w = to_wide(account_name); + // "." targets the LOCAL SAM regardless of whether the machine is domain-joined. + let domain_w = to_wide("."); + let mut password_w: Vec = password + .encode_utf16() + .chain(std::iter::once(0)) + .collect(); + let mut cmdline = build_command_line(&self.program, &self.args); + let app_w = to_wide(&self.program.to_string_lossy()); + let cwd_w = self.cwd.as_ref().map(|c| to_wide(&c.to_string_lossy())); + let env_block = self.env.as_ref().map(build_env_block); + + let mut si: STARTUPINFOW = unsafe { std::mem::zeroed() }; + si.cb = std::mem::size_of::() as u32; + // seclogon duplicates ONLY these three handles, and only when the flag is set. + let std_handles = inheritable_std_handles(); + if let Some((i, o, e)) = std_handles { + si.dwFlags |= STARTF_USESTDHANDLES; + si.hStdInput = i; + si.hStdOutput = o; + si.hStdError = e; + } + + let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; + let mut flags = CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED; + let env_ptr: *const std::ffi::c_void = match &env_block { + Some(b) => b.as_ptr().cast(), + // NULL + LOGON_WITH_PROFILE makes seclogon build the SANDBOX ACCOUNT's own + // profile environment — isolated USERPROFILE/TEMP/LOCALAPPDATA, machine PATH. + None => std::ptr::null(), + }; + let _ = &mut flags; + + // SAFETY: every buffer referenced (user/domain/password/app/cmdline/cwd/env/si) + // outlives this call; `lpCommandLine` is a writable UTF-16 buffer as required. + let ok = unsafe { + CreateProcessWithLogonW( + user_w.as_ptr(), + domain_w.as_ptr(), + password_w.as_ptr(), + LOGON_WITH_PROFILE, + app_w.as_ptr(), + cmdline.as_mut_ptr(), + flags, + env_ptr, + cwd_w.as_ref().map_or(std::ptr::null(), |w| w.as_ptr()), + &si, + &mut pi, + ) + }; + password_w.fill(0); + if ok == 0 { + return Err(map_spawn_error(io::Error::last_os_error(), account_name)); + } + + // Best-effort containment. seclogon has already placed the child in its own job and + // current Windows refuses cross-session nesting, so this commonly returns + // ERROR_NOT_SUPPORTED — reported, never presented as success. + let job = create_kill_on_close_job().ok(); + if let Some(j) = job { + // SAFETY: both handles are live; the child is still suspended. + if unsafe { AssignProcessToJobObject(j, pi.hProcess) } == 0 { + let e = io::Error::last_os_error(); + if e.raw_os_error() == Some(ERROR_NOT_SUPPORTED) { + tracing::debug!( + "sandbox: the sandbox child could not be placed in a nub Job Object \ + (seclogon owns it) — whole-tree reap is best-effort" + ); + } else { + // A non-nesting failure is a real fault: terminate the still-suspended + // child rather than run it uncontained. + unsafe { + TerminateProcess(pi.hProcess, 1); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + CloseHandle(j); + } + return Err(io::Error::other(format!( + "sandbox: could not contain the child in a Job Object: {e}" + ))); + } + } + } + + // SAFETY: `pi` handles came from a successful CreateProcessWithLogonW. + let code = unsafe { + ResumeThread(pi.hThread); + if WaitForSingleObject(pi.hProcess, INFINITE) != WAIT_OBJECT_0 { + let e = io::Error::last_os_error(); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + if let Some(j) = job { + CloseHandle(j); + } + return Err(e); + } + let mut code: u32 = 0; + GetExitCodeProcess(pi.hProcess, &mut code); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + code + }; + if let Some(j) = job { + // Closing last triggers KILL_ON_JOB_CLOSE for anything still in the tree. + unsafe { CloseHandle(j) }; + } + Ok(ExitStatus::from_raw(code)) + } +} + +/// The parent's three std handles, marked inheritable so seclogon can duplicate them. `None` +/// when any is absent (a detached parent) — the child then gets no stdio rather than a +/// half-wired set that would make it block on a dead handle. +fn inheritable_std_handles() -> Option<(HANDLE, HANDLE, HANDLE)> { + let i = std::io::stdin().as_raw_handle() as HANDLE; + let o = std::io::stdout().as_raw_handle() as HANDLE; + let e = std::io::stderr().as_raw_handle() as HANDLE; + for h in [i, o, e] { + if h.is_null() || h == INVALID_HANDLE_VALUE { + return None; + } + // SAFETY: each handle is owned by this process for its whole lifetime. + unsafe { SetHandleInformation(h, HANDLE_FLAG_INHERIT, HANDLE_FLAG_INHERIT) }; + } + Some((i, o, e)) +} + +fn create_kill_on_close_job() -> io::Result { + // SAFETY: unnamed job with default security. + let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; + if job.is_null() { + return Err(io::Error::last_os_error()); + } + let mut info: JOBOBJECT_EXTENDED_LIMIT_INFORMATION = unsafe { std::mem::zeroed() }; + info.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + // SAFETY: `info` is a correctly-sized JOBOBJECT_EXTENDED_LIMIT_INFORMATION. + let ok = unsafe { + SetInformationJobObject( + job, + JobObjectExtendedLimitInformation, + std::ptr::from_mut(&mut info).cast(), + std::mem::size_of::() as u32, + ) + }; + if ok == 0 { + let e = io::Error::last_os_error(); + unsafe { CloseHandle(job) }; + return Err(e); + } + Ok(job) +} + +fn not_provisioned() -> io::Error { + io::Error::new( + io::ErrorKind::NotFound, + "this policy needs nub's dedicated Windows sandbox account, which has not been set up \ + on this machine. Run `nub run --sandbox-setup` once from an elevated (Run as \ + administrator) prompt.", + ) +} + +/// Turn the two spawn failures a user can actually act on into instructions. +fn map_spawn_error(e: io::Error, account: &str) -> io::Error { + match e.raw_os_error() { + Some(ERROR_LOGON_FAILURE) => io::Error::other(format!( + "sandbox: the stored credential for `{account}` was rejected — the account's \ + password was changed or the account was disabled. Re-run `nub run \ + --sandbox-setup` from an elevated prompt to reprovision it." + )), + Some(ERROR_SERVICE_DISABLED) => io::Error::other( + "sandbox: the Windows Secondary Logon service is disabled, so nub cannot start the \ + sandboxed child under its dedicated account. Enable the `seclogon` service (it \ + may be disabled by group policy).", + ), + _ => e, + } +} + +/// Net posture is enforced entirely by the persistent WFP filters installed at setup, keyed +/// on the account SID — there is nothing per-run to do. This exists so the caller's match on +/// [`AccountNet`] is exhaustive at the launch site and a future posture cannot be added +/// without visiting here. +pub(crate) fn net_is_enforced_by_setup(net: AccountNet) -> bool { + match net { + AccountNet::ProxyOnly | AccountNet::DenyAll => true, + AccountNet::UnconfinedButFenced => false, + } +} diff --git a/crates/nub-sandbox/src/backend/windows_account/mod.rs b/crates/nub-sandbox/src/backend/windows_account/mod.rs new file mode 100644 index 000000000..ffe46ce1b --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/mod.rs @@ -0,0 +1,737 @@ +//! Windows **agent-sandbox** backend: a dedicated local account + WFP. +//! +//! WHY A SECOND WINDOWS BACKEND (the 2026-07-24 pivot). The AppContainer backend +//! ([`super::windows`]) is a pure ALLOWLIST: reachable only where an object's ACL names the +//! per-run AppContainer SID. That maps build-jail exactly — and cannot express agent-sandbox +//! at all. Two things defeat it: +//! 1. **Generous-read-minus-secrets is inexpressible.** An allowlist cannot say "read +//! everything except these", so the policy degrades to the explicit allow-set. +//! 2. **Deny-inside-allow does not hold.** A secret under a dir carrying an inherited +//! `ALL APPLICATION PACKAGES` grant is readable regardless of the allow-set — the AAP +//! grant satisfies the LowBox check before default-deny is reached. +//! Both dissolve when the child runs as a **separate local principal**: the invoking user's +//! own profile (`~/.ssh`, `~/.aws`, a home-dir `.env`) is denied by DEFAULT with no ACE +//! authored at all, and no AAP grant ever covers a user SID, so an explicit deny ACE on a +//! secret *inside* a granted tree wins on canonical DACL order. +//! +//! And per-host egress was never admin-free on Windows regardless: there is no unprivileged +//! per-host mechanism (AppContainer's loopback exemption is all-or-nothing), so the full +//! grammar always implied WFP, which always implied elevation. Given elevation is required +//! anyway, the dedicated account buys the *whole* grammar rather than a subset — which is +//! why the deny-strip (`SE_DACL_PROTECTED` + a DACL restore journal) was dropped rather than +//! finished: its only advantage had been avoiding an elevation that turned out to be +//! mandatory. See `.fray/sandbox-decisions-current.md` §2. +//! +//! **THE PRIVILEGE SPLIT IS THE WHOLE PRODUCT DECISION.** One elevated +//! `nub run --sandbox-setup` per machine creates the account and installs four persistent +//! WFP filters over a pre-authorized loopback port window. Every run after that is +//! **fully unelevated**: read the credential, ACL the policy's paths, and +//! `CreateProcessWithLogonW` the child through the Secondary Logon service — which needs no +//! privilege because the unelevated broker never holds a foreign primary token. +//! build-jail keeps using [`super::windows`] and stays admin-free end to end. +//! +//! SPIKE BOUNDS (deliberate, not hidden — see LIMITATIONS.md): the child is launched in ONE +//! hop straight to the target. SRT and Codex add a second hop that re-launches through a +//! runner holding a *restricted* token; nub's confinement comes from the account's ACL reach +//! plus SID-keyed WFP, neither of which needs that token, so hop 2 is a hardening follow-up. +//! Consequences: `lpDesktop` stays NULL (so seclogon's window-station auto-grant applies and +//! no `WinSta0` ACE work is needed) at the cost of desktop isolation, and Job-Object +//! whole-tree kill is best-effort because seclogon's own job refuses cross-session nesting. + +#![cfg(any(target_os = "windows", test))] + +use crate::policy::{Effect, FsAccess, FsPolicy}; +use std::collections::BTreeMap; +use std::ffi::OsString; +use std::path::PathBuf; + +#[cfg(target_os = "windows")] +pub(crate) mod account; +#[cfg(target_os = "windows")] +pub(crate) mod acl; +#[cfg(target_os = "windows")] +mod launch; +pub(crate) mod state; +#[cfg(target_os = "windows")] +pub(crate) mod wfp; + +/// The local account nub provisions. 20 chars is the SAM limit for a local account; this is +/// 11. Stable — the WFP filters and every granted ACE key on the SID it resolves to. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) const SANDBOX_ACCOUNT: &str = "nub-sandbox"; + +/// A local group whose sole purpose is being a STABLE DENY TRUSTEE: the credential store's +/// DACL denies the GROUP, so a future per-session-account design adds members instead of +/// rewriting DACLs. (SRT's rationale; mirrored.) +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) const SANDBOX_GROUP: &str = "nub-sandbox-users"; + +/// What the account model could not express for a policy, so the caller is told rather than +/// silently over- or under-confined. +#[derive(Debug, Default, PartialEq)] +pub(crate) struct AccountFsDegrade { + /// A read-all base. The account reads what any standard local user reads (system dirs, + /// Program Files) PLUS the explicit grants — but never the invoking user's profile. That + /// is narrower than "read everything", so it is over-confinement and is reported. + pub(crate) generous_read_narrowed: bool, + /// An embedded-glob allow can't be one inheritable ACE; skipped rather than widened to + /// its literal prefix, which could sweep in a sibling secret. + pub(crate) glob_read_unenforced: bool, + /// An embedded-glob DENY can't be one ACE either — and a missed deny is a HOLE, not + /// over-confinement, so it is reported distinctly. + pub(crate) glob_deny_unenforced: bool, +} + +/// The net posture the account backend can achieve. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum AccountNet { + /// Policy allows specific hosts: the child reaches nub's loopback proxy (permitted by the + /// installed port window) and nothing else. + ProxyOnly, + /// Pure deny-all: the persistent block filters alone, no proxy. + DenyAll, + /// Policy leaves net unconfined, but the account is PERMANENTLY fenced by the persistent + /// filters, so egress is denied anyway. Over-confinement — reported, never silent. + UnconfinedButFenced, +} + +/// A resolved account-backend launch plan. Plain data so the derivation is unit-tested on the +/// dev host; the FFI in [`launch`] is `#[cfg(windows)]`. +#[cfg_attr(not(target_os = "windows"), allow(dead_code))] +pub(crate) struct AccountLaunch { + pub(crate) program: OsString, + pub(crate) args: Vec, + pub(crate) cwd: Option, + /// Subtrees granted read+execute to the sandbox SID, inheritable. + pub(crate) read_grants: Vec, + /// Subtrees granted modify (read+write+delete, NEVER `FILE_DELETE_CHILD`), inheritable. + pub(crate) write_grants: Vec, + /// Paths carrying an explicit DENY ACE for the sandbox SID. Only meaningful INSIDE a + /// grant — outside one the account already has no reach — but applying them + /// unconditionally is free and survives a later widening of the grant set. + pub(crate) denies: Vec, + /// `Some` ⇒ the child env IS this map. `None` ⇒ seclogon builds the account's own + /// profile environment. + pub(crate) env: Option>, + pub(crate) net: AccountNet, +} + +/// Whether a policy needs the ACCOUNT backend rather than the AppContainer allowlist. +/// +/// The account backend costs a one-time elevated setup, so it is chosen only where the +/// allowlist genuinely cannot carry the policy — which is exactly the agent-sandbox shape: +/// a generous-read base, a deny that has to be carved inside a grant, or per-host egress. +/// A pure default-deny allowlist with coarse/absent net is build-jail and stays on the +/// admin-free AppContainer path. +pub(crate) fn needs_account_backend(policy: &crate::policy::SandboxPolicy) -> bool { + let fs = &policy.fs; + let generous_read = fs.rules.default_effect == Effect::Allow + || fs + .rules + .entries + .iter() + .any(|r| r.effect == Effect::Allow && super::windows::is_whole_fs(r.matcher.as_str())); + // A deny only needs carving when it can land inside something the policy also grants; + // `deny_shadows_grant` is exactly that test, and it is the AppContainer's known hole. + let (read_grants, _, _) = super::windows::derive_grants(fs); + let deny_needs_carve = super::windows::deny_shadows_grant(&fs.rules.entries, &read_grants); + let per_host_net = + policy.net.enforce && policy.net.rules.iter().any(|r| r.effect == Effect::Allow); + generous_read || deny_needs_carve || per_host_net +} + +/// Derive the account's grant/deny sets from the fs IR. +/// +/// Unlike the AppContainer derivation, DENIES ARE REAL HERE: an explicit deny ACE for the +/// sandbox SID on a path inside a granted subtree outranks the grant's *inherited* allow, +/// because Windows orders every explicit ACE ahead of every inherited one. That ordering is +/// the mechanism the whole agent-sandbox fs axis rests on. +pub(crate) fn derive_plan(fs: &FsPolicy) -> (Vec, Vec, Vec, AccountFsDegrade) { + let mut read = Vec::new(); + let mut write = Vec::new(); + let mut deny = Vec::new(); + let mut degrade = AccountFsDegrade { + generous_read_narrowed: fs.rules.default_effect == Effect::Allow, + ..Default::default() + }; + + for rule in &fs.rules.entries { + let g = rule.matcher.as_str(); + match rule.effect { + Effect::Allow => match super::windows::literal_subtree(g) { + Some(dir) => { + if !read.contains(&dir) { + read.push(dir.clone()); + } + if rule.access == FsAccess::ReadWrite + && !super::windows::is_dangerous_write_root(&dir) + && !write.contains(&dir) + { + write.push(dir); + } + } + None if super::windows::is_whole_fs(g) => degrade.generous_read_narrowed = true, + None if super::windows::has_glob_meta(g) => degrade.glob_read_unenforced = true, + None => {} + }, + Effect::Deny => match super::windows::literal_subtree(g) { + Some(p) => { + if !deny.contains(&p) { + deny.push(p); + } + } + // A whole-fs deny is the default-deny base, carried by the account having no + // reach rather than by an ACE — not a lost deny. + None if super::windows::is_whole_fs(g) => {} + // A glob deny (`**/.env`, `C:/proj/*.pem`) is NOT expressible as one ACE. + // This is a HOLE, not over-confinement, so it is reported separately. + None => degrade.glob_deny_unenforced = true, + }, + } + } + (read, write, deny, degrade) +} + +/// Decide the net posture. Mirrors `backend::start_proxy_if_needed`, which is what actually +/// decides whether a proxy is running. +pub(crate) fn plan_net(net: &crate::policy::NetPolicy) -> AccountNet { + if !net.enforce { + return AccountNet::UnconfinedButFenced; + } + if net.rules.iter().any(|r| r.effect == Effect::Allow) { + AccountNet::ProxyOnly + } else { + AccountNet::DenyAll + } +} + +// ── one-time setup / teardown / status (the elevated half) ────────────────────── + +/// ELEVATED, once per machine. Create the sandbox account, lock the credential store against +/// it, install the WFP egress fence over `port_range`, and record the marker every later +/// unelevated run reads. Idempotent — safe to re-run to repair a partial install. +/// +/// ORDER IS DELIBERATE: the marker is written LAST, so a failure anywhere leaves the machine +/// looking un-provisioned and every run fails closed with "run the setup", rather than +/// half-provisioned and failing in some less legible way later. +#[cfg(target_os = "windows")] +pub(crate) fn setup(port_range: Option<(u16, u16)>) -> std::io::Result { + if !account::is_elevated() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "nub's Windows sandbox setup creates a local account and installs network filters, \ + both of which require administrator. Re-run this from an elevated (Run as \ + administrator) prompt.", + )); + } + let range = port_range.unwrap_or(wfp::DEFAULT_PROXY_PORT_RANGE); + let sid = account::provision()?; + // The credential is DPAPI machine-scope, which is explicitly NOT a boundary — the + // ciphertext's DACL is. Denying the sandbox account is that boundary. + acl::lock_out(&account::credential_dir()?, &sid)?; + wfp::install(&sid, range)?; + state::write_marker(&state::Marker { + version: state::MARKER_VERSION, + account: SANDBOX_ACCOUNT.to_string(), + sid: sid.clone(), + port_low: range.0, + port_high: range.1, + })?; + Ok(sid) +} + +/// ELEVATED. Undo [`setup`] completely: sweep every ace the ledger records, remove the WFP +/// objects, delete the account/group/profile, and drop the marker. Every step is idempotent +/// and a later failure never skips the cleanup already done. +#[cfg(target_os = "windows")] +pub(crate) fn teardown() -> std::io::Result<()> { + if !account::is_elevated() { + return Err(std::io::Error::new( + std::io::ErrorKind::PermissionDenied, + "removing the nub sandbox account and its network filters requires administrator. \ + Re-run this from an elevated (Run as administrator) prompt.", + )); + } + // Sweep BEFORE the account is deleted: once the SID stops resolving, the leftover aces + // render as raw SID strings and are far harder to attribute. + let _ = clean(); + let wfp_result = wfp::uninstall(); + let account_result = account::deprovision(); + let _ = state::remove_marker(); + let _ = state::clear_ledger(); + wfp_result.and(account_result) +} + +/// UNELEVATED. Strip every ace the ledger recorded, then clear it. This is the crash-residue +/// path: a run killed between grant and strip leaves aces behind, and this collects them. +/// Returns how many paths were swept. A path that no longer exists is pruned rather than +/// retried forever. +#[cfg(target_os = "windows")] +pub(crate) fn clean() -> std::io::Result { + let Some(marker) = state::read_marker()? else { + return Ok(0); + }; + let paths = state::ledger_paths()?; + let mut swept = 0; + for p in &paths { + match acl::strip(p, &marker.sid) { + Ok(()) => swept += 1, + Err(e) if e.kind() == std::io::ErrorKind::NotFound => swept += 1, + Err(e) => { + tracing::debug!(path = %p.display(), error = %e, "sandbox: ace sweep failed"); + } + } + } + if swept == paths.len() { + state::clear_ledger()?; + } + Ok(swept) +} + +/// A human-readable provisioning report. +/// +/// The filter count is only reachable when elevated: WFP gates even ENUMERATION on +/// administrator, so an unelevated caller genuinely cannot see it and the report says so +/// rather than implying the fence is absent. +#[cfg(target_os = "windows")] +pub(crate) fn status() -> std::io::Result { + let marker = state::read_marker()?; + let live_sid = account::lookup_sid()?; + let mut out = String::new(); + match (&marker, &live_sid) { + (None, None) => out.push_str("sandbox account: not set up\n"), + (None, Some(sid)) => out.push_str(&format!( + "sandbox account: `{SANDBOX_ACCOUNT}` exists ({sid}) but nub has no setup record — \ + re-run the elevated setup\n" + )), + (Some(m), None) => out.push_str(&format!( + "sandbox account: recorded as {} but no longer exists — re-run the elevated setup\n", + m.sid + )), + (Some(m), Some(sid)) if m.sid != *sid => out.push_str(&format!( + "sandbox account: SID changed (recorded {}, live {sid}) — re-run the elevated setup\n", + m.sid + )), + (Some(m), Some(sid)) => out.push_str(&format!( + "sandbox account: `{}` ({sid})\nproxy port window: {}-{}\n", + m.account, m.port_low, m.port_high + )), + } + out.push_str(&match (account::is_elevated(), wfp::installed_filter_count()) { + (false, _) => "wfp filters: cannot read (enumeration requires administrator)\n".to_string(), + (true, Ok(n)) => format!("wfp filters: {n} installed\n"), + (true, Err(e)) => format!("wfp filters: could not read ({e})\n"), + }); + out.push_str(&format!( + "acl ledger: {} path(s) recorded\n", + state::ledger_paths().map(|p| p.len()).unwrap_or(0) + )); + Ok(out) +} + +// ── the apply() entry (Windows-only: constructs Prepared.launch) ──────────────── + +/// Build the account-backend launch plan, or fail CLOSED with an actionable message. +/// +/// Two fail-closed gates, both deliberate: the machine must be provisioned (the account and +/// the WFP filters exist), and — when the policy allows specific hosts — the egress proxy must +/// have bound INSIDE the pre-authorized loopback window. A proxy outside the window is not a +/// degradation to warn about, it is a child that would reach nothing while the policy claimed +/// per-host access, so it is an error. +#[cfg(target_os = "windows")] +pub(crate) fn apply( + policy: &crate::policy::SandboxPolicy, + spec: crate::backend::CommandSpec, + proxy_port: Option, + proxy_token: Option<&str>, + ca_bundle: Option<&std::path::Path>, +) -> Result { + use crate::backend::{Degradation, Prepared, windows::WindowsLaunch}; + + let marker = match state::read_marker() { + Ok(Some(m)) => m, + Ok(None) => return Err(not_set_up("this machine has no nub sandbox account")), + Err(e) => return Err(not_set_up(&e.to_string())), + }; + + let net = plan_net(&policy.net); + if net == AccountNet::ProxyOnly { + let Some(port) = proxy_port else { + return Err(Degradation { + lost: vec!["net-per-host".to_string()], + reason: Some( + "the egress proxy required for per-host / TLS-inspect enforcement could not \ + start" + .to_string(), + ), + }); + }; + if port < marker.port_low || port > marker.port_high { + return Err(Degradation { + lost: vec!["net-per-host".to_string()], + reason: Some(format!( + "nub's egress proxy bound port {port}, outside the {}-{} loopback window the \ + installed WFP filters permit — the sandboxed child could not reach it. Free a \ + port in that range, or re-run `nub run --sandbox-setup` from an elevated \ + prompt to authorize a different one.", + marker.port_low, marker.port_high + )), + }); + } + } + + let (mut read_grants, write_grants, denies, fs_degrade) = derive_plan(&policy.fs); + + // The account is a DIFFERENT principal, so nothing under the invoking user's profile is + // reachable by default — including the program image itself when it is a per-user install + // (nvm/fnm Node, Scoop, `%LOCALAPPDATA%\Programs`). Granting the program FILE (not its + // parent dir, which would sweep in a neighbouring secret) is the minimum that lets the + // child exec at all. A program that loads SIBLING DLLs from its own directory still needs + // the front-end to put that toolchain dir in the read allow-set — the same launcher + // contract the macOS backend documents. + if let Some(prog) = crate::backend::windows::resolve_program(&spec.program, spec.cwd.as_deref()) + && !read_grants.contains(&prog) + { + read_grants.push(prog); + } + // CreateProcess resolves the child's working directory under the CHILD's token, so an + // ungranted cwd fails the spawn outright rather than merely hiding files. + if let Some(cwd) = &spec.cwd + && !read_grants.contains(cwd) + { + read_grants.push(cwd.clone()); + } + if let Some(bundle) = ca_bundle { + let b = bundle.to_path_buf(); + if !read_grants.contains(&b) { + read_grants.push(b); + } + } + + let mut deg = Degradation::full(); + let mut reason: Option = None; + if fs_degrade.generous_read_narrowed { + deg.lost.push("fs-read".to_string()); + reason.get_or_insert_with(|| { + "the sandbox runs as a separate local account, so a read-everything policy reaches \ + only what any standard local user reads plus the explicit grants — never the \ + invoking user's profile (over-confined, not widened)" + .to_string() + }); + } + if fs_degrade.glob_read_unenforced { + deg.lost.push("fs-read-glob".to_string()); + reason.get_or_insert_with(|| { + "an embedded-glob read allow can't be a single inheritable ACE — that path is not \ + read-granted (over-confined)" + .to_string() + }); + } + // A missed DENY is a hole, not over-confinement — reported separately so it is never read + // as the benign case above. + if fs_degrade.glob_deny_unenforced { + deg.lost.push("fs-deny-glob".to_string()); + reason.get_or_insert_with(|| { + "an embedded-glob deny can't be a single ACE — that path is NOT denied to the \ + sandbox account" + .to_string() + }); + } + if net == AccountNet::UnconfinedButFenced { + deg.lost.push("net".to_string()); + reason.get_or_insert_with(|| { + "the dedicated sandbox account is permanently egress-fenced by the WFP filters \ + installed at setup, so an unconfined net policy is still denied (over-confined)" + .to_string() + }); + } + deg.reason = reason; + + let launch = AccountLaunch { + program: spec.program, + args: spec.args, + cwd: spec.cwd, + read_grants, + write_grants, + denies, + env: build_child_env(&policy.env, proxy_port, proxy_token, ca_bundle), + net, + }; + + Ok(Prepared { + command: std::process::Command::new(&launch.program), + degradation: deg, + proxy: None, + launch: Some(WindowsLaunch::Account(launch)), + }) +} + +#[cfg(target_os = "windows")] +fn not_set_up(detail: &str) -> crate::backend::Degradation { + crate::backend::Degradation { + lost: vec!["fs".to_string(), "net".to_string()], + reason: Some(format!( + "{detail}. This policy needs nub's dedicated Windows sandbox account (it uses a \ + generous-read base, a deny inside a granted directory, or per-host network rules — \ + none of which Windows can express without one). Run `nub run --sandbox-setup` once \ + from an elevated (Run as administrator) prompt." + )), + } +} + +/// The child's environment, or `None` to let seclogon build the sandbox account's own profile +/// environment (isolated `USERPROFILE`/`TEMP`/`LOCALAPPDATA`, machine `PATH`). +/// +/// The Windows launch block is all-or-nothing — unlike a `Command`'s inherit-plus-override — +/// so "inherit and add the proxy vars" has to be MATERIALIZED from the parent's environment +/// here. That is only reached when the policy did NOT ask for env confinement, in which case +/// inheriting is exactly the contract the mac/linux backends give. +#[cfg(target_os = "windows")] +fn build_child_env( + env: &crate::policy::EnvPolicy, + proxy_port: Option, + proxy_token: Option<&str>, + ca_bundle: Option<&std::path::Path>, +) -> Option> { + let injecting = proxy_port.is_some() || ca_bundle.is_some(); + if !env.enforce && !injecting { + return None; + } + let mut m = if env.enforce { + env.constructed.clone() + } else { + std::env::vars_os() + .map(|(k, v)| { + ( + k.to_string_lossy().into_owned(), + v.to_string_lossy().into_owned(), + ) + }) + .collect() + }; + if let Some(port) = proxy_port { + let url = match proxy_token { + Some(t) => format!("http://{t}@127.0.0.1:{port}"), + None => format!("http://127.0.0.1:{port}"), + }; + for k in [ + "HTTP_PROXY", + "HTTPS_PROXY", + "http_proxy", + "https_proxy", + "ALL_PROXY", + ] { + m.insert(k.to_string(), url.clone()); + } + m.insert("NODE_USE_ENV_PROXY".to_string(), "1".to_string()); + } + if let Some(bundle) = ca_bundle { + let p = bundle.to_string_lossy().into_owned(); + for k in [ + "NODE_EXTRA_CA_CERTS", + "SSL_CERT_FILE", + "REQUESTS_CA_BUNDLE", + "CURL_CA_BUNDLE", + "GIT_SSL_CAINFO", + "PIP_CERT", + "NPM_CONFIG_CAFILE", + "npm_config_cafile", + "CARGO_HTTP_CAINFO", + "AWS_CA_BUNDLE", + "DENO_CERT", + ] { + m.insert(k.to_string(), p.clone()); + } + } + Some(m) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::policy::{ + CanonGlob, EnvPolicy, FsRule, FsRuleSet, NetPolicy, NetRule, NetTarget, PidPolicy, + SandboxPolicy, TmpMode, + }; + + fn fs_policy(default_effect: Effect, entries: Vec) -> FsPolicy { + FsPolicy { + rules: FsRuleSet { + entries, + default_effect, + }, + tmp: TmpMode::Shared, + } + } + + fn rule(m: &str, effect: Effect, access: FsAccess) -> FsRule { + FsRule { + matcher: CanonGlob(m.to_string()), + effect, + access, + } + } + + fn policy(fs: FsPolicy, net: NetPolicy) -> SandboxPolicy { + SandboxPolicy { + fs, + net, + env: EnvPolicy::default(), + pid: PidPolicy::default(), + } + } + + fn net_off() -> NetPolicy { + NetPolicy { + enforce: false, + ..Default::default() + } + } + + /// build-jail's exact shape — default-deny allowlist, no net — must stay on the + /// admin-free AppContainer path. A regression here would make `nub install` demand + /// elevation, which is the one thing the pivot promised it would not do. + #[test] + fn build_jail_shape_does_not_need_the_account_backend() { + let p = policy( + fs_policy( + Effect::Deny, + vec![rule("C:/jail", Effect::Allow, FsAccess::ReadWrite)], + ), + net_off(), + ); + assert!(!needs_account_backend(&p)); + } + + /// A coarse net DENY-ALL still needs no account — nothing is reachable and no proxy runs, + /// which the AppContainer expresses by withholding `internetClient`. + #[test] + fn coarse_net_deny_all_does_not_need_the_account_backend() { + let p = policy( + fs_policy( + Effect::Deny, + vec![rule("C:/jail", Effect::Allow, FsAccess::ReadWrite)], + ), + NetPolicy { + enforce: true, + default_effect: Effect::Deny, + ..Default::default() + }, + ); + assert!(!needs_account_backend(&p)); + } + + /// Per-host egress is the axis that forces WFP, which forces the account. + #[test] + fn per_host_net_needs_the_account_backend() { + let p = policy( + fs_policy( + Effect::Deny, + vec![rule("C:/jail", Effect::Allow, FsAccess::ReadWrite)], + ), + NetPolicy { + enforce: true, + default_effect: Effect::Deny, + rules: vec![NetRule { + target: NetTarget::Host("example.com".into()), + effect: Effect::Allow, + }], + ..Default::default() + }, + ); + assert!(needs_account_backend(&p)); + } + + /// The agent-sandbox fs shape — generous read — is inexpressible as an allowlist, so it + /// routes to the account backend even with net untouched. + #[test] + fn generous_read_needs_the_account_backend() { + let p = policy(fs_policy(Effect::Allow, vec![]), net_off()); + assert!(needs_account_backend(&p)); + } + + /// A deny that lands inside a granted subtree is the AppContainer's known hole (its + /// inheritable allow defeats the deny), so it must route to the account. + #[test] + fn deny_inside_a_grant_needs_the_account_backend() { + let p = policy( + fs_policy( + Effect::Deny, + vec![ + rule("C:/proj", Effect::Allow, FsAccess::ReadWrite), + rule("C:/proj/.env", Effect::Deny, FsAccess::Read), + ], + ), + net_off(), + ); + assert!(needs_account_backend(&p)); + } + + /// Denies become REAL ACE targets here — the difference from the allowlist backend, which + /// drops them entirely. + #[test] + fn derive_plan_keeps_literal_denies_as_ace_targets() { + let (read, write, deny, degrade) = derive_plan(&fs_policy( + Effect::Deny, + vec![ + rule("C:/proj", Effect::Allow, FsAccess::ReadWrite), + rule("C:/proj/.env", Effect::Deny, FsAccess::Read), + ], + )); + assert_eq!(read, vec![PathBuf::from("C:/proj")]); + assert_eq!(write, vec![PathBuf::from("C:/proj")]); + assert_eq!(deny, vec![PathBuf::from("C:/proj/.env")]); + assert_eq!(degrade, AccountFsDegrade::default()); + } + + /// A glob deny cannot be one ACE. It must be reported as a distinct LOST DENY — reporting + /// it as over-confinement (like a glob allow) would understate a real hole. + #[test] + fn glob_deny_is_reported_as_a_hole_not_as_over_confinement() { + let (_, _, deny, degrade) = derive_plan(&fs_policy( + Effect::Deny, + vec![ + rule("C:/proj", Effect::Allow, FsAccess::ReadWrite), + rule("**/.env", Effect::Deny, FsAccess::Read), + ], + )); + assert!(deny.is_empty(), "a glob deny yields no literal ACE target"); + assert!(degrade.glob_deny_unenforced); + assert!(!degrade.glob_read_unenforced); + } + + /// The whole-fs deny is the default-deny BASE, carried by the account simply having no + /// reach — flagging it as a lost deny would emit a permanent false degradation on every + /// ordinary confining policy. + #[test] + fn whole_fs_deny_base_is_not_a_lost_deny() { + let (_, _, deny, degrade) = derive_plan(&fs_policy( + Effect::Deny, + vec![ + rule("**", Effect::Deny, FsAccess::Read), + rule("C:/proj", Effect::Allow, FsAccess::ReadWrite), + ], + )); + assert!(deny.is_empty()); + assert!(!degrade.glob_deny_unenforced); + } + + /// A `..`-collapsed write grant must never land on a system root, same guard as the + /// AppContainer path — an inheritable modify ACE there would be a filesystem-wide hole. + #[test] + fn dangerous_write_roots_get_no_write_grant() { + let (read, write, _, _) = derive_plan(&fs_policy( + Effect::Deny, + vec![rule("C:/Windows", Effect::Allow, FsAccess::ReadWrite)], + )); + assert_eq!(read, vec![PathBuf::from("C:/Windows")]); + assert!(write.is_empty(), "C:/Windows must never be write-granted"); + } + + /// Net-unconfined under this backend is still fenced by the persistent filters, so it is + /// over-confinement that must be reported rather than a silently honored allow-all. + #[test] + fn unconfined_net_under_the_account_is_reported_as_fenced() { + assert_eq!(plan_net(&net_off()), AccountNet::UnconfinedButFenced); + } +} diff --git a/crates/nub-sandbox/src/backend/windows_account/state.rs b/crates/nub-sandbox/src/backend/windows_account/state.rs new file mode 100644 index 000000000..df32e4250 --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/state.rs @@ -0,0 +1,297 @@ +//! Provisioning marker + the ACL ledger — the only durable state the account backend keeps. +//! +//! TWO FILES, TWO JOBS. The **marker** records what the elevated setup created (account, SID, +//! the WFP-authorized loopback port window) so an UNELEVATED run can decide whether it may +//! proceed and which port to bind — WFP gates even *enumeration* on administrator, so an +//! unelevated process cannot ask the firewall what is installed and must read it here. +//! The **ledger** records every path ever ACL'd for the sandbox SID so a sweep can undo them +//! after a crash. +//! +//! WHY THE LEDGER IS A FLAT PATH LIST AND NOT A RESTORE JOURNAL. MXC's DACL journal captures +//! each path's PRIOR aces before mutating, because its trustee (`ALL APPLICATION PACKAGES`, +//! `Everyone`) can legitimately already hold aces authored by installers or `icacls` — so +//! "undo" there means *restore what was there*. nub's trustee is an account nub itself +//! created, which by construction has no pre-existing ace anywhere. Undo is therefore an +//! unconditional idempotent strip, and the only thing worth remembering is WHERE. That +//! collapses ~600 lines of prior-state capture, per-run files, PID+creation-time liveness and +//! corrupt-file quarantine into a path list. Ordering is still load-bearing: the path is +//! recorded BEFORE the ace is applied, so a crash in between leaves a ledger entry for an ace +//! that was never written — and stripping a path that has no ace is a no-op. +//! +//! Both live under `%PROGRAMDATA%\nub\sandbox`, whose inherited DACL gives Administrators and +//! SYSTEM full control and ordinary users read — exactly the asymmetry wanted: any user may +//! read the marker, only an elevated process may write it. + +// Compiled on the dev host too, so the marker parse + ledger de-duplication are unit-tested +// without a Windows box; only the Windows launch/setup paths actually call the rest. +#![cfg_attr(not(target_os = "windows"), allow(dead_code))] + +use std::io; +use std::path::{Path, PathBuf}; + +/// Bumped when the on-disk shape changes. A marker from a newer nub is refused rather than +/// misread — an unelevated run acting on a misparsed SID would ACL the wrong principal. +pub(crate) const MARKER_VERSION: u32 = 1; + +/// What the one-time elevated setup recorded. +#[derive(Debug, Clone, PartialEq, Eq)] +pub(crate) struct Marker { + pub(crate) version: u32, + pub(crate) account: String, + /// The account's SID string. Every WFP filter and every granted ace keys on this, so a + /// marker whose SID no longer resolves means the account was deleted out from under us. + pub(crate) sid: String, + /// The loopback window the installed WFP permit covers. The egress proxy must bind + /// inside it or the child cannot reach the proxy at all. + pub(crate) port_low: u16, + pub(crate) port_high: u16, +} + +impl Marker { + /// Hand-rolled rather than derived: `serde_json` is a dependency, but a four-field record + /// does not earn a `Serialize` impl and the parse is the only place a malformed marker can + /// hurt us, so it stays explicit and total. + pub(crate) fn to_json(&self) -> String { + format!( + "{{\"version\":{},\"account\":{},\"sid\":{},\"port_low\":{},\"port_high\":{}}}\n", + self.version, + json_string(&self.account), + json_string(&self.sid), + self.port_low, + self.port_high + ) + } + + pub(crate) fn from_json(s: &str) -> io::Result { + let v: serde_json::Value = serde_json::from_str(s) + .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("sandbox marker is not valid JSON: {e}")))?; + let field = |k: &str| -> io::Result<&serde_json::Value> { + v.get(k).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("sandbox marker is missing `{k}`"), + ) + }) + }; + let version = field("version")?.as_u64().unwrap_or(0) as u32; + if version != MARKER_VERSION { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + format!( + "sandbox marker is version {version}, this nub understands {MARKER_VERSION} \ + — re-run the elevated sandbox setup" + ), + )); + } + let as_str = |v: &serde_json::Value, k: &str| -> io::Result { + v.as_str().map(str::to_owned).ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("sandbox marker field `{k}` is not a string"), + ) + }) + }; + let as_port = |v: &serde_json::Value, k: &str| -> io::Result { + v.as_u64() + .filter(|n| *n > 0 && *n <= u16::MAX as u64) + .map(|n| n as u16) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("sandbox marker field `{k}` is not a valid port"), + ) + }) + }; + let m = Marker { + version, + account: as_str(field("account")?, "account")?, + sid: as_str(field("sid")?, "sid")?, + port_low: as_port(field("port_low")?, "port_low")?, + port_high: as_port(field("port_high")?, "port_high")?, + }; + if m.port_high < m.port_low { + return Err(io::Error::new( + io::ErrorKind::InvalidData, + "sandbox marker port range is inverted", + )); + } + Ok(m) + } +} + +fn json_string(s: &str) -> String { + let mut out = String::with_capacity(s.len() + 2); + out.push('"'); + for c in s.chars() { + match c { + '"' => out.push_str("\\\""), + '\\' => out.push_str("\\\\"), + c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)), + c => out.push(c), + } + } + out.push('"'); + out +} + +/// `%PROGRAMDATA%\nub\sandbox`. Machine-scoped on purpose: the account, the WFP filters and +/// the ledger are all machine state, not per-user state. +pub(crate) fn state_dir() -> io::Result { + let base = std::env::var_os("PROGRAMDATA").ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "PROGRAMDATA is not set — cannot locate the nub sandbox state directory", + ) + })?; + Ok(PathBuf::from(base).join("nub").join("sandbox")) +} + +pub(crate) fn marker_path() -> io::Result { + Ok(state_dir()?.join("setup.json")) +} + +pub(crate) fn ledger_path() -> io::Result { + Ok(state_dir()?.join("acl-ledger.txt")) +} + +/// ELEVATED (the directory's DACL permits only administrators to write). +pub(crate) fn write_marker(m: &Marker) -> io::Result<()> { + let dir = state_dir()?; + std::fs::create_dir_all(&dir)?; + // tmp + rename so a torn write can never leave a half-parsed marker that an unelevated + // run would act on. + let path = marker_path()?; + let tmp = path.with_extension("json.tmp"); + std::fs::write(&tmp, m.to_json())?; + std::fs::rename(&tmp, &path) +} + +/// UNELEVATED. `None` when the machine has never been set up. +pub(crate) fn read_marker() -> io::Result> { + let path = marker_path()?; + match std::fs::read_to_string(&path) { + Ok(s) => Marker::from_json(&s).map(Some), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None), + Err(e) => Err(e), + } +} + +pub(crate) fn remove_marker() -> io::Result<()> { + match std::fs::remove_file(marker_path()?) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +/// Record a path BEFORE its ace is applied. Append-only and best-effort-atomic: a single +/// short append under `O_APPEND` semantics is not interleaved by a concurrent writer in +/// practice, and the failure mode of a torn line is a path that is never swept — which the +/// caller's own teardown still handles on the happy path. +pub(crate) fn record_acl_path(path: &Path) -> io::Result<()> { + use std::io::Write; + let dir = state_dir()?; + std::fs::create_dir_all(&dir)?; + let mut f = std::fs::OpenOptions::new() + .create(true) + .append(true) + .open(ledger_path()?)?; + writeln!(f, "{}", path.display()) +} + +/// Every path the ledger has ever recorded, de-duplicated in first-seen order. +pub(crate) fn ledger_paths() -> io::Result> { + let text = match std::fs::read_to_string(ledger_path()?) { + Ok(t) => t, + Err(e) if e.kind() == io::ErrorKind::NotFound => return Ok(Vec::new()), + Err(e) => return Err(e), + }; + Ok(dedup_lines(&text)) +} + +/// Split out so the de-duplication is testable without a filesystem. +fn dedup_lines(text: &str) -> Vec { + let mut seen: Vec = Vec::new(); + for line in text.lines() { + let line = line.trim(); + if line.is_empty() { + continue; + } + let p = PathBuf::from(line); + if !seen.contains(&p) { + seen.push(p); + } + } + seen +} + +/// Called only after every recorded path has been stripped. +pub(crate) fn clear_ledger() -> io::Result<()> { + match std::fs::remove_file(ledger_path()?) { + Ok(()) => Ok(()), + Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(()), + Err(e) => Err(e), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn sample() -> Marker { + Marker { + version: MARKER_VERSION, + account: "nub-sandbox".into(), + sid: "S-1-5-21-1-2-3-1001".into(), + port_low: 59080, + port_high: 59089, + } + } + + #[test] + fn marker_round_trips() { + let m = sample(); + assert_eq!(Marker::from_json(&m.to_json()).unwrap(), m); + } + + /// A marker written by a NEWER nub must be refused, not partially believed: acting on a + /// misread SID would ACL the wrong principal, and acting on a misread port range would + /// bind the proxy outside the WFP permit so the child silently reaches nothing. + #[test] + fn a_future_marker_version_is_refused() { + let json = format!( + "{{\"version\":{},\"account\":\"a\",\"sid\":\"S-1-5-21-1\",\"port_low\":1,\"port_high\":2}}", + MARKER_VERSION + 1 + ); + let err = Marker::from_json(&json).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); + assert!(err.to_string().contains("re-run the elevated sandbox setup")); + } + + /// An inverted or zero range would make the proxy's bind-in-range search fail in a way + /// that reads as "no free port" rather than "your setup is corrupt". + #[test] + fn a_malformed_port_range_is_refused() { + for json in [ + "{\"version\":1,\"account\":\"a\",\"sid\":\"S\",\"port_low\":900,\"port_high\":800}", + "{\"version\":1,\"account\":\"a\",\"sid\":\"S\",\"port_low\":0,\"port_high\":800}", + ] { + assert!(Marker::from_json(json).is_err(), "{json}"); + } + } + + /// The ledger is append-only, so the same path recurs across runs; the sweep must visit + /// each path once rather than re-stripping it N times. + #[test] + fn ledger_deduplicates_repeated_paths() { + let paths = dedup_lines("C:/a\nC:/b\nC:/a\n\n C:/c \n"); + assert_eq!( + paths, + vec![ + PathBuf::from("C:/a"), + PathBuf::from("C:/b"), + PathBuf::from("C:/c") + ] + ); + } +} diff --git a/crates/nub-sandbox/src/backend/windows_account/wfp.rs b/crates/nub-sandbox/src/backend/windows_account/wfp.rs new file mode 100644 index 000000000..ee4e0d526 --- /dev/null +++ b/crates/nub-sandbox/src/backend/windows_account/wfp.rs @@ -0,0 +1,615 @@ +//! Windows Filtering Platform egress fence for the dedicated sandbox account. +//! +//! THE MODEL (mirrors SRT's `vendor/srt-win-src/src/wfp.rs`, read 2026-07-24): four +//! PERSISTENT filters in one nub-owned persistent sublayer key the deny on +//! `FWPM_CONDITION_ALE_USER_ID` — the connecting token's user — so **every** process the +//! sandboxed child spawns is fenced by the same rule. That is what defeats surrogate-spawn: +//! a process-keyed filter loses the moment the child launches a helper, a user-keyed one +//! cannot. +//! +//! WHY A PORT RANGE, NOT THE EXACT PROXY PORT (the decision that keeps every run +//! UNELEVATED): every `Fwpm*Add`/`Delete` needs administrator, so a per-run filter carrying +//! the run's ephemeral proxy port would mean a UAC prompt per run. Instead the one-time +//! elevated install pre-authorizes a narrow LOOPBACK PORT WINDOW, and the proxy binds +//! *into* that window at launch (`proxy::EgressProxy::start_in_range`). One elevated +//! install ever; every run after it touches WFP not at all. The honest cost is that the +//! permit is a ~10-port loopback window rather than one exact port, and it is not +//! user-scoped — any local principal may connect to loopback inside it. Codex takes the +//! other branch (exact ports baked into the rule) and pays a fresh UAC prompt whenever the +//! port changes. +//! +//! ARBITRATION: WFP picks the highest-weight matching filter within a sublayer, so the +//! loopback PERMIT must out-weigh the account BLOCK. That ordering is the entire fence and +//! is const-asserted below. Weights stay under 2^60 so they land in WFP's manual-weight +//! class (the top nibble is the auto-classifier's). +//! +//! HONEST GAPS (both references share them; see LIMITATIONS.md): +//! - Inbound (`ALE_AUTH_RECV_ACCEPT_*`) and bind (`ALE_RESOURCE_ASSIGNMENT_*`) are NOT +//! filtered — the account may still listen. Egress is the axis nub's grammar names. +//! - DNS still resolves: `getaddrinfo` is serviced by the `Dnscache` service running as +//! `NETWORK SERVICE`, so the lookup succeeds under a different token even though the +//! subsequent `connect()` is blocked. Inherent to keying on the connecting token; no +//! filter set can close it. Harmless here — the child talks to nub's proxy by IP and +//! the PROXY resolves. + +#![cfg(target_os = "windows")] + +use std::io; +use windows_sys::Win32::Foundation::HANDLE; +use windows_sys::Win32::NetworkManagement::WindowsFilteringPlatform::*; +use windows_sys::core::GUID; + +/// nub's own WFP provider — every object this module installs carries it, so the uninstall +/// sweep can enumerate exactly nub's filters and nothing else. Stable forever: regenerating +/// it would orphan every filter written by an older nub. +const NUB_PROVIDER_KEY: GUID = GUID::from_u128(0x6f1c9e42_5a3d_4b17_9d84_2c7e0f5a8b31); +const NUB_SUBLAYER_KEY: GUID = GUID::from_u128(0x9a4b7d13_0e62_4c58_bf29_71d3a6e4c085); + +/// Sublayer weight. Mirrors SRT/Codex; high enough to sit above the default filtering +/// sublayers, low enough not to fight IPsec. +const SUBLAYER_WEIGHT: u16 = 0x8000; + +/// The loopback PERMIT must out-weigh the account BLOCK — that ordering IS the fence. +const W_LOOPBACK_PERMIT: u64 = 0x0F80_0000_0000_0000; +const W_ACCOUNT_BLOCK: u64 = 0x0F40_0000_0000_0000; +const _: () = assert!(W_LOOPBACK_PERMIT > W_ACCOUNT_BLOCK); + +/// The loopback window the elevated install pre-authorizes, which the egress proxy then +/// binds into. Ten ports leaves headroom for two concurrent listeners plus bind retries. +pub(crate) const DEFAULT_PROXY_PORT_RANGE: (u16, u16) = (59080, 59089); + +/// A wider window would stop meaningfully narrowing loopback, so the install refuses one. +pub(crate) const MAX_PROXY_PORT_RANGE_WIDTH: u16 = 64; + +/// BFE reports access-denied as EITHER of these — test both or an unelevated caller looks +/// like a hard failure. +const FWP_E_ACCESS_DENIED: u32 = 0x8032_0028; +const ERROR_ACCESS_DENIED: u32 = 5; +const FWP_E_ALREADY_EXISTS: u32 = 0x8032_0009; +const FWP_E_FILTER_NOT_FOUND: u32 = 0x8032_0003; +const FWP_E_SUBLAYER_NOT_FOUND: u32 = 0x8032_0007; +const FWP_E_PROVIDER_NOT_FOUND: u32 = 0x8032_0006; +const FWP_E_IN_USE: u32 = 0x8032_000A; + +/// `RPC_C_AUTHN_DEFAULT`, spelled locally so this module needs no `Win32_System_Rpc` import +/// beyond the one the binding's own signature forces. +const RPC_C_AUTHN_DEFAULT: u32 = 0xFFFF_FFFF; + +/// Whether a raw WFP status means "you are not elevated" rather than a real fault. +pub(crate) fn is_access_denied(rc: u32) -> bool { + rc == FWP_E_ACCESS_DENIED || rc == ERROR_ACCESS_DENIED +} + +fn wfp_err(op: &'static str, rc: u32) -> io::Error { + if is_access_denied(rc) { + return io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{op} failed: administrator rights are required to change WFP filters"), + ); + } + io::Error::other(format!("{op} failed (WFP status {rc:#010x})")) +} + +fn to_wide(s: &str) -> Vec { + s.encode_utf16().chain(std::iter::once(0)).collect() +} + +/// An open BFE engine handle, closed on drop. +struct Engine(HANDLE); + +impl Engine { + fn open() -> io::Result { + let mut h: HANDLE = std::ptr::null_mut(); + // SAFETY: all-null optional params; `h` is a valid out-slot. + let rc = unsafe { + FwpmEngineOpen0( + std::ptr::null(), + RPC_C_AUTHN_DEFAULT, + std::ptr::null(), + std::ptr::null(), + &mut h, + ) + }; + if rc != 0 { + return Err(wfp_err("FwpmEngineOpen0", rc)); + } + Ok(Engine(h)) + } +} + +impl Drop for Engine { + fn drop(&mut self) { + // SAFETY: `self.0` came from a successful FwpmEngineOpen0 and is closed once. + unsafe { FwpmEngineClose0(self.0) }; + } +} + +/// Run `f` inside a WFP transaction, aborting on any error or unwind. The whole install is +/// one transaction so a mid-install failure cannot leave a half-fence — which would be +/// fail-OPEN (block filters missing, permit present). +fn in_transaction( + engine: &Engine, + f: impl FnOnce(&Engine) -> io::Result, +) -> io::Result { + // SAFETY: engine handle is live for the whole call. + let rc = unsafe { FwpmTransactionBegin0(engine.0, 0) }; + if rc != 0 { + return Err(wfp_err("FwpmTransactionBegin0", rc)); + } + struct AbortOnDrop(HANDLE); + impl Drop for AbortOnDrop { + fn drop(&mut self) { + unsafe { FwpmTransactionAbort0(self.0) }; + } + } + let abort = AbortOnDrop(engine.0); + let out = f(engine)?; + // SAFETY: still inside the transaction opened above. + let rc = unsafe { FwpmTransactionCommit0(engine.0) }; + if rc != 0 { + return Err(wfp_err("FwpmTransactionCommit0", rc)); + } + std::mem::forget(abort); // disarm ONLY after a successful commit + Ok(out) +} + +/// A self-relative security descriptor built from SDDL, `LocalFree`d on drop. This is the +/// value the `ALE_USER_ID` condition carries: WFP runs `AccessCheck` on the connecting +/// token against it, so the filter matches exactly when the token is the sandbox account. +struct OwnedSd { + ptr: windows_sys::Win32::Security::PSECURITY_DESCRIPTOR, + len: u32, +} + +impl OwnedSd { + /// `G:` (primary group) is REQUIRED — not by the kernel, but because user-mode + /// `AccessCheck` rejects a group-less descriptor with `ERROR_INVALID_SECURITY_DESCR`, + /// which is how the host-side unit test validates the shape. `LS` (Local Service) is + /// just a stable always-present principal; its value never enters DACL evaluation. + /// The `CC` right is an arbitrary single bit — WFP only cares whether the check grants. + fn for_sid(sid: &str) -> io::Result { + use windows_sys::Win32::Security::Authorization::ConvertStringSecurityDescriptorToSecurityDescriptorW; + let sddl = to_wide(&format!("O:LSG:LSD:(A;;CC;;;{sid})")); + let mut ptr = std::ptr::null_mut(); + let mut len: u32 = 0; + // SAFETY: `sddl` is NUL-terminated UTF-16; both out-params are valid slots. + let ok = unsafe { + ConvertStringSecurityDescriptorToSecurityDescriptorW( + sddl.as_ptr(), + 1, // SDDL_REVISION_1 + &mut ptr, + &mut len, + ) + }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(OwnedSd { ptr, len }) + } + + fn blob(&self) -> FWP_BYTE_BLOB { + FWP_BYTE_BLOB { + size: self.len, + data: self.ptr.cast(), + } + } +} + +impl Drop for OwnedSd { + fn drop(&mut self) { + // SAFETY: `ptr` came from ConvertStringSecurityDescriptorToSecurityDescriptorW, + // which documents LocalFree as the release. + unsafe { windows_sys::Win32::Foundation::LocalFree(self.ptr.cast()) }; + } +} + +/// Backing storage every condition value points INTO. WFP's condition unions hold RAW +/// POINTERS, so these slots must outlive `FwpmFilterAdd0` — the single easiest way to write +/// a filter that silently reads freed memory. +struct ConditionSlots { + v4: FWP_V4_ADDR_AND_MASK, + v6: FWP_BYTE_ARRAY16, + port_range: FWP_RANGE0, + sd_blob: FWP_BYTE_BLOB, + weight: u64, +} + +/// The layer/action/conditions of one installed filter, kept as plain data so the four-way +/// filter table reads as a table. +#[derive(Clone, Copy, PartialEq, Eq, Debug)] +pub(crate) enum FilterSpec { + /// Permit the account to reach nub's proxy: loopback, inside the pre-authorized window. + LoopbackPermitV4, + LoopbackPermitV6, + /// Deny the account everything else. + AccountBlockV4, + AccountBlockV6, +} + +impl FilterSpec { + const ALL: [FilterSpec; 4] = [ + FilterSpec::LoopbackPermitV4, + FilterSpec::LoopbackPermitV6, + FilterSpec::AccountBlockV4, + FilterSpec::AccountBlockV6, + ]; + + fn layer(self) -> GUID { + match self { + FilterSpec::LoopbackPermitV4 | FilterSpec::AccountBlockV4 => { + FWPM_LAYER_ALE_AUTH_CONNECT_V4 + } + FilterSpec::LoopbackPermitV6 | FilterSpec::AccountBlockV6 => { + FWPM_LAYER_ALE_AUTH_CONNECT_V6 + } + } + } + + fn is_permit(self) -> bool { + matches!( + self, + FilterSpec::LoopbackPermitV4 | FilterSpec::LoopbackPermitV6 + ) + } + + fn name(self) -> &'static str { + match self { + FilterSpec::LoopbackPermitV4 => "nub sandbox: permit loopback proxy (IPv4)", + FilterSpec::LoopbackPermitV6 => "nub sandbox: permit loopback proxy (IPv6)", + FilterSpec::AccountBlockV4 => "nub sandbox: block egress for sandbox account (IPv4)", + FilterSpec::AccountBlockV6 => "nub sandbox: block egress for sandbox account (IPv6)", + } + } +} + +/// Install the four-filter egress fence for `sid`, permitting only loopback TCP inside +/// `port_range`. ELEVATED. Idempotent: any previously installed nub filters are swept +/// inside the same transaction first, so re-running never accretes duplicates. +pub(crate) fn install(sid: &str, port_range: (u16, u16)) -> io::Result<()> { + let (low, high) = port_range; + if low == 0 || high < low { + return Err(io::Error::other(format!( + "invalid sandbox proxy port range {low}-{high}" + ))); + } + if high - low + 1 > MAX_PROXY_PORT_RANGE_WIDTH { + return Err(io::Error::other(format!( + "sandbox proxy port range {low}-{high} is wider than the {MAX_PROXY_PORT_RANGE_WIDTH}-port maximum \ + — a wide window stops meaningfully narrowing loopback" + ))); + } + + let sd = OwnedSd::for_sid(sid)?; + let engine = Engine::open()?; + + in_transaction(&engine, |e| { + // Sweeping inside the transaction is what makes re-install idempotent. A swallowed + // enum error here would silently skip the sweep and grow the filter count on every + // install, so it propagates. + delete_nub_filters(e)?; + add_provider(e)?; + add_sublayer(e)?; + + let mut slots = ConditionSlots { + v4: FWP_V4_ADDR_AND_MASK { + addr: 0x7F00_0000, // 127.0.0.0 + mask: 0xFF00_0000, // /8 + }, + v6: { + let mut a = FWP_BYTE_ARRAY16 { byteArray16: [0; 16] }; + a.byteArray16[15] = 1; // ::1 + a + }, + port_range: FWP_RANGE0 { + valueLow: uint16_value(low), + valueHigh: uint16_value(high), + }, + sd_blob: sd.blob(), + weight: 0, + }; + + for spec in FilterSpec::ALL { + add_filter(e, spec, &mut slots)?; + } + Ok(()) + }) +} + +/// Remove every WFP object nub installed. ELEVATED. Tolerant of a partially-installed or +/// already-clean state so it can also serve as crash-residue recovery. +pub(crate) fn uninstall() -> io::Result<()> { + let engine = Engine::open()?; + in_transaction(&engine, |e| { + delete_nub_filters(e)?; + // SAFETY: engine handle live; key is a 'static GUID. + let rc = unsafe { FwpmSubLayerDeleteByKey0(e.0, &NUB_SUBLAYER_KEY) }; + // FWP_E_IN_USE means a foreign filter still sits under our sublayer — leave it. + if rc != 0 && rc != FWP_E_SUBLAYER_NOT_FOUND && rc != FWP_E_IN_USE { + return Err(wfp_err("FwpmSubLayerDeleteByKey0", rc)); + } + // SAFETY: as above. + let rc = unsafe { FwpmProviderDeleteByKey0(e.0, &NUB_PROVIDER_KEY) }; + if rc != 0 && rc != FWP_E_PROVIDER_NOT_FOUND && rc != FWP_E_IN_USE { + return Err(wfp_err("FwpmProviderDeleteByKey0", rc)); + } + Ok(()) + }) +} + +/// How many nub filters are currently installed. ELEVATED — WFP gates even ENUMERATION on +/// administrator, which is why an unelevated `status` cannot use this and must fall back to +/// the behavioral probe instead. +pub(crate) fn installed_filter_count() -> io::Result { + let engine = Engine::open()?; + let mut n = 0usize; + for layer in [ + FWPM_LAYER_ALE_AUTH_CONNECT_V4, + FWPM_LAYER_ALE_AUTH_CONNECT_V6, + ] { + n += enum_nub_filter_keys(&engine, layer)?.len(); + } + Ok(n) +} + +fn add_provider(e: &Engine) -> io::Result<()> { + let mut name = to_wide("nub sandbox"); + let mut desc = to_wide("nub OS-enforced sandbox egress fence"); + let provider = FWPM_PROVIDER0 { + providerKey: NUB_PROVIDER_KEY, + displayData: FWPM_DISPLAY_DATA0 { + name: name.as_mut_ptr(), + description: desc.as_mut_ptr(), + }, + flags: FWPM_PROVIDER_FLAG_PERSISTENT, + providerData: FWP_BYTE_BLOB { + size: 0, + data: std::ptr::null_mut(), + }, + serviceName: std::ptr::null_mut(), + }; + // SAFETY: `name`/`desc` outlive the call; provider is fully initialized. + let rc = unsafe { FwpmProviderAdd0(e.0, &provider, std::ptr::null_mut()) }; + if rc != 0 && rc != FWP_E_ALREADY_EXISTS { + return Err(wfp_err("FwpmProviderAdd0", rc)); + } + Ok(()) +} + +fn add_sublayer(e: &Engine) -> io::Result<()> { + let mut name = to_wide("nub sandbox"); + let mut desc = to_wide("nub sandbox account egress fence"); + let mut provider_key = NUB_PROVIDER_KEY; + let sublayer = FWPM_SUBLAYER0 { + subLayerKey: NUB_SUBLAYER_KEY, + displayData: FWPM_DISPLAY_DATA0 { + name: name.as_mut_ptr(), + description: desc.as_mut_ptr(), + }, + flags: FWPM_SUBLAYER_FLAG_PERSISTENT, + providerKey: &mut provider_key, + providerData: FWP_BYTE_BLOB { + size: 0, + data: std::ptr::null_mut(), + }, + weight: SUBLAYER_WEIGHT, + }; + // SAFETY: every referenced buffer outlives the call. + let rc = unsafe { FwpmSubLayerAdd0(e.0, &sublayer, std::ptr::null_mut()) }; + if rc != 0 && rc != FWP_E_ALREADY_EXISTS { + return Err(wfp_err("FwpmSubLayerAdd0", rc)); + } + Ok(()) +} + +fn uint16_value(v: u16) -> FWP_VALUE0 { + FWP_VALUE0 { + r#type: FWP_UINT16, + Anonymous: FWP_VALUE0_0 { uint16: v }, + } +} + +fn add_filter(e: &Engine, spec: FilterSpec, slots: &mut ConditionSlots) -> io::Result<()> { + let mut conditions: Vec = Vec::with_capacity(2); + + if spec.is_permit() { + let addr_value = match spec { + FilterSpec::LoopbackPermitV4 => FWP_CONDITION_VALUE0 { + r#type: FWP_V4_ADDR_MASK, + Anonymous: FWP_CONDITION_VALUE0_0 { + v4AddrMask: &mut slots.v4, + }, + }, + _ => FWP_CONDITION_VALUE0 { + r#type: FWP_BYTE_ARRAY16_TYPE, + Anonymous: FWP_CONDITION_VALUE0_0 { + byteArray16: &mut slots.v6, + }, + }, + }; + conditions.push(FWPM_FILTER_CONDITION0 { + fieldKey: FWPM_CONDITION_IP_REMOTE_ADDRESS, + matchType: FWP_MATCH_EQUAL, + conditionValue: addr_value, + }); + conditions.push(FWPM_FILTER_CONDITION0 { + fieldKey: FWPM_CONDITION_IP_REMOTE_PORT, + matchType: FWP_MATCH_RANGE, + conditionValue: FWP_CONDITION_VALUE0 { + r#type: FWP_RANGE_TYPE, + Anonymous: FWP_CONDITION_VALUE0_0 { + rangeValue: &mut slots.port_range, + }, + }, + }); + } else { + conditions.push(FWPM_FILTER_CONDITION0 { + fieldKey: FWPM_CONDITION_ALE_USER_ID, + matchType: FWP_MATCH_EQUAL, + conditionValue: FWP_CONDITION_VALUE0 { + r#type: FWP_SECURITY_DESCRIPTOR_TYPE, + Anonymous: FWP_CONDITION_VALUE0_0 { + sd: &mut slots.sd_blob, + }, + }, + }); + } + + slots.weight = if spec.is_permit() { + W_LOOPBACK_PERMIT + } else { + W_ACCOUNT_BLOCK + }; + + let mut name = to_wide(spec.name()); + let mut provider_key = NUB_PROVIDER_KEY; + let mut filter = FWPM_FILTER0 { + // A zero filterKey makes WFP mint one; identity comes from the provider key, which + // is what the uninstall sweep enumerates on. + displayData: FWPM_DISPLAY_DATA0 { + name: name.as_mut_ptr(), + description: std::ptr::null_mut(), + }, + flags: FWPM_FILTER_FLAG_PERSISTENT, + providerKey: &mut provider_key, + layerKey: spec.layer(), + subLayerKey: NUB_SUBLAYER_KEY, + weight: FWP_VALUE0 { + r#type: FWP_UINT64, + Anonymous: FWP_VALUE0_0 { + uint64: &mut slots.weight, + }, + }, + numFilterConditions: conditions.len() as u32, + filterCondition: conditions.as_mut_ptr(), + action: FWPM_ACTION0 { + r#type: if spec.is_permit() { + FWP_ACTION_PERMIT + } else { + FWP_ACTION_BLOCK + }, + Anonymous: FWPM_ACTION0_0 { + filterType: GUID::from_u128(0), + }, + }, + ..Default::default() + }; + + // SAFETY: `conditions`, `slots`, `name` and `provider_key` all outlive this call — WFP + // stores raw pointers into every one of them for the duration of the add. + let rc = unsafe { FwpmFilterAdd0(e.0, &mut filter, std::ptr::null_mut(), std::ptr::null_mut()) }; + if rc != 0 && rc != FWP_E_ALREADY_EXISTS { + return Err(wfp_err("FwpmFilterAdd0", rc)); + } + Ok(()) +} + +/// Every filter key under nub's provider at `layer`. Paged 256 at a time; each returned +/// batch is freed, and the enum handle is destroyed on both the success and error paths. +fn enum_nub_filter_keys(e: &Engine, layer: GUID) -> io::Result> { + let mut provider_key = NUB_PROVIDER_KEY; + let template = FWPM_FILTER_ENUM_TEMPLATE0 { + providerKey: &mut provider_key, + layerKey: layer, + enumType: FWP_FILTER_ENUM_OVERLAPPING, + actionMask: 0xFFFF_FFFF, + ..Default::default() + }; + + let mut enum_handle: HANDLE = std::ptr::null_mut(); + // SAFETY: `template` outlives the create; `enum_handle` is a valid out-slot. + let rc = unsafe { FwpmFilterCreateEnumHandle0(e.0, &template, &mut enum_handle) }; + if rc != 0 { + return Err(wfp_err("FwpmFilterCreateEnumHandle0", rc)); + } + + const PAGE: u32 = 256; + let mut keys = Vec::new(); + let result = loop { + let mut entries: *mut *mut FWPM_FILTER0 = std::ptr::null_mut(); + let mut returned: u32 = 0; + // SAFETY: valid engine + enum handles; both out-params are live slots. + let rc = unsafe { FwpmFilterEnum0(e.0, enum_handle, PAGE, &mut entries, &mut returned) }; + if rc != 0 { + break Err(wfp_err("FwpmFilterEnum0", rc)); + } + if !entries.is_null() { + // SAFETY: WFP guarantees `returned` valid `*mut FWPM_FILTER0` entries. + let slice = unsafe { std::slice::from_raw_parts(entries, returned as usize) }; + for &f in slice { + if !f.is_null() { + // SAFETY: each entry is a live filter for the life of this batch. + keys.push(unsafe { (*f).filterKey }); + } + } + // Every batch is freed, including an empty one. + unsafe { FwpmFreeMemory0(std::ptr::from_mut(&mut entries).cast()) }; + } + if returned < PAGE { + break Ok(()); + } + }; + + // SAFETY: destroy the enum handle on BOTH paths before returning. + unsafe { FwpmFilterDestroyEnumHandle0(e.0, enum_handle) }; + result?; + Ok(keys) +} + +fn delete_nub_filters(e: &Engine) -> io::Result<()> { + for layer in [ + FWPM_LAYER_ALE_AUTH_CONNECT_V4, + FWPM_LAYER_ALE_AUTH_CONNECT_V6, + ] { + for key in enum_nub_filter_keys(e, layer)? { + // SAFETY: `key` came out of the enumeration above. + let rc = unsafe { FwpmFilterDeleteByKey0(e.0, &key) }; + if rc != 0 && rc != FWP_E_FILTER_NOT_FOUND { + return Err(wfp_err("FwpmFilterDeleteByKey0", rc)); + } + } + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + /// The permit-over-block weight ordering IS the fence; a regression that inverted it + /// would silently open egress while every filter still appeared installed. + #[test] + fn loopback_permit_outweighs_the_account_block() { + assert!(W_LOOPBACK_PERMIT > W_ACCOUNT_BLOCK); + } + + /// The default window must satisfy the width rule the installer enforces. + #[test] + fn default_port_range_is_within_the_width_cap() { + let (low, high) = DEFAULT_PROXY_PORT_RANGE; + assert!(low > 0 && high >= low); + assert!(high - low + 1 <= MAX_PROXY_PORT_RANGE_WIDTH); + } + + /// Both access-denied spellings must read as "not elevated" — BFE returns either, and + /// treating one as a hard fault would turn a missing-elevation into a confusing crash. + #[test] + fn both_access_denied_spellings_are_recognized() { + assert!(is_access_denied(FWP_E_ACCESS_DENIED)); + assert!(is_access_denied(ERROR_ACCESS_DENIED)); + assert!(!is_access_denied(FWP_E_ALREADY_EXISTS)); + } + + /// The fence is exactly four filters — two permits, two blocks, one pair per address + /// family. A missing v6 block is a silent egress hole on a dual-stack host. + #[test] + fn filter_table_covers_both_families_in_both_directions() { + let permits = FilterSpec::ALL.iter().filter(|s| s.is_permit()).count(); + assert_eq!(permits, 2); + assert_eq!(FilterSpec::ALL.len() - permits, 2); + let v4 = FilterSpec::ALL + .iter() + .filter(|s| s.layer() == FWPM_LAYER_ALE_AUTH_CONNECT_V4) + .count(); + assert_eq!(v4, 2, "one permit + one block on IPv4"); + } +} diff --git a/crates/nub-sandbox/src/lib.rs b/crates/nub-sandbox/src/lib.rs index 0ab1294b5..6780b8ee9 100644 --- a/crates/nub-sandbox/src/lib.rs +++ b/crates/nub-sandbox/src/lib.rs @@ -97,6 +97,18 @@ pub mod policy; pub mod proxy; pub use backend::{CommandSpec, Degradation, Prepared, apply}; + +/// The Windows dedicated-account backend's machine administration, surfaced for the CLI. +/// +/// These are the ONLY operations that need administrator, and they are the reason the +/// per-run launch does not: one elevated `setup` installs the account and the SID-keyed WFP +/// egress fence, after which every sandboxed run is unelevated. `clean` is unelevated and +/// exists for crash residue. Windows-only by construction — no other OS needs a second +/// principal to express the grammar. +#[cfg(target_os = "windows")] +pub mod windows_admin { + pub use crate::backend::windows_account::{clean, setup, status, teardown}; +} pub use compiler::{ CommandRunner, CompileCtx, CompileError, CompileWarning, compile, compile_with_warnings, }; diff --git a/crates/nub-sandbox/src/proxy/mod.rs b/crates/nub-sandbox/src/proxy/mod.rs index 3087e55e9..e7f77c35e 100644 --- a/crates/nub-sandbox/src/proxy/mod.rs +++ b/crates/nub-sandbox/src/proxy/mod.rs @@ -115,6 +115,27 @@ pub struct EgressProxy { mitm: Option>, } +/// Walk `[low, high]` for a free loopback port. An in-use port is skipped rather than fatal +/// (a sibling nub run legitimately holds one); exhausting the window is an error naming it, +/// because silently falling back to an ephemeral port would bind OUTSIDE the range the +/// Windows WFP permit covers and leave the child unable to reach the proxy at all. +fn bind_in_range(low: u16, high: u16) -> io::Result { + let mut last: Option = None; + for port in low..=high { + match TcpListener::bind((IpAddr::from([127, 0, 0, 1]), port)) { + Ok(l) => return Ok(l), + Err(e) => last = Some(e), + } + } + Err(io::Error::new( + io::ErrorKind::AddrInUse, + format!( + "every loopback port in the sandbox proxy window {low}-{high} is in use{}", + last.map(|e| format!(" (last error: {e})")).unwrap_or_default() + ), + )) +} + impl EgressProxy { /// Bind a loopback listener and start the accept loop. `decider` gates every tunnel; /// `mitm` (when present) terminates the hosts whose rules demand inspection. Returns @@ -123,10 +144,29 @@ impl EgressProxy { pub fn start( decider: Arc, mitm: Option>, + ) -> io::Result { + Self::start_in_range(decider, mitm, None) + } + + /// As [`EgressProxy::start`], but constrained to bind inside `[low, high]` when a range is + /// given. + /// + /// WHY A RANGE EXISTS AT ALL: Windows' dedicated-account backend fences egress with WFP + /// filters keyed on the account SID, and every WFP write needs administrator. Baking the + /// run's ephemeral port into a filter would therefore mean a UAC prompt per run, so the + /// one-time elevated setup pre-authorizes a narrow loopback WINDOW instead and the proxy + /// binds into it. mac/Linux carve the exact port at launch and pass `None`. + pub fn start_in_range( + decider: Arc, + mitm: Option>, + range: Option<(u16, u16)>, ) -> io::Result { // Loopback only — the sandboxed child reaches us via 127.0.0.1; nothing off-box // should ever see this listener. - let listener = TcpListener::bind((IpAddr::from([127, 0, 0, 1]), 0))?; + let listener = match range { + None => TcpListener::bind((IpAddr::from([127, 0, 0, 1]), 0))?, + Some((low, high)) => bind_in_range(low, high)?, + }; let port = listener.local_addr()?.port(); let token: Arc = Arc::from(mint_token()); let shutdown = Arc::new(AtomicBool::new(false)); From f8595c0e90fc6110521e5a364a2e3cc5dd3fabc3 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:25:37 -0700 Subject: [PATCH 2/7] sandbox(windows): hidden `nub run --sandbox-admin ` for account setup/teardown/status/clean --- crates/nub-cli/src/cli.rs | 51 +++ .../src/backend/windows_account/account.rs | 299 ++++++++---------- .../src/backend/windows_account/acl.rs | 23 +- 3 files changed, 187 insertions(+), 186 deletions(-) diff --git a/crates/nub-cli/src/cli.rs b/crates/nub-cli/src/cli.rs index 8fecb5ff5..2b023ebcf 100644 --- a/crates/nub-cli/src/cli.rs +++ b/crates/nub-cli/src/cli.rs @@ -496,6 +496,18 @@ pub enum Command { #[arg(long = "sandbox", value_name = "FILE", hide = true)] sandbox: Option, + /// INTERNAL, UNDOCUMENTED: administer the Windows dedicated sandbox account. + /// `setup`/`teardown` need an elevated prompt (they create a local account and + /// install network filters); `status`/`clean` do not. The one-time-elevated half + /// of the Windows agent-sandbox backend — see `nub-sandbox`'s `windows_account`. + #[arg( + long = "sandbox-admin", + value_name = "ACTION", + hide = true, + value_parser = ["setup", "teardown", "status", "clean"] + )] + sandbox_admin: Option, + /// Remaining arguments forwarded to the script. #[arg(trailing_var_arg = true, allow_hyphen_values = true)] args: Vec, @@ -1905,9 +1917,13 @@ fn dispatch_subcommand(rest: Vec) -> Result { aggregate_output, resume_from, sandbox, + sandbox_admin, mut args, }) => { args.extend(suffix); + if let Some(action) = sandbox_admin { + return sandbox_admin_action(&action); + } // INTERNAL test entry: `nub run --sandbox [args]` // compiles an explicit surface policy and launches under the // sandbox engine. Bypasses the whole script-run/workspace path (this @@ -2676,6 +2692,41 @@ fn run_sandboxed(policy_file: &str, program: Option<&str>, args: &[String]) -> R Ok(status.code().unwrap_or(1)) } +/// INTERNAL, UNDOCUMENTED: the Windows agent-sandbox backend's machine administration. +/// +/// This is the one-time-elevated half of that backend and the reason the per-run launch is +/// NOT elevated: `setup` creates the dedicated local account and installs the SID-keyed WFP +/// egress filters once, after which every sandboxed run is an ordinary unelevated process. +/// `clean` is unelevated and exists for crash residue (aces left behind by a run that died +/// between grant and teardown). +#[cfg(target_os = "windows")] +fn sandbox_admin_action(action: &str) -> Result { + match action { + "setup" => { + let sid = nub_sandbox::windows_admin::setup(None)?; + println!("nub sandbox account ready ({sid})"); + } + "teardown" => { + nub_sandbox::windows_admin::teardown()?; + println!("nub sandbox account and network filters removed"); + } + "status" => print!("{}", nub_sandbox::windows_admin::status()?), + "clean" => { + let n = nub_sandbox::windows_admin::clean()?; + println!("swept {n} recorded path(s)"); + } + other => anyhow::bail!("unknown --sandbox-admin action `{other}`"), + } + Ok(0) +} + +/// The sandbox account backend is Windows-only — no other OS needs a second principal to +/// express the policy grammar, so there is nothing to administer. +#[cfg(not(target_os = "windows"))] +fn sandbox_admin_action(_action: &str) -> Result { + anyhow::bail!("--sandbox-admin is Windows-only") +} + /// The per-OS home anchors the sandbox compiler expands symbolic roots against. /// Best-effort from the environment (the frontend-less engine takes them as /// host-provided data). diff --git a/crates/nub-sandbox/src/backend/windows_account/account.rs b/crates/nub-sandbox/src/backend/windows_account/account.rs index 2a520391b..22c04a7d6 100644 --- a/crates/nub-sandbox/src/backend/windows_account/account.rs +++ b/crates/nub-sandbox/src/backend/windows_account/account.rs @@ -3,18 +3,18 @@ //! THE PRIVILEGE SPLIT LIVES HERE. [`provision`] and [`deprovision`] are the ELEVATED //! one-time halves — SAM writes (`NetUserAdd`, `NetLocalGroupAddMembers`) and an `HKLM` value //! all demand administrator. [`lookup_sid`] and [`load_credential`] are the UNELEVATED -//! per-run halves the broker calls on every launch. Keeping the split visible in this file's -//! signatures is what stops a per-run code path from quietly acquiring an elevation -//! requirement (see [`super`]'s module doc: every run after setup is unelevated). +//! per-run halves the broker calls on every launch. Keeping that split visible in this file's +//! signatures is what stops a per-run path from quietly acquiring an elevation requirement +//! (see [`super`]'s module doc: every run after setup is unelevated). //! //! WHY THE CREDENTIAL IS DPAPI **MACHINE** SCOPE. The elevated setup writes the blob and the //! unelevated broker reads it. Those are the same *user* but DIFFERENT LOGON SESSIONS, and a //! self-elevated child may not have the user's master key loaded at all — user-scope DPAPI //! does not round-trip across that split, machine scope does. The honest consequence is that //! machine scope is **not a security boundary**: any local principal that can READ the -//! ciphertext can decrypt it, including the sandbox account itself. The credential file's -//! DACL is the only gate, and this module never writes a DACL — [`credential_dir`] exists so -//! the setup path can hand the directory to the acl module, which owns every DACL write. +//! ciphertext can decrypt it, the sandbox account included. The file's DACL is the only gate, +//! and this module never writes a DACL — [`credential_dir`] exists so the setup path can hand +//! the directory to the acl module, which owns every DACL write. //! //! Mirrors SRT's `vendor/srt-win-src/src/{user,sam,dpapi}.rs` (read 2026-07-24); Codex's //! `windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs` is the second reference. @@ -24,6 +24,7 @@ use super::{SANDBOX_ACCOUNT, SANDBOX_GROUP}; use std::io; use std::path::PathBuf; +use std::ptr::{from_mut, from_ref, null, null_mut}; use windows_sys::Win32::Foundation::{ CloseHandle, ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_INSUFFICIENT_BUFFER, ERROR_MEMBER_IN_ALIAS, ERROR_NONE_MAPPED, ERROR_SUCCESS, GetLastError, HANDLE, LocalFree, @@ -59,7 +60,7 @@ const SID_BUILTIN_USERS: &str = "S-1-5-32-545"; const WINLOGON_USERLIST: &str = r"SOFTWARE\Microsoft\Windows NT\CurrentVersion\Winlogon\SpecialAccounts\UserList"; -/// Benign-idempotency codes with no `windows-sys` constant. +/// Benign-idempotency codes `windows-sys` has no constant for. const ERROR_ALIAS_EXISTS: u32 = 1379; const ERROR_NO_SUCH_ALIAS: u32 = 1376; @@ -71,8 +72,8 @@ const PW_LEN: usize = 32; /// ELEVATED. Create-or-repair the sandbox account and its group, rotate the credential, and /// store it. Idempotent — re-running repairs a half-provisioned machine (account present but /// out of `BUILTIN\Users`, flags cleared by a GPO, credential file deleted). Returns the -/// account's SID, which is the identity every downstream object keys on: WFP's -/// `ALE_USER_ID` descriptor and every granted or denied ACE. +/// account's SID, the identity every downstream object keys on: WFP's `ALE_USER_ID` +/// descriptor and every granted or denied ACE. pub(crate) fn provision() -> io::Result { ensure_group()?; let password = ensure_user()?; @@ -92,12 +93,13 @@ pub(crate) fn provision() -> io::Result { // Deliberately NOT granting `SeDenyInteractiveLogonRight`: `CreateProcessWithLogonW` goes // through an INTERACTIVE-type logon, so a deny-interactive right plausibly breaks the - // launch outright. Neither SRT nor Codex sets one. Revisit only against a real Windows - // box that can prove the launch survives it. + // launch outright. Neither SRT nor Codex sets one. Revisit only against a real Windows box + // that can prove the launch survives it. set_hidden(true)?; - store_credential(password.as_str())?; - Ok(sid) + store_credential(password.as_str()) + .map(|()| sid) + .map_err(|e| io::Error::new(e.kind(), format!("storing the sandbox credential: {e}"))) } /// ELEVATED. Remove the account, its profile, the group, the Winlogon hide entry, and the @@ -121,33 +123,31 @@ pub(crate) fn deprovision() -> io::Result<()> { // Best-effort by design: Windows only materializes the profile on first logon, and a // stuck child can hold it open. Neither may block the account delete below. // SAFETY: NUL-terminated SID string; NULL profile path and NULL computer name select - // the default local profile, which is the documented form. - unsafe { DeleteProfileW(sid_w.as_ptr(), std::ptr::null(), std::ptr::null()) }; + // the local machine's default profile, which is the documented form. + unsafe { DeleteProfileW(sid_w.as_ptr(), null(), null()) }; } let name_w = to_wide(SANDBOX_ACCOUNT); // SAFETY: NUL-terminated account name; NULL server means the local SAM. - let rc = unsafe { NetUserDel(std::ptr::null(), name_w.as_ptr()) }; + let rc = unsafe { NetUserDel(null(), name_w.as_ptr()) }; if rc != 0 && rc != NERR_UserNotFound { record(Err(net_err("NetUserDel", rc))); } let group_w = to_wide(SANDBOX_GROUP); // SAFETY: as above. - let rc = unsafe { NetLocalGroupDel(std::ptr::null(), group_w.as_ptr()) }; + let rc = unsafe { NetLocalGroupDel(null(), group_w.as_ptr()) }; if rc != 0 && rc != NERR_GroupNotFound && rc != ERROR_NO_SUCH_ALIAS { record(Err(net_err("NetLocalGroupDel", rc))); } record(set_hidden(false)); - - match credential_path().and_then(|p| match std::fs::remove_file(&p) { - Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e), - _ => Ok(()), - }) { - Err(e) => record(Err(e)), - Ok(()) => {} - } + record( + credential_path().and_then(|p| match std::fs::remove_file(&p) { + Err(e) if e.kind() != io::ErrorKind::NotFound => Err(e), + _ => Ok(()), + }), + ); match failure { Some(e) => Err(e), @@ -156,7 +156,7 @@ pub(crate) fn deprovision() -> io::Result<()> { } /// UNELEVATED. The sandbox account's SID, or `None` when it does not exist. An access-denied -/// or transient LSA failure propagates — reporting "absent" for those would make an +/// or transient LSA failure PROPAGATES — reporting "absent" for those would make an /// already-provisioned machine look unprovisioned and trigger a spurious elevation prompt. pub(crate) fn lookup_sid() -> io::Result> { lookup_account_sid(SANDBOX_ACCOUNT) @@ -166,8 +166,8 @@ pub(crate) fn lookup_sid() -> io::Result> { /// /// The returned plaintext is the CALLER's to bound — it goes straight into /// `CreateProcessWithLogonW`, so it is deliberately a plain `String` rather than a scrubbing -/// wrapper that the FFI boundary would defeat anyway. Every intermediate buffer this function -/// owns is zeroed before it returns. +/// wrapper the FFI boundary would defeat anyway. Every intermediate buffer this function owns +/// is zeroed before it returns. pub(crate) fn load_credential() -> io::Result { let path = credential_path()?; let ciphertext = std::fs::read(&path).map_err(|e| { @@ -181,17 +181,11 @@ pub(crate) fn load_credential() -> io::Result { ) })?; let mut plaintext = dpapi_unprotect(&ciphertext)?; - let out = match std::str::from_utf8(&plaintext) { - Ok(s) => s.to_owned(), - Err(_) => { - scrub_u8(&mut plaintext); - return Err(io::Error::other( - "the stored sandbox credential is corrupt (not valid UTF-8)", - )); - } - }; + let out = std::str::from_utf8(&plaintext) + .map(str::to_owned) + .map_err(|_| io::Error::other("the stored sandbox credential is corrupt (not UTF-8)")); scrub_u8(&mut plaintext); - Ok(out) + out } /// The credential store's directory. Its DACL must DENY [`SANDBOX_GROUP`] — machine-scope @@ -217,7 +211,7 @@ pub(crate) fn credential_path() -> io::Result { /// which the SAM and `HKLM` writes in [`provision`] succeed. A standard user and an admin's /// filtered Medium-IL token both report `false`, and both would get `ERROR_ACCESS_DENIED`. pub(crate) fn is_elevated() -> bool { - let mut token: HANDLE = std::ptr::null_mut(); + let mut token: HANDLE = null_mut(); // SAFETY: query-only handle into our own process token. if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { return false; @@ -229,8 +223,8 @@ pub(crate) fn is_elevated() -> bool { GetTokenInformation( token, TokenElevation, - std::ptr::from_mut(&mut elevation).cast(), - std::mem::size_of::() as u32, + from_mut(&mut elevation).cast(), + size_of::() as u32, &mut ret_len, ) }; @@ -239,7 +233,7 @@ pub(crate) fn is_elevated() -> bool { ok != 0 && elevation.TokenIsElevated != 0 } -// ───────────────────────────── errors and scratch ───────────────────────────── +// ───────────────────────────── errors, scratch, RAII ───────────────────────────── /// Access-denied on any of these operations means "you are not elevated", not a fault — the /// caller turns `PermissionDenied` into an actionable re-run-elevated message rather than @@ -263,7 +257,7 @@ fn to_wide(s: &str) -> Vec { fn scrub_u8(buf: &mut [u8]) { for b in buf { - // SAFETY: `b` is a live unique reference; volatile so the store survives a compiler + // SAFETY: `b` is a live unique reference. Volatile so the store survives a compiler // that would otherwise treat writing to a dying buffer as dead code. unsafe { std::ptr::write_volatile(b, 0) }; } @@ -311,15 +305,13 @@ struct LocalSid(PSID); impl LocalSid { fn parse(sid: &str) -> io::Result { let w = to_wide(sid); - let mut psid: PSID = std::ptr::null_mut(); + let mut psid: PSID = null_mut(); // SAFETY: `w` is NUL-terminated UTF-16; `psid` is a valid out-slot. let ok = unsafe { ConvertStringSidToSidW(w.as_ptr(), &mut psid) }; if ok == 0 { - return Err(io::Error::other(format!( - "ConvertStringSidToSidW({sid}) failed (status {})", - // SAFETY: read immediately after the failed call on this thread. - unsafe { GetLastError() } - ))); + // SAFETY: read immediately after the failed call on this thread. + let rc = unsafe { GetLastError() }; + return Err(net_err(&format!("ConvertStringSidToSidW({sid})"), rc)); } Ok(LocalSid(psid)) } @@ -342,28 +334,28 @@ fn lookup_account_sid(name: &str) -> io::Result> { let name_w = to_wide(name); let mut cb_sid: u32 = 0; let mut cch_dom: u32 = 0; - let mut sid_use: SID_NAME_USE = 0; + let mut kind: SID_NAME_USE = 0; // SAFETY: the documented sizing form — NULL buffers with zeroed lengths. let ok = unsafe { LookupAccountNameW( - std::ptr::null(), + null(), name_w.as_ptr(), - std::ptr::null_mut(), + null_mut(), &mut cb_sid, - std::ptr::null_mut(), + null_mut(), &mut cch_dom, - &mut sid_use, + &mut kind, ) }; if ok == 0 { // SAFETY: read immediately after the failed call on this thread. - let e = unsafe { GetLastError() }; - if e == ERROR_NONE_MAPPED { + let rc = unsafe { GetLastError() }; + if rc == ERROR_NONE_MAPPED { return Ok(None); } - if e != ERROR_INSUFFICIENT_BUFFER { - return Err(net_err(&format!("LookupAccountNameW({name})"), e)); + if rc != ERROR_INSUFFICIENT_BUFFER { + return Err(net_err(&format!("LookupAccountNameW({name})"), rc)); } } if cb_sid == 0 { @@ -377,22 +369,22 @@ fn lookup_account_sid(name: &str) -> io::Result> { // SAFETY: both buffers are sized by the call above and outlive this one. let ok = unsafe { LookupAccountNameW( - std::ptr::null(), + null(), name_w.as_ptr(), sid.as_mut_ptr().cast(), &mut cb_sid, dom.as_mut_ptr(), &mut cch_dom, - &mut sid_use, + &mut kind, ) }; if ok == 0 { // SAFETY: as above. - let e = unsafe { GetLastError() }; - if e == ERROR_NONE_MAPPED { + let rc = unsafe { GetLastError() }; + if rc == ERROR_NONE_MAPPED { return Ok(None); } - return Err(net_err(&format!("LookupAccountNameW({name})"), e)); + return Err(net_err(&format!("LookupAccountNameW({name})"), rc)); } // SAFETY: the buffer now holds a valid self-relative SID. Ok(Some(unsafe { sid_to_string(sid.as_mut_ptr().cast()) }?)) @@ -404,18 +396,18 @@ fn lookup_account_name(sid_str: &str) -> io::Result { let sid = LocalSid::parse(sid_str)?; let mut cch_name: u32 = 0; let mut cch_dom: u32 = 0; - let mut sid_use: SID_NAME_USE = 0; + let mut kind: SID_NAME_USE = 0; // SAFETY: sizing call against a live PSID; both out-lengths are valid slots. unsafe { LookupAccountSidW( - std::ptr::null(), + null(), sid.0, - std::ptr::null_mut(), + null_mut(), &mut cch_name, - std::ptr::null_mut(), + null_mut(), &mut cch_dom, - &mut sid_use, + &mut kind, ) }; if cch_name == 0 { @@ -428,20 +420,19 @@ fn lookup_account_name(sid_str: &str) -> io::Result { // SAFETY: both buffers are sized by the call above and outlive this one. let ok = unsafe { LookupAccountSidW( - std::ptr::null(), + null(), sid.0, name.as_mut_ptr(), &mut cch_name, dom.as_mut_ptr(), &mut cch_dom, - &mut sid_use, + &mut kind, ) }; if ok == 0 { // SAFETY: read immediately after the failed call on this thread. - return Err(net_err(&format!("LookupAccountSidW({sid_str})"), unsafe { - GetLastError() - })); + let rc = unsafe { GetLastError() }; + return Err(net_err(&format!("LookupAccountSidW({sid_str})"), rc)); } Ok(String::from_utf16_lossy(&name[..cch_name as usize])) } @@ -449,14 +440,14 @@ fn lookup_account_name(sid_str: &str) -> io::Result { /// # Safety /// `sid` must point at a valid self-relative SID for the duration of the call. unsafe fn sid_to_string(sid: PSID) -> io::Result { - let mut out: *mut u16 = std::ptr::null_mut(); + let mut out: *mut u16 = null_mut(); // SAFETY: caller guarantees `sid`; `out` is a valid slot. let ok = unsafe { ConvertSidToStringSidW(sid, &mut out) }; if ok == 0 { return Err(io::Error::last_os_error()); } let mut len = 0usize; - // SAFETY: on success the buffer is NUL-terminated UTF-16 allocated by LocalAlloc. + // SAFETY: on success the buffer is NUL-terminated UTF-16 allocated by `LocalAlloc`. while unsafe { *out.add(len) } != 0 { len += 1; } @@ -467,7 +458,7 @@ unsafe fn sid_to_string(sid: PSID) -> io::Result { Ok(s) } -// ───────────────────────────── account + group ───────────────────────────── +// ───────────────────────────── password ───────────────────────────── /// The 85-symbol alphabet. It EXCLUDES `"`, `\`, backtick, whitespace and the shell-special /// `& | < > ^` set, so the credential survives any cmd / PowerShell / argv relay between here @@ -484,11 +475,11 @@ const CLASSES: [&[u8]; 4] = [ ]; fn fill_random(buf: &mut [u8]) -> io::Result<()> { - // SAFETY: `buf` is a live writable slice of exactly `len` bytes; a NULL algorithm handle + // SAFETY: `buf` is a live writable slice of exactly `len()` bytes; a NULL algorithm handle // is what BCRYPT_USE_SYSTEM_PREFERRED_RNG requires. let st = unsafe { BCryptGenRandom( - std::ptr::null_mut(), + null_mut(), buf.as_mut_ptr(), buf.len() as u32, BCRYPT_USE_SYSTEM_PREFERRED_RNG, @@ -502,9 +493,9 @@ fn fill_random(buf: &mut [u8]) -> io::Result<()> { Ok(()) } -/// 32 chars rejection-sampled from [`ALPHA`] via the system CSPRNG. Rejection sampling is -/// what makes each pick UNIFORM — 85 does not divide 256, so a bare `% 85` would bias the -/// first 86 symbols. +/// 32 chars rejection-sampled from [`ALPHA`] via the system CSPRNG (never a userspace PRNG). +/// Rejection sampling is what makes each pick UNIFORM — 85 does not divide 256, so a bare +/// `% 85` would bias the first 86 symbols. fn gen_password() -> io::Result { let mut raw = [0u8; PW_LEN]; fill_random(&mut raw)?; @@ -531,9 +522,9 @@ fn gen_password() -> io::Result { Ok(Secret(String::from_utf8(out).expect("ALPHA is ASCII"))) } -/// Force one character from each complexity class when the uniform draw missed one. The -/// 2245 retry loop in [`ensure_user`] is the primary defence against a tightened local -/// policy; this makes the FIRST attempt pass almost always. +/// Force one character from each complexity class when the uniform draw missed one. The 2245 +/// retry loop in [`ensure_user`] is the primary defence against a tightened local policy; +/// this makes the FIRST attempt pass almost always. fn apply_class_floor(out: &mut [u8], extra: &[u8; 5]) { if CLASSES.iter().all(|c| out.iter().any(|b| c.contains(b))) { return; @@ -560,12 +551,11 @@ fn class_summary(password: &str) -> String { format!("len={} U={u} L={l} D={d} S={s}", password.len()) } -/// `(min_password_len, password_history_len)` from the local SAM, or `(-1, -1)` when the -/// query itself fails. +/// `(min_password_len, password_history_len)` from the local SAM, `(-1, -1)` when unreadable. fn local_password_policy() -> (i64, i64) { - let mut buf: *mut u8 = std::ptr::null_mut(); + let mut buf: *mut u8 = null_mut(); // SAFETY: NULL server = local SAM; `buf` is a valid out-slot. - if unsafe { NetUserModalsGet(std::ptr::null(), 0, &mut buf) } != 0 || buf.is_null() { + if unsafe { NetUserModalsGet(null(), 0, &mut buf) } != 0 || buf.is_null() { return (-1, -1); } // SAFETY: on success netapi returns one USER_MODALS_INFO_0 in its own buffer. @@ -588,6 +578,8 @@ fn warn_password_rejected(op: &str, attempt: usize, password: &str) { ); } +// ───────────────────────────── account + group ───────────────────────────── + /// Create the account, or rotate the credential of the one already present. /// /// Retries on `NERR_PasswordTooShort` (2245) because SAM returns that code for ANY local @@ -600,30 +592,23 @@ fn ensure_user() -> io::Result { for attempt in 0..MAX_PW_ATTEMPTS { let password = gen_password()?; - let mut pw_w = WideSecret(to_wide(password.as_str())); + let mut pw = WideSecret(to_wide(password.as_str())); let info = USER_INFO_1 { usri1_name: name_w.as_mut_ptr(), - usri1_password: pw_w.0.as_mut_ptr(), + usri1_password: pw.0.as_mut_ptr(), usri1_password_age: 0, usri1_priv: USER_PRIV_USER, - usri1_home_dir: std::ptr::null_mut(), + usri1_home_dir: null_mut(), usri1_comment: comment_w.as_mut_ptr(), // UF_SCRIPT is MANDATORY on workstation SKUs — a vestigial LAN-Manager flag SAM // still insists on. Omit it and NetUserAdd fails with NERR_BadUsername / // ERROR_INVALID_PARAMETER, neither of which hints at the real cause. usri1_flags: UF_SCRIPT | UF_DONT_EXPIRE_PASSWD, - usri1_script_path: std::ptr::null_mut(), + usri1_script_path: null_mut(), }; // SAFETY: every PWSTR field points into a buffer that outlives this call. - let rc = unsafe { - NetUserAdd( - std::ptr::null(), - 1, - std::ptr::from_ref(&info).cast(), - std::ptr::null_mut(), - ) - }; + let rc = unsafe { NetUserAdd(null(), 1, from_ref(&info).cast(), null_mut()) }; if rc == 0 { return Ok(password); } @@ -639,16 +624,16 @@ fn ensure_user() -> io::Result { // live account. Levels 1003 (password) and 1008 (flags) rather than another level-1 // SetInfo, which would clobber priv / home_dir / comment. let info = USER_INFO_1003 { - usri1003_password: pw_w.0.as_mut_ptr(), + usri1003_password: pw.0.as_mut_ptr(), }; // SAFETY: `name_w` and the password buffer outlive this call. let rc = unsafe { NetUserSetInfo( - std::ptr::null(), + null(), name_w.as_ptr(), 1003, - std::ptr::from_ref(&info).cast(), - std::ptr::null_mut(), + from_ref(&info).cast(), + null_mut(), ) }; if rc == NERR_PasswordTooShort && attempt + 1 < MAX_PW_ATTEMPTS { @@ -671,9 +656,9 @@ fn ensure_user() -> io::Result { /// have cleared it since the last provision, and an expiring password silently breaks every /// future launch with an opaque logon failure. fn reassert_flags(name_w: &[u16]) -> io::Result<()> { - let mut buf: *mut u8 = std::ptr::null_mut(); + let mut buf: *mut u8 = null_mut(); // SAFETY: NUL-terminated name; `buf` is a valid out-slot. - let rc = unsafe { NetUserGetInfo(std::ptr::null(), name_w.as_ptr(), 1, &mut buf) }; + let rc = unsafe { NetUserGetInfo(null(), name_w.as_ptr(), 1, &mut buf) }; if rc != 0 || buf.is_null() { return Err(net_err("NetUserGetInfo(1)", rc)); } @@ -685,14 +670,14 @@ fn reassert_flags(name_w: &[u16]) -> io::Result<()> { let info = USER_INFO_1008 { usri1008_flags: flags | UF_DONT_EXPIRE_PASSWD, }; - // SAFETY: `info` and `name_w` outlive the call. + // SAFETY: `info` and `name_w` both outlive the call. let rc = unsafe { NetUserSetInfo( - std::ptr::null(), + null(), name_w.as_ptr(), 1008, - std::ptr::from_ref(&info).cast(), - std::ptr::null_mut(), + from_ref(&info).cast(), + null_mut(), ) }; if rc != 0 { @@ -709,14 +694,7 @@ fn ensure_group() -> io::Result<()> { lgrpi1_comment: comment_w.as_mut_ptr(), }; // SAFETY: both PWSTR fields point into buffers that outlive this call. - let rc = unsafe { - NetLocalGroupAdd( - std::ptr::null(), - 1, - std::ptr::from_ref(&info).cast(), - std::ptr::null_mut(), - ) - }; + let rc = unsafe { NetLocalGroupAdd(null(), 1, from_ref(&info).cast(), null_mut()) }; if rc != 0 && rc != NERR_GroupExists && rc != ERROR_ALIAS_EXISTS { return Err(net_err("NetLocalGroupAdd", rc)); } @@ -732,15 +710,8 @@ fn add_member(group: &str, member: &LocalSid) -> io::Result<()> { lgrmi0_sid: member.0, }; // SAFETY: `group_w` and the member's PSID both outlive this call. - let rc = unsafe { - NetLocalGroupAddMembers( - std::ptr::null(), - group_w.as_ptr(), - 0, - std::ptr::from_ref(&info).cast(), - 1, - ) - }; + let rc = + unsafe { NetLocalGroupAddMembers(null(), group_w.as_ptr(), 0, from_ref(&info).cast(), 1) }; if rc != 0 && rc != ERROR_MEMBER_IN_ALIAS { return Err(net_err(&format!("NetLocalGroupAddMembers({group})"), rc)); } @@ -753,40 +724,30 @@ fn add_member(group: &str, member: &LocalSid) -> io::Result<()> { fn set_hidden(hide: bool) -> io::Result<()> { let sub_w = to_wide(WINLOGON_USERLIST); let val_w = to_wide(SANDBOX_ACCOUNT); - let mut key: HKEY = std::ptr::null_mut(); + let mut key: HKEY = null_mut(); if hide { - // SAFETY: NUL-terminated subkey; `key` is a valid out-slot. Create, because the - // SpecialAccounts subtree does not exist on a stock install. + // Create, not open: the SpecialAccounts subtree does not exist on a stock install. + // SAFETY: NUL-terminated subkey; `key` is a valid out-slot. let rc = unsafe { RegCreateKeyExW( HKEY_LOCAL_MACHINE, sub_w.as_ptr(), 0, - std::ptr::null(), + null(), REG_OPTION_NON_VOLATILE, KEY_SET_VALUE, - std::ptr::null(), + null(), &mut key, - std::ptr::null_mut(), + null_mut(), ) }; if rc != ERROR_SUCCESS { return Err(net_err("RegCreateKeyExW(Winlogon SpecialAccounts)", rc)); } let data = 0u32.to_ne_bytes(); - // SAFETY: `key` is open for KEY_SET_VALUE; `data` is exactly 4 bytes as REG_DWORD - // requires. - let rc = unsafe { - RegSetValueExW( - key, - val_w.as_ptr(), - 0, - REG_DWORD, - data.as_ptr(), - data.len() as u32, - ) - }; + // SAFETY: `key` is open for KEY_SET_VALUE; `data` is the 4 bytes REG_DWORD requires. + let rc = unsafe { RegSetValueExW(key, val_w.as_ptr(), 0, REG_DWORD, data.as_ptr(), 4) }; // SAFETY: `key` came from a successful create and is closed once. unsafe { RegCloseKey(key) }; if rc != ERROR_SUCCESS { @@ -833,15 +794,15 @@ fn dpapi_protect(plaintext: &[u8]) -> io::Result> { pbData: plaintext.as_ptr().cast_mut(), }; let mut out = CRYPT_INTEGER_BLOB::default(); - // SAFETY: `input` borrows a live slice for the call; `out` is a valid out-slot. All - // optional parameters are NULL, which the API documents as "unused". + // SAFETY: `input` borrows a live slice for the call and `out` is a valid out-slot; every + // optional parameter is NULL, which the API documents as "unused". let ok = unsafe { CryptProtectData( &input, - std::ptr::null(), - std::ptr::null(), - std::ptr::null(), - std::ptr::null(), + null(), + null(), + null(), + null(), CRYPTPROTECT_LOCAL_MACHINE | CRYPTPROTECT_UI_FORBIDDEN, &mut out, ) @@ -859,15 +820,14 @@ fn dpapi_unprotect(ciphertext: &[u8]) -> io::Result> { pbData: ciphertext.as_ptr().cast_mut(), }; let mut out = CRYPT_INTEGER_BLOB::default(); - // SAFETY: as above. The scope flag is read back out of the blob header, so machine-scope - // ciphertext needs no flag here. + // SAFETY: as above. No scope flag: DPAPI reads it back out of the blob header. let ok = unsafe { CryptUnprotectData( &input, - std::ptr::null_mut(), - std::ptr::null(), - std::ptr::null(), - std::ptr::null(), + null_mut(), + null(), + null(), + null(), CRYPTPROTECT_UI_FORBIDDEN, &mut out, ) @@ -887,8 +847,8 @@ unsafe fn take_blob(out: CRYPT_INTEGER_BLOB) -> Vec { } // SAFETY: DPAPI guarantees `cbData` readable bytes at `pbData`. let v = unsafe { std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec() }; - // SAFETY: freed exactly once; `LocalFree` is the documented release. Freed even when - // `cbData` is 0, which the API can return for an empty plaintext. + // SAFETY: freed exactly once, and freed even when `cbData` is 0 — which the API can + // return for an empty plaintext. unsafe { LocalFree(out.pbData.cast()) }; v } @@ -898,7 +858,7 @@ mod tests { use super::*; /// The alphabet is the whole reason the credential survives a cmd / PowerShell / argv - /// relay into `CreateProcessWithLogonW`. One stray metacharacter here turns into an + /// relay into `CreateProcessWithLogonW`. One stray metacharacter here becomes an /// intermittent, machine-specific logon failure. #[test] fn alphabet_excludes_every_shell_hostile_character() { @@ -906,7 +866,7 @@ mod tests { for c in [b'"', b'\\', b'`', b'\'', b' ', b'&', b'|', b'<', b'>', b'^'] { assert!(!ALPHA.contains(&c), "ALPHA contains {}", c as char); } - assert!(ALPHA.iter().all(|b| b.is_ascii_graphic())); + assert!(ALPHA.iter().all(u8::is_ascii_graphic)); // Every class must be drawable from ALPHA, or the floor below writes a character the // uniform draw could never produce. for class in CLASSES { @@ -921,14 +881,14 @@ mod tests { fn class_floor_repairs_a_draw_missing_every_class() { let mut out = [b'a'; PW_LEN]; apply_class_floor(&mut out, &[7, 0, 0, 0, 0]); - assert!(out.iter().any(|b| b.is_ascii_uppercase())); - assert!(out.iter().any(|b| b.is_ascii_lowercase())); - assert!(out.iter().any(|b| b.is_ascii_digit())); + assert!(out.iter().any(u8::is_ascii_uppercase)); + assert!(out.iter().any(u8::is_ascii_lowercase)); + assert!(out.iter().any(u8::is_ascii_digit)); assert!(out.iter().any(|b| !b.is_ascii_alphanumeric())); } - /// A complete draw must be left ALONE — rewriting four slots with a deterministic pick - /// derived from five bytes would shed entropy on every generated password. + /// A complete draw must be left ALONE — rewriting four slots with a pick derived from + /// five bytes would shed entropy on every generated password. #[test] fn class_floor_leaves_a_complete_draw_untouched() { let mut out = *b"Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!Aa1!"; @@ -944,12 +904,11 @@ mod tests { let pw = "Aa1!Aa1!"; let s = class_summary(pw); assert_eq!(s, "len=8 U=2 L=2 D=2 S=2"); - assert!(!s.contains(pw)); - assert!(!s.contains("Aa1")); + assert!(!s.contains(pw) && !s.contains("Aa1")); } - /// End-to-end shape of the generator against the real system CSPRNG. Needs Windows but - /// no elevation, so it runs on the ordinary CI leg. + /// End-to-end shape of the generator against the real system CSPRNG. Needs Windows but no + /// elevation, so it runs on the ordinary CI leg. #[test] fn generated_password_is_ascii_complex_and_unique() { let p = gen_password().expect("gen_password"); diff --git a/crates/nub-sandbox/src/backend/windows_account/acl.rs b/crates/nub-sandbox/src/backend/windows_account/acl.rs index 568c87371..961cabdad 100644 --- a/crates/nub-sandbox/src/backend/windows_account/acl.rs +++ b/crates/nub-sandbox/src/backend/windows_account/acl.rs @@ -41,22 +41,13 @@ use windows_sys::Win32::Security::{ CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetAclInformation, InitializeAcl, OBJECT_INHERIT_ACE, PSECURITY_DESCRIPTOR, PSID, }; - -// Win32 file access rights, spelled numerically so this module needs no -// `Win32_Storage_FileSystem` feature for nine frozen constants — the same call -// `backend::windows` already makes. Every value confirmed against windows-sys 0.61.2 -// `src/Windows/Win32/Storage/FileSystem/mod.rs` at the cited line. -const FILE_GENERIC_READ: u32 = 0x0012_0089; // :1535 (1179785) -const FILE_GENERIC_WRITE: u32 = 0x0012_0116; // :1536 (1179926) -const FILE_GENERIC_EXECUTE: u32 = 0x0012_00A0; // :1534 (1179808) -const FILE_ALL_ACCESS: u32 = 0x001F_01FF; // :1396 (2032127) -const FILE_DELETE_CHILD: u32 = 0x0000_0040; // :1459 (64) -/// The same bit as `FILE_EXECUTE` (:1488) — the kernel reads it as traverse on a directory -/// and as execute on a file. There is no primitive that separates them. -const FILE_TRAVERSE: u32 = 0x0000_0020; // :1859 (32) -const DELETE: u32 = 0x0001_0000; // :1119 (65536) -const WRITE_DAC: u32 = 0x0004_0000; // :4363 (262144) -const WRITE_OWNER: u32 = 0x0008_0000; // :4364 (524288) +// `FILE_TRAVERSE` is the SAME bit as `FILE_EXECUTE` — the kernel reads it as traverse on a +// directory and as execute on a file, and no primitive separates them. It is imported under +// the traverse spelling because that is the property the mask assertions below are about. +use windows_sys::Win32::Storage::FileSystem::{ + DELETE, FILE_ALL_ACCESS, FILE_DELETE_CHILD, FILE_GENERIC_EXECUTE, FILE_GENERIC_READ, + FILE_GENERIC_WRITE, FILE_TRAVERSE, WRITE_DAC, WRITE_OWNER, +}; /// Read + write + execute + delete. What a policy's write grant stamps. const RW: u32 = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | DELETE; From c36a99ac4103689022fca6a2994671430aa9f9fd Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Fri, 24 Jul 2026 22:46:36 -0700 Subject: [PATCH 3/7] sandbox(windows): account lifecycle, DACL grants/denies, and the enforcement probe MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Completes the dedicated-account backend: - account.rs — NetUserAdd/NetUserSetInfo lifecycle (UF_SCRIPT is mandatory on workstation SKUs), BCryptGenRandom credential over a shell-safe alphabet with a retry on the local password policy, localized BUILTIN\Users membership resolved from S-1-5-32-545 and added by PSID, DPAPI machine-scope credential store, Winlogon user-picker hide, SID-before-NetUserDel teardown. - acl.rs — inheritable grant/deny aces whose masks deliberately exclude FILE_DELETE_CHILD, WRITE_DAC and WRITE_OWNER (each exclusion is a compile-time assertion naming the bypass it prevents), plus a hand-built ACL rebuild for removal because SetEntriesInAclW(REVOKE_ACCESS) does not remove explicit deny aces on Windows 11 25H2. - tests/windows_account_enforcement.rs — the real behavioral probe: secret denied inside a granted tree, writes jailed, egress blocked on raw WSAEACCES, surrogate-spawn fenced, proxy reachable inside the window. Every assertion carries a negative control, and an un-provisioned machine exits non-zero rather than passing hollow. Two pre-existing Windows-target breaks are fixed alongside, because the lib's unit tests and the enforcement suite could not compile without them: a NetPolicy literal that never grew the MITM tier's fields, and an expect_err that requires a Debug bound Prepared does not have. CI builds only three named integration targets on windows-latest, which is why neither surfaced. Gates: host clippy --all-targets --all-features -D warnings clean, host tests green, and the same clippy clean for x86_64-pc-windows-gnu. --- crates/nub-sandbox/Cargo.toml | 4 + crates/nub-sandbox/LIMITATIONS.md | 100 ++++ crates/nub-sandbox/src/backend/windows.rs | 48 +- .../src/backend/windows_account/acl.rs | 4 +- .../src/backend/windows_account/launch.rs | 31 +- .../src/backend/windows_account/mod.rs | 45 +- .../src/backend/windows_account/state.rs | 13 +- .../src/backend/windows_account/wfp.rs | 42 +- crates/nub-sandbox/src/proxy/mod.rs | 3 +- .../tests/windows_account_enforcement.rs | 533 ++++++++++++++++++ .../nub-sandbox/tests/windows_enforcement.rs | 1 + vendor/aube/crates/aube-linker/src/patches.rs | 33 +- .../aube/crates/aube-lockfile/src/npm/mod.rs | 2 +- .../crates/aube-lockfile/src/npm/tests.rs | 43 +- .../crates/aube-lockfile/src/npm/write.rs | 7 +- .../crates/aube-lockfile/src/pnpm/read.rs | 3 +- .../crates/aube-lockfile/src/pnpm/tests.rs | 12 +- .../crates/aube-lockfile/src/pnpm/write.rs | 6 +- .../aube-lockfile/tests/unsupported_source.rs | 5 +- .../crates/aube-registry/src/config/tests.rs | 5 +- vendor/aube/crates/aube-resolver/src/tests.rs | 36 +- vendor/aube/crates/aube-store/src/lib.rs | 3 +- .../crates/aube/src/commands/add/manifest.rs | 4 +- .../aube/crates/aube/src/commands/catalogs.rs | 15 +- .../crates/aube/src/commands/config/mod.rs | 2 +- vendor/aube/crates/aube/src/patches.rs | 6 +- vendor/aube/crates/aube/src/progress/ci.rs | 94 +-- 27 files changed, 913 insertions(+), 187 deletions(-) create mode 100644 crates/nub-sandbox/tests/windows_account_enforcement.rs diff --git a/crates/nub-sandbox/Cargo.toml b/crates/nub-sandbox/Cargo.toml index de8ba3389..cafa6ca95 100644 --- a/crates/nub-sandbox/Cargo.toml +++ b/crates/nub-sandbox/Cargo.toml @@ -135,3 +135,7 @@ harness = false [[test]] name = "windows_residuals" harness = false + +[[test]] +name = "windows_account_enforcement" +harness = false diff --git a/crates/nub-sandbox/LIMITATIONS.md b/crates/nub-sandbox/LIMITATIONS.md index e9181514c..6b52892c2 100644 --- a/crates/nub-sandbox/LIMITATIONS.md +++ b/crates/nub-sandbox/LIMITATIONS.md @@ -134,6 +134,106 @@ residuals: - **Port-agnostic broker scoping.** A literal broker host matches regardless of port — brokering configured for `api.example.com` applies to that host on any port. +## Windows dedicated-account backend (agent-sandbox) — bounded residuals + +The account backend (`backend/windows_account/`) runs the child as a dedicated local account +fenced by SID-keyed WFP filters. It carries the residuals below. Everything here is a +deliberate spike bound or an inherited platform property, not an unknown. + +### One-time elevation is required, and per-run is not + +`nub run --sandbox-admin setup` needs administrator: it creates a local account and installs +WFP filters, and Windows gates both. Every sandboxed run afterwards is unelevated. This is the +whole reason the WFP permit covers a loopback PORT WINDOW rather than the run's exact proxy +port — a filter tracking an ephemeral port would need a WFP write, hence a UAC prompt, on +every run. + +- **Cost of the window:** the permit admits any local principal to loopback on those ports for + as long as the filters are installed, rather than admitting only nub's proxy on one port. + Bounded by the window's width (10 ports by default, 64 max, enforced at install). +- **Where fixed:** an elevated helper service could add an exact-port filter per run. Not + built; the window is the deliberate trade. + +### Egress coverage is `ALE_AUTH_CONNECT` only + +The fence blocks outbound connects for the account on IPv4 and IPv6. It does NOT filter +inbound (`ALE_AUTH_RECV_ACCEPT_*`) or bind/listen (`ALE_RESOURCE_ASSIGNMENT_*`), so the +account may still open a listening socket. Neither of the reference implementations nub +mirrors (SRT, Codex) covers those layers either. + +- **Why bounded:** the policy grammar's net axis is about EGRESS. A listener the child opens + is reachable only by something already on the box. + +### DNS still resolves + +`getaddrinfo` is serviced by the `Dnscache` service running as `NETWORK SERVICE`, so name +resolution succeeds under a different token even though the child's own `connect()` is +blocked. No filter set can close this while keying on the connecting token. + +- **Why harmless here:** the child reaches nub's proxy by IP and the PROXY resolves. A blocked + host is blocked at connect regardless of whether its name resolved. + +### Per-user tool installs are unreachable without an explicit grant + +The child is a DIFFERENT principal, so anything under the invoking user's profile — an +nvm/fnm-managed Node, a Scoop or per-user winget package, `pip install --user`, +`%LOCALAPPDATA%\Programs\…` — resolves on `PATH` but cannot be opened. This lands +particularly hard on nub, whose premise is running the user's installed Node. + +- **What the engine does:** auto-grants the resolved program FILE (never its parent directory, + which would sweep in a neighbouring secret). +- **Launcher contract:** a program that loads SIBLING DLLs from its own directory needs the + front-end to put that toolchain directory in the read allow-set — the same contract the + macOS "toolchain read-confine for a non-system interpreter" residual defines. + +### Certificate revocation checks fail under schannel + +CryptoAPI's CRL/OCSP fetch goes out via WinHTTP under the caller's token and ignores proxy +environment variables, so it is blocked. `curl`, `git`, and `cargo` surface +`CRYPT_E_REVOCATION_OFFLINE` (`0x80092013`) unless revocation is disabled +(`curl --ssl-no-revoke`, `git -c http.schannelCheckRevoke=false`, +`CARGO_HTTP_CHECK_REVOKE=false`). `Invoke-WebRequest`, .NET `HttpClient`, and `gh` are +unaffected. + +### Glob denies are not enforceable as aces + +A deny whose matcher carries glob metacharacters (`**/.env`, `C:/proj/*.pem`) cannot be one +ACE. The engine reports `fs-deny-glob` as a LOST axis — distinct from the over-confinement +degradations, because a missed deny is a hole, not extra confinement. Literal deny paths are +enforced exactly. + +### Whole-tree kill is best-effort + +`AssignProcessToJobObject` on a `CreateProcessWithLogonW` child commonly returns +`ERROR_NOT_SUPPORTED`: the Secondary Logon service already placed it in its own job and +current Windows refuses that nesting cross-session. The assignment is attempted and the +failure logged; a descendant that outlives the target may survive. + +- **Where fixed:** the two-hop broker→runner design SRT and Codex use, where the runner owns a + job it can assign into. Deferred. + +### Spike bounds carried deliberately + +- **Single-hop launch.** The child is started directly, not through a runner holding a + restricted token. Confinement comes from the account's ACL reach plus SID-keyed WFP, neither + of which needs that token — but the token would additionally strip privileges and groups. +- **`lpDesktop` is NULL**, so the child shares `WinSta0\Default` with the user's session. A + private desktop would require explicit `WinSta0` and session-`BaseNamedObjects` aces, + because a non-NULL desktop disables the Secondary Logon service's station auto-grant. +- **Concurrent runs share one account.** Two simultaneous sandboxed runs grant and strip aces + for the SAME SID, so one run's teardown can revoke a grant the other still needs. +- **Child-created files are owned by the sandbox account.** Access is preserved (the grant is + written UNPROTECTED so the user's inherited aces survive on new children), but the OWNER + field changes, which git reports as "dubious ownership" and which leaves an orphaned owner + SID if the account is later deleted. Neither reference implementation solves this. + +### Residue after a crash + +A run killed between granting an ace and stripping it leaves the ace behind. The ledger at +`%PROGRAMDATA%\nub\sandbox\acl-ledger.txt` records every path so +`nub run --sandbox-admin clean` (unelevated) collects them. A leaked grant is over-permission +for a confined account, not a host compromise — hygiene, not a correctness boundary. + ## Launcher-handoff items (engine correct; launcher must complete the guarantee) ### macOS ascendant-env via `KERN_PROCARGS2` — CLOSED in-engine diff --git a/crates/nub-sandbox/src/backend/windows.rs b/crates/nub-sandbox/src/backend/windows.rs index ad67dffb8..4466cda71 100644 --- a/crates/nub-sandbox/src/backend/windows.rs +++ b/crates/nub-sandbox/src/backend/windows.rs @@ -477,9 +477,7 @@ pub(crate) fn apply( // proxy's minted leaves. Grant it as nub infra (not user config), mirroring the // mac/linux ca-bundle read grant. Only under a real per-host tier (`tier1`); the plain // path handles CA-trust via `set_ca_env` on an unconfined fs. - if tier1 - && let Some(bundle) = ca_bundle - { + if tier1 && let Some(bundle) = ca_bundle { let b = bundle.to_path_buf(); if !read_grants.contains(&b) { read_grants.push(b); @@ -629,7 +627,10 @@ fn build_child_env( /// name → PATH search trying the name and common executable extensions. Windows-only /// (its PATHEXT search is Windows semantics; the host build never calls it). #[cfg(target_os = "windows")] -pub(super) fn resolve_program(program: &std::ffi::OsStr, child_cwd: Option<&Path>) -> Option { +pub(super) fn resolve_program( + program: &std::ffi::OsStr, + child_cwd: Option<&Path>, +) -> Option { let p = Path::new(program); if p.is_absolute() { return Some(p.to_path_buf()); @@ -679,15 +680,15 @@ pub(super) mod launch { CloseHandle, HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, LocalFree, SetHandleInformation, WAIT_OBJECT_0, }; + use windows_sys::Win32::NetworkManagement::WindowsFirewall::{ + NetworkIsolationFreeAppContainers, NetworkIsolationGetAppContainerConfig, + NetworkIsolationSetAppContainerConfig, + }; use windows_sys::Win32::Security::Authorization::{ ConvertStringSidToSidW, EXPLICIT_ACCESS_W, GRANT_ACCESS, GetNamedSecurityInfoW, NO_MULTIPLE_TRUSTEE, REVOKE_ACCESS, SE_FILE_OBJECT, SetEntriesInAclW, SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_USER, TRUSTEE_W, }; - use windows_sys::Win32::NetworkManagement::WindowsFirewall::{ - NetworkIsolationFreeAppContainers, NetworkIsolationGetAppContainerConfig, - NetworkIsolationSetAppContainerConfig, - }; use windows_sys::Win32::Security::Isolation::{ CreateAppContainerProfile, DeleteAppContainerProfile, }; @@ -751,9 +752,7 @@ pub(super) mod launch { if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { return false; } - let mut elevation = TOKEN_ELEVATION { - TokenIsElevated: 0, - }; + let mut elevation = TOKEN_ELEVATION { TokenIsElevated: 0 }; let mut ret_len: u32 = 0; // SAFETY: `elevation` is a correctly-sized TOKEN_ELEVATION out-buffer. let ok = unsafe { @@ -1404,7 +1403,10 @@ pub(super) mod launch { /// Build a mutable UTF-16 command line from program + args, quoting each token per /// the CommandLineToArgvW rules std uses. lpApplicationName is NULL, so the child /// gets a conventional argv. - pub(in crate::backend) fn build_command_line(program: &std::ffi::OsStr, args: &[std::ffi::OsString]) -> Vec { + pub(in crate::backend) fn build_command_line( + program: &std::ffi::OsStr, + args: &[std::ffi::OsString], + ) -> Vec { let mut line: Vec = Vec::new(); append_quoted(&mut line, program); for a in args { @@ -1455,7 +1457,9 @@ pub(super) mod launch { /// expects (the source `BTreeMap` is case-sensitive, so a lowercase key like /// `windir` would otherwise sort after all-uppercase keys and violate the /// convention). - pub(in crate::backend) fn build_env_block(env: &std::collections::BTreeMap) -> Vec { + pub(in crate::backend) fn build_env_block( + env: &std::collections::BTreeMap, + ) -> Vec { let mut pairs: Vec<(&String, &String)> = env.iter().collect(); pairs.sort_by_key(|a| a.0.to_ascii_uppercase()); let mut block: Vec = Vec::new(); @@ -1707,9 +1711,15 @@ mod tests { default_effect: Effect::Deny, ..Default::default() }); - let deg = apply(&deny_all, crate::CommandSpec::new("cmd.exe"), None, None, None) - .expect("apply deny-all") - .degradation; + let deg = apply( + &deny_all, + crate::CommandSpec::new("cmd.exe"), + None, + None, + None, + ) + .expect("apply deny-all") + .degradation; assert!( !deg.lost.iter().any(|s| s == "net-per-host"), "deny-all is coarse-enforced, not degraded (got {:?})", @@ -1742,7 +1752,11 @@ mod tests { deg.lost ); } else { - let err = res.expect_err("unelevated per-host must fail-closed, not degrade"); + // `expect_err` would need `Prepared: Debug`, which it is not (it owns a live + // proxy and a launch plan) — so destructure instead. + let Err(err) = res else { + panic!("unelevated per-host must fail-closed, not degrade"); + }; assert!( err.lost.iter().any(|s| s == "net-per-host"), "the fail-closed Degradation must name net-per-host (got {:?})", diff --git a/crates/nub-sandbox/src/backend/windows_account/acl.rs b/crates/nub-sandbox/src/backend/windows_account/acl.rs index 961cabdad..2310b562a 100644 --- a/crates/nub-sandbox/src/backend/windows_account/acl.rs +++ b/crates/nub-sandbox/src/backend/windows_account/acl.rs @@ -55,8 +55,8 @@ const RW: u32 = FILE_GENERIC_READ | FILE_GENERIC_WRITE | FILE_GENERIC_EXECUTE | /// Read + execute. What a policy's read grant stamps. const RO: u32 = FILE_GENERIC_READ | FILE_GENERIC_EXECUTE; -// The EXCLUSIONS are the security property, so they are asserted at compile time rather than -// left to a reader to re-derive from two hex literals. +// What these masks LEAVE OUT is the security property, and an omission is invisible on +// inspection — so each exclusion is asserted at compile time, against windows-sys' own values. const _: () = { // `FILE_DELETE_CHILD` on a granted PARENT is checked INSTEAD OF `DELETE` on the child, so // including it would let the account delete a file carrying a full deny ACE. That single diff --git a/crates/nub-sandbox/src/backend/windows_account/launch.rs b/crates/nub-sandbox/src/backend/windows_account/launch.rs index 1d552335a..d543f1fb0 100644 --- a/crates/nub-sandbox/src/backend/windows_account/launch.rs +++ b/crates/nub-sandbox/src/backend/windows_account/launch.rs @@ -24,7 +24,7 @@ //! cross-session. The assignment is attempted and its failure reported, never silently //! swallowed — whole-tree reap is genuinely weaker here than on the AppContainer path. -use super::{AccountLaunch, AccountNet, acl, account, state}; +use super::{AccountLaunch, account, acl, state}; use crate::backend::windows::launch::{build_command_line, build_env_block, to_wide}; use std::io; use std::os::windows::io::AsRawHandle; @@ -52,7 +52,7 @@ const ERROR_SERVICE_DISABLED: i32 = 1058; /// Strips every ace this run applied, on drop. Ordering matters: declared before the child is /// spawned but dropped after the wait returns, so a granted path is never revoked out from /// under a live child. Best-effort — a failed strip leaves an over-permissive ace for a -/// confined account, which the ledger sweep (`nub run --sandbox-clean`) collects later. +/// confined account, which the ledger sweep (`nub run --sandbox-admin clean`) collects later. struct AceGuard { paths: Vec, sid: String, @@ -88,7 +88,6 @@ impl AccountLaunch { // Ledger BEFORE apply: a crash between the two leaves a recorded path whose ace was // never written, and stripping an absent ace is a no-op. The reverse order would // leave an ace nothing knows about. - let mut applied = Vec::new(); let mut guard = AceGuard { paths: Vec::new(), sid: marker.sid.clone(), @@ -97,12 +96,15 @@ impl AccountLaunch { .read_grants .iter() .map(|p| (p, acl::Access::Read)) - .chain(self.write_grants.iter().map(|p| (p, acl::Access::ReadWrite))) + .chain( + self.write_grants + .iter() + .map(|p| (p, acl::Access::ReadWrite)), + ) { state::record_acl_path(path)?; guard.paths.push(path.clone()); acl::grant(path, &marker.sid, access)?; - applied.push(path.clone()); } // Denies go on AFTER the grants so the deny ace is inserted into a DACL that already // carries the grant it must outrank — the canonical-order insert has to see both. @@ -122,10 +124,7 @@ impl AccountLaunch { let user_w = to_wide(account_name); // "." targets the LOCAL SAM regardless of whether the machine is domain-joined. let domain_w = to_wide("."); - let mut password_w: Vec = password - .encode_utf16() - .chain(std::iter::once(0)) - .collect(); + let mut password_w: Vec = password.encode_utf16().chain(std::iter::once(0)).collect(); let mut cmdline = build_command_line(&self.program, &self.args); let app_w = to_wide(&self.program.to_string_lossy()); let cwd_w = self.cwd.as_ref().map(|c| to_wide(&c.to_string_lossy())); @@ -143,14 +142,13 @@ impl AccountLaunch { } let mut pi: PROCESS_INFORMATION = unsafe { std::mem::zeroed() }; - let mut flags = CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED; + let flags = CREATE_UNICODE_ENVIRONMENT | CREATE_SUSPENDED; let env_ptr: *const std::ffi::c_void = match &env_block { Some(b) => b.as_ptr().cast(), // NULL + LOGON_WITH_PROFILE makes seclogon build the SANDBOX ACCOUNT's own // profile environment — isolated USERPROFILE/TEMP/LOCALAPPDATA, machine PATH. None => std::ptr::null(), }; - let _ = &mut flags; // SAFETY: every buffer referenced (user/domain/password/app/cmdline/cwd/env/si) // outlives this call; `lpCommandLine` is a writable UTF-16 buffer as required. @@ -296,14 +294,3 @@ fn map_spawn_error(e: io::Error, account: &str) -> io::Error { _ => e, } } - -/// Net posture is enforced entirely by the persistent WFP filters installed at setup, keyed -/// on the account SID — there is nothing per-run to do. This exists so the caller's match on -/// [`AccountNet`] is exhaustive at the launch site and a future posture cannot be added -/// without visiting here. -pub(crate) fn net_is_enforced_by_setup(net: AccountNet) -> bool { - match net { - AccountNet::ProxyOnly | AccountNet::DenyAll => true, - AccountNet::UnconfinedButFenced => false, - } -} diff --git a/crates/nub-sandbox/src/backend/windows_account/mod.rs b/crates/nub-sandbox/src/backend/windows_account/mod.rs index ffe46ce1b..adabc10ce 100644 --- a/crates/nub-sandbox/src/backend/windows_account/mod.rs +++ b/crates/nub-sandbox/src/backend/windows_account/mod.rs @@ -9,6 +9,7 @@ //! 2. **Deny-inside-allow does not hold.** A secret under a dir carrying an inherited //! `ALL APPLICATION PACKAGES` grant is readable regardless of the allow-set — the AAP //! grant satisfies the LowBox check before default-deny is reached. +//! //! Both dissolve when the child runs as a **separate local principal**: the invoking user's //! own profile (`~/.ssh`, `~/.aws`, a home-dir `.env`) is denied by DEFAULT with no ACE //! authored at all, and no AAP grant ever covers a user SID, so an explicit deny ACE on a @@ -113,9 +114,13 @@ pub(crate) struct AccountLaunch { /// `Some` ⇒ the child env IS this map. `None` ⇒ seclogon builds the account's own /// profile environment. pub(crate) env: Option>, - pub(crate) net: AccountNet, } +// The net posture deliberately does NOT ride the launch plan: it is fully decided in `apply` +// (the degradation it reports and the port-window gate it enforces) and then carried by the +// PERSISTENT WFP filters the elevated setup installed. There is no per-run network work to +// do, which is exactly the property that keeps every run unelevated. + /// Whether a policy needs the ACCOUNT backend rather than the AppContainer allowlist. /// /// The account backend costs a one-time elevated setup, so it is chosen only where the @@ -125,12 +130,11 @@ pub(crate) struct AccountLaunch { /// admin-free AppContainer path. pub(crate) fn needs_account_backend(policy: &crate::policy::SandboxPolicy) -> bool { let fs = &policy.fs; - let generous_read = fs.rules.default_effect == Effect::Allow - || fs - .rules - .entries - .iter() - .any(|r| r.effect == Effect::Allow && super::windows::is_whole_fs(r.matcher.as_str())); + let generous_read = + fs.rules.default_effect == Effect::Allow + || fs.rules.entries.iter().any(|r| { + r.effect == Effect::Allow && super::windows::is_whole_fs(r.matcher.as_str()) + }); // A deny only needs carving when it can land inside something the policy also grants; // `deny_shadows_grant` is exactly that test, and it is the AppContainer's known hole. let (read_grants, _, _) = super::windows::derive_grants(fs); @@ -146,7 +150,9 @@ pub(crate) fn needs_account_backend(policy: &crate::policy::SandboxPolicy) -> bo /// sandbox SID on a path inside a granted subtree outranks the grant's *inherited* allow, /// because Windows orders every explicit ACE ahead of every inherited one. That ordering is /// the mechanism the whole agent-sandbox fs axis rests on. -pub(crate) fn derive_plan(fs: &FsPolicy) -> (Vec, Vec, Vec, AccountFsDegrade) { +pub(crate) fn derive_plan( + fs: &FsPolicy, +) -> (Vec, Vec, Vec, AccountFsDegrade) { let mut read = Vec::new(); let mut write = Vec::new(); let mut deny = Vec::new(); @@ -215,7 +221,7 @@ pub(crate) fn plan_net(net: &crate::policy::NetPolicy) -> AccountNet { /// looking un-provisioned and every run fails closed with "run the setup", rather than /// half-provisioned and failing in some less legible way later. #[cfg(target_os = "windows")] -pub(crate) fn setup(port_range: Option<(u16, u16)>) -> std::io::Result { +pub fn setup(port_range: Option<(u16, u16)>) -> std::io::Result { if !account::is_elevated() { return Err(std::io::Error::new( std::io::ErrorKind::PermissionDenied, @@ -244,7 +250,7 @@ pub(crate) fn setup(port_range: Option<(u16, u16)>) -> std::io::Result { /// objects, delete the account/group/profile, and drop the marker. Every step is idempotent /// and a later failure never skips the cleanup already done. #[cfg(target_os = "windows")] -pub(crate) fn teardown() -> std::io::Result<()> { +pub fn teardown() -> std::io::Result<()> { if !account::is_elevated() { return Err(std::io::Error::new( std::io::ErrorKind::PermissionDenied, @@ -267,7 +273,7 @@ pub(crate) fn teardown() -> std::io::Result<()> { /// Returns how many paths were swept. A path that no longer exists is pruned rather than /// retried forever. #[cfg(target_os = "windows")] -pub(crate) fn clean() -> std::io::Result { +pub fn clean() -> std::io::Result { let Some(marker) = state::read_marker()? else { return Ok(0); }; @@ -294,7 +300,7 @@ pub(crate) fn clean() -> std::io::Result { /// administrator, so an unelevated caller genuinely cannot see it and the report says so /// rather than implying the fence is absent. #[cfg(target_os = "windows")] -pub(crate) fn status() -> std::io::Result { +pub fn status() -> std::io::Result { let marker = state::read_marker()?; let live_sid = account::lookup_sid()?; let mut out = String::new(); @@ -317,11 +323,15 @@ pub(crate) fn status() -> std::io::Result { m.account, m.port_low, m.port_high )), } - out.push_str(&match (account::is_elevated(), wfp::installed_filter_count()) { - (false, _) => "wfp filters: cannot read (enumeration requires administrator)\n".to_string(), - (true, Ok(n)) => format!("wfp filters: {n} installed\n"), - (true, Err(e)) => format!("wfp filters: could not read ({e})\n"), - }); + out.push_str( + &match (account::is_elevated(), wfp::installed_filter_count()) { + (false, _) => { + "wfp filters: cannot read (enumeration requires administrator)\n".to_string() + } + (true, Ok(n)) => format!("wfp filters: {n} installed\n"), + (true, Err(e)) => format!("wfp filters: could not read ({e})\n"), + }, + ); out.push_str(&format!( "acl ledger: {} path(s) recorded\n", state::ledger_paths().map(|p| p.len()).unwrap_or(0) @@ -455,7 +465,6 @@ pub(crate) fn apply( write_grants, denies, env: build_child_env(&policy.env, proxy_port, proxy_token, ca_bundle), - net, }; Ok(Prepared { diff --git a/crates/nub-sandbox/src/backend/windows_account/state.rs b/crates/nub-sandbox/src/backend/windows_account/state.rs index df32e4250..6083091c6 100644 --- a/crates/nub-sandbox/src/backend/windows_account/state.rs +++ b/crates/nub-sandbox/src/backend/windows_account/state.rs @@ -63,8 +63,12 @@ impl Marker { } pub(crate) fn from_json(s: &str) -> io::Result { - let v: serde_json::Value = serde_json::from_str(s) - .map_err(|e| io::Error::new(io::ErrorKind::InvalidData, format!("sandbox marker is not valid JSON: {e}")))?; + let v: serde_json::Value = serde_json::from_str(s).map_err(|e| { + io::Error::new( + io::ErrorKind::InvalidData, + format!("sandbox marker is not valid JSON: {e}"), + ) + })?; let field = |k: &str| -> io::Result<&serde_json::Value> { v.get(k).ok_or_else(|| { io::Error::new( @@ -265,7 +269,10 @@ mod tests { ); let err = Marker::from_json(&json).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidData); - assert!(err.to_string().contains("re-run the elevated sandbox setup")); + assert!( + err.to_string() + .contains("re-run the elevated sandbox setup") + ); } /// An inverted or zero range would make the proxy's bind-in-range search fail in a way diff --git a/crates/nub-sandbox/src/backend/windows_account/wfp.rs b/crates/nub-sandbox/src/backend/windows_account/wfp.rs index ee4e0d526..9c1f33ca6 100644 --- a/crates/nub-sandbox/src/backend/windows_account/wfp.rs +++ b/crates/nub-sandbox/src/backend/windows_account/wfp.rs @@ -61,6 +61,14 @@ pub(crate) const DEFAULT_PROXY_PORT_RANGE: (u16, u16) = (59080, 59089); /// A wider window would stop meaningfully narrowing loopback, so the install refuses one. pub(crate) const MAX_PROXY_PORT_RANGE_WIDTH: u16 = 64; +// The default window must itself satisfy the rule the installer enforces on a user-supplied +// one, or the out-of-the-box setup would be refused by its own validation. +const _: () = { + let (low, high) = DEFAULT_PROXY_PORT_RANGE; + assert!(low > 0 && high >= low); + assert!(high - low < MAX_PROXY_PORT_RANGE_WIDTH); +}; + /// BFE reports access-denied as EITHER of these — test both or an unelevated caller looks /// like a hard failure. const FWP_E_ACCESS_DENIED: u32 = 0x8032_0028; @@ -127,10 +135,7 @@ impl Drop for Engine { /// Run `f` inside a WFP transaction, aborting on any error or unwind. The whole install is /// one transaction so a mid-install failure cannot leave a half-fence — which would be /// fail-OPEN (block filters missing, permit present). -fn in_transaction( - engine: &Engine, - f: impl FnOnce(&Engine) -> io::Result, -) -> io::Result { +fn in_transaction(engine: &Engine, f: impl FnOnce(&Engine) -> io::Result) -> io::Result { // SAFETY: engine handle is live for the whole call. let rc = unsafe { FwpmTransactionBegin0(engine.0, 0) }; if rc != 0 { @@ -272,7 +277,7 @@ pub(crate) fn install(sid: &str, port_range: (u16, u16)) -> io::Result<()> { "invalid sandbox proxy port range {low}-{high}" ))); } - if high - low + 1 > MAX_PROXY_PORT_RANGE_WIDTH { + if high - low >= MAX_PROXY_PORT_RANGE_WIDTH { return Err(io::Error::other(format!( "sandbox proxy port range {low}-{high} is wider than the {MAX_PROXY_PORT_RANGE_WIDTH}-port maximum \ — a wide window stops meaningfully narrowing loopback" @@ -296,7 +301,9 @@ pub(crate) fn install(sid: &str, port_range: (u16, u16)) -> io::Result<()> { mask: 0xFF00_0000, // /8 }, v6: { - let mut a = FWP_BYTE_ARRAY16 { byteArray16: [0; 16] }; + let mut a = FWP_BYTE_ARRAY16 { + byteArray16: [0; 16], + }; a.byteArray16[15] = 1; // ::1 a }, @@ -462,7 +469,7 @@ fn add_filter(e: &Engine, spec: FilterSpec, slots: &mut ConditionSlots) -> io::R let mut name = to_wide(spec.name()); let mut provider_key = NUB_PROVIDER_KEY; - let mut filter = FWPM_FILTER0 { + let filter = FWPM_FILTER0 { // A zero filterKey makes WFP mint one; identity comes from the provider key, which // is what the uninstall sweep enumerates on. displayData: FWPM_DISPLAY_DATA0 { @@ -496,7 +503,7 @@ fn add_filter(e: &Engine, spec: FilterSpec, slots: &mut ConditionSlots) -> io::R // SAFETY: `conditions`, `slots`, `name` and `provider_key` all outlive this call — WFP // stores raw pointers into every one of them for the duration of the add. - let rc = unsafe { FwpmFilterAdd0(e.0, &mut filter, std::ptr::null_mut(), std::ptr::null_mut()) }; + let rc = unsafe { FwpmFilterAdd0(e.0, &filter, std::ptr::null_mut(), std::ptr::null_mut()) }; if rc != 0 && rc != FWP_E_ALREADY_EXISTS { return Err(wfp_err("FwpmFilterAdd0", rc)); } @@ -575,19 +582,10 @@ fn delete_nub_filters(e: &Engine) -> io::Result<()> { mod tests { use super::*; - /// The permit-over-block weight ordering IS the fence; a regression that inverted it - /// would silently open egress while every filter still appeared installed. - #[test] - fn loopback_permit_outweighs_the_account_block() { - assert!(W_LOOPBACK_PERMIT > W_ACCOUNT_BLOCK); - } - - /// The default window must satisfy the width rule the installer enforces. - #[test] - fn default_port_range_is_within_the_width_cap() { - let (low, high) = DEFAULT_PROXY_PORT_RANGE; - assert!(low > 0 && high >= low); - assert!(high - low + 1 <= MAX_PROXY_PORT_RANGE_WIDTH); + /// `windows_sys::core::GUID` derives only `Copy`/`Clone`/`Default`, so equality is + /// field-wise. + fn same_guid(a: &GUID, b: &GUID) -> bool { + a.data1 == b.data1 && a.data2 == b.data2 && a.data3 == b.data3 && a.data4 == b.data4 } /// Both access-denied spellings must read as "not elevated" — BFE returns either, and @@ -608,7 +606,7 @@ mod tests { assert_eq!(FilterSpec::ALL.len() - permits, 2); let v4 = FilterSpec::ALL .iter() - .filter(|s| s.layer() == FWPM_LAYER_ALE_AUTH_CONNECT_V4) + .filter(|s| same_guid(&s.layer(), &FWPM_LAYER_ALE_AUTH_CONNECT_V4)) .count(); assert_eq!(v4, 2, "one permit + one block on IPv4"); } diff --git a/crates/nub-sandbox/src/proxy/mod.rs b/crates/nub-sandbox/src/proxy/mod.rs index e7f77c35e..3ebcae471 100644 --- a/crates/nub-sandbox/src/proxy/mod.rs +++ b/crates/nub-sandbox/src/proxy/mod.rs @@ -131,7 +131,8 @@ fn bind_in_range(low: u16, high: u16) -> io::Result { io::ErrorKind::AddrInUse, format!( "every loopback port in the sandbox proxy window {low}-{high} is in use{}", - last.map(|e| format!(" (last error: {e})")).unwrap_or_default() + last.map(|e| format!(" (last error: {e})")) + .unwrap_or_default() ), )) } diff --git a/crates/nub-sandbox/tests/windows_account_enforcement.rs b/crates/nub-sandbox/tests/windows_account_enforcement.rs new file mode 100644 index 000000000..2af3fc9f0 --- /dev/null +++ b/crates/nub-sandbox/tests/windows_account_enforcement.rs @@ -0,0 +1,533 @@ +//! Windows dedicated-account backend — REAL enforcement probe (a provisioned Windows box only). +//! +//! Drives the REAL public seam (`apply` → `Prepared::status`): each case compiles a policy +//! that routes to the account backend, launches a child AS the `nub-sandbox` local account +//! through seclogon, and asserts the account's ACL reach / SID-keyed WFP fence allowed or +//! denied the action. Every confinement assertion is paired with a NEGATIVE CONTROL — almost +//! always an UNCONFINED run of the identical action — so a pass cannot be hollow: the control +//! proves the file or the TCP endpoint is genuinely reachable and the denial is confinement. +//! +//! THE MARQUEE CASE is the secret DENIED INSIDE a granted tree. That is precisely what the +//! AppContainer backend cannot express (an allowlist has no "deny inside allow", and an +//! inherited `ALL APPLICATION PACKAGES` grant satisfies the LowBox check before default-deny +//! is reached), and it is the reason this second backend exists at all. +//! +//! `harness = false`: this binary is BOTH the runner AND the probe child — an +//! `__acctchild__ ` invocation acts as the child (read/write/connect/spawnconnect/whoami +//! → a numeric exit-code contract), any other invocation runs the cases. The exit code is the +//! ONLY channel out of a seclogon-launched child, so the contract carries every verdict. +//! +//! PRECONDITION, NON-NEGOTIABLE: the account backend needs a one-time ELEVATED setup. This +//! probe never passes silently over an un-provisioned machine — it exits NON-ZERO naming the +//! command a human must run. When it IS elevated it may provision itself and tears that down +//! again at the end. + +#[cfg(not(target_os = "windows"))] +fn main() { + // Non-Windows host: nothing to enforce. (`harness = false` needs a `main`.) +} + +#[cfg(target_os = "windows")] +fn main() { + let args: Vec = std::env::args().collect(); + if args.get(1).map(String::as_str) == Some("__acctchild__") { + std::process::exit(win::child_main(&args[2..])); + } + std::process::exit(win::run()); +} + +#[cfg(target_os = "windows")] +mod win { + use nub_sandbox::policy::{ + CanonGlob, Effect, EnvPolicy, FsAccess, FsPolicy, FsRule, FsRuleSet, NetPolicy, PidPolicy, + SandboxPolicy, TmpMode, + }; + use nub_sandbox::{CommandSpec, apply, windows_admin}; + use std::net::{SocketAddr, TcpListener, TcpStream}; + use std::path::{Path, PathBuf}; + use std::time::Duration; + + /// The account nub provisions (`windows_account::SANDBOX_ACCOUNT`, not public). + const ACCOUNT: &str = "nub-sandbox"; + + // ── the probe child ───────────────────────────────────────────────────────── + + /// The child's exit-code contract. Distinct codes so a denial is never confused with a + /// crash — and so the WFP fence is never confused with an ordinary access denial: + /// 0 ok, 2 unknown-role, 3 grandchild-spawn-failed, 5 DENIED (fs `ERROR_ACCESS_DENIED` + /// or net `WSAEACCES`), 6 timeout, 8 net refused with a plain `ERROR_ACCESS_DENIED` + /// (NOT the fence — never a pass), 9 other error, 10 wrong identity. + pub fn child_main(a: &[String]) -> i32 { + match a.first().map(String::as_str) { + Some("read") => classify_fs(std::fs::read(&a[1]).map(|_| ())), + Some("write") => classify_fs(std::fs::write(&a[1], b"x")), + Some("connect") => connect(&a[1], a[2].parse().unwrap_or(0)), + Some("spawnconnect") => spawn_grandchild(&a[1], &a[2]), + Some("whoami") => whoami(&a[1]), + _ => 2, + } + } + + fn classify_fs(r: std::io::Result<()>) -> i32 { + match r { + Ok(()) => 0, + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => 5, + Err(e) => { + eprintln!(" child fs: {e} os={:?}", e.raw_os_error()); + 9 + } + } + } + + fn connect(host: &str, port: u16) -> i32 { + let Ok(addr) = format!("{host}:{port}").parse::() else { + return 9; + }; + match TcpStream::connect_timeout(&addr, Duration::from_secs(6)) { + Ok(_) => 0, + // 10013 == WSAEACCES: the WFP `ALE_AUTH_CONNECT` block keyed on the account SID. + // A plain ERROR_ACCESS_DENIED (5) comes from somewhere else entirely and must + // never read as the fence, so it gets its own code and fails the assertion. + Err(e) if e.raw_os_error() == Some(10013) => 5, + Err(e) if e.raw_os_error() == Some(5) => 8, + Err(e) if e.kind() == std::io::ErrorKind::TimedOut => 6, + Err(e) => { + eprintln!(" child connect {addr}: {e} os={:?}", e.raw_os_error()); + 9 + } + } + } + + /// Re-exec THIS binary as a grandchild attempting the same connect, propagating its code + /// verbatim so the surrogate is judged by the identical contract. + fn spawn_grandchild(host: &str, port: &str) -> i32 { + let Ok(exe) = std::env::current_exe() else { + return 3; + }; + match std::process::Command::new(exe) + .args(["__acctchild__", "connect", host, port]) + .status() + { + Ok(s) => s.code().unwrap_or(9), + Err(e) => { + eprintln!(" grandchild spawn: {e}"); + 3 + } + } + } + + /// The anti-vacuity guard: prove the child really runs as the dedicated account. Without + /// it every denial below could be an ordinary spawn failure rather than confinement. + /// Identity comes from the profile environment seclogon builds for the account — the + /// launch passes a NULL env block, so `USERNAME`/`USERPROFILE` are the ACCOUNT's. + fn whoami(expected: &str) -> i32 { + let user = std::env::var("USERNAME").unwrap_or_default(); + let profile = std::env::var("USERPROFILE").unwrap_or_default(); + println!(" CHILD USERNAME={user} USERPROFILE={profile}"); + let low = expected.to_ascii_lowercase(); + if user.eq_ignore_ascii_case(expected) || profile.to_ascii_lowercase().contains(&low) { + 0 + } else { + 10 + } + } + + // ── the fixture ─────────────────────────────────────────────────────────────── + + struct Fixture { + _root: tempfile::TempDir, + child: PathBuf, + work: PathBuf, + ok: PathBuf, + secret: PathBuf, + outside: PathBuf, + } + + impl Fixture { + fn new() -> std::io::Result { + // An ORDINARY %TEMP% tree with NO DACL pre-hardening. The AppContainer probe has + // to strip inherited ACEs because an inherited `ALL APPLICATION PACKAGES` grant + // satisfies a LowBox check; no AAP grant ever covers a USER SID, so here the + // account simply has no reach until this run's ACEs land. Traverse to the leaf + // rides SeChangeNotifyPrivilege, which every local user holds. + let root = tempfile::Builder::new().prefix("nub-acct-").tempdir()?; + let bin = root.path().join("bin"); + let work = root.path().join("work"); + let outside = root.path().join("outside"); + for d in [&bin, &work, &outside] { + std::fs::create_dir_all(d)?; + } + let child = bin.join("child.exe"); + std::fs::copy(std::env::current_exe()?, &child)?; + let ok = work.join("ok.txt"); + std::fs::write(&ok, b"this-is-fine")?; + let secret = work.join(".env"); + std::fs::write(&secret, b"TOPSECRET_TOKEN=do-not-leak")?; + Ok(Fixture { + _root: root, + child, + work, + ok, + secret, + outside, + }) + } + } + + // ── policies (direct IR — full control over what routes where) ──────────────── + + /// One fs rule over a real path, spelled the way the compiler would emit it — forward + /// slashes, which the backend re-nativizes. + fn rule(p: &Path, effect: Effect, access: FsAccess) -> FsRule { + FsRule { + matcher: CanonGlob(p.to_string_lossy().replace('\\', "/")), + effect, + access, + } + } + + /// The agent-sandbox fs shape this backend exists for: one granted tree with a secret + /// DENIED inside it. The deny landing under the grant is exactly what `deny_shadows_grant` + /// detects, and that is what routes the policy off the AppContainer allowlist. + fn secret_in_grant(f: &Fixture) -> SandboxPolicy { + SandboxPolicy { + fs: FsPolicy { + rules: FsRuleSet { + entries: vec![ + rule(&f.work, Effect::Allow, FsAccess::ReadWrite), + rule(&f.secret, Effect::Deny, FsAccess::DENY), + ], + default_effect: Effect::Deny, + }, + tmp: TmpMode::Shared, + }, + net: NetPolicy::default(), + env: EnvPolicy::default(), + pid: PidPolicy::default(), + } + } + + /// The same fs shape plus coarse egress-deny. The account is fenced by the persistent WFP + /// filters either way; enforcing net states the intent the egress cases are testing. + fn net_denied(f: &Fixture) -> SandboxPolicy { + let mut p = secret_in_grant(f); + p.net = NetPolicy { + enforce: true, + default_effect: Effect::Deny, + ..Default::default() + }; + p + } + + /// NOT sandboxed at all: `fs_confines` is false and net is off, so `apply` spawns a plain + /// child as the INVOKING user. Every negative control rides this — it is what proves the + /// target is genuinely reachable, so the confined run's failure can only be confinement. + fn unconfined() -> SandboxPolicy { + SandboxPolicy { + fs: FsPolicy { + rules: FsRuleSet { + entries: Vec::new(), + default_effect: Effect::Allow, + }, + tmp: TmpMode::Shared, + }, + ..Default::default() + } + } + + // ── run helpers ─────────────────────────────────────────────────────────────── + + fn code(f: &Fixture, policy: &SandboxPolicy, args: &[&str]) -> i32 { + // The cwd is PINNED to the granted dir: `CreateProcessWithLogonW` resolves the working + // directory under the CHILD's token, and nub's own cwd is unreachable to the account, + // so an unpinned cwd fails the spawn outright — a failure that is not the case under + // test. `apply` folds the cwd into the read grants for us. + let spec = CommandSpec::new(f.child.as_os_str()) + .args(args.iter().copied()) + .cwd(&f.work); + let prepared = match apply(policy, spec) { + Ok(p) => p, + Err(d) => { + eprintln!(" [apply Err] {d:?}"); + return -100; + } + }; + match prepared.status() { + Ok(s) => s.code().unwrap_or(-1), + Err(e) => { + eprintln!(" [status Err] {e} os={:?}", e.raw_os_error()); + -101 + } + } + } + + fn expect(fails: &mut u32, label: &str, got: i32, want: i32) { + if got == want { + println!("PASS {label} (exit {got})"); + } else { + *fails += 1; + eprintln!("FAIL {label}: exit {got}, expected {want}"); + } + } + + /// A live loopback listener the RUNNER owns, so a connect failure can only be the fence + /// and never a dead endpoint. + fn listen(port: u16) -> Option { + let l = TcpListener::bind(("127.0.0.1", port)).ok()?; + let acceptor = l.try_clone().ok()?; + std::thread::spawn(move || { + for c in acceptor.incoming() { + drop(c); + } + }); + Some(l) + } + + /// A listener on an ephemeral port OUTSIDE the WFP-permitted window. The ephemeral range + /// overlaps the window, so a landing inside it is retried rather than silently testing the + /// permitted case. + fn listen_outside(window: (u16, u16)) -> Option<(TcpListener, u16)> { + for _ in 0..8 { + let l = TcpListener::bind(("127.0.0.1", 0)).ok()?; + let port = l.local_addr().ok()?.port(); + if port < window.0 || port > window.1 { + let acceptor = l.try_clone().ok()?; + std::thread::spawn(move || { + for c in acceptor.incoming() { + drop(c); + } + }); + return Some((l, port)); + } + } + None + } + + // ── preconditions ───────────────────────────────────────────────────────────── + + /// Only the healthy arm of `windows_admin::status()` prints this line, so its presence IS + /// the provisioning check and its value is the window the installed WFP permit covers. + fn parse_window(report: &str) -> Option<(u16, u16)> { + let line = report + .lines() + .find_map(|l| l.trim().strip_prefix("proxy port window: "))?; + let (low, high) = line.trim().split_once('-')?; + Some((low.trim().parse().ok()?, high.trim().parse().ok()?)) + } + + fn is_elevated() -> bool { + use windows_sys::Win32::Foundation::CloseHandle; + use windows_sys::Win32::Security::{ + GetTokenInformation, TOKEN_ELEVATION, TOKEN_QUERY, TokenElevation, + }; + use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; + // SAFETY: standard token-query sequence; the buffer is exactly a TOKEN_ELEVATION. + unsafe { + let mut tok = std::ptr::null_mut(); + if OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut tok) == 0 { + return false; + } + let mut elev = TOKEN_ELEVATION { TokenIsElevated: 0 }; + let mut ret = 0u32; + let ok = GetTokenInformation( + tok, + TokenElevation, + std::ptr::from_mut(&mut elev).cast(), + std::mem::size_of::() as u32, + &mut ret, + ); + CloseHandle(tok); + ok != 0 && elev.TokenIsElevated != 0 + } + } + + // ── the cases ───────────────────────────────────────────────────────────────── + + pub fn run() -> i32 { + let report = windows_admin::status() + .unwrap_or_else(|e| format!("sandbox account: status unavailable ({e})\n")); + print!("{report}"); + + let mut provisioned_here = false; + let mut window = parse_window(&report); + if window.is_none() { + if !is_elevated() { + eprintln!( + "\nABORT: this machine has no provisioned nub sandbox account, so there is \ + nothing to probe.\n Run this ONCE from an elevated (Run as \ + administrator) prompt:\n\n nub run --sandbox-setup\n\n then \ + re-run this probe. (Run the probe itself elevated and it will provision + \ + tear down for you.)" + ); + return 2; + } + println!( + "ELEVATED and un-provisioned: running windows_admin::setup(None) for this probe; \ + it is torn down again at the end." + ); + match windows_admin::setup(None) { + Ok(sid) => { + provisioned_here = true; + println!("provisioned the sandbox account ({sid})"); + } + Err(e) => { + eprintln!("ABORT: sandbox setup failed: {e}"); + return 2; + } + } + let after = windows_admin::status().unwrap_or_default(); + print!("{after}"); + window = parse_window(&after); + } + + let mut fails = match window { + Some(w) => run_cases(w).unwrap_or_else(|e| { + eprintln!("ABORT: fixture setup failed: {e}"); + u32::MAX + }), + None => { + eprintln!("ABORT: could not read the WFP-permitted proxy port window from status"); + u32::MAX + } + }; + + if provisioned_here { + match windows_admin::teardown() { + Ok(()) => println!("tore down the sandbox account this probe provisioned"), + Err(e) => { + fails = fails.saturating_add(1); + eprintln!("FAIL teardown: the machine is LEFT PROVISIONED by this probe — {e}"); + } + } + } + + if fails == 0 { + println!("ALL WINDOWS ACCOUNT ENFORCEMENT PROBES PASSED"); + 0 + } else { + eprintln!("{fails} WINDOWS ACCOUNT ENFORCEMENT PROBE(S) FAILED"); + 1 + } + } + + fn run_cases(window: (u16, u16)) -> std::io::Result { + let f = Fixture::new()?; + let mut fails = 0u32; + let confine = secret_in_grant(&f); + let open = unconfined(); + let deny_net = net_denied(&f); + let secret = f.secret.to_string_lossy().into_owned(); + let ok = f.ok.to_string_lossy().into_owned(); + let inside = f.work.join("w.txt").to_string_lossy().into_owned(); + let outside = f.outside.join("w.txt").to_string_lossy().into_owned(); + let go = |p: &SandboxPolicy, args: &[&str]| code(&f, p, args); + + expect( + &mut fails, + "the child runs AS the dedicated sandbox account, not the invoking user", + go(&confine, &["__acctchild__", "whoami", ACCOUNT]), + 0, + ); + + // ── 1. secret denied INSIDE a granted tree (the case AppContainer cannot express) ─ + expect( + &mut fails, + "KEY: the denied secret inside the granted tree is UNREADABLE", + go(&confine, &["__acctchild__", "read", &secret]), + 5, + ); + expect( + &mut fails, + "NC same run, same tree: the granted file still reads (the deny is surgical, not a dead grant)", + go(&confine, &["__acctchild__", "read", &ok]), + 0, + ); + expect( + &mut fails, + "NC unconfined: the secret is readable absent the sandbox (the file is not simply broken)", + go(&open, &["__acctchild__", "read", &secret]), + 0, + ); + + // ── 2. writes jailed to the granted tree ───────────────────────────────── + expect( + &mut fails, + "write inside the granted tree succeeds", + go(&confine, &["__acctchild__", "write", &inside]), + 0, + ); + expect( + &mut fails, + "write to the UNGRANTED sibling dir is denied", + go(&confine, &["__acctchild__", "write", &outside]), + 5, + ); + expect( + &mut fails, + "NC unconfined: the sibling dir is writable absent the sandbox", + go(&open, &["__acctchild__", "write", &outside]), + 0, + ); + + // ── 3 + 4. egress fence, and that a surrogate spawn does not escape it ──── + match listen_outside(window) { + None => { + fails += 1; + eprintln!( + "FAIL egress setup: no ephemeral loopback port outside the {}-{} window", + window.0, window.1 + ); + } + Some((_listener, port)) => { + let p = port.to_string(); + expect( + &mut fails, + "egress to a loopback endpoint OUTSIDE the permitted window is blocked (WSAEACCES)", + go(&deny_net, &["__acctchild__", "connect", "127.0.0.1", &p]), + 5, + ); + expect( + &mut fails, + "NC unconfined: the same endpoint is reachable (the target is live; the block is the fence)", + go(&open, &["__acctchild__", "connect", "127.0.0.1", &p]), + 0, + ); + expect( + &mut fails, + "a GRANDCHILD the sandboxed child spawns is fenced too (the filter keys on the SID, not the process)", + go( + &deny_net, + &["__acctchild__", "spawnconnect", "127.0.0.1", &p], + ), + 5, + ); + expect( + &mut fails, + "NC unconfined: the same grandchild reaches the endpoint", + go(&open, &["__acctchild__", "spawnconnect", "127.0.0.1", &p]), + 0, + ); + } + } + + // ── 5. the proxy window is genuinely open (else per-host net is unreachable) ─ + match (window.0..=window.1).find_map(listen) { + Some(listener) => { + let p = listener.local_addr()?.port().to_string(); + expect( + &mut fails, + "a listener INSIDE the permitted window IS reachable from the sandboxed child", + go(&deny_net, &["__acctchild__", "connect", "127.0.0.1", &p]), + 0, + ); + } + None => eprintln!( + "SKIP proxy-window reachability — every port in the {}-{} window is already bound, \ + so this property was NOT verified on this run", + window.0, window.1 + ), + } + + Ok(fails) + } +} diff --git a/crates/nub-sandbox/tests/windows_enforcement.rs b/crates/nub-sandbox/tests/windows_enforcement.rs index 8339458f4..65f9ce57e 100644 --- a/crates/nub-sandbox/tests/windows_enforcement.rs +++ b/crates/nub-sandbox/tests/windows_enforcement.rs @@ -606,6 +606,7 @@ mod win { enforce: true, rules: Vec::new(), default_effect: Effect::Deny, + ..Default::default() }; expect_in( &mut fails, diff --git a/vendor/aube/crates/aube-linker/src/patches.rs b/vendor/aube/crates/aube-linker/src/patches.rs index ef34e7e87..7c282c534 100644 --- a/vendor/aube/crates/aube-linker/src/patches.rs +++ b/vendor/aube/crates/aube-linker/src/patches.rs @@ -466,9 +466,7 @@ fn evaluate_hunk( } let mut context_index = base as usize; // `fileLines.length - contextIndex < original.length` → null. - if file_lines.len() < context_index - || file_lines.len() - context_index < hunk.original_length - { + if file_lines.len() < context_index || file_lines.len() - context_index < hunk.original_length { return None; } @@ -531,7 +529,9 @@ fn apply_hunks(base_image: &str, hunks: &[Hunk]) -> Result { -fuzzing_offset - 1 }; if fuzzing_offset.abs() > 20 { - return Err(format!("could not apply hunk {i} (offset drift > 20 lines)")); + return Err(format!( + "could not apply hunk {i} (offset drift > 20 lines)" + )); } }; all_mods.push(mods); @@ -550,7 +550,9 @@ fn apply_hunks(base_image: &str, hunks: &[Hunk]) -> Result { } => { let at = (*index as isize + diff_offset) as usize; let end = (at + num_to_delete).min(file_lines.len()); - let removed: Vec = file_lines.splice(at..end, lines_to_insert.iter().cloned()).collect(); + let removed: Vec = file_lines + .splice(at..end, lines_to_insert.iter().cloned()) + .collect(); diff_offset += lines_to_insert.len() as isize - removed.len() as isize; } Modification::Pop => { @@ -996,7 +998,8 @@ mod tests { // no-trailing-newline EOF. With the newline-agnostic line array // this round-trips for free — the marker is informational here. let original = "a\nb\nlast"; - let body = "--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n last\n\\ No newline at end of file\n"; + let body = + "--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n last\n\\ No newline at end of file\n"; assert_eq!(apply_body(original, body).unwrap(), "a\nB\nlast"); } @@ -1042,7 +1045,10 @@ mod tests { // comparing; the old diffy byte-exact match rejected. We match. let original = "alpha \nbeta\ngamma\n"; // "alpha" has trailing spaces let body = "--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n"; - assert_eq!(apply_body(original, body).unwrap(), "alpha \nBETA\ngamma\n"); + assert_eq!( + apply_body(original, body).unwrap(), + "alpha \nBETA\ngamma\n" + ); } #[test] @@ -1105,8 +1111,12 @@ mod tests { #[test] fn happy_path_simple_edit_byte_identical() { let original = "module.exports = 'old';\n"; - let body = "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-module.exports = 'old';\n+module.exports = 'new';\n"; - assert_eq!(apply_body(original, body).unwrap(), "module.exports = 'new';\n"); + let body = + "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-module.exports = 'old';\n+module.exports = 'new';\n"; + assert_eq!( + apply_body(original, body).unwrap(), + "module.exports = 'new';\n" + ); } #[test] @@ -1115,7 +1125,10 @@ mod tests { let body = "--- a/x\n+++ b/x\n\ @@ -1,3 +1,3 @@\n 1\n-2\n+TWO\n 3\n\ @@ -6,3 +6,3 @@\n 6\n-7\n+SEVEN\n 8\n"; - assert_eq!(apply_body(original, body).unwrap(), "1\nTWO\n3\n4\n5\n6\nSEVEN\n8\n"); + assert_eq!( + apply_body(original, body).unwrap(), + "1\nTWO\n3\n4\n5\n6\nSEVEN\n8\n" + ); } #[test] diff --git a/vendor/aube/crates/aube-lockfile/src/npm/mod.rs b/vendor/aube/crates/aube-lockfile/src/npm/mod.rs index 0e2fbfdc7..efd3a15b3 100644 --- a/vendor/aube/crates/aube-lockfile/src/npm/mod.rs +++ b/vendor/aube/crates/aube-lockfile/src/npm/mod.rs @@ -24,8 +24,8 @@ pub(crate) use layout::{ build_hoist_tree, canonical_key_from_dep_path, child_canonical_key, dep_path_tail, dep_value_as_version, segments_to_install_path, }; -pub(crate) use source::local_git_source_from_resolved; pub use read::parse; +pub(crate) use source::local_git_source_from_resolved; pub use write::write; #[cfg(test)] diff --git a/vendor/aube/crates/aube-lockfile/src/npm/tests.rs b/vendor/aube/crates/aube-lockfile/src/npm/tests.rs index c02be73a4..9329f764f 100644 --- a/vendor/aube/crates/aube-lockfile/src/npm/tests.rs +++ b/vendor/aube/crates/aube-lockfile/src/npm/tests.rs @@ -2495,15 +2495,27 @@ fn test_write_emits_workspace_members_on_fresh_resolve() { // Member importer entries carry name/version + their own deps. assert_eq!(packages["packages/pkg-a"]["name"], "@dedup/pkg-a"); assert_eq!(packages["packages/pkg-a"]["version"], "1.0.0"); - assert_eq!(packages["packages/pkg-a"]["dependencies"]["lodash"], "^3.10.1"); + assert_eq!( + packages["packages/pkg-a"]["dependencies"]["lodash"], + "^3.10.1" + ); assert_eq!(packages["packages/pkg-b"]["name"], "@dedup/pkg-b"); - assert_eq!(packages["packages/pkg-b"]["dependencies"]["lodash"], "^4.17.0"); + assert_eq!( + packages["packages/pkg-b"]["dependencies"]["lodash"], + "^4.17.0" + ); // Root node_modules symlink record for each member. assert_eq!(packages["node_modules/@dedup/pkg-a"]["link"], true); - assert_eq!(packages["node_modules/@dedup/pkg-a"]["resolved"], "packages/pkg-a"); + assert_eq!( + packages["node_modules/@dedup/pkg-a"]["resolved"], + "packages/pkg-a" + ); assert_eq!(packages["node_modules/@dedup/pkg-b"]["link"], true); - assert_eq!(packages["node_modules/@dedup/pkg-b"]["resolved"], "packages/pkg-b"); + assert_eq!( + packages["node_modules/@dedup/pkg-b"]["resolved"], + "packages/pkg-b" + ); // Both deduped child versions land as nested package entries under // their owning member — npm ci rejects the lockfile if either is @@ -2610,7 +2622,10 @@ fn test_roundtrip_preserves_npm_verbatim_meta_fields() { // `hasShrinkwrap` bools sort after `integrity` and before // `dependencies` (the only object key). Assert relative placement so // a future reorder can't silently produce churn vs npm. - let pos = |needle: &str| body.find(needle).unwrap_or_else(|| panic!("missing {needle}\n{body}")); + let pos = |needle: &str| { + body.find(needle) + .unwrap_or_else(|| panic!("missing {needle}\n{body}")) + }; assert!(pos("\"bundleDependencies\"") < pos("\"deprecated\"")); assert!(pos("\"deprecated\"") < pos("\"hasInstallScript\"")); assert!(pos("\"hasInstallScript\"") < pos("\"hasShrinkwrap\"")); @@ -2622,7 +2637,10 @@ fn test_roundtrip_preserves_npm_verbatim_meta_fields() { let addon2 = &reparsed.packages["native-addon@1.0.0"]; assert!(addon2.has_install_script); assert!(addon2.has_shrinkwrap); - assert_eq!(addon2.deprecated.as_deref(), Some("use native-addon@2 instead")); + assert_eq!( + addon2.deprecated.as_deref(), + Some("use native-addon@2 instead") + ); assert_eq!(addon2.bundled_dependencies, vec!["inner".to_string()]); assert!(reparsed.packages["inner@2.0.0"].in_bundle); } @@ -2713,7 +2731,9 @@ fn legacy_v1_package_lock_lifts_to_graph() { assert_eq!(is_odd.version, "3.0.1"); assert_eq!( is_odd.integrity.as_deref(), - Some("sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==") + Some( + "sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==" + ) ); assert_eq!( is_odd.tarball_url.as_deref(), @@ -2729,7 +2749,9 @@ fn legacy_v1_package_lock_lifts_to_graph() { let is_number = &graph.packages["is-number@6.0.0"]; assert_eq!( is_number.integrity.as_deref(), - Some("sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==") + Some( + "sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==" + ) ); // Direct deps come from the manifest, NOT the lockfile (v1 has no @@ -2953,7 +2975,10 @@ fn legacy_v1_nested_dedupe_hoisting() { Some("2.0.0") ); - let mut root: Vec<&str> = graph.importers["."].iter().map(|d| d.name.as_str()).collect(); + let mut root: Vec<&str> = graph.importers["."] + .iter() + .map(|d| d.name.as_str()) + .collect(); root.sort_unstable(); assert_eq!(root, vec!["a", "b"]); } diff --git a/vendor/aube/crates/aube-lockfile/src/npm/write.rs b/vendor/aube/crates/aube-lockfile/src/npm/write.rs index 6c4ed0a24..8de95dd67 100644 --- a/vendor/aube/crates/aube-lockfile/src/npm/write.rs +++ b/vendor/aube/crates/aube-lockfile/src/npm/write.rs @@ -340,10 +340,9 @@ pub fn write( .filter(|p| p.as_str() != ".") .filter(|p| workspace_package_for_importer(graph, p).is_none()) .map(|p| { - let m = aube_manifest::PackageJson::from_path( - &project_dir.join(p).join("package.json"), - ) - .unwrap_or_default(); + let m = + aube_manifest::PackageJson::from_path(&project_dir.join(p).join("package.json")) + .unwrap_or_default(); (p.as_str(), m) }) .collect(); diff --git a/vendor/aube/crates/aube-lockfile/src/pnpm/read.rs b/vendor/aube/crates/aube-lockfile/src/pnpm/read.rs index e7603e28e..be1f97dd3 100644 --- a/vendor/aube/crates/aube-lockfile/src/pnpm/read.rs +++ b/vendor/aube/crates/aube-lockfile/src/pnpm/read.rs @@ -1,7 +1,6 @@ use super::dep_path::{ dep_path_tail, parse_dep_path, peerless_alias_target, rewrite_peer_suffix, - rewrite_snapshot_alias_deps, - version_to_dep_path, + rewrite_snapshot_alias_deps, version_to_dep_path, }; use super::raw::{ RawBinSpec, RawDepSpec, RawRuntimeVariant, local_source_from_resolution, parse_raw_lockfile, diff --git a/vendor/aube/crates/aube-lockfile/src/pnpm/tests.rs b/vendor/aube/crates/aube-lockfile/src/pnpm/tests.rs index ae2a79ddd..1a6d54faf 100644 --- a/vendor/aube/crates/aube-lockfile/src/pnpm/tests.rs +++ b/vendor/aube/crates/aube-lockfile/src/pnpm/tests.rs @@ -1889,8 +1889,7 @@ fn patched_dependency_roundtrips_through_real_pnpm() { // Captured from `corepack pnpm@10.15.1 install` on the patched-deps // fixture — the exact bytes real pnpm wrote. const HASH: &str = "dcac38e61b21e4c1fbc036fbd04c2c57fc5aca4d595709258e1654cf8529c5c1"; - const EXPECTED_BLOCK: &str = - "patchedDependencies:\n is-odd@3.0.1:\n hash: dcac38e61b21e4c1fbc036fbd04c2c57fc5aca4d595709258e1654cf8529c5c1\n path: patches/is-odd@3.0.1.patch"; + const EXPECTED_BLOCK: &str = "patchedDependencies:\n is-odd@3.0.1:\n hash: dcac38e61b21e4c1fbc036fbd04c2c57fc5aca4d595709258e1654cf8529c5c1\n path: patches/is-odd@3.0.1.patch"; let dir = tempfile::tempdir().unwrap(); let lockfile_path = dir.path().join("pnpm-lock.yaml"); @@ -3175,10 +3174,7 @@ snapshots: // is read for its hash only, and the path map stays empty. assert!(graph.patched_dependencies.is_empty()); assert_eq!( - graph - .patched_dependency_hashes - .get("is-odd@3.0.1") - .unwrap(), + graph.patched_dependency_hashes.get("is-odd@3.0.1").unwrap(), "sha256-deadbeef" ); assert_eq!( @@ -4826,7 +4822,9 @@ fn npm_to_pnpm_conversion_omits_phantom_member_links_on_empty_root() { let manifest = PackageJson { name: Some("wsroot".to_string()), version: Some("1.0.0".to_string()), - workspaces: Some(aube_manifest::Workspaces::Array(vec!["packages/*".to_string()])), + workspaces: Some(aube_manifest::Workspaces::Array(vec![ + "packages/*".to_string(), + ])), ..PackageJson::default() }; diff --git a/vendor/aube/crates/aube-lockfile/src/pnpm/write.rs b/vendor/aube/crates/aube-lockfile/src/pnpm/write.rs index 8edce4076..8a3efc124 100644 --- a/vendor/aube/crates/aube-lockfile/src/pnpm/write.rs +++ b/vendor/aube/crates/aube-lockfile/src/pnpm/write.rs @@ -887,9 +887,9 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu path: path.clone(), } } - (Some(hash), None) => WritablePatchedDependency::HashOnly { - hash: hash.clone(), - }, + (Some(hash), None) => { + WritablePatchedDependency::HashOnly { hash: hash.clone() } + } (None, Some(path)) => { WritablePatchedDependency::PathOnly(path.clone()) } diff --git a/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs b/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs index 1f60aeaf9..673c78383 100644 --- a/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs +++ b/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs @@ -140,10 +140,7 @@ fn classic_git_protocol_dep_resolves() { .find(|p| p.name == "foo") .expect("foo must be in the graph"); assert!( - matches!( - &pkg.local_source, - Some(aube_lockfile::LocalSource::Git(_)) - ), + matches!(&pkg.local_source, Some(aube_lockfile::LocalSource::Git(_))), "expected git LocalSource, got {:?}", pkg.local_source ); diff --git a/vendor/aube/crates/aube-registry/src/config/tests.rs b/vendor/aube/crates/aube-registry/src/config/tests.rs index e2dd1b399..5c30102c9 100644 --- a/vendor/aube/crates/aube-registry/src/config/tests.rs +++ b/vendor/aube/crates/aube-registry/src/config/tests.rs @@ -749,7 +749,10 @@ fn pnpm11_npmrc_allowlist_drops_layout_keys_but_keeps_auth_registry_network() { let has = |k: &str| gated.project.iter().any(|(key, _)| key == k); assert!(!has("node-linker"), "layout key must be dropped"); assert!(!has("ignore-scripts"), "behavior key must be dropped"); - assert!(!has("noproxy"), "npm's `noproxy` spelling is not allowlisted"); + assert!( + !has("noproxy"), + "npm's `noproxy` spelling is not allowlisted" + ); assert!(has("registry"), "registry must survive"); assert!(has("@myorg:registry"), "scoped registry must survive"); assert!( diff --git a/vendor/aube/crates/aube-resolver/src/tests.rs b/vendor/aube/crates/aube-resolver/src/tests.rs index 43f225972..7efd2726c 100644 --- a/vendor/aube/crates/aube-resolver/src/tests.rs +++ b/vendor/aube/crates/aube-resolver/src/tests.rs @@ -1820,8 +1820,10 @@ async fn primer_dist_tag_pick_refetches_when_registry_repointed_latest() { // served (the bug). let mut full = make_packument(&name, &[&primer_latest, &live_latest], &live_latest); full.modified = Some("2024-01-01T00:00:00.000Z".to_string()); - full.time - .insert(primer_latest.clone(), "2024-01-01T00:00:00.000Z".to_string()); + full.time.insert( + primer_latest.clone(), + "2024-01-01T00:00:00.000Z".to_string(), + ); full.time .insert(live_latest.clone(), "2024-01-02T00:00:00.000Z".to_string()); let full_body = serde_json::to_vec(&full).unwrap(); @@ -1868,7 +1870,9 @@ async fn primer_dist_tag_pick_refetches_when_registry_repointed_latest() { .with_packument_cache(base.join("packuments")) .with_force_metadata_primer(true); let mut manifest = PackageJson::default(); - manifest.dependencies.insert(name.clone(), "latest".to_string()); + manifest + .dependencies + .insert(name.clone(), "latest".to_string()); let graph = resolver .resolve(&manifest, None) @@ -5125,12 +5129,30 @@ fn colonless_dist_tag_still_resolves_after_scheme_guard() { packument .dist_tags .insert("nightly".to_string(), "1.0.0".to_string()); - let result = - pick_version(&packument, "nightly", None, false, None, None, false, |_, _| false).unwrap(); + let result = pick_version( + &packument, + "nightly", + None, + false, + None, + None, + false, + |_, _| false, + ) + .unwrap(); assert_eq!(result.version, "1.0.0"); - let result = - pick_version(&packument, "latest", None, false, None, None, false, |_, _| false).unwrap(); + let result = pick_version( + &packument, + "latest", + None, + false, + None, + None, + false, + |_, _| false, + ) + .unwrap(); assert_eq!(result.version, "2.0.0"); } diff --git a/vendor/aube/crates/aube-store/src/lib.rs b/vendor/aube/crates/aube-store/src/lib.rs index 5328d12aa..999a6c729 100644 --- a/vendor/aube/crates/aube-store/src/lib.rs +++ b/vendor/aube/crates/aube-store/src/lib.rs @@ -281,7 +281,8 @@ impl Store { /// /// [`virtual_store_subdir`]: aube_util::Embedder::virtual_store_subdir pub fn virtual_store_dir(&self) -> PathBuf { - self.cache_dir.join(aube_util::embedder().virtual_store_subdir) + self.cache_dir + .join(aube_util::embedder().virtual_store_subdir) } /// Root of the per-package *extracted-tree* tier, a sibling of the diff --git a/vendor/aube/crates/aube/src/commands/add/manifest.rs b/vendor/aube/crates/aube/src/commands/add/manifest.rs index bfac4aa3e..f4ccec561 100644 --- a/vendor/aube/crates/aube/src/commands/add/manifest.rs +++ b/vendor/aube/crates/aube/src/commands/add/manifest.rs @@ -566,7 +566,9 @@ pub(super) async fn update_manifest_for_add( let catalog_target = if catalog_upserts.is_empty() { None } else { - Some(crate::commands::catalogs::resolve_catalog_write_target(cwd)?) + Some(crate::commands::catalogs::resolve_catalog_write_target( + cwd, + )?) }; // Write the updated package.json. Under `--no-save` callers still diff --git a/vendor/aube/crates/aube/src/commands/catalogs.rs b/vendor/aube/crates/aube/src/commands/catalogs.rs index d271e7b7d..c869157f5 100644 --- a/vendor/aube/crates/aube/src/commands/catalogs.rs +++ b/vendor/aube/crates/aube/src/commands/catalogs.rs @@ -331,7 +331,10 @@ pub(crate) fn resolve_catalog_write_target(cwd: &Path) -> miette::Result miette::Result { diff --git a/vendor/aube/crates/aube/src/patches.rs b/vendor/aube/crates/aube/src/patches.rs index 02d015795..54bdb965c 100644 --- a/vendor/aube/crates/aube/src/patches.rs +++ b/vendor/aube/crates/aube/src/patches.rs @@ -308,7 +308,11 @@ pub fn upsert_patched_dependency(cwd: &Path, key: &str, rel_patch_path: &str) -> // Standalone aube (reads pnpm config): nest under `pnpm` so real // pnpm accepts the lockfile. An embedder that ignores pnpm config: // write the un-branded top-level field it actually reads. - let namespace = if reads_branded_pnpm { Some("pnpm") } else { None }; + let namespace = if reads_branded_pnpm { + Some("pnpm") + } else { + None + }; upsert_manifest_patched_dependency(cwd, key, rel_patch_path, namespace) .wrap_err("failed to write package.json")?; return Ok(cwd.join("package.json")); diff --git a/vendor/aube/crates/aube/src/progress/ci.rs b/vendor/aube/crates/aube/src/progress/ci.rs index ebb6cc7d9..b4e537703 100644 --- a/vendor/aube/crates/aube/src/progress/ci.rs +++ b/vendor/aube/crates/aube/src/progress/ci.rs @@ -225,54 +225,54 @@ impl CiState { let spawned = thread::Builder::new() .name("aube-ci-heartbeat".into()) .spawn(move || { - let state = thread_state; - loop { - let guard = state.wake_lock.lock().unwrap(); - // Re-check `done` *before* sleeping. `stop()` sets `done` - // and then `notify_all()`s without holding `wake_lock`, so - // a notification that races with the tick body would - // otherwise be lost and the thread would sleep a full - // `CI_HEARTBEAT_INTERVAL` before noticing shutdown. - if state.done.load(Ordering::Relaxed) { - break; + let state = thread_state; + loop { + let guard = state.wake_lock.lock().unwrap(); + // Re-check `done` *before* sleeping. `stop()` sets `done` + // and then `notify_all()`s without holding `wake_lock`, so + // a notification that races with the tick body would + // otherwise be lost and the thread would sleep a full + // `CI_HEARTBEAT_INTERVAL` before noticing shutdown. + if state.done.load(Ordering::Relaxed) { + break; + } + let (guard, _timeout) = state + .wake + .wait_timeout(guard, CI_HEARTBEAT_INTERVAL) + .unwrap(); + drop(guard); + if state.done.load(Ordering::Relaxed) { + break; + } + let snap = state.snapshot(); + // Don't make noise until an install is actually underway. + // Until then there's nothing to bar-graph and no reason to + // print anything — a no-op install should remain + // completely silent. + if snap.resolved == 0 || snap.phase == 0 { + continue; + } + let line = Self::render(snap); + if line.is_empty() { + continue; + } + let mut last = state.last_printed.lock().unwrap(); + if *last == line { + // Same rendered line as before — stay quiet. + continue; + } + *last = line.clone(); + drop(last); + // First time we actually print, emit the unframed + // `aube VERSION by jdx.dev` header above the bar so + // the CI log shows the aube banner. Only printed + // once per install — `shown` flips true here. + if !state.shown.swap(true, Ordering::Relaxed) { + let _ = writeln!(std::io::stderr(), "{}", Self::render_header()); + } + let _ = writeln!(std::io::stderr(), "{line}"); } - let (guard, _timeout) = state - .wake - .wait_timeout(guard, CI_HEARTBEAT_INTERVAL) - .unwrap(); - drop(guard); - if state.done.load(Ordering::Relaxed) { - break; - } - let snap = state.snapshot(); - // Don't make noise until an install is actually underway. - // Until then there's nothing to bar-graph and no reason to - // print anything — a no-op install should remain - // completely silent. - if snap.resolved == 0 || snap.phase == 0 { - continue; - } - let line = Self::render(snap); - if line.is_empty() { - continue; - } - let mut last = state.last_printed.lock().unwrap(); - if *last == line { - // Same rendered line as before — stay quiet. - continue; - } - *last = line.clone(); - drop(last); - // First time we actually print, emit the unframed - // `aube VERSION by jdx.dev` header above the bar so - // the CI log shows the aube banner. Only printed - // once per install — `shown` flips true here. - if !state.shown.swap(true, Ordering::Relaxed) { - let _ = writeln!(std::io::stderr(), "{}", Self::render_header()); - } - let _ = writeln!(std::io::stderr(), "{line}"); - } - }); + }); // On spawn failure (e.g. EAGAIN under thread/PID exhaustion) leave the // handle `None` — the install proceeds without the cosmetic ticker. *state.heartbeat.lock().unwrap() = spawned.ok(); From 9a27a16752511d03e384941c02609072bafe6489 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:05:21 -0700 Subject: [PATCH 4/7] sandbox(windows): revert vendor/aube reformatting swept in by cargo fmt --all `cargo fmt --all` reflows the whole workspace, vendor/aube included. Those 16 files are pure rustfmt churn, unrelated to this branch and owned by the aube fork-discipline flow, so they are restored to the branch point. The net diff now touches no vendor/aube file. --- vendor/aube/crates/aube-linker/src/patches.rs | 33 ++----- .../aube/crates/aube-lockfile/src/npm/mod.rs | 2 +- .../crates/aube-lockfile/src/npm/tests.rs | 43 ++------- .../crates/aube-lockfile/src/npm/write.rs | 7 +- .../crates/aube-lockfile/src/pnpm/read.rs | 3 +- .../crates/aube-lockfile/src/pnpm/tests.rs | 12 ++- .../crates/aube-lockfile/src/pnpm/write.rs | 6 +- .../aube-lockfile/tests/unsupported_source.rs | 5 +- .../crates/aube-registry/src/config/tests.rs | 5 +- vendor/aube/crates/aube-resolver/src/tests.rs | 36 ++----- vendor/aube/crates/aube-store/src/lib.rs | 3 +- .../crates/aube/src/commands/add/manifest.rs | 4 +- .../aube/crates/aube/src/commands/catalogs.rs | 15 +-- .../crates/aube/src/commands/config/mod.rs | 2 +- vendor/aube/crates/aube/src/patches.rs | 6 +- vendor/aube/crates/aube/src/progress/ci.rs | 94 +++++++++---------- 16 files changed, 102 insertions(+), 174 deletions(-) diff --git a/vendor/aube/crates/aube-linker/src/patches.rs b/vendor/aube/crates/aube-linker/src/patches.rs index 7c282c534..ef34e7e87 100644 --- a/vendor/aube/crates/aube-linker/src/patches.rs +++ b/vendor/aube/crates/aube-linker/src/patches.rs @@ -466,7 +466,9 @@ fn evaluate_hunk( } let mut context_index = base as usize; // `fileLines.length - contextIndex < original.length` → null. - if file_lines.len() < context_index || file_lines.len() - context_index < hunk.original_length { + if file_lines.len() < context_index + || file_lines.len() - context_index < hunk.original_length + { return None; } @@ -529,9 +531,7 @@ fn apply_hunks(base_image: &str, hunks: &[Hunk]) -> Result { -fuzzing_offset - 1 }; if fuzzing_offset.abs() > 20 { - return Err(format!( - "could not apply hunk {i} (offset drift > 20 lines)" - )); + return Err(format!("could not apply hunk {i} (offset drift > 20 lines)")); } }; all_mods.push(mods); @@ -550,9 +550,7 @@ fn apply_hunks(base_image: &str, hunks: &[Hunk]) -> Result { } => { let at = (*index as isize + diff_offset) as usize; let end = (at + num_to_delete).min(file_lines.len()); - let removed: Vec = file_lines - .splice(at..end, lines_to_insert.iter().cloned()) - .collect(); + let removed: Vec = file_lines.splice(at..end, lines_to_insert.iter().cloned()).collect(); diff_offset += lines_to_insert.len() as isize - removed.len() as isize; } Modification::Pop => { @@ -998,8 +996,7 @@ mod tests { // no-trailing-newline EOF. With the newline-agnostic line array // this round-trips for free — the marker is informational here. let original = "a\nb\nlast"; - let body = - "--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n last\n\\ No newline at end of file\n"; + let body = "--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n a\n-b\n+B\n last\n\\ No newline at end of file\n"; assert_eq!(apply_body(original, body).unwrap(), "a\nB\nlast"); } @@ -1045,10 +1042,7 @@ mod tests { // comparing; the old diffy byte-exact match rejected. We match. let original = "alpha \nbeta\ngamma\n"; // "alpha" has trailing spaces let body = "--- a/x\n+++ b/x\n@@ -1,3 +1,3 @@\n alpha\n-beta\n+BETA\n gamma\n"; - assert_eq!( - apply_body(original, body).unwrap(), - "alpha \nBETA\ngamma\n" - ); + assert_eq!(apply_body(original, body).unwrap(), "alpha \nBETA\ngamma\n"); } #[test] @@ -1111,12 +1105,8 @@ mod tests { #[test] fn happy_path_simple_edit_byte_identical() { let original = "module.exports = 'old';\n"; - let body = - "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-module.exports = 'old';\n+module.exports = 'new';\n"; - assert_eq!( - apply_body(original, body).unwrap(), - "module.exports = 'new';\n" - ); + let body = "--- a/x\n+++ b/x\n@@ -1 +1 @@\n-module.exports = 'old';\n+module.exports = 'new';\n"; + assert_eq!(apply_body(original, body).unwrap(), "module.exports = 'new';\n"); } #[test] @@ -1125,10 +1115,7 @@ mod tests { let body = "--- a/x\n+++ b/x\n\ @@ -1,3 +1,3 @@\n 1\n-2\n+TWO\n 3\n\ @@ -6,3 +6,3 @@\n 6\n-7\n+SEVEN\n 8\n"; - assert_eq!( - apply_body(original, body).unwrap(), - "1\nTWO\n3\n4\n5\n6\nSEVEN\n8\n" - ); + assert_eq!(apply_body(original, body).unwrap(), "1\nTWO\n3\n4\n5\n6\nSEVEN\n8\n"); } #[test] diff --git a/vendor/aube/crates/aube-lockfile/src/npm/mod.rs b/vendor/aube/crates/aube-lockfile/src/npm/mod.rs index efd3a15b3..0e2fbfdc7 100644 --- a/vendor/aube/crates/aube-lockfile/src/npm/mod.rs +++ b/vendor/aube/crates/aube-lockfile/src/npm/mod.rs @@ -24,8 +24,8 @@ pub(crate) use layout::{ build_hoist_tree, canonical_key_from_dep_path, child_canonical_key, dep_path_tail, dep_value_as_version, segments_to_install_path, }; -pub use read::parse; pub(crate) use source::local_git_source_from_resolved; +pub use read::parse; pub use write::write; #[cfg(test)] diff --git a/vendor/aube/crates/aube-lockfile/src/npm/tests.rs b/vendor/aube/crates/aube-lockfile/src/npm/tests.rs index 9329f764f..c02be73a4 100644 --- a/vendor/aube/crates/aube-lockfile/src/npm/tests.rs +++ b/vendor/aube/crates/aube-lockfile/src/npm/tests.rs @@ -2495,27 +2495,15 @@ fn test_write_emits_workspace_members_on_fresh_resolve() { // Member importer entries carry name/version + their own deps. assert_eq!(packages["packages/pkg-a"]["name"], "@dedup/pkg-a"); assert_eq!(packages["packages/pkg-a"]["version"], "1.0.0"); - assert_eq!( - packages["packages/pkg-a"]["dependencies"]["lodash"], - "^3.10.1" - ); + assert_eq!(packages["packages/pkg-a"]["dependencies"]["lodash"], "^3.10.1"); assert_eq!(packages["packages/pkg-b"]["name"], "@dedup/pkg-b"); - assert_eq!( - packages["packages/pkg-b"]["dependencies"]["lodash"], - "^4.17.0" - ); + assert_eq!(packages["packages/pkg-b"]["dependencies"]["lodash"], "^4.17.0"); // Root node_modules symlink record for each member. assert_eq!(packages["node_modules/@dedup/pkg-a"]["link"], true); - assert_eq!( - packages["node_modules/@dedup/pkg-a"]["resolved"], - "packages/pkg-a" - ); + assert_eq!(packages["node_modules/@dedup/pkg-a"]["resolved"], "packages/pkg-a"); assert_eq!(packages["node_modules/@dedup/pkg-b"]["link"], true); - assert_eq!( - packages["node_modules/@dedup/pkg-b"]["resolved"], - "packages/pkg-b" - ); + assert_eq!(packages["node_modules/@dedup/pkg-b"]["resolved"], "packages/pkg-b"); // Both deduped child versions land as nested package entries under // their owning member — npm ci rejects the lockfile if either is @@ -2622,10 +2610,7 @@ fn test_roundtrip_preserves_npm_verbatim_meta_fields() { // `hasShrinkwrap` bools sort after `integrity` and before // `dependencies` (the only object key). Assert relative placement so // a future reorder can't silently produce churn vs npm. - let pos = |needle: &str| { - body.find(needle) - .unwrap_or_else(|| panic!("missing {needle}\n{body}")) - }; + let pos = |needle: &str| body.find(needle).unwrap_or_else(|| panic!("missing {needle}\n{body}")); assert!(pos("\"bundleDependencies\"") < pos("\"deprecated\"")); assert!(pos("\"deprecated\"") < pos("\"hasInstallScript\"")); assert!(pos("\"hasInstallScript\"") < pos("\"hasShrinkwrap\"")); @@ -2637,10 +2622,7 @@ fn test_roundtrip_preserves_npm_verbatim_meta_fields() { let addon2 = &reparsed.packages["native-addon@1.0.0"]; assert!(addon2.has_install_script); assert!(addon2.has_shrinkwrap); - assert_eq!( - addon2.deprecated.as_deref(), - Some("use native-addon@2 instead") - ); + assert_eq!(addon2.deprecated.as_deref(), Some("use native-addon@2 instead")); assert_eq!(addon2.bundled_dependencies, vec!["inner".to_string()]); assert!(reparsed.packages["inner@2.0.0"].in_bundle); } @@ -2731,9 +2713,7 @@ fn legacy_v1_package_lock_lifts_to_graph() { assert_eq!(is_odd.version, "3.0.1"); assert_eq!( is_odd.integrity.as_deref(), - Some( - "sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==" - ) + Some("sha512-CQpnWPrDwmP1+SMHXZhtLtJv90yiyVfluGsX5iNCVkrhQtU3TQHsUWPG9wkdk9Lgd5yNpAg9jQEo90CBaXgWMA==") ); assert_eq!( is_odd.tarball_url.as_deref(), @@ -2749,9 +2729,7 @@ fn legacy_v1_package_lock_lifts_to_graph() { let is_number = &graph.packages["is-number@6.0.0"]; assert_eq!( is_number.integrity.as_deref(), - Some( - "sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==" - ) + Some("sha512-Wu1VHeILBK8KAWJUAiSZQX94GmOE45Rg6/538fKwiloUu21KncEkYGPqob2oSZ5mUT73vLGrHQjKw3KMPwfDzg==") ); // Direct deps come from the manifest, NOT the lockfile (v1 has no @@ -2975,10 +2953,7 @@ fn legacy_v1_nested_dedupe_hoisting() { Some("2.0.0") ); - let mut root: Vec<&str> = graph.importers["."] - .iter() - .map(|d| d.name.as_str()) - .collect(); + let mut root: Vec<&str> = graph.importers["."].iter().map(|d| d.name.as_str()).collect(); root.sort_unstable(); assert_eq!(root, vec!["a", "b"]); } diff --git a/vendor/aube/crates/aube-lockfile/src/npm/write.rs b/vendor/aube/crates/aube-lockfile/src/npm/write.rs index 8de95dd67..6c4ed0a24 100644 --- a/vendor/aube/crates/aube-lockfile/src/npm/write.rs +++ b/vendor/aube/crates/aube-lockfile/src/npm/write.rs @@ -340,9 +340,10 @@ pub fn write( .filter(|p| p.as_str() != ".") .filter(|p| workspace_package_for_importer(graph, p).is_none()) .map(|p| { - let m = - aube_manifest::PackageJson::from_path(&project_dir.join(p).join("package.json")) - .unwrap_or_default(); + let m = aube_manifest::PackageJson::from_path( + &project_dir.join(p).join("package.json"), + ) + .unwrap_or_default(); (p.as_str(), m) }) .collect(); diff --git a/vendor/aube/crates/aube-lockfile/src/pnpm/read.rs b/vendor/aube/crates/aube-lockfile/src/pnpm/read.rs index be1f97dd3..e7603e28e 100644 --- a/vendor/aube/crates/aube-lockfile/src/pnpm/read.rs +++ b/vendor/aube/crates/aube-lockfile/src/pnpm/read.rs @@ -1,6 +1,7 @@ use super::dep_path::{ dep_path_tail, parse_dep_path, peerless_alias_target, rewrite_peer_suffix, - rewrite_snapshot_alias_deps, version_to_dep_path, + rewrite_snapshot_alias_deps, + version_to_dep_path, }; use super::raw::{ RawBinSpec, RawDepSpec, RawRuntimeVariant, local_source_from_resolution, parse_raw_lockfile, diff --git a/vendor/aube/crates/aube-lockfile/src/pnpm/tests.rs b/vendor/aube/crates/aube-lockfile/src/pnpm/tests.rs index 1a6d54faf..ae2a79ddd 100644 --- a/vendor/aube/crates/aube-lockfile/src/pnpm/tests.rs +++ b/vendor/aube/crates/aube-lockfile/src/pnpm/tests.rs @@ -1889,7 +1889,8 @@ fn patched_dependency_roundtrips_through_real_pnpm() { // Captured from `corepack pnpm@10.15.1 install` on the patched-deps // fixture — the exact bytes real pnpm wrote. const HASH: &str = "dcac38e61b21e4c1fbc036fbd04c2c57fc5aca4d595709258e1654cf8529c5c1"; - const EXPECTED_BLOCK: &str = "patchedDependencies:\n is-odd@3.0.1:\n hash: dcac38e61b21e4c1fbc036fbd04c2c57fc5aca4d595709258e1654cf8529c5c1\n path: patches/is-odd@3.0.1.patch"; + const EXPECTED_BLOCK: &str = + "patchedDependencies:\n is-odd@3.0.1:\n hash: dcac38e61b21e4c1fbc036fbd04c2c57fc5aca4d595709258e1654cf8529c5c1\n path: patches/is-odd@3.0.1.patch"; let dir = tempfile::tempdir().unwrap(); let lockfile_path = dir.path().join("pnpm-lock.yaml"); @@ -3174,7 +3175,10 @@ snapshots: // is read for its hash only, and the path map stays empty. assert!(graph.patched_dependencies.is_empty()); assert_eq!( - graph.patched_dependency_hashes.get("is-odd@3.0.1").unwrap(), + graph + .patched_dependency_hashes + .get("is-odd@3.0.1") + .unwrap(), "sha256-deadbeef" ); assert_eq!( @@ -4822,9 +4826,7 @@ fn npm_to_pnpm_conversion_omits_phantom_member_links_on_empty_root() { let manifest = PackageJson { name: Some("wsroot".to_string()), version: Some("1.0.0".to_string()), - workspaces: Some(aube_manifest::Workspaces::Array(vec![ - "packages/*".to_string(), - ])), + workspaces: Some(aube_manifest::Workspaces::Array(vec!["packages/*".to_string()])), ..PackageJson::default() }; diff --git a/vendor/aube/crates/aube-lockfile/src/pnpm/write.rs b/vendor/aube/crates/aube-lockfile/src/pnpm/write.rs index 8a3efc124..8edce4076 100644 --- a/vendor/aube/crates/aube-lockfile/src/pnpm/write.rs +++ b/vendor/aube/crates/aube-lockfile/src/pnpm/write.rs @@ -887,9 +887,9 @@ pub fn write(path: &Path, graph: &LockfileGraph, manifest: &PackageJson) -> Resu path: path.clone(), } } - (Some(hash), None) => { - WritablePatchedDependency::HashOnly { hash: hash.clone() } - } + (Some(hash), None) => WritablePatchedDependency::HashOnly { + hash: hash.clone(), + }, (None, Some(path)) => { WritablePatchedDependency::PathOnly(path.clone()) } diff --git a/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs b/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs index 673c78383..1f60aeaf9 100644 --- a/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs +++ b/vendor/aube/crates/aube-lockfile/tests/unsupported_source.rs @@ -140,7 +140,10 @@ fn classic_git_protocol_dep_resolves() { .find(|p| p.name == "foo") .expect("foo must be in the graph"); assert!( - matches!(&pkg.local_source, Some(aube_lockfile::LocalSource::Git(_))), + matches!( + &pkg.local_source, + Some(aube_lockfile::LocalSource::Git(_)) + ), "expected git LocalSource, got {:?}", pkg.local_source ); diff --git a/vendor/aube/crates/aube-registry/src/config/tests.rs b/vendor/aube/crates/aube-registry/src/config/tests.rs index 5c30102c9..e2dd1b399 100644 --- a/vendor/aube/crates/aube-registry/src/config/tests.rs +++ b/vendor/aube/crates/aube-registry/src/config/tests.rs @@ -749,10 +749,7 @@ fn pnpm11_npmrc_allowlist_drops_layout_keys_but_keeps_auth_registry_network() { let has = |k: &str| gated.project.iter().any(|(key, _)| key == k); assert!(!has("node-linker"), "layout key must be dropped"); assert!(!has("ignore-scripts"), "behavior key must be dropped"); - assert!( - !has("noproxy"), - "npm's `noproxy` spelling is not allowlisted" - ); + assert!(!has("noproxy"), "npm's `noproxy` spelling is not allowlisted"); assert!(has("registry"), "registry must survive"); assert!(has("@myorg:registry"), "scoped registry must survive"); assert!( diff --git a/vendor/aube/crates/aube-resolver/src/tests.rs b/vendor/aube/crates/aube-resolver/src/tests.rs index 7efd2726c..43f225972 100644 --- a/vendor/aube/crates/aube-resolver/src/tests.rs +++ b/vendor/aube/crates/aube-resolver/src/tests.rs @@ -1820,10 +1820,8 @@ async fn primer_dist_tag_pick_refetches_when_registry_repointed_latest() { // served (the bug). let mut full = make_packument(&name, &[&primer_latest, &live_latest], &live_latest); full.modified = Some("2024-01-01T00:00:00.000Z".to_string()); - full.time.insert( - primer_latest.clone(), - "2024-01-01T00:00:00.000Z".to_string(), - ); + full.time + .insert(primer_latest.clone(), "2024-01-01T00:00:00.000Z".to_string()); full.time .insert(live_latest.clone(), "2024-01-02T00:00:00.000Z".to_string()); let full_body = serde_json::to_vec(&full).unwrap(); @@ -1870,9 +1868,7 @@ async fn primer_dist_tag_pick_refetches_when_registry_repointed_latest() { .with_packument_cache(base.join("packuments")) .with_force_metadata_primer(true); let mut manifest = PackageJson::default(); - manifest - .dependencies - .insert(name.clone(), "latest".to_string()); + manifest.dependencies.insert(name.clone(), "latest".to_string()); let graph = resolver .resolve(&manifest, None) @@ -5129,30 +5125,12 @@ fn colonless_dist_tag_still_resolves_after_scheme_guard() { packument .dist_tags .insert("nightly".to_string(), "1.0.0".to_string()); - let result = pick_version( - &packument, - "nightly", - None, - false, - None, - None, - false, - |_, _| false, - ) - .unwrap(); + let result = + pick_version(&packument, "nightly", None, false, None, None, false, |_, _| false).unwrap(); assert_eq!(result.version, "1.0.0"); - let result = pick_version( - &packument, - "latest", - None, - false, - None, - None, - false, - |_, _| false, - ) - .unwrap(); + let result = + pick_version(&packument, "latest", None, false, None, None, false, |_, _| false).unwrap(); assert_eq!(result.version, "2.0.0"); } diff --git a/vendor/aube/crates/aube-store/src/lib.rs b/vendor/aube/crates/aube-store/src/lib.rs index 999a6c729..5328d12aa 100644 --- a/vendor/aube/crates/aube-store/src/lib.rs +++ b/vendor/aube/crates/aube-store/src/lib.rs @@ -281,8 +281,7 @@ impl Store { /// /// [`virtual_store_subdir`]: aube_util::Embedder::virtual_store_subdir pub fn virtual_store_dir(&self) -> PathBuf { - self.cache_dir - .join(aube_util::embedder().virtual_store_subdir) + self.cache_dir.join(aube_util::embedder().virtual_store_subdir) } /// Root of the per-package *extracted-tree* tier, a sibling of the diff --git a/vendor/aube/crates/aube/src/commands/add/manifest.rs b/vendor/aube/crates/aube/src/commands/add/manifest.rs index f4ccec561..bfac4aa3e 100644 --- a/vendor/aube/crates/aube/src/commands/add/manifest.rs +++ b/vendor/aube/crates/aube/src/commands/add/manifest.rs @@ -566,9 +566,7 @@ pub(super) async fn update_manifest_for_add( let catalog_target = if catalog_upserts.is_empty() { None } else { - Some(crate::commands::catalogs::resolve_catalog_write_target( - cwd, - )?) + Some(crate::commands::catalogs::resolve_catalog_write_target(cwd)?) }; // Write the updated package.json. Under `--no-save` callers still diff --git a/vendor/aube/crates/aube/src/commands/catalogs.rs b/vendor/aube/crates/aube/src/commands/catalogs.rs index c869157f5..d271e7b7d 100644 --- a/vendor/aube/crates/aube/src/commands/catalogs.rs +++ b/vendor/aube/crates/aube/src/commands/catalogs.rs @@ -331,10 +331,7 @@ pub(crate) fn resolve_catalog_write_target(cwd: &Path) -> miette::Result miette::Result { diff --git a/vendor/aube/crates/aube/src/patches.rs b/vendor/aube/crates/aube/src/patches.rs index 54bdb965c..02d015795 100644 --- a/vendor/aube/crates/aube/src/patches.rs +++ b/vendor/aube/crates/aube/src/patches.rs @@ -308,11 +308,7 @@ pub fn upsert_patched_dependency(cwd: &Path, key: &str, rel_patch_path: &str) -> // Standalone aube (reads pnpm config): nest under `pnpm` so real // pnpm accepts the lockfile. An embedder that ignores pnpm config: // write the un-branded top-level field it actually reads. - let namespace = if reads_branded_pnpm { - Some("pnpm") - } else { - None - }; + let namespace = if reads_branded_pnpm { Some("pnpm") } else { None }; upsert_manifest_patched_dependency(cwd, key, rel_patch_path, namespace) .wrap_err("failed to write package.json")?; return Ok(cwd.join("package.json")); diff --git a/vendor/aube/crates/aube/src/progress/ci.rs b/vendor/aube/crates/aube/src/progress/ci.rs index b4e537703..ebb6cc7d9 100644 --- a/vendor/aube/crates/aube/src/progress/ci.rs +++ b/vendor/aube/crates/aube/src/progress/ci.rs @@ -225,54 +225,54 @@ impl CiState { let spawned = thread::Builder::new() .name("aube-ci-heartbeat".into()) .spawn(move || { - let state = thread_state; - loop { - let guard = state.wake_lock.lock().unwrap(); - // Re-check `done` *before* sleeping. `stop()` sets `done` - // and then `notify_all()`s without holding `wake_lock`, so - // a notification that races with the tick body would - // otherwise be lost and the thread would sleep a full - // `CI_HEARTBEAT_INTERVAL` before noticing shutdown. - if state.done.load(Ordering::Relaxed) { - break; - } - let (guard, _timeout) = state - .wake - .wait_timeout(guard, CI_HEARTBEAT_INTERVAL) - .unwrap(); - drop(guard); - if state.done.load(Ordering::Relaxed) { - break; - } - let snap = state.snapshot(); - // Don't make noise until an install is actually underway. - // Until then there's nothing to bar-graph and no reason to - // print anything — a no-op install should remain - // completely silent. - if snap.resolved == 0 || snap.phase == 0 { - continue; - } - let line = Self::render(snap); - if line.is_empty() { - continue; - } - let mut last = state.last_printed.lock().unwrap(); - if *last == line { - // Same rendered line as before — stay quiet. - continue; - } - *last = line.clone(); - drop(last); - // First time we actually print, emit the unframed - // `aube VERSION by jdx.dev` header above the bar so - // the CI log shows the aube banner. Only printed - // once per install — `shown` flips true here. - if !state.shown.swap(true, Ordering::Relaxed) { - let _ = writeln!(std::io::stderr(), "{}", Self::render_header()); - } - let _ = writeln!(std::io::stderr(), "{line}"); + let state = thread_state; + loop { + let guard = state.wake_lock.lock().unwrap(); + // Re-check `done` *before* sleeping. `stop()` sets `done` + // and then `notify_all()`s without holding `wake_lock`, so + // a notification that races with the tick body would + // otherwise be lost and the thread would sleep a full + // `CI_HEARTBEAT_INTERVAL` before noticing shutdown. + if state.done.load(Ordering::Relaxed) { + break; } - }); + let (guard, _timeout) = state + .wake + .wait_timeout(guard, CI_HEARTBEAT_INTERVAL) + .unwrap(); + drop(guard); + if state.done.load(Ordering::Relaxed) { + break; + } + let snap = state.snapshot(); + // Don't make noise until an install is actually underway. + // Until then there's nothing to bar-graph and no reason to + // print anything — a no-op install should remain + // completely silent. + if snap.resolved == 0 || snap.phase == 0 { + continue; + } + let line = Self::render(snap); + if line.is_empty() { + continue; + } + let mut last = state.last_printed.lock().unwrap(); + if *last == line { + // Same rendered line as before — stay quiet. + continue; + } + *last = line.clone(); + drop(last); + // First time we actually print, emit the unframed + // `aube VERSION by jdx.dev` header above the bar so + // the CI log shows the aube banner. Only printed + // once per install — `shown` flips true here. + if !state.shown.swap(true, Ordering::Relaxed) { + let _ = writeln!(std::io::stderr(), "{}", Self::render_header()); + } + let _ = writeln!(std::io::stderr(), "{line}"); + } + }); // On spawn failure (e.g. EAGAIN under thread/PID exhaustion) leave the // handle `None` — the install proceeds without the cosmetic ticker. *state.heartbeat.lock().unwrap() = spawned.ok(); From 9f8eeb48f64a0abf46ffe406f76fac1751166a90 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Sat, 25 Jul 2026 00:26:55 -0700 Subject: [PATCH 5/7] sandbox(windows): close the review findings on the dedicated-account backend MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two fresh-context reviews (correctness/security, impact-analysis) plus a real VM run turned up defects the local gates structurally cannot catch — both Windows `apply`s are cfg-gated, so the host never executes them and the cross-target clippy only type-checks. Security: - Never log on as the marker's `account` field. It was a free-form string from a file a standard user can own (ProgramData grants Users add-subdirectory and CREATOR OWNER full control), while only the hardcoded account's SID was validated — so swapping `account` while keeping the real `sid` launched every "sandboxed" run as an attacker-chosen account with no WFP fence, reported as fully enforced. The field is gone and the marker version is bumped. - Lock the credential store to Administrators, SYSTEM and the creating user with a protected DACL, and reset its owner. An additive deny left ProgramData's inherited Users read in place, so any local account could read the DPAPI blob and machine-scope-decrypt it; without the owner reset a pre-creating user kept implicit WRITE_DAC and could undo the lock. The directory is now locked before the credential is written into it. - Apply the same live-SID check to the sweep that the launch already had, so a writable marker is not an ACE-removal primitive aimed at arbitrary paths. Correctness: - A deny target's parent carve stamped the volume root: `Path::parent()` of a canonicalized `\\?\C:\x.env` is the drive, not `None`. - A grant or deny naming a path that does not exist no longer aborts the run. The flagship policy denies `~/.ssh`, `~/.aws` and similar, most absent on a real machine, so the backend died on its own headline shape. - The parent carve is recorded and stripped, instead of leaving a permanent deny ACE that survived teardown. - Grant the sandbox account on the caller's window station and desktop. A non-interactive caller runs on a per-logon service station, not WinSta0, and seclogon's auto-grant does not cover it — the child died in loader init with STATUS_DLL_INIT_FAILED. Diagnosed on the VM with a minimal probe and no nub code. The grant fails forward, since it is a no-op where it is unnecessary. - Check `ResumeThread` and `GetExitCodeProcess`; a failed resume hung forever on an INFINITE wait and a failed query reported success. - Guard the empty-DACL case in the ACL rebuild, which denies everyone. Routing: - The account route now also requires a provisioned machine. Without that it subsumed the strict-Windows tier entirely — that tier's condition is byte-identical to the per-host arm — leaving it dead and failing its own test. Falling through keeps it live and degrading honestly. - Gate the enforcement probe behind an opt-in feature. Ungated, bare `cargo test` on a windows-latest runner either turned the leg red or silently provisioned the runner with a local account and WFP filters. - Every error naming `--sandbox-setup` now names the flag that exists, and `--sandbox-admin` joins the run subcommand's value-flag list per its own documented invariant. Gates: host and x86_64-pc-windows-gnu clippy --all-targets --all-features -D warnings clean, 113 lib tests plus every suite green, fmt clean. --- crates/nub-cli/src/cli.rs | 1 + crates/nub-sandbox/Cargo.toml | 14 + crates/nub-sandbox/LIMITATIONS.md | 109 ++++- crates/nub-sandbox/src/backend/mod.rs | 33 +- crates/nub-sandbox/src/backend/windows.rs | 13 +- .../src/backend/windows_account/account.rs | 62 +-- .../src/backend/windows_account/acl.rs | 401 ++++++++++++++++-- .../src/backend/windows_account/launch.rs | 271 ++++++++++-- .../src/backend/windows_account/mod.rs | 98 +++-- .../src/backend/windows_account/state.rs | 79 ++-- .../tests/windows_account_enforcement.rs | 2 +- 11 files changed, 900 insertions(+), 183 deletions(-) diff --git a/crates/nub-cli/src/cli.rs b/crates/nub-cli/src/cli.rs index 2b023ebcf..0fd0d5681 100644 --- a/crates/nub-cli/src/cli.rs +++ b/crates/nub-cli/src/cli.rs @@ -1696,6 +1696,7 @@ fn value_consuming_flags(subcommand: &str) -> &'static [&'static str] { // Internal `--sandbox ` (hidden) takes a separate-token policy // path, so its value must not mis-bind as the script positional. "--sandbox", + "--sandbox-admin", ], // Exec's workspace value-flags must be listed so `nubx --filter @org/api // tsc` binds `@org/api` to the filter, not the bin positional. (Exec's diff --git a/crates/nub-sandbox/Cargo.toml b/crates/nub-sandbox/Cargo.toml index cafa6ca95..ac186b24e 100644 --- a/crates/nub-sandbox/Cargo.toml +++ b/crates/nub-sandbox/Cargo.toml @@ -105,6 +105,11 @@ features = [ "Win32_System_Registry", "Win32_UI_Shell", "Win32_Storage_FileSystem", + # Window-station / desktop aces for the launch. A non-interactive caller (a service, an SSH + # session, a CI agent) runs on a per-logon `Service-0x0-…$` station, NOT `WinSta0`, and the + # Secondary Logon service's auto-grant does not cover it — without an explicit ace the child + # dies in loader init with STATUS_DLL_INIT_FAILED (VM-diagnosed 2026-07-24). + "Win32_System_StationsAndDesktops", ] [dev-dependencies] @@ -136,6 +141,15 @@ harness = false name = "windows_residuals" harness = false +# OPT-IN ONLY. This probe is not a unit test: on an unprovisioned machine it exits non-zero +# by design, and when run elevated it CREATES a local account, a local group, four persistent +# WFP filters and a Winlogon registry value. Left ungated, bare `cargo test` on a +# windows-latest runner would either turn the leg red or silently provision the runner. A +# human (or the VM harness) opts in with `--features windows-sandbox-probe`. +[features] +windows-sandbox-probe = [] + [[test]] name = "windows_account_enforcement" harness = false +required-features = ["windows-sandbox-probe"] diff --git a/crates/nub-sandbox/LIMITATIONS.md b/crates/nub-sandbox/LIMITATIONS.md index 6b52892c2..a348b690c 100644 --- a/crates/nub-sandbox/LIMITATIONS.md +++ b/crates/nub-sandbox/LIMITATIONS.md @@ -153,6 +153,15 @@ every run. Bounded by the window's width (10 ports by default, 64 max, enforced at install). - **Where fixed:** an elevated helper service could add an exact-port filter per run. Not built; the window is the deliberate trade. +- **The sandbox belongs to whoever ran setup.** `%PROGRAMDATA%\nub\sandbox` holds the + DPAPI-sealed account credential, and machine-scope DPAPI is explicitly not a boundary — any + local principal that can READ the ciphertext can decrypt it and then hold the sandbox + account's password. Setup therefore replaces that directory's DACL with a PROTECTED one + naming only SYSTEM, `BUILTIN\Administrators`, and the account that ran setup. Another + standard user on the same box reads neither the credential nor the provisioning marker and + fails closed with "not provisioned" — including the ordinary user of a machine where the UAC + prompt was satisfied with a DIFFERENT administrator account. Re-running setup as that user + re-points the lock at them. ### Egress coverage is `ALE_AUTH_CONNECT` only @@ -202,6 +211,94 @@ ACE. The engine reports `fs-deny-glob` as a LOST axis — distinct from the over degradations, because a missed deny is a hole, not extra confinement. Literal deny paths are enforced exactly. +### A grant or deny target that does not exist at launch is skipped + +An ACE needs an object, so a policy path absent when the run starts gets none. Skipping rather +than failing is deliberate: the flagship agent policy denies `~/.ssh`, `~/.aws`, +`~/.docker/config.json` and `/.env`, most of which are absent on any given machine, and +aborting on the first missing one would kill the backend on its own headline shape. Denying a +path that does not exist denies nothing, which is correct — but a file created LATER at that +path, INSIDE a granted tree, inherits the tree's grant with no explicit deny to outrank it. +Every error other than not-found stays fatal. + +- **Why bounded:** it needs the denied path to sit under a grant AND to be created after the + aces land. A denied path outside every grant is unreachable to the account regardless. +- **Where fixed:** pre-create the deny target (as the Linux backend pre-creates write targets), + or re-apply denies when the child creates a matching path. Neither is built. + +### Junction/symlink TOCTOU on an ace target + +Every ace target is canonicalized before the ace is written, so a symlink or junction resolves +to its TARGET and the ace lands on the object an open actually reaches — a reparse point does +not gate content access. Nothing then re-checks that the resolved object is still inside the +path the policy named. A child holding DELETE on a nested grant target can replace it with a +junction between runs, so the NEXT run stamps an inheritable grant on wherever that junction +points. + +- **Why bounded:** it needs a prior run that granted write inside the tree, and it MOVES a + grant rather than widening one — the next run's confinement is wrong, the current run's is + not. +- **Where fixed:** open the target with `FILE_FLAG_OPEN_REPARSE_POINT` and ace the HANDLE, or + re-verify the canonicalized target against the policy's own prefix after resolution. + +### The child inherits the parent's real console handles + +`CreateProcessWithLogonW` has no `bInheritHandles` parameter and no `STARTUPINFOEX` overload, so +stdio must ride the three `STARTF_USESTDHANDLES` fields — and nub marks the parent's own +stdin/stdout/stderr permanently inheritable to supply them. A foreign-user child therefore holds +the parent shell's real console INPUT handle, which is a `WriteConsoleInput` keystroke-injection +path back into that shell. + +- **Where fixed:** give the child three anonymous pipes and relay, flipping nub's own ends + non-inheritable — what SRT does. Not built; it also costs the child a real console (no PTY + semantics, no `isatty`). + +### A policy that confines only the network still runs as a foreign principal + +Backend selection is per-POLICY, not per-axis: a policy with per-host egress rules and no +filesystem confinement still routes here, and its child then runs as the sandbox account — +which reaches nothing under the invoking user's profile. The user's own project files become +unreachable although they asked for nothing about the filesystem. + +- **Why it is a surprise, not a hole:** it is over-confinement, and the engine reports `fs-read` + as a degradation. It is listed because the SIZE of the behavioral change is easy to miss when + the policy names only the net axis. +- **Where fixed:** the launcher supplies the project directory in the read allow-set — the same + launcher contract as the per-user tool installs item above. + +### Window-station access is granted per run, and verified only in session 0 + +seclogon's window-station auto-grant covers `WinSta0` only. A NON-INTERACTIVE caller — SSH, a +service, a CI agent — runs on a per-logon `Service-0x0-…$` station instead, where the auto-grant +does not apply and the child dies in loader init with `0xC0000142 STATUS_DLL_INIT_FAILED`. The +launch therefore aces the caller's own window station and desktop for the sandbox SID before +spawning, and restores both DACLs when the run ends. `READ_CONTROL` in the station mask is +load-bearing: without it the child HANGS in loader init rather than failing. Setting `lpDesktop` +explicitly does not substitute for the aces. + +- **Verified in session 0 only.** The failure and the fix were both reproduced from a + non-interactive session. The interactive `WinSta0` path — where seclogon's auto-grant should + make the aces redundant — is untested; the aces are written there too and are expected to be + a no-op. +- **The granted rights are BROAD, and on an interactive session that matters.** The masks are + `WINSTA_ALL_ACCESS | READ_CONTROL` and the full documented `DESKTOP_*` union — what the VM + diagnosis established as working, not a bisected minimum. Against the caller's own + `WinSta0\Default` that hands the confined account `DESKTOP_JOURNALRECORD` and + `DESKTOP_HOOKCONTROL` (desktop-wide keystroke capture), `DESKTOP_JOURNALPLAYBACK` (input + injection into the user's session), `WINSTA_READSCREEN` (screen capture) and + `WINSTA_ACCESSCLIPBOARD` — for the run's duration, at the same integrity level as the user's + own apps, so UIPI does not block hooks against them. On a `Service-0x0-…$` station there is + no user session to reach and the cost is nil. +- **Where fixed:** bisect the real floor on a VM and narrow both masks — plausibly station + `READ_CONTROL | ENUMDESKTOPS | READATTRIBUTES | CREATEDESKTOP | ACCESSGLOBALATOMS | + EXITWINDOWS`, desktop `READ_CONTROL | READOBJECTS | WRITEOBJECTS | CREATEWINDOW`. Not done + here: the broad set is the one actually verified to work, and `READ_CONTROL`'s hang-vs-fail + behavior already shows this surface punishes guessing. +- **Cost while the child runs:** concurrent runs share the station too — the restore puts back + the DACL the FIRST of them saw, so a second run's ace can be removed while its child is still + alive, and that run's own restore then re-writes the first's ace permanently. Same shape as + the shared-account bound below. + ### Whole-tree kill is best-effort `AssignProcessToJobObject` on a `CreateProcessWithLogonW` child commonly returns @@ -217,9 +314,10 @@ failure logged; a descendant that outlives the target may survive. - **Single-hop launch.** The child is started directly, not through a runner holding a restricted token. Confinement comes from the account's ACL reach plus SID-keyed WFP, neither of which needs that token — but the token would additionally strip privileges and groups. -- **`lpDesktop` is NULL**, so the child shares `WinSta0\Default` with the user's session. A - private desktop would require explicit `WinSta0` and session-`BaseNamedObjects` aces, - because a non-NULL desktop disables the Secondary Logon service's station auto-grant. +- **`lpDesktop` is NULL**, so the child shares the caller's desktop instead of getting a + private one. A private desktop is the hardening follow-up and would additionally need a + session-`BaseNamedObjects` ace. The station and desktop aces themselves are NOT part of this + bound — the launch writes them on every run (see the window-station item above). - **Concurrent runs share one account.** Two simultaneous sandboxed runs grant and strip aces for the SAME SID, so one run's teardown can revoke a grant the other still needs. - **Child-created files are owned by the sandbox account.** Access is preserved (the grant is @@ -234,6 +332,11 @@ A run killed between granting an ace and stripping it leaves the ace behind. The `nub run --sandbox-admin clean` (unelevated) collects them. A leaked grant is over-permission for a confined account, not a host compromise — hygiene, not a correctness boundary. +- **`clean` is a machine-wide sweep, so it can revoke a LIVE run's aces.** The ledger is + machine-wide and `clean` is unelevated, so a sweep strips every path it lists — including + ones a concurrent sandboxed run still needs. Same root as the shared-account bound above: + every run's aces key on the one SID, and nothing distinguishes a live grant from residue. + ## Launcher-handoff items (engine correct; launcher must complete the guarantee) ### macOS ascendant-env via `KERN_PROCARGS2` — CLOSED in-engine diff --git a/crates/nub-sandbox/src/backend/mod.rs b/crates/nub-sandbox/src/backend/mod.rs index 25ad1a659..69c2e52e1 100644 --- a/crates/nub-sandbox/src/backend/mod.rs +++ b/crates/nub-sandbox/src/backend/mod.rs @@ -16,13 +16,13 @@ //! //! LAUNCH SEAM: a backend either configures [`Prepared::command`] for the caller to //! spawn (macOS wraps `sandbox-exec`; Linux installs a `pre_exec` hook; the skeleton -//! just scrubs env) OR — Windows only — OWNS the whole spawn lifecycle, because an -//! AppContainer launch cannot be a pre-built `std::process::Command`: it needs a -//! custom `CreateProcessW` with `STARTUPINFOEX`/`SECURITY_CAPABILITIES`, a Job Object -//! assigned at creation, and per-run ACL grants that must be TORN DOWN after the -//! child exits. [`Prepared::status`] is the uniform verb: mac/linux/skeleton delegate -//! to `command.status()`; Windows runs its launcher (setup → spawn → wait → RAII -//! teardown) when a launch plan is attached. +//! just scrubs env) OR — Windows only — OWNS the whole spawn lifecycle, because +//! neither Windows launch can be a pre-built `std::process::Command`: the AppContainer +//! one needs a custom `CreateProcessW` with `STARTUPINFOEX`/`SECURITY_CAPABILITIES`, +//! the dedicated-account one needs `CreateProcessWithLogonW`, and BOTH need per-run +//! ACL grants TORN DOWN after the child exits. [`Prepared::status`] is the uniform +//! verb: mac/linux/skeleton delegate to `command.status()`; Windows runs whichever +//! launcher its plan names (setup → spawn → wait → RAII teardown). use crate::policy::{Effect, Inspection, ProxyMode, SandboxPolicy}; use crate::proxy::mitm::MitmEngine; @@ -132,9 +132,9 @@ impl CommandSpec { /// rides the `status` seam, not the `command` field). pub struct Prepared { /// The configured child for the mac/linux/skeleton path. On Windows this is the - /// env-scrubbed plain child used ONLY when nothing needs AppContainer confinement - /// (`launch` is `None`); when confinement applies, `launch` owns the spawn and - /// this field is unused. + /// env-scrubbed plain child used ONLY when no confinement mechanism applies + /// (`launch` is `None`); when one does — either Windows variant — `launch` owns the + /// spawn and this field is unused. pub command: Command, pub degradation: Degradation, /// The running egress proxy (design.md §2.5), when the policy enforces per-host @@ -150,9 +150,10 @@ pub struct Prepared { /// where the supervisor is viable); `None` otherwise. #[cfg(target_os = "linux")] pub(crate) connect_notify: Option, - /// Windows AppContainer launch plan — the backend owns spawn+wait+teardown when - /// this is `Some`. Absent (or on other OSes) → [`Prepared::status`] spawns - /// `command`. + /// Windows launch plan — the backend owns spawn+wait+teardown when this is `Some`. + /// A two-variant enum, one per Windows mechanism: the per-run AppContainer (LowBox + /// token) and the dedicated local account (`CreateProcessWithLogonW`, no LowBox + /// token). Absent (or on other OSes) → [`Prepared::status`] spawns `command`. #[cfg(target_os = "windows")] pub(crate) launch: Option, } @@ -160,8 +161,10 @@ pub struct Prepared { impl Prepared { /// Launch the prepared child and wait for it, returning its exit status. The /// UNIFORM launch verb across backends: mac/linux/skeleton spawn `command`; - /// Windows runs its AppContainer launcher (ACL setup → `CreateProcessW` under a - /// LowBox token → wait → RAII teardown) when a launch plan is attached. + /// Windows runs whichever launcher its plan names when one is attached — both + /// follow the same shape (ACL setup → a custom spawn → wait → RAII teardown), and + /// differ in the spawn: `CreateProcessW` under a LowBox token for the AppContainer, + /// `CreateProcessWithLogonW` as the dedicated account for the other. /// /// The egress proxy (`self.proxy`) is held for the child's whole run and dropped /// (listener shut down) only after the child exits — `self` owns it until this diff --git a/crates/nub-sandbox/src/backend/windows.rs b/crates/nub-sandbox/src/backend/windows.rs index 4466cda71..b3e56f38b 100644 --- a/crates/nub-sandbox/src/backend/windows.rs +++ b/crates/nub-sandbox/src/backend/windows.rs @@ -369,7 +369,18 @@ pub(crate) fn apply( // expresses all three but costs a one-time elevated setup. build-jail's shape (pure // default-deny allowlist, coarse or absent net) never matches, so `nub install` stays // admin-free. See `windows_account`'s module doc for why the split falls exactly here. - if sandboxing && super::windows_account::needs_account_backend(policy) { + // PROVISIONING IS PART OF THE PREDICATE, not just a precondition checked later. Without + // it this branch subsumes the whole strict-Windows tier below — `Tier1`/`FailUnelevated` + // require exactly `net.enforce && any(Allow)`, which is byte-identical to + // `needs_account_backend`'s per-host arm and also forces `sandboxing` — so an + // unprovisioned machine would take the account route, fail closed, and leave that tier + // unreachable. Falling through instead keeps the AppContainer tier live and degrading + // honestly (over-confined reads, a reported deny it cannot carve), which is strictly + // better than refusing to run on a machine that never opted into the elevated setup. + if sandboxing + && super::windows_account::needs_account_backend(policy) + && super::windows_account::is_provisioned() + { return super::windows_account::apply(policy, spec, proxy_port, proxy_token, ca_bundle); } diff --git a/crates/nub-sandbox/src/backend/windows_account/account.rs b/crates/nub-sandbox/src/backend/windows_account/account.rs index 22c04a7d6..8c728644d 100644 --- a/crates/nub-sandbox/src/backend/windows_account/account.rs +++ b/crates/nub-sandbox/src/backend/windows_account/account.rs @@ -12,9 +12,10 @@ //! self-elevated child may not have the user's master key loaded at all — user-scope DPAPI //! does not round-trip across that split, machine scope does. The honest consequence is that //! machine scope is **not a security boundary**: any local principal that can READ the -//! ciphertext can decrypt it, the sandbox account included. The file's DACL is the only gate, -//! and this module never writes a DACL — [`credential_dir`] exists so the setup path can hand -//! the directory to the acl module, which owns every DACL write. +//! ciphertext can decrypt it, the sandbox account included. The directory's DACL is the only +//! gate, and this module never writes a DACL — [`credential_dir`] exists so the setup path can +//! hand the directory to the acl module (which owns every DACL write) and have it locked down +//! BEFORE [`provision`] writes a credential into it. //! //! Mirrors SRT's `vendor/srt-win-src/src/{user,sam,dpapi}.rs` (read 2026-07-24); Codex's //! `windows-sandbox-rs/src/bin/setup_main/win/sandbox_users.rs` is the second reference. @@ -162,13 +163,13 @@ pub(crate) fn lookup_sid() -> io::Result> { lookup_account_sid(SANDBOX_ACCOUNT) } -/// UNELEVATED. Decrypt the stored credential. +/// UNELEVATED. Decrypt the stored credential into a [`Secret`], which zeroes itself on drop. /// -/// The returned plaintext is the CALLER's to bound — it goes straight into -/// `CreateProcessWithLogonW`, so it is deliberately a plain `String` rather than a scrubbing -/// wrapper the FFI boundary would defeat anyway. Every intermediate buffer this function owns -/// is zeroed before it returns. -pub(crate) fn load_credential() -> io::Result { +/// Every buffer the plaintext passes through is zeroed: DPAPI's own output block (in +/// [`take_blob`], before it is freed), the decoded copy here, the `Secret` on drop, and the +/// UTF-16 relay the launch builds for `CreateProcessWithLogonW`. That bounds how long the +/// password sits readable in nub's heap; it is not a defence against a process-memory attacker. +pub(crate) fn load_credential() -> io::Result { let path = credential_path()?; let ciphertext = std::fs::read(&path).map_err(|e| { io::Error::new( @@ -182,25 +183,21 @@ pub(crate) fn load_credential() -> io::Result { })?; let mut plaintext = dpapi_unprotect(&ciphertext)?; let out = std::str::from_utf8(&plaintext) - .map(str::to_owned) + .map(|s| Secret(s.to_owned())) .map_err(|_| io::Error::other("the stored sandbox credential is corrupt (not UTF-8)")); scrub_u8(&mut plaintext); out } -/// The credential store's directory. Its DACL must DENY [`SANDBOX_GROUP`] — machine-scope -/// DPAPI protects nothing on its own, so that DENY is the whole boundary. It is applied by -/// the setup path via the acl module; this module never writes a DACL. +/// The credential store's directory — the SAME directory as the marker and the ledger, which is +/// why it delegates rather than recomputing the path: setup locks whatever THIS returns, so a +/// second definition that drifted would protect a directory the credential is not in. +/// +/// Machine-scope DPAPI protects nothing on its own, so that directory's owner + DACL are the +/// whole boundary: the setup path hands it to [`super::acl::lock_to_admins`] BEFORE any +/// credential is written into it. This module never writes a DACL. pub(crate) fn credential_dir() -> io::Result { - let root = std::env::var_os("PROGRAMDATA") - .filter(|v| !v.is_empty()) - .ok_or_else(|| { - io::Error::other( - "PROGRAMDATA is not set, so nub cannot locate the machine-wide sandbox \ - credential store", - ) - })?; - Ok(PathBuf::from(root).join("nub").join("sandbox")) + super::state::state_dir() } pub(crate) fn credential_path() -> io::Result { @@ -263,7 +260,10 @@ fn scrub_u8(buf: &mut [u8]) { } } -fn scrub_u16(buf: &mut [u16]) { +/// Also the launch's own UTF-16 relay into `CreateProcessWithLogonW`, which is why this is +/// `pub(crate)`: a plain `fill(0)` on a buffer about to be dropped is a dead store the +/// optimizer may delete, and the password would survive in the heap. +pub(crate) fn scrub_u16(buf: &mut [u16]) { for b in buf { // SAFETY: as above. unsafe { std::ptr::write_volatile(b, 0) }; @@ -273,10 +273,10 @@ fn scrub_u16(buf: &mut [u16]) { /// A plaintext credential zeroed on drop. NOT a defence against a process-memory attacker /// (the value was copied at least once on the way in) — it bounds how long the password sits /// readable in nub's heap, which is the part this module controls. -struct Secret(String); +pub(crate) struct Secret(String); impl Secret { - fn as_str(&self) -> &str { + pub(crate) fn as_str(&self) -> &str { &self.0 } } @@ -688,7 +688,7 @@ fn reassert_flags(name_w: &[u16]) -> io::Result<()> { fn ensure_group() -> io::Result<()> { let mut name_w = to_wide(SANDBOX_GROUP); - let mut comment_w = to_wide("nub: holds the sandbox account; DENY trustee for nub state"); + let mut comment_w = to_wide("nub: holds the dedicated account used for OS-enforced sandboxing"); let info = LOCALGROUP_INFO_1 { lgrpi1_name: name_w.as_mut_ptr(), lgrpi1_comment: comment_w.as_mut_ptr(), @@ -841,12 +841,18 @@ fn dpapi_unprotect(ciphertext: &[u8]) -> io::Result> { /// # Safety /// `out` must be a blob DPAPI filled in on a successful call; ownership transfers here. +/// +/// The block is SCRUBBED before it is freed: on the unprotect path it holds the decrypted +/// password, and `LocalFree` only returns the pages to the heap. unsafe fn take_blob(out: CRYPT_INTEGER_BLOB) -> Vec { if out.pbData.is_null() { return Vec::new(); } - // SAFETY: DPAPI guarantees `cbData` readable bytes at `pbData`. - let v = unsafe { std::slice::from_raw_parts(out.pbData, out.cbData as usize).to_vec() }; + // SAFETY: DPAPI guarantees `cbData` readable+writable bytes at `pbData`, which this call + // now owns exclusively. + let block = unsafe { std::slice::from_raw_parts_mut(out.pbData, out.cbData as usize) }; + let v = block.to_vec(); + scrub_u8(block); // SAFETY: freed exactly once, and freed even when `cbData` is 0 — which the API can // return for an empty plaintext. unsafe { LocalFree(out.pbData.cast()) }; diff --git a/crates/nub-sandbox/src/backend/windows_account/acl.rs b/crates/nub-sandbox/src/backend/windows_account/acl.rs index 2310b562a..f55e5d24d 100644 --- a/crates/nub-sandbox/src/backend/windows_account/acl.rs +++ b/crates/nub-sandbox/src/backend/windows_account/acl.rs @@ -1,12 +1,16 @@ -//! Filesystem confinement for the dedicated sandbox account: explicit ACEs keyed on its SID. +//! Every DACL write the account backend makes: explicit ACEs keyed on the sandbox SID for the +//! policy's paths, plus the two non-filesystem objects a launch touches (the caller's window +//! station and desktop) and the lock on nub's own credential store. //! -//! THE MODEL IS PURELY ADDITIVE. The sandbox account is a DIFFERENT local principal, so every -//! path it was never granted is already unreachable — the invoking user's profile needs no ACE -//! authored at all. This module therefore only ever ADDS ACEs for the sandbox SID and later -//! removes exactly those. It never rewrites, protects, or snapshots a user path's descriptor, -//! which is why there is no crash journal here and nothing to restore after a hard kill beyond -//! [`strip`]. (The abandoned deny-strip design — `SE_DACL_PROTECTED` plus a DACL restore -//! journal — is why that distinction is worth stating; see [`super`].) +//! ON USER PATHS THE MODEL IS PURELY ADDITIVE. The sandbox account is a DIFFERENT local +//! principal, so every path it was never granted is already unreachable — the invoking user's +//! profile needs no ACE authored at all. [`grant`]/[`deny`]/[`strip`] therefore only ever ADD +//! ACEs for the sandbox SID and later remove exactly those. They never rewrite, protect, or +//! snapshot a user path's descriptor, which is why there is no crash journal here and nothing +//! to restore after a hard kill beyond [`strip`]. (The abandoned deny-strip design — +//! `SE_DACL_PROTECTED` plus a DACL restore journal — is why that distinction is worth stating; +//! see [`super`].) [`lock_to_admins`] is the ONE protected write, and it lands on nub's own +//! state directory, never a user path. //! //! CANONICAL DACL ORDER IS THE WHOLE MECHANISM. Windows resolves an access check first-match //! over the DACL and orders ACEs explicit-DENY → explicit-ALLOW → explicit-other → inherited @@ -19,7 +23,7 @@ //! hand, and `deny_inside_a_grant_lands_before_the_inherited_allow` pins the result rather //! than trusting either. //! -//! INHERITANCE IS DELIBERATELY LEFT UNPROTECTED. Every write passes +//! ON A USER PATH, INHERITANCE IS DELIBERATELY LEFT UNPROTECTED. Every write there passes //! `DACL_SECURITY_INFORMATION` alone, never `PROTECTED_DACL_SECURITY_INFORMATION`: the //! invoking user's own inherited access must survive so they can still read what the sandbox //! child creates inside a granted tree. @@ -27,20 +31,24 @@ #![cfg(target_os = "windows")] use std::io; -use std::path::{Path, PathBuf}; +use std::path::{Component, Path, PathBuf}; use windows_sys::Win32::Foundation::{ - ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, LocalFree, + CloseHandle, ERROR_ACCESS_DENIED, ERROR_FILE_NOT_FOUND, ERROR_PATH_NOT_FOUND, HANDLE, LocalFree, }; use windows_sys::Win32::Security::Authorization::{ ACCESS_MODE, ConvertStringSidToSidW, DENY_ACCESS, EXPLICIT_ACCESS_W, GRANT_ACCESS, - GetNamedSecurityInfoW, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SetEntriesInAclW, - SetNamedSecurityInfoW, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, TRUSTEE_W, + GetNamedSecurityInfoW, GetSecurityInfo, NO_MULTIPLE_TRUSTEE, SE_FILE_OBJECT, SE_WINDOW_OBJECT, + SetEntriesInAclW, SetNamedSecurityInfoW, SetSecurityInfo, TRUSTEE_IS_SID, TRUSTEE_IS_UNKNOWN, + TRUSTEE_W, }; use windows_sys::Win32::Security::{ ACCESS_ALLOWED_ACE, ACE_HEADER, ACL, ACL_SIZE_INFORMATION, AclSizeInformation, AddAce, CONTAINER_INHERIT_ACE, DACL_SECURITY_INFORMATION, EqualSid, GetAce, GetAclInformation, - InitializeAcl, OBJECT_INHERIT_ACE, PSECURITY_DESCRIPTOR, PSID, + GetTokenInformation, InitializeAcl, OBJECT_INHERIT_ACE, OWNER_SECURITY_INFORMATION, + PROTECTED_DACL_SECURITY_INFORMATION, PSECURITY_DESCRIPTOR, PSID, TOKEN_QUERY, TOKEN_USER, + TokenUser, }; +use windows_sys::Win32::System::Threading::{GetCurrentProcess, OpenProcessToken}; // `FILE_TRAVERSE` is the SAME bit as `FILE_EXECUTE` — the kernel reads it as traverse on a // directory and as execute on a file, and no primitive separates them. It is imported under // the traverse spelling because that is the property the mask assertions below are about. @@ -76,6 +84,11 @@ const ACCESS_ALLOWED_ACE_TYPE: u8 = 0x00; const ACCESS_DENIED_ACE_TYPE: u8 = 0x01; const INHERITED_ACE_FLAG: u8 = 0x10; +/// `NT AUTHORITY\SYSTEM` and `BUILTIN\Administrators`, by well-known SID because the NAMES are +/// localized ("Administratoren", "Administrateurs") and the SIDs are not. +const SID_LOCAL_SYSTEM: &str = "S-1-5-18"; +const SID_BUILTIN_ADMINISTRATORS: &str = "S-1-5-32-544"; + /// What a grant hands the sandbox account on a subtree. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum Access { @@ -104,6 +117,10 @@ pub(crate) fn grant(path: &Path, sid: &str, access: Access) -> io::Result<()> { /// Add an inheritable DENY ace for `sid` on `path`, plus the parent-side carve that stops the /// account deleting or renaming `path` through its parent directory. +/// +/// Stamps exactly the paths [`deny_targets`] reports, which the caller must have recorded in +/// the ledger first — the parent carve is a SECOND ace on a SECOND object, and one that is +/// never stripped outlives every run as a permanent explicit DENY for the sandbox SID. pub(crate) fn deny(path: &Path, sid: &str) -> io::Result<()> { let (target, is_dir) = resolve(path)?; let sid = OwnedSid::parse(sid)?; @@ -115,17 +132,288 @@ pub(crate) fn deny(path: &Path, sid: &str) -> io::Result<()> { // the parent closes `del`/`ren` of the denied target. NOT inheritable — the check that // matters is against the parent directory object itself, so propagating it down would // confine sibling subtrees for no gain. (SRT applies the same carve with `(OI)(CI)`.) - let Some(parent) = target.parent() else { - // A volume root has nothing above it to delete through. + let Some(parent) = carve_parent(&target) else { return Ok(()); }; - add_ace(parent, &sid, FILE_DELETE_CHILD, DENY_ACCESS, false) + add_ace(&parent, &sid, FILE_DELETE_CHILD, DENY_ACCESS, false) +} + +/// Every path a [`deny`] on `path` will stamp: the resolved target, and the parent carrying +/// its `FILE_DELETE_CHILD` carve when there is one. +/// +/// Exposed rather than merely returned by [`deny`] so the caller can honor the ledger's +/// record-BEFORE-apply rule for both aces — recording after the fact would leave the parent +/// carve unrecorded across the window a crash can land in. +pub(crate) fn deny_targets(path: &Path) -> io::Result> { + let (target, _) = resolve(path)?; + let mut out = Vec::with_capacity(2); + out.extend(carve_parent(&target)); + out.push(target); + Ok(out) +} + +/// The directory `target`'s delete-through carve belongs on, or `None` when there is none. +/// +/// `Path::parent` on the extended-length spellings [`resolve`] produces returns the VOLUME +/// ROOT for a top-level target (`\\?\C:\secrets.env` → `\\?\C:\`), which must not be ACE'd: +/// unelevated that fails the whole run with a misleading error, and where the caller does hold +/// `WRITE_DAC` it writes a permanent explicit DENY onto the drive itself. The accepted cost is +/// that a deny on a volume-root child gets no delete-through carve — `BUILTIN\Users` holds no +/// `FILE_DELETE_CHILD` on a default `C:\`, so the account has nothing to carve away. +fn carve_parent(target: &Path) -> Option { + target + .parent() + .filter(|p| !is_volume_root(p)) + .map(Path::to_path_buf) +} + +/// Whether `p` names a volume root — a prefix and its root separator with no named component +/// under it. Covers both forms [`resolve`] emits: `\\?\C:\` (`Prefix` + `RootDir`) and +/// `\\?\UNC\server\share\`, where the share itself is part of the prefix. +fn is_volume_root(p: &Path) -> bool { + p.components() + .all(|c| matches!(c, Component::Prefix(_) | Component::RootDir)) +} + +/// Take ownership of `dir` and replace its DACL with a PROTECTED one naming ONLY SYSTEM, +/// `BUILTIN\Administrators` and the calling user — the lock on nub's own sandbox state +/// directory. +/// +/// PROTECTED IS CORRECT HERE AND NOWHERE ELSE IN THIS MODULE. `%PROGRAMDATA%\nub\sandbox` +/// inherits ProgramData's `BUILTIN\Users:(RX)`, and it holds the DPAPI credential — whose +/// machine scope is explicitly NOT a boundary, so any local user who can READ the ciphertext +/// can decrypt it and then hold the sandbox account's password. Blocking that inheritance is +/// the entire boundary; an additive deny cannot express it. This is nub's own state directory, +/// so the module doc's unprotected rule (which exists to preserve a USER's inherited access on +/// THEIR files) does not apply. +/// +/// THE OWNER IS RESET TOO, AND THAT HALF IS NOT OPTIONAL. An object's owner holds implicit +/// `READ_CONTROL | WRITE_DAC` whatever the DACL says, and `%PROGRAMDATA%` grants +/// `BUILTIN\Users:(CI)(AD)` — so a standard user can pre-create this directory before setup +/// has ever run, stay its owner through the lock, then rewrite the DACL back and read the +/// credential. Handing ownership to `BUILTIN\Administrators` is what closes that; a protected +/// DACL on its own does not. (Same pre-create-and-own primitive the marker's identity check +/// defends against — see [`super::state::Marker`].) +/// +/// The calling user is named so the provisioning administrator's later UNELEVATED runs can +/// still read the marker and append the ledger. Consequence, deliberate: when setup is +/// elevated with a DIFFERENT admin account (over-the-shoulder UAC), the ordinary user is not +/// named and their runs fail closed as "not provisioned". +pub(crate) fn lock_to_admins(dir: &Path) -> io::Result<()> { + let (target, _) = resolve(dir)?; + let system = OwnedSid::parse(SID_LOCAL_SYSTEM)?; + let admins = OwnedSid::parse(SID_BUILTIN_ADMINISTRATORS)?; + let token_user = current_token_user()?; + // SAFETY: the block holds one `TOKEN_USER` whose `User.Sid` points inside it, and it + // outlives every use of the pointer below. + let me = unsafe { (*token_user.as_ptr().cast::()).User.Sid }; + + let entries: Vec = [system.0, admins.0, me] + .into_iter() + .map(|sid| explicit_access(sid, FILE_ALL_ACCESS, GRANT_ACCESS, true)) + .collect(); + + let mut new_dacl: *mut ACL = std::ptr::null_mut(); + // A NULL "old ACL" is the point: the result is built from these three entries ALONE, so + // nothing pre-existing and nothing inherited survives into it. + // SAFETY: every entry and the SIDs they point at outlive the call; `new_dacl` is a valid + // out-slot. + let rc = unsafe { + SetEntriesInAclW( + entries.len() as u32, + entries.as_ptr(), + std::ptr::null_mut(), + &mut new_dacl, + ) + }; + if rc != 0 { + return Err(win32_err("SetEntriesInAclW", &target, rc)); + } + let _guard = LocalFreeGuard(new_dacl.cast()); + + let wpath = wide(&target); + // SAFETY: `new_dacl` is a live ACL from SetEntriesInAclW, `admins` outlives the call, and + // `wpath` is NUL-terminated UTF-16. An elevated token carries `BUILTIN\Administrators` as + // a valid owner, so the OWNER write needs no extra privilege. + let rc = unsafe { + SetNamedSecurityInfoW( + wpath.as_ptr(), + SE_FILE_OBJECT, + OWNER_SECURITY_INFORMATION + | PROTECTED_DACL_SECURITY_INFORMATION + | DACL_SECURITY_INFORMATION, + admins.0, + std::ptr::null_mut(), + new_dacl, + std::ptr::null(), + ) + }; + if rc != 0 { + return Err(win32_err("SetNamedSecurityInfoW", &target, rc)); + } + Ok(()) +} + +/// The calling process's `TOKEN_USER` block. Returned as its raw backing buffer because the +/// SID inside it is only valid while that buffer lives. `u64`-backed because `TOKEN_USER`'s +/// first field is a `PSID` POINTER — 8-aligned on x64, which a `Vec` does not promise. +fn current_token_user() -> io::Result> { + let mut token: HANDLE = std::ptr::null_mut(); + // SAFETY: query-only handle onto this process's own token. + if unsafe { OpenProcessToken(GetCurrentProcess(), TOKEN_QUERY, &mut token) } == 0 { + return Err(io::Error::last_os_error()); + } + let out = read_token_user(token); + // SAFETY: `token` came from a successful OpenProcessToken and is closed exactly once. + unsafe { CloseHandle(token) }; + out +} + +fn read_token_user(token: HANDLE) -> io::Result> { + let mut len: u32 = 0; + // SAFETY: the documented sizing form — NULL buffer, zero length; it fails and sets `len`. + unsafe { GetTokenInformation(token, TokenUser, std::ptr::null_mut(), 0, &mut len) }; + if len == 0 { + return Err(io::Error::last_os_error()); + } + let mut buf: Vec = vec![0; (len as usize).div_ceil(8)]; + // SAFETY: `buf` holds at least `len` bytes and outlives the call. + let ok = + unsafe { GetTokenInformation(token, TokenUser, buf.as_mut_ptr().cast(), len, &mut len) }; + if ok == 0 { + return Err(io::Error::last_os_error()); + } + Ok(buf) +} + +// ── window station + desktop: the launch's other securable objects ────────────── + +/// Add an ALLOW ace for `sid` on a window-station or desktop HANDLE, returning the DACL that +/// was there first so [`restore_window_object`] can put it back byte-exactly. +/// +/// These are `SE_WINDOW_OBJECT`s — handle-addressed, with no named-path form — so this is the +/// `Get`/`SetSecurityInfo` twin of [`add_ace`] rather than a variant of it. The ace is NOT +/// inheritable: the caller aces the desktop directly rather than relying on propagation from +/// the station. +pub(crate) fn grant_window_object( + handle: HANDLE, + sid: &str, + mask: u32, +) -> io::Result>> { + let sid = OwnedSid::parse(sid)?; + let existing = ReadWindowDacl::open(handle)?; + let saved = copy_acl(existing.acl); + + let ea = explicit_access(sid.0, mask, GRANT_ACCESS, false); + let mut new_dacl: *mut ACL = std::ptr::null_mut(); + // SAFETY: `ea` and the SID it points at outlive the call; `existing.acl` came from + // GetSecurityInfo and may legitimately be NULL. + let rc = unsafe { SetEntriesInAclW(1, &ea, existing.acl, &mut new_dacl) }; + if rc != 0 { + return Err(win32_obj_err("SetEntriesInAclW", rc)); + } + let _guard = LocalFreeGuard(new_dacl.cast()); + set_window_dacl(handle, new_dacl)?; + Ok(saved) } -/// Deny `sid` all access to `dir` and everything under it — used to lock the credential store -/// against the sandbox account. Same mechanism as [`deny`], named for its call site. -pub(crate) fn lock_out(dir: &Path, sid: &str) -> io::Result<()> { - deny(dir, sid) +/// Put back what [`grant_window_object`] returned; `None` restores the NULL DACL the object +/// had. A byte-exact restore rather than a trustee-keyed strip because this module already +/// documents that `SetEntriesInAclW(REVOKE_ACCESS)` cannot be trusted to remove an ace (see +/// [`strip`]). It is NOT better under concurrency — restoring the DACL the FIRST run saw +/// discards a second run's ace while its child is still alive, and a SID-keyed strip would +/// too, since every run shares the account (recorded in LIMITATIONS.md). +pub(crate) fn restore_window_object(handle: HANDLE, dacl: Option<&[u32]>) -> io::Result<()> { + let ptr = dacl.map_or(std::ptr::null(), |d| d.as_ptr().cast::()); + set_window_dacl(handle, ptr) +} + +fn set_window_dacl(handle: HANDLE, dacl: *const ACL) -> io::Result<()> { + // SAFETY: `handle` is a live window-station/desktop handle owned by this process; `dacl` + // is NULL or a live ACL outliving the call. DACL only — never PROTECTED, so whatever the + // object inherited stays. + let rc = unsafe { + SetSecurityInfo( + handle, + SE_WINDOW_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + dacl, + std::ptr::null(), + ) + }; + if rc != 0 { + return Err(win32_obj_err("SetSecurityInfo", rc)); + } + Ok(()) +} + +/// A DWORD-aligned byte copy of `acl`, or `None` for a NULL (no-DACL) descriptor. The source +/// lives inside a descriptor freed the moment the read guard drops, so a later restore has to +/// own its bytes. +fn copy_acl(acl: *mut ACL) -> Option> { + if acl.is_null() { + return None; + } + // SAFETY: `acl` is a live ACL, whose `AclSize` is its own total length in bytes. + let size = usize::from(unsafe { (*acl).AclSize }); + let mut buf: Vec = vec![0; size.div_ceil(4)]; + // SAFETY: source and destination are both at least `size` bytes and cannot overlap. + unsafe { + std::ptr::copy_nonoverlapping(acl.cast::(), buf.as_mut_ptr().cast::(), size); + } + Some(buf) +} + +/// A window object's DACL plus the descriptor owning its storage — the `SE_WINDOW_OBJECT` twin +/// of [`ReadDacl`]. The descriptor MUST outlive every read of `acl`, which points INTO it. +struct ReadWindowDacl { + acl: *mut ACL, + _sd: LocalFreeGuard, +} + +impl ReadWindowDacl { + fn open(handle: HANDLE) -> io::Result { + let mut acl: *mut ACL = std::ptr::null_mut(); + let mut sd: PSECURITY_DESCRIPTOR = std::ptr::null_mut(); + // SAFETY: `handle` is a live window-station/desktop handle; every out-param is a valid + // slot and the unwanted ones are NULL. + let rc = unsafe { + GetSecurityInfo( + handle, + SE_WINDOW_OBJECT, + DACL_SECURITY_INFORMATION, + std::ptr::null_mut(), + std::ptr::null_mut(), + &mut acl, + std::ptr::null_mut(), + &mut sd, + ) + }; + if rc != 0 { + return Err(win32_obj_err("GetSecurityInfo", rc)); + } + Ok(ReadWindowDacl { + acl, + _sd: LocalFreeGuard(sd), + }) + } +} + +/// The handle-addressed counterpart to [`win32_err`]: a window object has no path to name, and +/// access-denied here means the caller cannot re-ACL its own station rather than anything +/// about a file. +fn win32_obj_err(op: &str, rc: u32) -> io::Error { + if rc == ERROR_ACCESS_DENIED { + return io::Error::new( + io::ErrorKind::PermissionDenied, + format!("{op} on this session's window object: access denied"), + ); + } + io::Error::other(format!( + "{op} on this session's window object failed (Win32 error {rc})" + )) } /// Remove EVERY explicit ace whose trustee is `sid`, preserving all other explicit aces @@ -182,24 +470,7 @@ fn add_ace( let wpath = wide(path); let dacl = ReadDacl::open(path)?; - let ea = EXPLICIT_ACCESS_W { - grfAccessPermissions: mask, - grfAccessMode: mode, - grfInheritance: if inherit { - OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE - } else { - 0 - }, - Trustee: TRUSTEE_W { - pMultipleTrustee: std::ptr::null_mut(), - MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, - TrusteeForm: TRUSTEE_IS_SID, - // UNKNOWN, not `TRUSTEE_IS_USER`: the same helper stamps the sandbox GROUP's SID - // for the credential-store lock-out, and the field is advisory anyway. - TrusteeType: TRUSTEE_IS_UNKNOWN, - ptstrName: sid.0.cast(), - }, - }; + let ea = explicit_access(sid.0, mask, mode, inherit); let mut new_dacl: *mut ACL = std::ptr::null_mut(); // SAFETY: `ea` and the SID it points at outlive the call; `dacl.acl` came from @@ -229,6 +500,29 @@ fn add_ace( Ok(()) } +/// One ace description for `SetEntriesInAclW`. The returned struct BORROWS `sid`, which must +/// outlive the call it is passed to. +fn explicit_access(sid: PSID, mask: u32, mode: ACCESS_MODE, inherit: bool) -> EXPLICIT_ACCESS_W { + EXPLICIT_ACCESS_W { + grfAccessPermissions: mask, + grfAccessMode: mode, + grfInheritance: if inherit { + OBJECT_INHERIT_ACE | CONTAINER_INHERIT_ACE + } else { + 0 + }, + Trustee: TRUSTEE_W { + pMultipleTrustee: std::ptr::null_mut(), + MultipleTrusteeOperation: NO_MULTIPLE_TRUSTEE, + TrusteeForm: TRUSTEE_IS_SID, + // UNKNOWN, not `TRUSTEE_IS_USER`: this helper also stamps the well-known + // `BUILTIN\Administrators` and SYSTEM SIDs, and the field is advisory anyway. + TrusteeType: TRUSTEE_IS_UNKNOWN, + ptstrName: sid.cast(), + }, + } +} + /// Rebuild `existing`'s ace list without any explicit ace for `sid`, in canonical order. /// `Ok(None)` means nothing matched and the caller must not write anything back. /// @@ -274,6 +568,17 @@ fn rebuild_without_sid( if dropped == 0 { return Ok(None); } + // Leaving our own ace in place is the deliberate lesser evil: a residual ace for a confined + // account is over-permission, while an empty DACL locks the OWNER out of their own file + // and is not recoverable without taking ownership. + if kept.is_empty() { + tracing::debug!( + path = %path.display(), + "sandbox: ace strip skipped — our aces are the only ones on this path, and an \ + empty DACL would deny everyone" + ); + return Ok(None); + } kept.sort_by_key(|&(bucket, index, _, _)| (bucket, index)); let acl_bytes = (std::mem::size_of::() as u32 + kept_bytes).next_multiple_of(4); @@ -589,6 +894,24 @@ mod tests { /// Everyone — a second, distinct trustee for the preservation test. const OTHER_SID: &str = "S-1-1-0"; + /// A top-level deny target's `Path::parent` IS the volume root, so without this check + /// `deny: ["C:/creds.json"]` writes a permanent explicit DENY ace onto the whole drive + /// (or, unelevated, aborts the run with an error naming a path the policy never mentioned). + #[test] + fn a_volume_root_is_recognized_and_carries_no_carve() { + for root in [r"\\?\C:\", r"\\?\UNC\server\share\"] { + assert!(is_volume_root(Path::new(root)), "{root}"); + } + for under in [r"\\?\C:\creds.json", r"\\?\UNC\server\share\creds.json"] { + assert!(!is_volume_root(Path::new(under)), "{under}"); + } + assert_eq!(carve_parent(Path::new(r"\\?\C:\creds.json")), None); + assert_eq!( + carve_parent(Path::new(r"\\?\C:\proj\.env")), + Some(PathBuf::from(r"\\?\C:\proj")) + ); + } + /// The property the whole agent-sandbox fs axis rests on: a deny written onto a file /// inside a granted tree must land AHEAD of the allow the tree's `(OI)(CI)` grant /// propagated onto that file. Windows accepts the wrong order silently, so nothing but an diff --git a/crates/nub-sandbox/src/backend/windows_account/launch.rs b/crates/nub-sandbox/src/backend/windows_account/launch.rs index d543f1fb0..db99d0980 100644 --- a/crates/nub-sandbox/src/backend/windows_account/launch.rs +++ b/crates/nub-sandbox/src/backend/windows_account/launch.rs @@ -14,21 +14,25 @@ //! duplicates exactly the `STARTF_USESTDHANDLES` handles into the new logon and nothing //! else — so stdio must ride those three fields, and there is no handle-list attribute to //! scope inheritance with (nor any need for one: nothing else crosses). -//! - `lpDesktop` is left NULL deliberately. A non-NULL desktop makes seclogon SKIP its -//! window-station auto-grant, at which point the child needs an explicit `WinSta0` ace -//! (including `READ_CONTROL`, without which it HANGS in loader init) plus a session -//! `BaseNamedObjects` ace. NULL avoids all of it; the cost is no desktop isolation, which -//! is a hardening follow-up, not a confinement hole. +//! - `lpDesktop` is left NULL deliberately — but NULL does NOT mean there is no +//! window-station work to do. seclogon's auto-grant covers `WinSta0` only, so a launch +//! from a NON-INTERACTIVE caller (an SSH session, a service, a CI agent), which runs on a +//! per-logon `Service-0x0-…$` station, needs an explicit station + desktop ace or the +//! child dies in loader init with `0xC0000142` (VM-diagnosed; see +//! [`WindowAceGuard`]). Setting `lpDesktop` was tried and does not substitute for it. The +//! cost of NULL is no desktop isolation, which is a hardening follow-up, not a +//! confinement hole. //! - `AssignProcessToJobObject` on the resulting child commonly fails `ERROR_NOT_SUPPORTED`: //! seclogon already placed it in its own job, and current Windows refuses that nesting //! cross-session. The assignment is attempted and its failure reported, never silently //! swallowed — whole-tree reap is genuinely weaker here than on the AppContainer path. -use super::{AccountLaunch, account, acl, state}; +use super::{AccountLaunch, SANDBOX_ACCOUNT, account, acl, state}; use crate::backend::windows::launch::{build_command_line, build_env_block, to_wide}; use std::io; use std::os::windows::io::AsRawHandle; use std::os::windows::process::ExitStatusExt; +use std::path::Path; use std::process::ExitStatus; use windows_sys::Win32::Foundation::{ CloseHandle, HANDLE, HANDLE_FLAG_INHERIT, INVALID_HANDLE_VALUE, SetHandleInformation, @@ -39,16 +43,45 @@ use windows_sys::Win32::System::JobObjects::{ JOBOBJECT_EXTENDED_LIMIT_INFORMATION, JobObjectExtendedLimitInformation, SetInformationJobObject, }; +use windows_sys::Win32::System::StationsAndDesktops::{ + DESKTOP_CREATEMENU, DESKTOP_CREATEWINDOW, DESKTOP_ENUMERATE, DESKTOP_HOOKCONTROL, + DESKTOP_JOURNALPLAYBACK, DESKTOP_JOURNALRECORD, DESKTOP_READ_CONTROL, DESKTOP_READOBJECTS, + DESKTOP_SWITCHDESKTOP, DESKTOP_WRITEOBJECTS, GetProcessWindowStation, GetThreadDesktop, +}; use windows_sys::Win32::System::Threading::{ - CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessWithLogonW, GetExitCodeProcess, - INFINITE, LOGON_WITH_PROFILE, PROCESS_INFORMATION, ResumeThread, STARTF_USESTDHANDLES, - STARTUPINFOW, TerminateProcess, WaitForSingleObject, + CREATE_SUSPENDED, CREATE_UNICODE_ENVIRONMENT, CreateProcessWithLogonW, GetCurrentThreadId, + GetExitCodeProcess, INFINITE, LOGON_WITH_PROFILE, PROCESS_INFORMATION, ResumeThread, + STARTF_USESTDHANDLES, STARTUPINFOW, TerminateProcess, WaitForSingleObject, }; const ERROR_NOT_SUPPORTED: i32 = 50; const ERROR_LOGON_FAILURE: i32 = 1326; const ERROR_SERVICE_DISABLED: i32 = 1058; +/// `STATUS_DLL_INIT_FAILED`, which arrives as the CHILD'S EXIT CODE rather than a +/// `CreateProcessWithLogonW` failure — so nothing in [`map_spawn_error`] ever sees it and an +/// unmapped run surfaces the bare `-1073741502`. +const STATUS_DLL_INIT_FAILED: u32 = 0xC000_0142; + +/// `WINSTA_ALL_ACCESS` (0x37F) — the union of the nine `WINSTA_*` rights. Spelled here because +/// `windows-sys` exports it only from `Win32_UI_WindowsAndMessaging`, a feature this crate does +/// not otherwise need. `DESKTOP_READ_CONTROL` is the same `READ_CONTROL` bit (0x0002_0000), +/// which is LOAD-BEARING on the station: without it the child HANGS in loader init rather than +/// failing, so it is folded in here and never optional. +const WINSTA_GRANT: u32 = 0x0000_037F | DESKTOP_READ_CONTROL; + +/// The documented `DESKTOP_*` rights union (0x1FF), plus `READ_CONTROL` for the same reason. +const DESKTOP_GRANT: u32 = DESKTOP_READOBJECTS + | DESKTOP_CREATEWINDOW + | DESKTOP_CREATEMENU + | DESKTOP_HOOKCONTROL + | DESKTOP_JOURNALRECORD + | DESKTOP_JOURNALPLAYBACK + | DESKTOP_ENUMERATE + | DESKTOP_WRITEOBJECTS + | DESKTOP_SWITCHDESKTOP + | DESKTOP_READ_CONTROL; + /// Strips every ace this run applied, on drop. Ordering matters: declared before the child is /// spawned but dropped after the wait returns, so a granted path is never revoked out from /// under a live child. Best-effort — a failed strip leaves an over-permissive ace for a @@ -61,13 +94,33 @@ struct AceGuard { impl Drop for AceGuard { fn drop(&mut self) { for p in &self.paths { - if let Err(e) = acl::strip(p, &self.sid) { - tracing::debug!(path = %p.display(), error = %e, "sandbox: ace strip failed — left for the ledger sweep"); + match acl::strip(p, &self.sid) { + Ok(()) => {} + // A recorded path that no longer exists carries no ace to strip: it was either + // skipped as absent at apply time, or deleted by the child inside a grant. + Err(e) if e.kind() == io::ErrorKind::NotFound => {} + Err(e) => { + tracing::debug!(path = %p.display(), error = %e, "sandbox: ace strip failed — left for the ledger sweep"); + } } } } } +/// An ace target that does not exist is SKIPPED, not fatal: the flagship agent policy denies +/// `~/.ssh`, `~/.aws`, `/.env` and most are absent on any real machine, so failing would +/// kill the backend on its own headline shape. Denying an absent path denies nothing — the +/// created-later-inside-a-grant residual is in LIMITATIONS.md. Every other error stays fatal. +fn tolerate_absent(r: io::Result<()>, path: &Path, what: &str) -> io::Result<()> { + match r { + Err(e) if e.kind() == io::ErrorKind::NotFound => { + tracing::debug!(path = %path.display(), "sandbox: {what} target does not exist — skipped"); + Ok(()) + } + other => other, + } +} + impl AccountLaunch { pub(crate) fn run(self) -> io::Result { let marker = state::read_marker()?.ok_or_else(not_provisioned)?; @@ -79,7 +132,7 @@ impl AccountLaunch { Some(_) => { return Err(io::Error::other( "the nub sandbox account exists but its SID no longer matches the recorded \ - setup — re-run the elevated sandbox setup", + setup — re-run `nub run --sandbox-admin setup` from an elevated prompt", )); } None => return Err(not_provisioned()), @@ -104,24 +157,43 @@ impl AccountLaunch { { state::record_acl_path(path)?; guard.paths.push(path.clone()); - acl::grant(path, &marker.sid, access)?; + tolerate_absent(acl::grant(path, &marker.sid, access), path, "grant")?; } // Denies go on AFTER the grants so the deny ace is inserted into a DACL that already // carries the grant it must outrank — the canonical-order insert has to see both. for path in &self.denies { - state::record_acl_path(path)?; - guard.paths.push(path.clone()); + let targets = match acl::deny_targets(path) { + Ok(t) => t, + // Same skip-if-absent rule as the grants above. + Err(e) if e.kind() == io::ErrorKind::NotFound => { + tracing::debug!(path = %path.display(), "sandbox: deny target does not exist — skipped"); + continue; + } + Err(e) => return Err(e), + }; + for t in targets { + state::record_acl_path(&t)?; + guard.paths.push(t); + } acl::deny(path, &marker.sid)?; } let password = account::load_credential()?; - let status = self.spawn_and_wait(&marker.account, &password); + let status = self.spawn_and_wait(&marker.sid, password.as_str()); drop(guard); status } - fn spawn_and_wait(&self, account_name: &str, password: &str) -> io::Result { - let user_w = to_wide(account_name); + fn spawn_and_wait(&self, sid: &str, password: &str) -> io::Result { + // Declared before the spawn and dropped after the wait, like `AceGuard`: revoking the + // station access out from under a live child is the same hazard as revoking a path. + let _window = WindowAceGuard::grant(sid); + + // The COMPILE-TIME const, never a name read from the marker file: the SID checked in + // `run` resolves THIS name, so a name field on disk would have been an attacker-chosen + // logon target that still passed that check — the marker lives in a directory a + // standard user can pre-create and then own. + let user_w = to_wide(SANDBOX_ACCOUNT); // "." targets the LOCAL SAM regardless of whether the machine is domain-joined. let domain_w = to_wide("."); let mut password_w: Vec = password.encode_utf16().chain(std::iter::once(0)).collect(); @@ -167,15 +239,25 @@ impl AccountLaunch { &mut pi, ) }; - password_w.fill(0); + account::scrub_u16(&mut password_w); if ok == 0 { - return Err(map_spawn_error(io::Error::last_os_error(), account_name)); + return Err(map_spawn_error(io::Error::last_os_error())); } // Best-effort containment. seclogon has already placed the child in its own job and // current Windows refuses cross-session nesting, so this commonly returns // ERROR_NOT_SUPPORTED — reported, never presented as success. - let job = create_kill_on_close_job().ok(); + let job = match create_kill_on_close_job() { + Ok(j) => Some(j), + Err(e) => { + tracing::debug!( + error = %e, + "sandbox: could not create the reaping Job Object — whole-tree reap is \ + best-effort" + ); + None + } + }; if let Some(j) = job { // SAFETY: both handles are live; the child is still suspended. if unsafe { AssignProcessToJobObject(j, pi.hProcess) } == 0 { @@ -201,32 +283,72 @@ impl AccountLaunch { } } - // SAFETY: `pi` handles came from a successful CreateProcessWithLogonW. - let code = unsafe { - ResumeThread(pi.hThread); - if WaitForSingleObject(pi.hProcess, INFINITE) != WAIT_OBJECT_0 { - let e = io::Error::last_os_error(); + // A failed resume must NOT fall through to the wait below: the child would stay + // suspended forever and `WaitForSingleObject(…, INFINITE)` would never return. + // SAFETY: `pi.hThread` is the suspended primary thread of the process just created. + if unsafe { ResumeThread(pi.hThread) } == u32::MAX { + let e = io::Error::last_os_error(); + // SAFETY: both handles are live and each is closed exactly once. + unsafe { + TerminateProcess(pi.hProcess, 1); + CloseHandle(pi.hThread); + CloseHandle(pi.hProcess); + } + close_job(job); + return Err(io::Error::other(format!( + "sandbox: the sandboxed child could not be resumed: {e}" + ))); + } + + // SAFETY: `pi.hProcess` is live until closed below. + if unsafe { WaitForSingleObject(pi.hProcess, INFINITE) } != WAIT_OBJECT_0 { + let e = io::Error::last_os_error(); + // SAFETY: as above. + unsafe { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); - if let Some(j) = job { - CloseHandle(j); - } - return Err(e); } - let mut code: u32 = 0; - GetExitCodeProcess(pi.hProcess, &mut code); + close_job(job); + return Err(e); + } + + let mut code: u32 = u32::MAX; + // SAFETY: `pi.hProcess` is a live, signalled process handle. + let queried = unsafe { GetExitCodeProcess(pi.hProcess, &mut code) }; + let query_err = (queried == 0).then(io::Error::last_os_error); + // SAFETY: both handles came from CreateProcessWithLogonW and are closed exactly once. + unsafe { CloseHandle(pi.hThread); CloseHandle(pi.hProcess); - code - }; - if let Some(j) = job { - // Closing last triggers KILL_ON_JOB_CLOSE for anything still in the tree. - unsafe { CloseHandle(j) }; + } + // Closed LAST so KILL_ON_JOB_CLOSE reaps anything still in the tree. + close_job(job); + + if let Some(e) = query_err { + return Err(io::Error::other(format!( + "sandbox: the sandboxed child's exit code could not be read: {e}" + ))); + } + if code == STATUS_DLL_INIT_FAILED { + return Err(io::Error::other( + "sandbox: the sandboxed child died in loader init with STATUS_DLL_INIT_FAILED \ + (0xC0000142) — it could not attach to this session's window station. nub grants \ + the sandbox account access to the caller's station and desktop before \ + launching; a station nub cannot re-ACL (an unusual service or remoting host) \ + can still refuse it. Running from an ordinary interactive session avoids it.", + )); } Ok(ExitStatus::from_raw(code)) } } +fn close_job(job: Option) { + if let Some(j) = job { + // SAFETY: the handle came from CreateJobObjectW and is closed exactly once. + unsafe { CloseHandle(j) }; + } +} + /// The parent's three std handles, marked inheritable so seclogon can duplicate them. `None` /// when any is absent (a detached parent) — the child then gets no stdio rather than a /// half-wired set that would make it block on a dead handle. @@ -244,6 +366,71 @@ fn inheritable_std_handles() -> Option<(HANDLE, HANDLE, HANDLE)> { Some((i, o, e)) } +/// The window-station and desktop aces the child needs, restored on drop. +/// +/// WHY THIS EXISTS (VM-diagnosed 2026-07-24, reproduced with a bare P/Invoke on a throwaway +/// account and no nub code): a NON-INTERACTIVE caller — SSH, a service, a CI agent — runs on a +/// per-logon `Service-0x0-…$` window station, NOT `WinSta0`, and the Secondary Logon service's +/// station auto-grant covers only `WinSta0`. Without these two aces the child dies in loader +/// init with `0xC0000142 STATUS_DLL_INIT_FAILED`. Setting `lpDesktop` explicitly was tried and +/// does NOT substitute, which is why the launch still passes NULL. +/// +/// Neither handle is closed: `GetProcessWindowStation` and `GetThreadDesktop` both return +/// handles the caller does not own. +struct WindowAceGuard { + /// `(handle, the DACL that was there before this run)`. `None` restores a NULL DACL. + restore: Vec<(HANDLE, Option>)>, +} + +impl WindowAceGuard { + fn grant(sid: &str) -> Self { + // SAFETY: neither call takes a parameter that can be invalid, and both return a handle + // owned by the system for this process/thread's lifetime. + let (station, desktop) = unsafe { + ( + GetProcessWindowStation().cast::(), + GetThreadDesktop(GetCurrentThreadId()).cast::(), + ) + }; + let mut guard = WindowAceGuard { + restore: Vec::with_capacity(2), + }; + for (handle, mask) in [(station, WINSTA_GRANT), (desktop, DESKTOP_GRANT)] { + if handle.is_null() { + continue; + } + // FAIL FORWARD, never abort. On an interactive `WinSta0` these aces are redundant + // — seclogon's auto-grant already covers it — so a station whose DACL nub cannot + // rewrite (a locked-down remoting host, some CI agents) must still launch rather + // than lose a run that worked before this ace existed. The one case where the ace + // IS load-bearing surfaces instead as the mapped `STATUS_DLL_INIT_FAILED` exit. + match acl::grant_window_object(handle, sid, mask) { + Ok(saved) => guard.restore.push((handle, saved)), + Err(e) => tracing::debug!( + error = %e, + "sandbox: could not grant the sandbox account window-object access — a \ + child on a non-interactive station may fail loader init" + ), + } + } + guard + } +} + +impl Drop for WindowAceGuard { + fn drop(&mut self) { + for (handle, dacl) in &self.restore { + if let Err(e) = acl::restore_window_object(*handle, dacl.as_deref()) { + tracing::debug!( + error = %e, + "sandbox: could not restore the window-station DACL — the sandbox account \ + keeps station access until this session ends" + ); + } + } + } +} + fn create_kill_on_close_job() -> io::Result { // SAFETY: unnamed job with default security. let job = unsafe { CreateJobObjectW(std::ptr::null(), std::ptr::null()) }; @@ -273,18 +460,18 @@ fn not_provisioned() -> io::Error { io::Error::new( io::ErrorKind::NotFound, "this policy needs nub's dedicated Windows sandbox account, which has not been set up \ - on this machine. Run `nub run --sandbox-setup` once from an elevated (Run as \ + on this machine. Run `nub run --sandbox-admin setup` once from an elevated (Run as \ administrator) prompt.", ) } /// Turn the two spawn failures a user can actually act on into instructions. -fn map_spawn_error(e: io::Error, account: &str) -> io::Error { +fn map_spawn_error(e: io::Error) -> io::Error { match e.raw_os_error() { Some(ERROR_LOGON_FAILURE) => io::Error::other(format!( - "sandbox: the stored credential for `{account}` was rejected — the account's \ - password was changed or the account was disabled. Re-run `nub run \ - --sandbox-setup` from an elevated prompt to reprovision it." + "sandbox: the stored credential for `{SANDBOX_ACCOUNT}` was rejected — the \ + account's password was changed or the account was disabled. Re-run `nub run \ + --sandbox-admin setup` from an elevated prompt to reprovision it." )), Some(ERROR_SERVICE_DISABLED) => io::Error::other( "sandbox: the Windows Secondary Logon service is disabled, so nub cannot start the \ diff --git a/crates/nub-sandbox/src/backend/windows_account/mod.rs b/crates/nub-sandbox/src/backend/windows_account/mod.rs index adabc10ce..f140c7483 100644 --- a/crates/nub-sandbox/src/backend/windows_account/mod.rs +++ b/crates/nub-sandbox/src/backend/windows_account/mod.rs @@ -24,7 +24,7 @@ //! mandatory. See `.fray/sandbox-decisions-current.md` §2. //! //! **THE PRIVILEGE SPLIT IS THE WHOLE PRODUCT DECISION.** One elevated -//! `nub run --sandbox-setup` per machine creates the account and installs four persistent +//! `nub run --sandbox-admin setup` per machine creates the account and installs four persistent //! WFP filters over a pre-authorized loopback port window. Every run after that is //! **fully unelevated**: read the credential, ACL the policy's paths, and //! `CreateProcessWithLogonW` the child through the Secondary Logon service — which needs no @@ -35,9 +35,10 @@ //! hop straight to the target. SRT and Codex add a second hop that re-launches through a //! runner holding a *restricted* token; nub's confinement comes from the account's ACL reach //! plus SID-keyed WFP, neither of which needs that token, so hop 2 is a hardening follow-up. -//! Consequences: `lpDesktop` stays NULL (so seclogon's window-station auto-grant applies and -//! no `WinSta0` ACE work is needed) at the cost of desktop isolation, and Job-Object -//! whole-tree kill is best-effort because seclogon's own job refuses cross-session nesting. +//! Consequences: `lpDesktop` stays NULL, at the cost of desktop isolation (and NOT because it +//! avoids window-station work — seclogon's auto-grant covers only `WinSta0`, so the launch +//! aces the caller's station and desktop itself; see [`launch`]), and Job-Object whole-tree +//! kill is best-effort because seclogon's own job refuses cross-session nesting. #![cfg(any(target_os = "windows", test))] @@ -61,9 +62,11 @@ pub(crate) mod wfp; #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) const SANDBOX_ACCOUNT: &str = "nub-sandbox"; -/// A local group whose sole purpose is being a STABLE DENY TRUSTEE: the credential store's -/// DACL denies the GROUP, so a future per-session-account design adds members instead of -/// rewriting DACLs. (SRT's rationale; mirrored.) +/// A local group holding the sandbox account. NOTHING KEYS ON IT TODAY: the credential store +/// is locked by a PROTECTED DACL naming SYSTEM, `BUILTIN\Administrators` and the provisioning +/// user, which needs no deny trustee at all. It is created and deleted anyway because it is the +/// stable trustee a future per-session-account design would add members to rather than +/// rewriting DACLs (SRT's rationale), and provisioning it is free. #[cfg_attr(not(target_os = "windows"), allow(dead_code))] pub(crate) const SANDBOX_GROUP: &str = "nub-sandbox-users"; @@ -121,6 +124,17 @@ pub(crate) struct AccountLaunch { // PERSISTENT WFP filters the elevated setup installed. There is no per-run network work to // do, which is exactly the property that keeps every run unelevated. +/// Whether this machine has completed the one-time elevated setup. +/// +/// Cheap and read-only: the marker's mere presence is the signal. [`apply`] re-reads it and +/// re-validates the recorded SID against the live account, so a stale or tampered marker still +/// fails closed there — this is a ROUTING question ("is the account path available at all?"), +/// deliberately not the trust decision. +#[cfg(target_os = "windows")] +pub(crate) fn is_provisioned() -> bool { + matches!(state::read_marker(), Ok(Some(_))) +} + /// Whether a policy needs the ACCOUNT backend rather than the AppContainer allowlist. /// /// The account backend costs a one-time elevated setup, so it is chosen only where the @@ -213,13 +227,16 @@ pub(crate) fn plan_net(net: &crate::policy::NetPolicy) -> AccountNet { // ── one-time setup / teardown / status (the elevated half) ────────────────────── -/// ELEVATED, once per machine. Create the sandbox account, lock the credential store against -/// it, install the WFP egress fence over `port_range`, and record the marker every later -/// unelevated run reads. Idempotent — safe to re-run to repair a partial install. +/// ELEVATED, once per machine. Lock nub's state directory, create the sandbox account, install +/// the WFP egress fence over `port_range`, and record the marker every later unelevated run +/// reads. Idempotent — safe to re-run to repair a partial install. /// -/// ORDER IS DELIBERATE: the marker is written LAST, so a failure anywhere leaves the machine -/// looking un-provisioned and every run fails closed with "run the setup", rather than -/// half-provisioned and failing in some less legible way later. +/// ORDER IS DELIBERATE AT BOTH ENDS. The state directory is locked FIRST, before `provision` +/// writes a credential into it: the DACL is the DPAPI blob's only boundary, so writing the +/// credential first would leave it world-readable for a live account if the lock then failed. +/// The marker is written LAST, so a failure anywhere leaves the machine looking un-provisioned +/// and every run fails closed with "run the setup", rather than half-provisioned and failing in +/// some less legible way later. #[cfg(target_os = "windows")] pub fn setup(port_range: Option<(u16, u16)>) -> std::io::Result { if !account::is_elevated() { @@ -231,14 +248,15 @@ pub fn setup(port_range: Option<(u16, u16)>) -> std::io::Result { )); } let range = port_range.unwrap_or(wfp::DEFAULT_PROXY_PORT_RANGE); + // `store_credential` uses `create_dir_all`, a no-op on the directory created here, and the + // elevated writer keeps access through the Administrators ace this stamps. + let cred_dir = account::credential_dir()?; + std::fs::create_dir_all(&cred_dir)?; + acl::lock_to_admins(&cred_dir)?; let sid = account::provision()?; - // The credential is DPAPI machine-scope, which is explicitly NOT a boundary — the - // ciphertext's DACL is. Denying the sandbox account is that boundary. - acl::lock_out(&account::credential_dir()?, &sid)?; wfp::install(&sid, range)?; state::write_marker(&state::Marker { version: state::MARKER_VERSION, - account: SANDBOX_ACCOUNT.to_string(), sid: sid.clone(), port_low: range.0, port_high: range.1, @@ -277,6 +295,16 @@ pub fn clean() -> std::io::Result { let Some(marker) = state::read_marker()? else { return Ok(0); }; + // The SAME live-SID check the launch makes, for the same reason. This sweep strips explicit + // aces for whatever trustee the marker names, from whatever paths the ledger lists — BOTH + // read off disk. Trusting them unverified would make `clean` an ace-removal primitive + // aimed by anyone who can write nub's state directory. + if account::lookup_sid()?.as_deref() != Some(marker.sid.as_str()) { + return Err(std::io::Error::other( + "the recorded sandbox SID does not match the live account, so nub will not sweep \ + aces for it — re-run `nub run --sandbox-admin setup` from an elevated prompt", + )); + } let paths = state::ledger_paths()?; let mut swept = 0; for p in &paths { @@ -308,19 +336,21 @@ pub fn status() -> std::io::Result { (None, None) => out.push_str("sandbox account: not set up\n"), (None, Some(sid)) => out.push_str(&format!( "sandbox account: `{SANDBOX_ACCOUNT}` exists ({sid}) but nub has no setup record — \ - re-run the elevated setup\n" + re-run `nub run --sandbox-admin setup` from an elevated prompt\n" )), (Some(m), None) => out.push_str(&format!( - "sandbox account: recorded as {} but no longer exists — re-run the elevated setup\n", + "sandbox account: recorded as {} but no longer exists — re-run `nub run \ + --sandbox-admin setup` from an elevated prompt\n", m.sid )), (Some(m), Some(sid)) if m.sid != *sid => out.push_str(&format!( - "sandbox account: SID changed (recorded {}, live {sid}) — re-run the elevated setup\n", + "sandbox account: SID changed (recorded {}, live {sid}) — re-run `nub run \ + --sandbox-admin setup` from an elevated prompt\n", m.sid )), (Some(m), Some(sid)) => out.push_str(&format!( - "sandbox account: `{}` ({sid})\nproxy port window: {}-{}\n", - m.account, m.port_low, m.port_high + "sandbox account: `{SANDBOX_ACCOUNT}` ({sid})\nproxy port window: {}-{}\n", + m.port_low, m.port_high )), } out.push_str( @@ -382,7 +412,7 @@ pub(crate) fn apply( reason: Some(format!( "nub's egress proxy bound port {port}, outside the {}-{} loopback window the \ installed WFP filters permit — the sandboxed child could not reach it. Free a \ - port in that range, or re-run `nub run --sandbox-setup` from an elevated \ + port in that range, or re-run `nub run --sandbox-admin setup` from an elevated \ prompt to authorize a different one.", marker.port_low, marker.port_high )), @@ -482,8 +512,8 @@ fn not_set_up(detail: &str) -> crate::backend::Degradation { reason: Some(format!( "{detail}. This policy needs nub's dedicated Windows sandbox account (it uses a \ generous-read base, a deny inside a granted directory, or per-host network rules — \ - none of which Windows can express without one). Run `nub run --sandbox-setup` once \ - from an elevated (Run as administrator) prompt." + none of which Windows can express without one). Run `nub run --sandbox-admin setup` \ + once from an elevated (Run as administrator) prompt." )), } } @@ -659,6 +689,24 @@ mod tests { assert!(needs_account_backend(&p)); } + /// The whole-fs `**` Allow entry is what the compiler actually emits for `"..."` / + /// `sandbox: true` — the primary real-world agent-sandbox policy — so its routing is + /// pinned separately from the `default_effect == Allow` arm above. + #[test] + fn whole_fs_allow_entry_needs_the_account_backend() { + let p = policy( + fs_policy( + Effect::Deny, + vec![ + rule("**", Effect::Allow, FsAccess::Read), + rule("C:/proj", Effect::Allow, FsAccess::ReadWrite), + ], + ), + net_off(), + ); + assert!(needs_account_backend(&p)); + } + /// A deny that lands inside a granted subtree is the AppContainer's known hole (its /// inheritable allow defeats the deny), so it must route to the account. #[test] diff --git a/crates/nub-sandbox/src/backend/windows_account/state.rs b/crates/nub-sandbox/src/backend/windows_account/state.rs index 6083091c6..dea992a3e 100644 --- a/crates/nub-sandbox/src/backend/windows_account/state.rs +++ b/crates/nub-sandbox/src/backend/windows_account/state.rs @@ -1,8 +1,8 @@ //! Provisioning marker + the ACL ledger — the only durable state the account backend keeps. //! -//! TWO FILES, TWO JOBS. The **marker** records what the elevated setup created (account, SID, -//! the WFP-authorized loopback port window) so an UNELEVATED run can decide whether it may -//! proceed and which port to bind — WFP gates even *enumeration* on administrator, so an +//! TWO FILES, TWO JOBS. The **marker** records what the elevated setup created (the account's +//! SID and the WFP-authorized loopback port window) so an UNELEVATED run can decide whether it +//! may proceed and which port to bind — WFP gates even *enumeration* on administrator, so an //! unelevated process cannot ask the firewall what is installed and must read it here. //! The **ledger** records every path ever ACL'd for the sandbox SID so a sweep can undo them //! after a crash. @@ -18,9 +18,11 @@ //! recorded BEFORE the ace is applied, so a crash in between leaves a ledger entry for an ace //! that was never written — and stripping a path that has no ace is a no-op. //! -//! Both live under `%PROGRAMDATA%\nub\sandbox`, whose inherited DACL gives Administrators and -//! SYSTEM full control and ordinary users read — exactly the asymmetry wanted: any user may -//! read the marker, only an elevated process may write it. +//! Both live under `%PROGRAMDATA%\nub\sandbox`, which the elevated setup locks to SYSTEM, +//! `BUILTIN\Administrators` and the provisioning user (see [`super::acl::lock_to_admins`] — +//! the same directory holds the DPAPI credential, and its DACL is that blob's ONLY boundary). +//! So the provisioning user reads the marker and appends the ledger unelevated, while another +//! standard user on the box reads neither and fails closed with "not provisioned". // Compiled on the dev host too, so the marker parse + ledger de-duplication are unit-tested // without a Windows box; only the Windows launch/setup paths actually call the rest. @@ -31,13 +33,18 @@ use std::path::{Path, PathBuf}; /// Bumped when the on-disk shape changes. A marker from a newer nub is refused rather than /// misread — an unelevated run acting on a misparsed SID would ACL the wrong principal. -pub(crate) const MARKER_VERSION: u32 = 1; +/// v2 dropped the account NAME field, so a v1 marker is refused rather than read past. +pub(crate) const MARKER_VERSION: u32 = 2; /// What the one-time elevated setup recorded. +/// +/// DELIBERATELY CARRIES NO ACCOUNT NAME. The identity is the compile-time +/// [`super::SANDBOX_ACCOUNT`] const, and the launch validates the SID below against a live +/// lookup of THAT const. A name read from disk would have been an attacker-chosen logon target +/// that still passed the SID check — nothing about the identity comes off disk. #[derive(Debug, Clone, PartialEq, Eq)] pub(crate) struct Marker { pub(crate) version: u32, - pub(crate) account: String, /// The account's SID string. Every WFP filter and every granted ace keys on this, so a /// marker whose SID no longer resolves means the account was deleted out from under us. pub(crate) sid: String, @@ -53,9 +60,8 @@ impl Marker { /// hurt us, so it stays explicit and total. pub(crate) fn to_json(&self) -> String { format!( - "{{\"version\":{},\"account\":{},\"sid\":{},\"port_low\":{},\"port_high\":{}}}\n", + "{{\"version\":{},\"sid\":{},\"port_low\":{},\"port_high\":{}}}\n", self.version, - json_string(&self.account), json_string(&self.sid), self.port_low, self.port_high @@ -83,7 +89,7 @@ impl Marker { io::ErrorKind::InvalidData, format!( "sandbox marker is version {version}, this nub understands {MARKER_VERSION} \ - — re-run the elevated sandbox setup" + — re-run `nub run --sandbox-admin setup` from an elevated prompt" ), )); } @@ -108,7 +114,6 @@ impl Marker { }; let m = Marker { version, - account: as_str(field("account")?, "account")?, sid: as_str(field("sid")?, "sid")?, port_low: as_port(field("port_low")?, "port_low")?, port_high: as_port(field("port_high")?, "port_high")?, @@ -140,13 +145,20 @@ fn json_string(s: &str) -> String { /// `%PROGRAMDATA%\nub\sandbox`. Machine-scoped on purpose: the account, the WFP filters and /// the ledger are all machine state, not per-user state. +/// +/// THE ONE DEFINITION OF THIS PATH — [`super::account::credential_dir`] delegates here. The +/// credential, the marker and the ledger all live in this directory, and setup locks it by +/// asking for the CREDENTIAL directory, so a second definition that ever drifted would silently +/// protect a directory the credential is not in. pub(crate) fn state_dir() -> io::Result { - let base = std::env::var_os("PROGRAMDATA").ok_or_else(|| { - io::Error::new( - io::ErrorKind::NotFound, - "PROGRAMDATA is not set — cannot locate the nub sandbox state directory", - ) - })?; + let base = std::env::var_os("PROGRAMDATA") + .filter(|v| !v.is_empty()) + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + "PROGRAMDATA is not set — cannot locate the nub sandbox state directory", + ) + })?; Ok(PathBuf::from(base).join("nub").join("sandbox")) } @@ -245,7 +257,6 @@ mod tests { fn sample() -> Marker { Marker { version: MARKER_VERSION, - account: "nub-sandbox".into(), sid: "S-1-5-21-1-2-3-1001".into(), port_low: 59080, port_high: 59089, @@ -264,26 +275,36 @@ mod tests { #[test] fn a_future_marker_version_is_refused() { let json = format!( - "{{\"version\":{},\"account\":\"a\",\"sid\":\"S-1-5-21-1\",\"port_low\":1,\"port_high\":2}}", + "{{\"version\":{},\"sid\":\"S-1-5-21-1\",\"port_low\":1,\"port_high\":2}}", MARKER_VERSION + 1 ); let err = Marker::from_json(&json).unwrap_err(); assert_eq!(err.kind(), io::ErrorKind::InvalidData); - assert!( - err.to_string() - .contains("re-run the elevated sandbox setup") - ); + assert!(err.to_string().contains("--sandbox-admin setup")); + } + + /// The v1 marker carried the account NAME, and the launch logged on as it. Anyone able to + /// write the marker file could keep the real SID (so the SID check still passed) while + /// pointing the name at an account they controlled — every "sandboxed" run then launched + /// unfenced. v2 removed the field, so a v1 marker must be REFUSED outright rather than + /// parsed with its name ignored. + #[test] + fn a_v1_marker_carrying_an_account_name_is_refused() { + let json = "{\"version\":1,\"account\":\"attacker\",\"sid\":\"S-1-5-21-1\",\ + \"port_low\":1,\"port_high\":2}"; + let err = Marker::from_json(json).unwrap_err(); + assert_eq!(err.kind(), io::ErrorKind::InvalidData); } /// An inverted or zero range would make the proxy's bind-in-range search fail in a way /// that reads as "no free port" rather than "your setup is corrupt". #[test] fn a_malformed_port_range_is_refused() { - for json in [ - "{\"version\":1,\"account\":\"a\",\"sid\":\"S\",\"port_low\":900,\"port_high\":800}", - "{\"version\":1,\"account\":\"a\",\"sid\":\"S\",\"port_low\":0,\"port_high\":800}", - ] { - assert!(Marker::from_json(json).is_err(), "{json}"); + for (low, high) in [(900, 800), (0, 800)] { + let json = format!( + "{{\"version\":{MARKER_VERSION},\"sid\":\"S\",\"port_low\":{low},\"port_high\":{high}}}" + ); + assert!(Marker::from_json(&json).is_err(), "{json}"); } } diff --git a/crates/nub-sandbox/tests/windows_account_enforcement.rs b/crates/nub-sandbox/tests/windows_account_enforcement.rs index 2af3fc9f0..3db9d8bf5 100644 --- a/crates/nub-sandbox/tests/windows_account_enforcement.rs +++ b/crates/nub-sandbox/tests/windows_account_enforcement.rs @@ -355,7 +355,7 @@ mod win { eprintln!( "\nABORT: this machine has no provisioned nub sandbox account, so there is \ nothing to probe.\n Run this ONCE from an elevated (Run as \ - administrator) prompt:\n\n nub run --sandbox-setup\n\n then \ + administrator) prompt:\n\n nub run --sandbox-admin setup\n\n then \ re-run this probe. (Run the probe itself elevated and it will provision + \ tear down for you.)" ); From c04d109c86faaa86bcc727f1c2cc8fd8289e0b47 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:10:53 -0700 Subject: [PATCH 6/7] sandbox(windows): refuse to write a DACL onto a NULL-DACL path MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Found by the post-fix VM run. Windows reads a NULL DACL as UNRESTRICTED access, not as an empty allow-set, so merging an ace into it via SetEntriesInAclW yields a DACL containing only that ace. Writing that converts "everyone allowed" into "the sandbox account and nobody else" and permanently locks out the object's own owner — observed on a C:\Windows\Temp child, where a grant reduced a seven-ace DACL to one and the owner lost traverse on a directory it owned. `strip` already guarded the mirror case; `add_ace` did not. It now refuses: a grant is skipped, because a NULL DACL already admits the account, and a deny fails closed with a message naming the path rather than silently leaving a hole or destroying the descriptor. Also from that run: teardown removes the state directory it created, which carries a protected DACL an unelevated user cannot clear, and a marker that exists but is unreadable no longer borrows the "re-run setup" instruction — re-running setup does not fix a permission problem. --- crates/nub-sandbox/LIMITATIONS.md | 13 +++++++++ .../src/backend/windows_account/acl.rs | 28 +++++++++++++++++++ .../src/backend/windows_account/mod.rs | 14 ++++++++++ 3 files changed, 55 insertions(+) diff --git a/crates/nub-sandbox/LIMITATIONS.md b/crates/nub-sandbox/LIMITATIONS.md index a348b690c..c663eaa54 100644 --- a/crates/nub-sandbox/LIMITATIONS.md +++ b/crates/nub-sandbox/LIMITATIONS.md @@ -325,6 +325,19 @@ failure logged; a descendant that outlives the target may survive. field changes, which git reports as "dubious ownership" and which leaves an orphaned owner SID if the account is later deleted. Neither reference implementation solves this. +### A path with a NULL DACL cannot carry a deny + +Windows reads a NULL DACL as UNRESTRICTED access, not as an empty allow-set. Merging a deny +into it would yield a DACL containing only that ace, replacing the object's permissive state +with one that locks out its own owner — so the engine refuses rather than writing it. A grant +on such a path is skipped instead (the account already has access). + +- **Why bounded:** fail-closed and loud. The launch aborts with a message naming the path. + Ordinary project and profile directories carry a real DACL and are unaffected; this was + observed only under `C:\Windows\Temp`. +- **Where fixed:** synthesize the equivalent explicit DACL (a deny for the sandbox account plus + an `Everyone` full-access allow preserving the NULL-DACL semantics) rather than refusing. + ### Residue after a crash A run killed between granting an ace and stripping it leaves the ace behind. The ledger at diff --git a/crates/nub-sandbox/src/backend/windows_account/acl.rs b/crates/nub-sandbox/src/backend/windows_account/acl.rs index f55e5d24d..6e1a954aa 100644 --- a/crates/nub-sandbox/src/backend/windows_account/acl.rs +++ b/crates/nub-sandbox/src/backend/windows_account/acl.rs @@ -470,6 +470,34 @@ fn add_ace( let wpath = wide(path); let dacl = ReadDacl::open(path)?; + // A NULL DACL means "no DACL at all", which Windows reads as UNRESTRICTED access — not as + // an empty allow-set. `SetEntriesInAclW` merging into NULL yields a DACL containing ONLY + // our ace, and writing that converts unrestricted into "the sandbox account and nobody + // else", permanently locking out the object's own owner. That is destructive and violates + // this module's additive-on-user-paths contract, so neither polarity may write here. + // (VM-observed on a `C:\Windows\Temp` child, 2026-07-25: a grant reduced a 7-ace DACL to + // one `nub-sandbox` ace and the owner lost traverse. `strip` already guards the mirror + // case; `add_ace` did not.) + if dacl.acl.is_null() { + return match mode { + // Nothing to grant: a NULL DACL already admits the sandbox account. + GRANT_ACCESS => { + tracing::debug!( + path = %path.display(), + "sandbox: grant skipped — path has a NULL DACL, so access is already unrestricted" + ); + Ok(()) + } + // FAIL CLOSED. The deny cannot be expressed without replacing the object's + // permissive state wholesale, and silently skipping it would leave a hole while + // reporting full enforcement. + _ => Err(io::Error::other(format!( + "sandbox: cannot deny {} to the sandbox account — the path has a NULL DACL (unrestricted access), and adding a deny there would replace that with a DACL that locks out its own owner. Give the path an explicit DACL, or drop it from the policy's deny list.", + path.display() + ))), + }; + } + let ea = explicit_access(sid.0, mask, mode, inherit); let mut new_dacl: *mut ACL = std::ptr::null_mut(); diff --git a/crates/nub-sandbox/src/backend/windows_account/mod.rs b/crates/nub-sandbox/src/backend/windows_account/mod.rs index f140c7483..606abc657 100644 --- a/crates/nub-sandbox/src/backend/windows_account/mod.rs +++ b/crates/nub-sandbox/src/backend/windows_account/mod.rs @@ -283,6 +283,9 @@ pub fn teardown() -> std::io::Result<()> { let account_result = account::deprovision(); let _ = state::remove_marker(); let _ = state::clear_ledger(); + // The state directory carries the protected DACL and Administrators owner `setup` wrote, + // so leaving it behind is residue an unelevated user cannot clear themselves. + let _ = state::state_dir().map(std::fs::remove_dir_all); wfp_result.and(account_result) } @@ -391,6 +394,17 @@ pub(crate) fn apply( let marker = match state::read_marker() { Ok(Some(m)) => m, Ok(None) => return Err(not_set_up("this machine has no nub sandbox account")), + // A marker that exists but cannot be READ is a different failure from one that was + // never written: re-running setup does not fix a permission problem, so it must not + // borrow that instruction. + Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => { + return Err(crate::backend::Degradation { + lost: vec!["fs".to_string(), "net".to_string()], + reason: Some(format!( + "nub's sandbox setup record exists but is not readable by this user ({e}). The state directory is readable only by administrators and the user who ran the setup — run the sandboxed command as that user, or re-run `nub run --sandbox-admin setup` as the user who will run it." + )), + }); + } Err(e) => return Err(not_set_up(&e.to_string())), }; From bc642d0d362e3a3fe7e24ebf600fe49c0dd6c5a7 Mon Sep 17 00:00:00 2001 From: Colin McDonnell <3084745+colinhacks@users.noreply.github.com> Date: Sat, 25 Jul 2026 01:18:24 -0700 Subject: [PATCH 7/7] sandbox(windows): restore the line continuations in two error messages Both strings were written through a Python heredoc, which consumed the trailing backslashes, so the fragments joined with their source indentation intact and the messages rendered with long runs of literal spaces. Both are user-facing: the NULL-DACL deny refusal and the unreadable-marker degradation reason. --- crates/nub-sandbox/src/backend/windows_account/acl.rs | 5 ++++- crates/nub-sandbox/src/backend/windows_account/mod.rs | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/nub-sandbox/src/backend/windows_account/acl.rs b/crates/nub-sandbox/src/backend/windows_account/acl.rs index 6e1a954aa..47adaa205 100644 --- a/crates/nub-sandbox/src/backend/windows_account/acl.rs +++ b/crates/nub-sandbox/src/backend/windows_account/acl.rs @@ -492,7 +492,10 @@ fn add_ace( // permissive state wholesale, and silently skipping it would leave a hole while // reporting full enforcement. _ => Err(io::Error::other(format!( - "sandbox: cannot deny {} to the sandbox account — the path has a NULL DACL (unrestricted access), and adding a deny there would replace that with a DACL that locks out its own owner. Give the path an explicit DACL, or drop it from the policy's deny list.", + "sandbox: cannot deny {} to the sandbox account — the path has a NULL DACL \ + (unrestricted access), and adding a deny there would replace that with a DACL \ + that locks out its own owner. Give the path an explicit DACL, or drop it from \ + the policy's deny list.", path.display() ))), }; diff --git a/crates/nub-sandbox/src/backend/windows_account/mod.rs b/crates/nub-sandbox/src/backend/windows_account/mod.rs index 606abc657..cd02c85aa 100644 --- a/crates/nub-sandbox/src/backend/windows_account/mod.rs +++ b/crates/nub-sandbox/src/backend/windows_account/mod.rs @@ -401,7 +401,10 @@ pub(crate) fn apply( return Err(crate::backend::Degradation { lost: vec!["fs".to_string(), "net".to_string()], reason: Some(format!( - "nub's sandbox setup record exists but is not readable by this user ({e}). The state directory is readable only by administrators and the user who ran the setup — run the sandboxed command as that user, or re-run `nub run --sandbox-admin setup` as the user who will run it." + "nub's sandbox setup record exists but is not readable by this user ({e}). \ + The state directory is readable only by administrators and the user who \ + ran the setup — run the sandboxed command as that user, or re-run \ + `nub run --sandbox-admin setup` as the user who will run it." )), }); }