diff --git a/CMakeLists.txt b/CMakeLists.txt index 4842aa0..9402e87 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -78,6 +78,17 @@ if(APPLE) src/core/fd.cpp src/macos/spawn.cpp) endif() + +# The Windows apply path. Same split as linux/macos: compile.cpp is pure and +# builds everywhere (its tests run in CI on a linux runner), spawn.cpp calls +# the AppContainer/Job/CreateProcess APIs and only exists on windows. +# Conditional rather than unconditional so the MSVC/MinGW library link keeps +# working for agentty's settings pane (which compiles plans on windows to +# preview walls) while the apply step only lands where it can work. +if(WIN32) + target_sources(claybin PRIVATE src/windows/spawn.cpp) + target_link_libraries(claybin PUBLIC userenv advapi32) +endif() target_include_directories(claybin PUBLIC include) # GNU-style flags are GNU-ONLY. MSVC takes `/W4` and treats `-Wextra` as a # malformed `/W` -- `cl : Command line error D8021 : invalid numeric argument @@ -150,6 +161,11 @@ if(CLAYBIN_TESTS) # the apply path, which can only be meaningful on a mac: it enters a real # seatbelt sandbox and asserts the walls hold. skips itself elsewhere. claybin_test(macos_live_check) + # the windows apply path: a real AppContainer profile, real ACLs, a real + # confined child. windows-only, because only a windows host can mean it. + if(WIN32) + claybin_test(windows_live_check) + endif() claybin_test(compile_fuzz_test) # the syscall tables get an extra translation unit: it compiles the aarch64 diff --git a/README.md b/README.md index acf0914..72e7565 100644 --- a/README.md +++ b/README.md @@ -35,7 +35,11 @@ that job belongs to [jaal](https://github.com/1ay1/jaal) — see > > **works and is tested.** the mount model, landlock (including abi 6 scoping), > seccomp with argument filtering, cgroup2 memory/pids/cpu limits, the policy -> algebra, and seccomp-notify brokering. 19 test binaries, an escape corpus of +> algebra, and seccomp-notify brokering. the macOS port (seatbelt + rlimits, +> live-checked on darwin 24) and the Windows port (appcontainer + restricted +> token + job object, live-checked on windows 11 insider; see +> docs/platforms.md "notes from writing the windows backend") are applied and +> measured on their hosts rather than promised. 19 test binaries, an escape corpus of > 47 named attacks that spawns real processes and tries to break out (46 > blocked, 0 escaped, 1 not applicable on this kernel), a differential BPF > interpreter, 1.28M fuzzed policies, and 57 conformance checks against real @@ -44,8 +48,9 @@ that job belongs to [jaal](https://github.com/1ay1/jaal) — see > > **not tested.** aarch64 and riscv64 have never executed — the aarch64 syscall > tables are cross-checked against the kernel's own headers, which is not the -> same thing. macOS and Windows compile a plan but cannot apply one. only two -> kernels have ever run this (6.8 and 7.2). +> same thing. only two linux kernels have ever run this (6.8 and 7.2); macOS +> and Windows each ran the port's own live checks on their respective hosts +> (darwin 24, windows 11 insider 26300) but no CI lane exercises either. > > **known open.** `bind_fd` has a TOCTOU window between resolving the descriptor > and mounting its path. it is not closable from inside claybin — both diff --git a/docs/platforms.md b/docs/platforms.md index fb1e2ec..6c91f29 100644 --- a/docs/platforms.md +++ b/docs/platforms.md @@ -64,9 +64,9 @@ that the ranking is per-capability, not per-OS. what a policy compiles to, per capability, on a modern host. the linux column is measured on this machine (landlock abi 10, cgroup v2 delegated); the macOS column is measured on darwin 24 (`tests/macos_live_check.cpp` enters the -profile and asserts the walls hold); the windows column is what -`src/windows/compile.cpp` reports and is unit-tested, though the apply step is -not written yet. +profile and asserts the walls hold); the windows column is measured on windows +11 insider (`tests/windows_live_check.cpp` creates the profile, stamps real +ACLs and spawns a real confined child). | capability | linux | windows | macOS | |---|---|---|---| @@ -151,7 +151,10 @@ beyond that is a per-platform decision the caller has to make explicitly. so it is the only place the design can be validated end to end. *done.* 2. **windows second.** job objects and AppContainer are well documented and the model is genuinely strong; the filesystem translation is the real work. - *compile step done and unit-tested; apply step not written.* + *done: compile step in `src/windows/compile.cpp`, apply step in + `src/windows/spawn.cpp` (appcontainer profile + DACL/integrity grants, + restricted token where the host proves it usable, job object, mitigation + policies), live-checked in `tests/windows_live_check.cpp`.* 3. **macOS third.** seatbelt is deprecated-but-universal (chrome still uses it), undocumented, and the App Sandbox alternative needs entitlements and code signing, which rules out sandboxing arbitrary binaries. *done: compile step @@ -223,3 +226,61 @@ reasons: doing it after requires the profile to permit `open("/dev/null")` and to stderr when a profile is refused. with stderr not yet redirected, that message lands on the caller's terminal instead of in the pipe they supplied for exactly this purpose. + +## notes from writing the windows backend + +recorded the same way, because windows cost the most debugging time per line +of code. + +### the probe must be process-lifetime cached + +the restricted-token + appcontainer child probe is **process-warmth +dependent** on some builds (insider 26300 measured): early calls in a process +report death, later ones -- after enough real spawns -- report success. a +`compile()` that re-probes per call can therefore see a verdict that +contradicts the caller's own measurement minutes earlier, and build a wall +that kills every guest with 0xC0000142 during ntdll init. the verdict is now +taken once, from the coldest call, in a function-local `const` static (a +magic static: thread-safe once-init, and the vendored-library lint rejects +both `` and mutable statics). + +the corollary: a probe verdict that overpromises is worse than none. the +probe child must run in the **exact production spawn shape** -- writable +command line (with `lpApplicationName=nullptr` createprocess parses and +modifies it, so a string literal there is an access violation), +CREATE_SUSPENDED, assigned to a job before resume, PROC_THREAD_ATTRIBUTE_ +HANDLE_LIST, the real restricted list (BUILTIN\Users + Everyone, not the +near-no-op user-SID list) -- and must survive **three** runs, because one +pass means nothing on a host where the combo is flaky. + +### graceful stop cannot reach grandchildren + +an adopted child carries no job handle in the host's process abstraction, so +a graceful stop is TerminateProcess on the LEADER only. a grandchild +(`ping` inside `cmd`) survives it and keeps the stdout pipe open -- and the +post-exit drain then waits for the grandchild to finish naturally (measured: +19.9 s for `ping -n 21`; after the fix: 0.35 s). the exec loop therefore +fires the sandbox's exact tree kill (TerminateJobObject) BEFORE the graceful +stop, and keeps the grace window only as a fallback. "ask first, then kill" +is a posix shape; on windows the order is "kill the tree, then flush". + +### /proc and /dev are policy roots without filesystems + +the portable policy vocabulary binds procfs and devfs; windows has neither. +mapping the roots to a real directory would be a GRANT -- the opposite of +what a policy asking for dev_fs("/dev") means -- so they resolve to a +virtual-object-namespace sentinel and the grant loop skips them: nothing to +stamp an ACL on, and the appcontainer's default-deny is the isolation those +roots asked for. + +### profiles are state on disk, and the appx service wedges + +CreateAppContainerProfile leaves a directory under %LOCALAPPDATA%\Packages +keyed by the profile name (claybin suffixes a hash of the capability set, so +different capability sets never share one). two operational notes: a probe +that crashes between create and delete leaves the profile behind, and the +next run must tolerate ERROR_ALREADY_EXISTS by deriving the existing SID +rather than failing; and on insider builds the appx deployment service can +wedge after hundreds of create/delete cycles (0x800706C6, +RPC_S_SERVER_UNAVAILABLE), which looks like a code failure but is host +state -- a reboot clears it, a service restart often does not. diff --git a/include/claybin/windows/backend.hpp b/include/claybin/windows/backend.hpp index ab814f5..53605a5 100644 --- a/include/claybin/windows/backend.hpp +++ b/include/claybin/windows/backend.hpp @@ -100,6 +100,13 @@ struct Compiled { std::uint64_t cpu_rate_percent{0}; // 0 = none; job object CPU rate control std::uint64_t user_time_limit_100ns{0}; + // ---- the read wall's gate ---- + // copied from host.restricted_tokens, which PROBED rather than assumed: + // several insider builds accept CreateRestrictedToken and then kill + // every child during ntdll init, so only a host that ran a real child + // under a restricted token and saw it exit cleanly gets this wall. + bool restricted_tokens_ok{false}; + // ---- mitigation policies ---- bool disable_dynamic_code{false}; bool disable_child_processes{false}; @@ -128,6 +135,83 @@ Result compile(const Policy& policy, const HostCapabilities& h const Options& opts = {}); // probe the running system. returns none() off windows. +// +// the WINDOWS version caches its verdict for the process lifetime (first +// call wins). the restricted+appcontainer child probe is process-warmth +// dependent on some insider builds -- re-probing per compile() call can +// return a verdict that contradicts the caller's earlier measurement and +// produce spawns that die. see spawn.cpp cached_host_probe(). HostCapabilities probe_host(); +// ---- the apply step ------------------------------------------------------- +// +// everything impure lives in src/windows/spawn.cpp, guarded by _WIN32, for the +// same reason the linux and macos backends split compile from apply: the +// translation is testable anywhere, the application is not. on linux the +// kernel does the isolating in the child after fork; on windows the walls are +// stamped onto the process AT CREATION -- token, job, mitigation policies are +// CreateProcess arguments, not things a child can do to itself afterwards -- +// so there is no fork-and-apply here: spawn() builds the process in the +// parent, fully confined before its first instruction runs, and the caller +// adopts it. the child-side async-signal-safety rules of the other backends +// simply do not arise. + +struct Spawned { + int pid{-1}; + // The process HANDLE, already open. The caller OWNS it: it is the waitable + // exit (signalled when the process ends, level-triggered) and the reap + // source (GetExitCodeProcess). Closing it does NOT end the child; that is + // the job's business. nullptr only on failure, which never reaches a + // caller because failure is an error return. + void* process{nullptr}; + // The job object handle, already open. The caller OWNS it: closing it is + // the kill switch (KILL_ON_JOB_CLOSE takes the whole tree down, and a + // grandchild cannot escape a job the way it can escape a process group). + // nullptr when no job was applied (never, when the host probed healthy). + void* job{nullptr}; +}; + +// create the sandboxed process. mirrors clay::macos::spawn() structurally, so +// an embedder's spawn path reads the same on all three platforms. +// +// `fds` maps CALLER handles onto child descriptors -- on windows the parent +// side of a pipe is a HANDLE, not an int, so the map is void* where the other +// backends say int. each mapped handle is duplicated inheritable here and the +// duplicate closed after the child has its copy; the caller's original is +// untouched. `stdin_fd` -2 opens the NUL device in the child, -1 inherits. +struct SpawnRequest { + const char* program{nullptr}; + const char* const* argv{nullptr}; + // Full environment for the child, or nullptr to inherit ours unchanged. + // The block is built and sorted here: CreateProcess wants a case- + // insensitively sorted double-NUL-terminated block and a caller that + // guesses at that order gets silently-dropped variables. + const char* const* envp{nullptr}; + + struct FdMap { + void* parent{nullptr}; // caller-side HANDLE + int child_fd{-1}; // 0 = stdin, 1 = stdout, 2 = stderr + }; + static constexpr int kMaxFdMaps = 8; + FdMap fds[kMaxFdMaps]{}; + int fd_count{0}; + + // -2 means the NUL device, -1 means inherit. same sentinels as Command. + static constexpr int kDevNull = -2; + int stdin_fd{-1}; + int stdout_fd{-1}; + int stderr_fd{-1}; + + std::string workdir{}; +}; + +// returns in the parent only. unlike the posix backends there is no child-side +// exit protocol (kExitPlanFailed 126/127): every failure mode here is visible +// in the parent BEFORE the target program runs, so a failed spawn is an error +// return, never a child that half-started unconfined. no supervision: polling, +// reaping and teardown stay with the caller, which holds the returned job. +// takes Compiled by value: the ACL pass may have to degrade acls_applied when +// the host refuses the stamping, and the report stays honest about it. +Result spawn(Compiled c, const SpawnRequest& req); + } // namespace clay::windows diff --git a/src/windows/compile.cpp b/src/windows/compile.cpp index 8064c8e..8367b1c 100644 --- a/src/windows/compile.cpp +++ b/src/windows/compile.cpp @@ -102,12 +102,144 @@ Result compile(const Policy& policy, const HostCapabilities& h degrade(CapId::proc_isolation); report.record(CapId::proc_isolation, Enforcement::none, "appcontainer unavailable"); } + // the read wall rides on restricted tokens; the host capability already + // carries a PROBE VERDICT (a real child ran and exited cleanly), not an + // API-existence guess. see spawn.cpp step 7. + out.restricted_tokens_ok = host.restricted_tokens; // -- filesystem -------------------------------------------------------- + // + // ── windows path shape ─────────────────────────────────────── + // + // claybin's policy layer is POSIX-shaped by design (path::normalize + // treats / as the only separator, so "C:\\Windows" normalizes to + // "/C:\\Windows" and prefix tests like covers() are then wrong in both + // directions). the windows backend therefore re-normalizes every grant + // path into a canonical native form ONCE, here, before it becomes an ACL + // target: + // + // "/C:\\Windows" → "C:\\Windows" (posix-normalized back to native) + // "C:\\x/..\\y" → "C:\\y" (mixed separators collapse) + // "C:\\a\\b\\" → "C:\\a\\b" (no trailing separators) + // + // pseudo-filesystem roots (/proc, /dev) resolve to this sentinel: the + // path translates, but the grant loop below SKIPS it. the appcontainer's + // object-namespace default-deny (graded `partial`) is the isolation + // these policy roots ask for. + static constexpr char kVirtualObjectNS[] = "\\\\.\\virtual-object-namespace"; + // + // a path with no drive prefix is REJECTED: what a relative path resolves + // against is the caller's cwd, an authority that names no boundary at + // all, and an ACL on something we cannot pin down is a grant we cannot + // reason about. a policy that names one is malformed rather than merely + // unusual -- fail loudly instead of stamping ACLs at the wrong object. + auto native_path = [](std::string_view p) -> Result { + if (p.empty()) return std::unexpected(Error{Errc::invalid_policy, "empty path"}); + + std::string_view s = p; + + // a leading slash in front of a drive letter is claybin's posix- + // normalized spelling of a NATIVE windows path ("/C:/work" == + // "C:\\work", what builder("C:/work") becomes once path::normalize + // runs). it is host vocabulary, not policy vocabulary: strip it BEFORE + // the root table below so a native path is never mistaken for a + // portable root. + if (s.size() >= 3 && s[0] == '/' && s[1] >= 'A' && s[1] <= 'Z' && s[2] == ':') + s.remove_prefix(1); + + // a path with no drive prefix is REJECTED -- unless it is one of claybin's + // canonical POSIX system roots, which are POLICY VOCABULARY rather than + // host paths: `system_ro()` names "/usr" and "/bin" because the same + // policy shape compiles on every platform, and the windows backend owns + // translating them. an unmapped relative path still fails loudly: what a + // bare "foo" resolves against is the caller's cwd, an authority that + // names no boundary at all. + static const struct { const char* posix; const char* native; } kSystemRoots[] = { + {"/usr", "C:\\Windows"}, // toolchain + libs, read-only + {"/bin", "C:\\Windows\\System32"}, // executables + {"/lib", "C:\\Windows\\System32"}, // loader + shared libraries + {"/lib64", "C:\\Windows\\System32"}, + {"/sbin", "C:\\Windows\\System32"}, + {"/etc", "C:\\Windows\\System32\\config"}, // machine config; HKLM's on-disk half + // pseudo-filesystems from the portable policy vocabulary. windows has + // no procfs/devfs; the AppContainer's object-namespace default-deny + // (graded `partial` below) is what stands in for their isolation. + // these roots resolve to the "virtual" sentinel below: the path + // translates successfully, and the grant loop SKIPS it -- no ACL can + // be stamped on an object namespace, and mapping them to a real + // directory would be a GRANT, the opposite of what dev_fs("/dev") + // means. + {"/proc", kVirtualObjectNS}, + {"/dev", kVirtualObjectNS}, + // the portable shapes' workspace vocabulary. a policy that says + // builder("/work") asks for "a place the guest may write"; the exact + // directory is the caller's to pin with a native path when it + // matters, and this mapping keeps the portable shape compiling. + {"/work", "C:\\claybin-work"}, + {"/tmp", "C:\\claybin-tmp"}, + {"/app", "C:\\claybin-app"}, + {"/home", "C:\\Users"}, + }; + if (!s.empty() && s.front() == '/') { + for (const auto& m : kSystemRoots) { + const std::string_view pp = m.posix; + if (s != pp && !(s.size() > pp.size() && s.starts_with(pp) && + s[pp.size()] == '/')) + continue; + if (m.native[0] == '\0') + return std::unexpected(Error{Errc::unsupported, + std::string("no windows mapping for policy root '") + m.posix + + "'; name a native path instead"}); + // splice the remainder after the root onto the native path + std::string rest{s.substr(pp.size())}; // "" or "/sub/..." + for (char& ch : rest) if (ch == '/') ch = '\\'; + return std::string{m.native} + rest; + } + return std::unexpected(Error{Errc::unsupported, + "unmapped policy root '" + std::string{s} + + "'; windows knows /usr /bin /lib /lib64 /sbin /etc"}); + } + + // past the policy-vocabulary roots: native forms only, with the + // drive prefix already stripped above. anything else starting with + // "/" failed the root table and never reaches here. + std::string out; + out.reserve(s.size()); + bool prev_sep = false; + for (const char c : s) { + if (c == '/' || c == '\\') { + if (!prev_sep && !out.empty()) out.push_back('\\'); + prev_sep = true; + } else { + out.push_back(c); + prev_sep = false; + } + } + while (!out.empty() && out.back() == '\\') out.pop_back(); + if (out.empty()) + return std::unexpected(Error{Errc::invalid_policy, "path normalizes to nothing"}); + + const bool drive = out.size() >= 2 && out[1] == ':' && + ((out[0] >= 'A' && out[0] <= 'Z') || + (out[0] >= 'a' && out[0] <= 'z')); + const bool unc = out.size() >= 2 && out[0] == '\\' && out[1] == '\\'; + if (!drive && !unc) + return std::unexpected(Error{Errc::invalid_policy, + "path has no drive prefix (need C:\\... or \\\\server\\\\share)"}); + return out; + }; + for (const auto& g : effective.grants()) { + auto native = native_path(g.path); + if (!native) + return std::unexpected(native.error()); + // /proc and /dev translate to the virtual object namespace: nothing + // to stamp an ACL on (and stamping one would be a grant, not an + // isolation). the appcontainer default-deny carries the isolation. + if (*native == std::string_view{kVirtualObjectNS}) continue; std::uint32_t mask = to_access_mask(g.rights); if (mask == (kSynchronize | kReadControl)) continue; // nothing substantive - out.acl_grants.emplace_back(g.path, mask); + out.acl_grants.emplace_back(std::move(*native), mask); } if (!host.app_container) { diff --git a/src/windows/spawn.cpp b/src/windows/spawn.cpp new file mode 100644 index 0000000..d267be1 --- /dev/null +++ b/src/windows/spawn.cpp @@ -0,0 +1,1236 @@ +// the windows backend's apply step. everything impure and windows-only, which +// is exactly why it is a separate file from compile.cpp -- the split mirrors +// linux (compile.cpp pure, apply.cpp + spawn.cpp impure) and macos (compile.cpp +// pure, spawn.cpp impure) so the testable surface stays the same shape on all +// three platforms. +// +// THE STRUCTURAL DIFFERENCE, up front, because it is the whole reason this file +// looks nothing like its posix siblings: on linux and macos the child applies +// its own walls after fork. on windows the walls are CreateProcess ARGUMENTS -- +// token, job, mitigation policies are stamped onto the process before its +// first instruction runs, by the parent. there is no fork-and-apply, no +// async-signal-safety dance, no child-side exit protocol: a spawn here either +// returns a fully confined process or returns an error. an unconfined child +// that half-started is not a failure mode this backend has. +// +// the walls, in the order they are built: +// +// appcontainer default-deny token: no access to any object whose +// ACL does not mention our SID or a capability we hold. +// profile per capability-set, created (or found) here. +// DACL grants positive grants for the paths the policy allows: +// an ACE for the appcontainer SID on each grant root, +// inheritable, stamped in the PARENT before creation. +// job object memory / process-count / cpu-rate / user-time caps, +// plus KILL_ON_JOB_CLOSE so closing the returned handle +// is the kill switch for the whole tree. +// mitigation policies no dynamic code, no extension points, optionally no +// win32k, optionally no child processes. +// +#include "claybin/windows/backend.hpp" + +#if defined(_WIN32) + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#ifndef NOMINMAX +#define NOMINMAX +#endif +#include +#include // CreateAppContainerProfile, DeriveAppContainerSidFromAppContainerName +#include // SetNamedSecurityInfoW, GetNamedSecurityInfoW, EXPLICIT_ACCESS_W +#include // ConvertSidToStringSidW (diagnostics only) + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace clay::windows { +namespace { + +// a HANDLE that closes exactly once. the same idea as jaal's owned_handle but +// local: claybin deliberately has no OS header in its core. +struct handle_guard { + HANDLE h{nullptr}; + handle_guard() = default; + explicit handle_guard(HANDLE v) : h(v) {} + ~handle_guard() { if (h) ::CloseHandle(h); } + handle_guard(const handle_guard&) = delete; + handle_guard& operator=(const handle_guard&) = delete; + handle_guard(handle_guard&& o) noexcept : h(o.h) { o.h = nullptr; } + handle_guard& operator=(handle_guard&& o) noexcept { + if (this != &o) { if (h) ::CloseHandle(h); h = o.h; o.h = nullptr; } + return *this; + } + HANDLE get() const noexcept { return h; } + HANDLE release() noexcept { HANDLE t = h; h = nullptr; return t; } + explicit operator bool() const noexcept { return h != nullptr; } +}; + +// a LocalAlloc'd block (SIDs, ACLs) -- LocalFree, not CloseHandle. a second +// guard because the two allocators are not interchangeable and a PSID in a +// HANDLE guard would be freed with the wrong call. +struct local_guard { + void* p{nullptr}; + local_guard() = default; + explicit local_guard(void* v) : p(v) {} + ~local_guard() { if (p) ::LocalFree(p); } + local_guard(const local_guard&) = delete; + local_guard& operator=(const local_guard&) = delete; + local_guard(local_guard&& o) noexcept : p(o.p) { o.p = nullptr; } + local_guard& operator=(local_guard&& o) noexcept { + if (this != &o) { if (p) ::LocalFree(p); p = o.p; o.p = nullptr; } + return *this; + } + void* get() const noexcept { return p; } + void* release() noexcept { void* t = p; p = nullptr; return t; } + explicit operator bool() const noexcept { return p != nullptr; } +}; + +Error win_error(std::string_view mechanism) { + return Error{Errc::io_error, mechanism, static_cast(::GetLastError())}; +} + +std::wstring widen(std::string_view s) { + if (s.empty()) return {}; + const int n = ::MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast(s.size()), + nullptr, 0); + if (n <= 0) return {}; + std::wstring out(static_cast(n), L'\0'); + ::MultiByteToWideChar(CP_UTF8, 0, s.data(), static_cast(s.size()), out.data(), n); + return out; +} + +std::string narrow(const wchar_t* s) { + if (!s) return {}; + const int n = ::WideCharToMultiByte(CP_UTF8, 0, s, -1, nullptr, 0, nullptr, nullptr); + if (n <= 0) return {}; + std::string out(static_cast(n - 1 > 0 ? n - 1 : 0), '\0'); + ::WideCharToMultiByte(CP_UTF8, 0, s, -1, out.data(), n, nullptr, nullptr); + return out; +} + +// quote one argument by CommandLineToArgvW rules, so the child's C runtime +// recovers it exactly. +std::string quote_arg(std::string_view arg) { + const bool needs = arg.empty() || + arg.find_first_of(" \t\"") != std::string_view::npos; + if (!needs) return std::string{arg}; + std::string out{"\""}; + size_t backslashes = 0; + for (const char c : arg) { + if (c == '\\') { ++backslashes; continue; } + if (c == '"') { + // a quote is preceded by 2n backslashes doubled, plus itself escaped + out.append(backslashes * 2 + 1, '\\'); + out.push_back('"'); + } else { + out.append(backslashes, '\\'); + out.push_back(c); + } + backslashes = 0; + } + // trailing backslashes before the closing quote are doubled + out.append(backslashes * 2, '\\'); + out.push_back('"'); + return out; +} + +// ── appcontainer profile management ──────────────────────────────────────── +// +// a profile name is at most 64 chars of [A-Za-z0-9.], and a profile's +// capability set is FIXED AT CREATION. spawn() is called with a different +// capability set per policy (network on / network off), so the profile name +// carries a hash of the capability names: one profile per distinct set, found +// again on the next run, never silently reused with the wrong caps. + +std::string sanitize_profile_name(std::string_view raw) { + std::string out; + out.reserve(raw.size()); + for (const char c : raw) { + if ((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || + (c >= '0' && c <= '9') || c == '.') + out.push_back(c); + else + out.push_back('_'); + } + return out; +} + +std::string capability_profile_suffix(const std::vector& caps) { + // FNV-1a over the sorted capability names. not cryptographic; it only has + // to keep two different capability sets from sharing one profile name. + std::uint64_t h = 1469598103934665603ull; + for (const auto& c : caps) { + for (const char ch : c) { h ^= static_cast(ch); h *= 1099511628211ull; } + h ^= 0xff; h *= 1099511628211ull; + } + char buf[17]{}; + for (int i = 0; i < 16; ++i) buf[i] = "0123456789abcdef"[(h >> (60 - 4 * i)) & 0xf]; + return std::string{buf}; +} + +// DeriveCapabilitySidsFromName is in kernelbase and is absent from some +// import libraries, so it is resolved at run time. absent means win7-era or +// a stripped down system: we fail closed on network grants rather than +// building a capability SID from memory. +using DeriveCapabilitySidsFn = + HRESULT (WINAPI*)(PCWSTR, PSID_AND_ATTRIBUTES*, DWORD*, PSID*, DWORD*); + +// PSID is a void*; mingw declares the derive call with PSID*, which resolves +// to void** -- same bytes, wrong type for our guards. +using DeriveAppContainerSidFn = HRESULT (WINAPI*)(PCWSTR, void**); + +// the function-local const static is thread-safe-once initialization +// (C++11), and reads as `const` to the vendored-library lint, which rejects +// mutable statics in the libraries it watches. +const DeriveCapabilitySidsFn kDeriveCapabilitySids = []() -> DeriveCapabilitySidsFn { + HMODULE kb = ::GetModuleHandleW(L"kernelbase.dll"); + if (!kb) kb = ::LoadLibraryW(L"kernelbase.dll"); + return kb ? reinterpret_cast( + reinterpret_cast(::GetProcAddress(kb, "DeriveCapabilitySidsFromName"))) + : nullptr; +}(); + +DeriveCapabilitySidsFn derive_capability_sids_fn() { + return kDeriveCapabilitySids; +} + +// DeriveAppContainerSidFromAppContainerName in the import library resolves to +// a stub that fails with ERROR_NO_TOKEN (a live check on this very host), so +// it is loaded from userenv at run time -- the derivation has to work when a +// second process with the same capability set finds the first run's profile. +const DeriveAppContainerSidFn kDeriveAppContainerSid = []() -> DeriveAppContainerSidFn { + HMODULE ue = ::GetModuleHandleW(L"userenv.dll"); + if (!ue) ue = ::LoadLibraryW(L"userenv.dll"); + return ue ? reinterpret_cast( + reinterpret_cast(::GetProcAddress( + ue, "DeriveAppContainerSidFromAppContainerName"))) + : nullptr; +}(); + +DeriveAppContainerSidFn derive_appcontainer_sid_fn() { + return kDeriveAppContainerSid; +} + +// the canonical network capability names. compile() says only `allow_network`; +// mapping that intent to names is the apply step's job, which keeps the pure +// compiler free of windows vocabulary. +constexpr const wchar_t* kNetworkCapabilities[] = { + L"internetClient", // outbound to the internet + L"privateNetworkClientServer", // loopback + LAN, so a local dev server works +}; + +// the access-mask bits this file consults, restated from winnt.h. compile.cpp +// has its own copy because it must build on every host; only windows includes +// this file, but these are still spelled here rather than in the header so +// the header stays free of windows vocabulary. +constexpr std::uint32_t kFileWriteData = 0x0002; +constexpr std::uint32_t kFileAppendData = 0x0004; +constexpr std::uint32_t kFileWriteAttributes = 0x0100; +constexpr std::uint32_t kFileDeleteChild = 0x0040; +constexpr std::uint32_t kDelete = 0x00010000; + +// mingw-w64's winbase.h carries the attribute MACROS but not the mitigation +// VALUE defines (they live in the SDK's WinBase.h). defined here with the +// exact values from the Windows 10 SDK, guarded, so a header that does grow +// them stays in charge. +#ifndef PROCESS_CREATION_MITIGATION_POLICY_PROHIBIT_DYNAMIC_CODE_ALWAYS_ON +#define PROCESS_CREATION_MITIGATION_POLICY_PROHIBIT_DYNAMIC_CODE_ALWAYS_ON \ + (UINT64_C(0x00000001) << 36) +#endif +#ifndef PROCESS_CREATION_MITIGATION_POLICY_WIN32K_SYSTEM_CALL_DISABLE_ALWAYS_ON +#define PROCESS_CREATION_MITIGATION_POLICY_WIN32K_SYSTEM_CALL_DISABLE_ALWAYS_ON \ + (UINT64_C(0x00000001) << 28) +#endif +#ifndef PROCESS_CREATION_MITIGATION_POLICY_EXTENSION_POINT_DISABLE_ALWAYS_ON +#define PROCESS_CREATION_MITIGATION_POLICY_EXTENSION_POINT_DISABLE_ALWAYS_ON \ + (UINT64_C(0x00000001) << 32) +#endif +#ifndef PROCESS_CREATION_CHILD_PROCESS_RESTRICTED +#define PROCESS_CREATION_CHILD_PROCESS_RESTRICTED 0x01 +#endif +#ifndef LUA_TOKEN +#define LUA_TOKEN (0x00000004) +#endif + +struct container_context { + local_guard profile_sid; // the package SID; LocalFree'd, not CloseHandle'd + std::vector cap_blob; + std::vector caps; + // the ACL entries are stamped for THIS sid; kept for diagnostics + std::string sid_string; +}; + +// create or find the appcontainer profile for this capability set. +// on failure: an error, never a silently uncontained process. +Result make_container(const Compiled& c) { + container_context ctx; + + std::vector caps = c.capabilities; + if (c.allow_network) { + for (const wchar_t* w : kNetworkCapabilities) + caps.push_back(narrow(w)); + } + std::sort(caps.begin(), caps.end()); + caps.erase(std::unique(caps.begin(), caps.end()), caps.end()); + + const std::string base = sanitize_profile_name( + c.container_name.empty() ? "claybin.sandbox" : c.container_name); + const std::string name = (base + "." + capability_profile_suffix(caps)) + .substr(0, 64); // USERENV: max 64 chars + + const std::wstring wname = widen(name); + + // DeriveCapabilitySidsFromName for every capability we mean to hold. The + // well-known ones (internetClient & co) resolve here; an unknown name + // fails and spawn fails closed rather than granting nothing silently. + // + // SIDs are copied into cap_blob FIRST, all of them, and the SID_AND_ + // ATTRIBUTES pointers are built AFTER the vector reaches its final size: + // a push_back that reallocs would invalidate every pointer already stored + // -- the classic vector-of-pointers-into-itself bug, and a silent one + // until the token builder reads a freed SID. + if (!caps.empty()) { + auto fn = derive_capability_sids_fn(); + if (!fn) + return std::unexpected(Error{Errc::unsupported, "appcontainer: " + "DeriveCapabilitySidsFromName unavailable; cannot grant capabilities"}); + for (const auto& cap : caps) { + const std::wstring wcap = widen(cap); + PSID_AND_ATTRIBUTES attr = nullptr; + DWORD attr_count = 0; + PSID group_sids = nullptr; + DWORD group_count = 0; + const HRESULT hr = fn(wcap.c_str(), &attr, &attr_count, + &group_sids, &group_count); + if (FAILED(hr) || !attr || attr_count == 0 || !attr[0].Sid) { + if (group_sids) ::LocalFree(group_sids); + if (attr) ::LocalFree(attr); + return std::unexpected(Error{Errc::invalid_policy, "appcontainer: " + "capability name not derivable", static_cast(hr)}); + } + // THE capability SID is attr[0].Sid. The `sids` out-param is the + // capability's GROUP list (r/o membership granted alongside) and + // is several hundred bytes of variable-length data -- treating its + // first entry as the capability SID produces a malformed SID that + // CreateAppContainerProfile rejects with RPC_S_INVALID_BOUND. The + // group list is not wanted here: the profile carries exactly the + // named capability, nothing ambient. + const PSID cap_sid = attr[0].Sid; + const DWORD bytes = ::GetSidLengthRequired( + static_cast(*::GetSidSubAuthorityCount(cap_sid))); + const size_t base_off = ctx.cap_blob.size(); + ctx.cap_blob.resize(base_off + bytes); + std::memcpy(ctx.cap_blob.data() + base_off, cap_sid, bytes); + ::LocalFree(attr); + ::LocalFree(group_sids); + } + // the blob is final now; safe to point into it + ctx.caps.reserve(ctx.cap_blob.size() / sizeof(SID)); + size_t off = 0; + while (off < ctx.cap_blob.size()) { + SID_AND_ATTRIBUTES a{}; + a.Sid = ctx.cap_blob.data() + off; + a.Attributes = SE_GROUP_ENABLED; + ctx.caps.push_back(a); + off += ::GetSidLengthRequired( + static_cast(*::GetSidSubAuthorityCount( + reinterpret_cast(ctx.cap_blob.data() + off)))); + } + } + + // create; if a profile with this exact name already exists it was created + // by a previous run with the SAME capability hash, so deriving its SID is + // equivalent -- the caps are baked into the name. + PSID package_sid = nullptr; + const HRESULT hr = ::CreateAppContainerProfile( + wname.c_str(), wname.c_str(), L"claybin sandbox", + ctx.caps.empty() ? nullptr : ctx.caps.data(), + static_cast(ctx.caps.size()), &package_sid); +#ifdef CLAYBIN_SPAWN_TRACE + std::fprintf(stderr, "[claybin] profile name='%s' hr=0x%08lx caps=%zu\n", + name.c_str(), static_cast(hr), ctx.caps.size()); +#endif + if (FAILED(hr)) { +#ifdef CLAYBIN_SPAWN_TRACE + std::fprintf(stderr, "[claybin] code=%luFacility=%lu\n", + static_cast(HRESULT_CODE(hr)), + static_cast(HRESULT_FACILITY(hr))); +#endif + if (HRESULT_CODE(hr) != ERROR_ALREADY_EXISTS) { + return std::unexpected(Error{Errc::permission_denied, "appcontainer: " + "CreateAppContainerProfile failed", static_cast(hr)}); + } + // Profile from a previous run with the same capability set. Derive + // through the RUN-TIME binding: the import-lib stub fails with + // ERROR_NO_TOKEN, and the probe's leftover profile makes this path + // the one every second run takes. + auto derive = derive_appcontainer_sid_fn(); + if (!derive || FAILED(derive(wname.c_str(), &package_sid)) || !package_sid) { + return std::unexpected(Error{Errc::permission_denied, "appcontainer: " + "profile exists but its SID is not derivable", + static_cast(::GetLastError())}); + } + } + ctx.profile_sid = local_guard{package_sid}; + return ctx; +} + +// ── DACL grants ──────────────────────────────────────────────────────────── +// +// one ACE per grant root, for the appcontainer package SID, inheritable. +// SetNamedSecurityInfoW on the ROOT (not a tree walk): inheritable ACEs +// propagate to descendants automatically, including objects created later, +// which is the closest an ACL model gets to landlock's subtree rule. +// +// merging, not replacing: the guest still needs whatever the DACL already +// allowed (the user's own files), and a PROTECTED ACL here would break things +// we were never asked to break. + +Result stamp_acl(const std::string& path, std::uint32_t mask, PSID grantee) { + const std::wstring wpath = widen(path); + if (wpath.empty()) return {}; + + // a grant on a path that does not exist yet is a common shape: a build + // tool's output dir, the mapped /tmp root. the ACE must exist BEFORE the + // first child write lands there, so the grant creates the directory -- + // the same thing a workspace checkout does on first use. a path that + // cannot be created is an error, not a silent skip: the policy said this + // is a boundary and half of one is a lie. + if (DWORD attrs = ::GetFileAttributesW(wpath.c_str()); + attrs == INVALID_FILE_ATTRIBUTES) { + if (!::CreateDirectoryW(wpath.c_str(), nullptr) && + ::GetLastError() != ERROR_ALREADY_EXISTS) { + return std::unexpected(Error{Errc::io_error, + "acl: grant path cannot be created", + static_cast(::GetLastError())}); + } + } + + PACL existing = nullptr; + // GetNamedSecurityInfoW allocations are LocalFree'd; the DACL is copied + // into a vector so SetEntriesInAclW reads a copy that outlives the SD, + // which cannot -- a PACL pointing into a freed descriptor is the classic + // use-after-free of this API. + { + PSECURITY_DESCRIPTOR sd = nullptr; + const DWORD g = ::GetNamedSecurityInfoW( + const_cast(wpath.c_str()), SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, nullptr, nullptr, &existing, nullptr, &sd); +#ifdef CLAYBIN_SPAWN_TRACE + std::fprintf(stderr, "[claybin] acl get '%s' g=%lu gle=%lu\n", + path.c_str(), static_cast(g), + static_cast(::GetLastError())); +#endif + if (g != ERROR_SUCCESS) { + if (sd) ::LocalFree(sd); + return std::unexpected(Error{Errc::permission_denied, + "acl: cannot read existing DACL", + static_cast(g)}); + } + // keep the descriptor alive until we have copied the ACL below + const std::uint16_t acl_bytes = existing ? existing->AclSize : 0; + std::vector acl_copy(acl_bytes); + if (acl_bytes) std::memcpy(acl_copy.data(), existing, acl_bytes); + ::LocalFree(sd); + existing = acl_bytes ? reinterpret_cast(acl_copy.data()) : nullptr; + + EXPLICIT_ACCESSW ea{}; + ea.grfAccessPermissions = mask; + ea.grfAccessMode = GRANT_ACCESS; + ea.grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT; + ea.Trustee.TrusteeForm = TRUSTEE_IS_SID; + ea.Trustee.TrusteeType = TRUSTEE_IS_UNKNOWN; + ea.Trustee.ptstrName = reinterpret_cast(grantee); + + PACL merged = nullptr; + const DWORD m = ::SetEntriesInAclW(1, &ea, existing, &merged); + if (m != ERROR_SUCCESS || !merged) { + if (merged) ::LocalFree(merged); + return std::unexpected(Error{Errc::permission_denied, + "acl: cannot merge ACE", static_cast(m)}); + } + const DWORD s = ::SetNamedSecurityInfoW( + const_cast(wpath.c_str()), SE_FILE_OBJECT, + DACL_SECURITY_INFORMATION, nullptr, nullptr, merged, nullptr); + ::LocalFree(merged); + if (s != ERROR_SUCCESS) { + return std::unexpected(Error{Errc::permission_denied, + "acl: cannot write DACL", static_cast(s)}); + } + + // write grants need the object's mandatory label at Low as well: an + // appcontainer token is Low integrity, and every user-created object + // is Medium with the default NO-WRITE-UP policy -- the integrity + // check fires BEFORE the DACL is consulted, so the package-SID ACE + // above never gets a chance to matter. this is the same step every + // real appcontainer host takes for its writable directories (chrome's + // profile, UWP's local state): label Low, ACE for the package SID. + // medium-IL processes are unaffected: they sit ABOVE low and + // write-down is never restricted, so the user's own tools keep full + // access to their workspace. + const bool writes = (mask & (kFileWriteData | kFileAppendData | + kFileWriteAttributes | kFileDeleteChild | + kDelete)) != 0; + if (writes) { + BYTE low_buf[SECURITY_MAX_SID_SIZE]{}; + DWORD low_size = sizeof(low_buf); + if (::CreateWellKnownSid(WinLowLabelSid, nullptr, low_buf, &low_size)) { + // the label is a SACL ACE, not a bare SID: build the one-ACE + // mandatory-label ACL and install it as the object's SACL. + // NO_WRITE_UP alone: a Low token cannot write a Medium object, + // which is exactly the rule an appcontainer lives under; the + // object's existing read/exec behaviour stays untouched. + constexpr DWORD kLabelAclSize = + sizeof(ACL) + sizeof(SYSTEM_MANDATORY_LABEL_ACE) + SECURITY_MAX_SID_SIZE; + alignas(DWORD) BYTE label_buf[kLabelAclSize]{}; + auto* label_acl = reinterpret_cast(label_buf); + if (::InitializeAcl(label_acl, kLabelAclSize, ACL_REVISION) && + ::AddMandatoryAce(label_acl, ACL_REVISION, 0, + SYSTEM_MANDATORY_LABEL_NO_WRITE_UP, + reinterpret_cast(low_buf))) { + const DWORD l = ::SetNamedSecurityInfoW( + const_cast(wpath.c_str()), SE_FILE_OBJECT, + LABEL_SECURITY_INFORMATION, nullptr, nullptr, + nullptr, label_acl); + if (l != ERROR_SUCCESS) { + return std::unexpected(Error{Errc::permission_denied, + "acl: cannot set mandatory label", + static_cast(l)}); + } + } + } + } + } + return {}; +} + +// ── environment block ────────────────────────────────────────────────────── +// +// CreateProcess wants the block sorted case-insensitively by name and +// terminated by a double NUL. a caller that guesses at that order gets +// silently-dropped variables, so the sort happens here, once, from the same +// char* strings the posix backends take. +std::vector build_env_block(const char* const* envp) { + if (!envp) return {}; + struct ci_less { + bool operator()(const std::wstring& a, const std::wstring& b) const noexcept { + return ::_wcsicmp(a.c_str(), b.c_str()) < 0; + } + }; + std::map vars; + for (const char* const* e = envp; *e; ++e) { + const std::string_view kv{*e}; + const auto eq = kv.find('='); + if (eq == std::string_view::npos) continue; + vars[widen(kv.substr(0, eq))] = widen(kv.substr(eq + 1)); + } + std::vector block; + for (const auto& [k, v] : vars) { + block.insert(block.end(), k.begin(), k.end()); + block.push_back(L'='); + block.insert(block.end(), v.begin(), v.end()); + block.push_back(L'\0'); + } + if (block.empty()) return {}; + block.push_back(L'\0'); + return block; +} + +// ── the NUL device ───────────────────────────────────────────────────────── +handle_guard open_nul(bool inheritable) { + SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, inheritable ? TRUE : FALSE}; + HANDLE h = ::CreateFileW(L"NUL", GENERIC_READ | GENERIC_WRITE, + FILE_SHARE_READ | FILE_SHARE_WRITE, &sa, + OPEN_EXISTING, 0, nullptr); + return handle_guard(h); +} + +handle_guard dup_inheritable(HANDLE parent) { + HANDLE dup = nullptr; + if (!::DuplicateHandle(::GetCurrentProcess(), parent, ::GetCurrentProcess(), &dup, + 0, TRUE, DUPLICATE_SAME_ACCESS)) + return handle_guard{}; + return handle_guard(dup); +} + +// ── the job object ───────────────────────────────────────────────────────── +// +// every limit the compiled plan asks for, in one SetInformationJobObject +// call each. KILL_ON_JOB_CLOSE unconditionally: the returned handle is the +// kill switch, and a caller that leaks it leaks a running tree otherwise. +Result make_job(const Compiled& c) { + HANDLE job = ::CreateJobObjectW(nullptr, nullptr); + if (!job) return std::unexpected(win_error("job: CreateJobObjectW")); + + JOBOBJECT_BASIC_LIMIT_INFORMATION basic{}; + basic.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + if (c.active_process_limit) { + basic.LimitFlags |= JOB_OBJECT_LIMIT_ACTIVE_PROCESS; + basic.ActiveProcessLimit = c.active_process_limit; + } + if (c.user_time_limit_100ns) { + basic.LimitFlags |= JOB_OBJECT_LIMIT_JOB_TIME; + basic.PerJobUserTimeLimit.QuadPart = + static_cast(c.user_time_limit_100ns); + } + + JOBOBJECT_EXTENDED_LIMIT_INFORMATION ext{}; + ext.BasicLimitInformation = basic; + if (c.memory_limit_bytes) { + ext.BasicLimitInformation.LimitFlags |= + JOB_OBJECT_LIMIT_PROCESS_MEMORY | JOB_OBJECT_LIMIT_JOB_MEMORY; + ext.ProcessMemoryLimit = c.memory_limit_bytes; + ext.JobMemoryLimit = c.memory_limit_bytes; + } + + if (!::SetInformationJobObject(job, JobObjectExtendedLimitInformation, + &ext, sizeof(ext))) { + const Error e = win_error("job: SetInformationJobObject(limits)"); + ::CloseHandle(job); + return std::unexpected(e); + } + + if (c.cpu_rate_percent) { + JOBOBJECT_CPU_RATE_CONTROL_INFORMATION cpu{}; + cpu.ControlFlags = JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | + JOB_OBJECT_CPU_RATE_CONTROL_HARD_CAP; + // per-ten-thousand: 1% is the floor, 100% is 10000. + std::uint64_t rate = c.cpu_rate_percent * 100; + if (rate < 1000) rate = 1000; + if (rate > 10000) rate = 10000; + cpu.CpuRate = static_cast(rate); + if (!::SetInformationJobObject(job, JobObjectCpuRateControlInformation, + &cpu, sizeof(cpu))) { + const Error e = win_error("job: SetInformationJobObject(cpu rate)"); + ::CloseHandle(job); + return std::unexpected(e); + } + } + return job; +} + +} // namespace + +// the probe verdict is CACHED for the process lifetime, and the cache is +// written by the FIRST call. this is not an optimization: on insider build +// 26300 the restricted+appcontainer child probe is process-warmth dependent +// -- early calls report death (rt=0), later calls (after ~20 real spawns) +// report success (rt=1). a compile() that re-probes per call can therefore +// see a verdict that contradicts the one the caller just measured, and build +// a wall that kills every guest. the first answer is also the COLDEST, i.e. +// the most representative of what a fresh guest faces, so it is the one the +// process sticks with. +// +// the function-local const static is the whole mechanism: C++11 guarantees +// its initialization is thread-safe and happens exactly once, which keeps +// this file inside the vendored-library rules (no , no mutable +// statics). +HostCapabilities cached_host_probe() { + static const HostCapabilities cached = probe_host_uncached(); + return cached; +} + +// ── probe_host, windows side ─────────────────────────────────────────────── +// +// the same discipline as the linux and macos probes: a capability that reads +// as present but fails on use is worse than absence, because a caller's Auto +// mode then degrades silently instead of loudly. so each answer comes from +// actually doing the thing, cheaply, once. +HostCapabilities probe_host_uncached() { + HostCapabilities h = HostCapabilities::none(); + OSVERSIONINFOW vi{}; + vi.dwOSVersionInfoSize = sizeof(vi); + if (!::GetVersionExW(&vi)) return h; + h.build = vi.dwBuildNumber; + + // job objects: create one. NT has had them since the beginning; creating + // costs microseconds and proves it rather than guessing. + if (HANDLE j = ::CreateJobObjectW(nullptr, nullptr)) { + ::CloseHandle(j); + h.job_objects = true; + } + + // mitigation policies: the attribute list API is the gate. updating with + // a DEFER policy is a no-op on the child side and proves the plumbing. + { + SIZE_T size = 0; + ::InitializeProcThreadAttributeList(nullptr, 1, 0, &size); + std::vector buf(size); + auto* list = reinterpret_cast(buf.data()); + if (::InitializeProcThreadAttributeList(list, 1, 0, &size)) { + DWORD64 defer = 0; + h.mitigation_policies = ::UpdateProcThreadAttribute( + list, 0, PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, + &defer, sizeof(defer), nullptr, nullptr) != FALSE; + ::DeleteProcThreadAttributeList(list); + } + } + + // restricted tokens: CreateRestrictedToken on a copy of our own token, + // then PROOF under the REAL conditions a guest will face: the + // appcontainer attribute is part of every claybin spawn, so the probe + // child carries it too. Measured on this host (insider build 26300): a + // restricted token ALONE starts cleanly, but restricted + appcontainer + // kills the child during ntdll init (0xc0000142) -- a capability that + // reads as present and fails on use is worse than absence, so the probe + // asks the only question that matters: does a child run under exactly + // the walls the backend builds? When it does not, the read wall is + // reported absent and spawn falls back to the appcontainer-only path, + // which is the weaker but honest boundary. + { + HANDLE tok = nullptr; + if (::OpenProcessToken(::GetCurrentProcess(), + TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY | + TOKEN_QUERY, &tok)) { + handle_guard tok_guard{tok}; + DWORD len = 0; + std::vector ub; + if (::GetTokenInformation(tok, TokenUser, nullptr, 0, &len) || len) { + ub.resize(len); + if (::GetTokenInformation(tok, TokenUser, ub.data(), len, &len)) { + auto* tu = reinterpret_cast(ub.data()); + // the restricted list must be the PRODUCTION one, or the + // probe proves the wrong token. spawn() subtracts with + // BUILTIN\Users + Everyone (the read wall: neither grants + // read on the user's own files, so the user-SID grants stop + // counting). a probe list of [user SID] is nearly a no-op + // restriction -- a child under it can pass while every + // production child dies (measured: exactly that on insider + // build 26300 -- 0xc0000142 in ntdll init, reproducibly, + // with the real list but never with the probe's). + SID_AND_ATTRIBUTES prod_list[2] = {}; + std::vector sids_blob(2 * SECURITY_MAX_SID_SIZE); + DWORD n_prod = 0; + const WELL_KNOWN_SID_TYPE types[2] = { + WinBuiltinUsersSid, WinWorldSid}; + for (int i = 0; i < 2; ++i) { + DWORD sid_len = SECURITY_MAX_SID_SIZE; + auto* sid = reinterpret_cast( + sids_blob.data() + i * SECURITY_MAX_SID_SIZE); + if (!::CreateWellKnownSid(types[i], nullptr, sid, &sid_len)) + continue; + prod_list[n_prod++] = SID_AND_ATTRIBUTES{sid, 0}; + } + SID_AND_ATTRIBUTES fallback_list{tu->User.Sid, 0}; + SID_AND_ATTRIBUTES* list = prod_list; + DWORD list_n = n_prod; + if (n_prod != 2) { list = &fallback_list; list_n = 1; } + HANDLE rt = nullptr; + if (::CreateRestrictedToken(tok, 0, 0, nullptr, 0, nullptr, + list_n, list, &rt)) { + handle_guard rt_guard{rt}; + // the probe's attribute list: the SAME appcontainer + // attribute spawn() will add. A profile is created and + // deleted here -- the throwaway "claybin.probe". + PSID pkg = nullptr; + const HRESULT hr = ::CreateAppContainerProfile( + L"claybin.probe", L"claybin probe", L"capability probe", + nullptr, 0, &pkg); + if (SUCCEEDED(hr) && pkg) { + h.app_container = true; + h.lowbox_network = true; + SIZE_T asize = 0; + ::InitializeProcThreadAttributeList(nullptr, 1, 0, &asize); + std::vector abuf(asize); + auto* alist = reinterpret_cast(abuf.data()); + if (::InitializeProcThreadAttributeList(alist, 1, 0, &asize)) { + SECURITY_CAPABILITIES sc{}; + sc.AppContainerSid = pkg; + if (::UpdateProcThreadAttribute( + alist, 0, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + &sc, sizeof(sc), nullptr, nullptr)) { + STARTUPINFOEXW si{}; + si.StartupInfo.cb = sizeof(si); + si.lpAttributeList = alist; + PROCESS_INFORMATION pi{}; + // EXACTLY the production shape: lpApplicationName=nullptr + // (command line only), inheritable handles TRUE, an env + // block, a workdir, and — the part that turned out to be + // the discriminator on insider builds — CREATE_SUSPENDED + // with the job assignment BEFORE the first instruction, + // exactly what spawn() does. A non-suspended child passes + // here while every suspended one dies in ntdll init with + // 0xc0000142, which reads as a capability that isn't one. + // the command line lives in a WRITABLE buffer: with + // lpApplicationName=nullptr CreateProcess parses (and + // modifies) it, so a string literal here is an AV. + const wchar_t probe_env[] = L"SystemRoot=C:\\Windows\0"; + wchar_t probe_cmdline[] = L"cmd.exe /d /s /c \"exit 7\""; + // the handle list, same as production step 4: with + // EXTENDED_STARTUPINFO the child only gets what is listed. + HANDLE probe_std[3] = { + ::GetStdHandle(STD_INPUT_HANDLE), + ::GetStdHandle(STD_OUTPUT_HANDLE), + ::GetStdHandle(STD_ERROR_HANDLE)}; + STARTUPINFOEXW psi{}; + psi.StartupInfo.cb = sizeof(psi); + psi.lpAttributeList = alist; + if (::UpdateProcThreadAttribute( + alist, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + probe_std, sizeof(probe_std), nullptr, nullptr)) { + psi.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + psi.StartupInfo.hStdInput = probe_std[0]; + psi.StartupInfo.hStdOutput = probe_std[1]; + psi.StartupInfo.hStdError = probe_std[2]; + HANDLE probe_job = ::CreateJobObjectW(nullptr, nullptr); + JOBOBJECT_BASIC_LIMIT_INFORMATION jb{}; + jb.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE; + ::SetInformationJobObject( + probe_job, JobObjectBasicLimitInformation, + &jb, sizeof(jb)); + // the child is measured THREE times: on insider + // build 26300 the restricted+appcontainer combo dies + // in ntdll init NONDETERMINISTICALLY (measured: the + // same process reported both verdicts minutes apart, + // and a single-run verdict made spawn() build a wall + // that killed every real guest). all runs must exit 7; + // one death is enough to call the wall unbuildable, + // because a guest that sometimes dies is worse than + // no read wall. + h.restricted_tokens = true; + for (int run = 0; run < 3; ++run) { + const BOOL ok = ::CreateProcessAsUserW( + rt, nullptr, probe_cmdline, + nullptr, nullptr, TRUE, + CREATE_SUSPENDED | CREATE_NO_WINDOW | + CREATE_UNICODE_ENVIRONMENT | + EXTENDED_STARTUPINFO_PRESENT, + const_cast( + static_cast(probe_env)), + const_cast(L"C:\\Windows"), + &psi.StartupInfo, &pi); + if (!ok) { h.restricted_tokens = false; break; } + if (!::AssignProcessToJobObject(probe_job, pi.hProcess)) { + ::TerminateProcess(pi.hProcess, 1); + ::CloseHandle(pi.hThread); + ::CloseHandle(pi.hProcess); + h.restricted_tokens = false; + break; + } + ::ResumeThread(pi.hThread); + ::WaitForSingleObject(pi.hProcess, 10000); + DWORD code = 0; + ::GetExitCodeProcess(pi.hProcess, &code); + if (code != 7) { h.restricted_tokens = false; break; } + } + ::CloseHandle(probe_job); + } + } + ::DeleteProcThreadAttributeList(alist); + } + ::LocalFree(pkg); + ::DeleteAppContainerProfile(L"claybin.probe"); + } + } + } + } + } + } + + // appcontainer: the honest probe CREATES a throwaway profile and deletes + // it, because "the API exists" and "this token may create one" are + // different answers (services, runas tokens and enterprise policy all + // say no in their own way). a normal appcontainer is sufficient for every + // wall this backend builds; the caps-less no-network case blocks socket + // creation on normal containers just as it does on lowbox ones, so the + // lowbox flag carries that claim rather than a literal LPAC check. + { + PSID sid = nullptr; + const HRESULT hr = ::CreateAppContainerProfile( + L"claybin.probe", L"claybin probe", L"capability probe", + nullptr, 0, &sid); + if (SUCCEEDED(hr) && sid) { + h.app_container = true; + h.lowbox_network = true; + ::LocalFree(sid); + ::DeleteAppContainerProfile(L"claybin.probe"); + } + } + return h; +} + +// ── spawn ────────────────────────────────────────────────────────────────── +Result spawn(Compiled c, const SpawnRequest& req) { + if (!req.program || !req.argv) return std::unexpected( + Error{Errc::invalid_policy, "spawn: no program"}); + + Spawned out; + + // ── 1. the container ────────────────────────────────────────── + container_context cc; + if (c.use_app_container) { + auto made = make_container(c); + if (!made) return std::unexpected(made.error()); + cc = std::move(*made); + // narrow the SID for the error paths and diagnostics + LPWSTR str = nullptr; + if (::ConvertSidToStringSidW(cc.profile_sid.get(), &str) && str) { + cc.sid_string = narrow(str); + ::LocalFree(str); + } + } + + // ── 2. the positive grants, in the parent, before the child exists ──── + // + // this is the apply step's whole reason to be a separate pass: on posix + // the grants happen inside the child after fork; here they are property + // mutations on host objects and MUST precede creation, or the child's + // first instruction already runs against a wall it cannot pass. + if (c.acls_applied && cc.profile_sid) { + for (const auto& [path, mask] : c.acl_grants) { + if (auto st = stamp_acl(path, mask, cc.profile_sid.get()); !st) { + // distinguish the two failure classes honestly: a write to a + // path this token does not own is a HOST limitation (the + // daemon/agent runs unprivileged), not a policy refusal. + // compile() reported acls_applied on the strength of the + // probe's ability to read DACLs, which overpromised. degrading + // to default-deny keeps the walls that DO hold (appcontainer, + // job, mitigations) and reports fs as weaker -- a sandbox + // that half-applies and says so beats one that lies. + if (st.error().sys_errno == ERROR_ACCESS_DENIED) { + c.acls_applied = false; + break; + } + return std::unexpected(st.error()); + } + } + } + + // ── 3. the job ──────────────────────────────────────────────────────── + handle_guard job; + { + auto j = make_job(c); + if (!j) return std::unexpected(j.error()); + job = handle_guard{*j}; + } + + // ── 4. standard handles ─────────────────────────────────────────── + // + // the caller's handles are duplicated inheritable rather than used in + // place: the child gets its own copies, the caller keeps ownership of + // its originals, and the FdMap stays borrow-only like Command::FdMap. + // the guards live at function scope so the duplicates survive until + // CreateProcess has consumed them, and close when spawn returns. + HANDLE child_std[3] = {nullptr, nullptr, nullptr}; + handle_guard std_dups[3]; + { + // FdMap first, so an explicit mapping wins over the sentinel fields. + for (int i = 0; i < req.fd_count && i < SpawnRequest::kMaxFdMaps; ++i) { + const auto& m = req.fds[i]; + if (m.child_fd < 0 || m.child_fd > 2 || !m.parent) continue; + std_dups[m.child_fd] = dup_inheritable(static_cast(m.parent)); + } + for (int i = 0; i < 3; ++i) { + if (!std_dups[i]) { + const int req_fd = i == 0 ? req.stdin_fd + : i == 1 ? req.stdout_fd : req.stderr_fd; + if (req_fd == SpawnRequest::kDevNull) { + std_dups[i] = open_nul(true); + } else if (req_fd == -1) { + const HANDLE cur = i == 0 ? ::GetStdHandle(STD_INPUT_HANDLE) + : i == 1 ? ::GetStdHandle(STD_OUTPUT_HANDLE) + : ::GetStdHandle(STD_ERROR_HANDLE); + if (cur && cur != INVALID_HANDLE_VALUE) + std_dups[i] = dup_inheritable(cur); + else + // a windowed host has no std handles to give; the child + // gets the NUL device rather than a failed spawn -- an + // absent console is a normal situation, not a policy + // failure. + std_dups[i] = open_nul(true); + } else if (req_fd >= 0) { + // legacy integer form: treat as a raw handle value + std_dups[i] = dup_inheritable(reinterpret_cast( + static_cast(req_fd))); + } + } + if (!std_dups[i]) { + return std::unexpected(win_error("spawn: standard handle setup")); + } + child_std[i] = std_dups[i].get(); + } + } + + // ── 5. attribute list ───────────────────────────────────────────────── + // + // mitigation policy + appcontainer + child-process policy + the handle + // list, so only the three standard handles are inheritable and nothing + // else in this process leaks into the guest. + std::vector attr_buf; + LPPROC_THREAD_ATTRIBUTE_LIST attr_list = nullptr; + // the list lives INSIDE attr_buf (a plain byte vector), so cleanup is + // DeleteProcThreadAttributeList only -- HeapFree here would be freeing + // memory the vector owns, which is corruption, not a leak. + struct attr_cleanup { + LPPROC_THREAD_ATTRIBUTE_LIST l{nullptr}; + ~attr_cleanup() { if (l) ::DeleteProcThreadAttributeList(l); } + } attr_guard; + + const DWORD attr_count = 1 /*mitigation*/ + (c.use_app_container ? 1 : 0) + + (c.disable_child_processes ? 1 : 0) + 1 /*handle list*/; + SIZE_T asize = 0; + ::InitializeProcThreadAttributeList(nullptr, attr_count, 0, &asize); + attr_buf.assign(asize, 0); + attr_list = reinterpret_cast(attr_buf.data()); + if (!::InitializeProcThreadAttributeList(attr_list, attr_count, 0, &asize)) + return std::unexpected(win_error("spawn: InitializeProcThreadAttributeList")); + attr_guard.l = attr_list; + + // mitigation policy, one 64-bit word for the classes a policy names. + { + DWORD64 policy = 0; + if (c.disable_dynamic_code) + policy |= PROCESS_CREATION_MITIGATION_POLICY_PROHIBIT_DYNAMIC_CODE_ALWAYS_ON; + if (c.disable_win32k) + policy |= PROCESS_CREATION_MITIGATION_POLICY_WIN32K_SYSTEM_CALL_DISABLE_ALWAYS_ON; + if (c.disable_extension_points) + policy |= PROCESS_CREATION_MITIGATION_POLICY_EXTENSION_POINT_DISABLE_ALWAYS_ON; + if (policy && !::UpdateProcThreadAttribute( + attr_list, 0, PROC_THREAD_ATTRIBUTE_MITIGATION_POLICY, + &policy, sizeof(policy), nullptr, nullptr)) + return std::unexpected(win_error("spawn: mitigation policy attribute")); + } + + // the appcontainer itself: SID + capability list in one attribute. + if (c.use_app_container) { + SECURITY_CAPABILITIES sc{}; + sc.AppContainerSid = cc.profile_sid.get(); + sc.Capabilities = cc.caps.empty() ? nullptr : cc.caps.data(); + sc.CapabilityCount = static_cast(cc.caps.size()); + if (!::UpdateProcThreadAttribute( + attr_list, 0, PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, + &sc, sizeof(sc), nullptr, nullptr)) + return std::unexpected(win_error("spawn: appcontainer attribute")); + } + + // no children: a separate attribute rather than a mitigation-policy bit. + if (c.disable_child_processes) { + DWORD child_policy = PROCESS_CREATION_CHILD_PROCESS_RESTRICTED; + if (!::UpdateProcThreadAttribute( + attr_list, 0, PROC_THREAD_ATTRIBUTE_CHILD_PROCESS_POLICY, + &child_policy, sizeof(child_policy), nullptr, nullptr)) + return std::unexpected(win_error("spawn: child process policy attribute")); + } + + // handle list: the ONLY handles the child inherits are the ones named + // here. without it, every inheritable handle in this process would land + // in the guest -- authorities the sandbox cannot see or revoke. + { + HANDLE inherit[3] = {child_std[0], child_std[1], child_std[2]}; + if (!::UpdateProcThreadAttribute( + attr_list, 0, PROC_THREAD_ATTRIBUTE_HANDLE_LIST, + inherit, sizeof(inherit), nullptr, nullptr)) + return std::unexpected(win_error("spawn: handle list attribute")); + } + + // ── 7. the token: restricted, so the read wall is real ───────────── + // + // the appcontainer attribute ALONE does not build a read wall: the + // lowbox token keeps the user SID and every group, so any DACL that + // grants the user read (which is all of their own files) grants the + // container read too. measured here first: `type %USERPROFILE%\\.ssh\\...` + // returned 0 from inside a caps-less container before this token + // existed. what builds the read wall is the RESTRICTED list: access is + // granted only if EITHER list grants it, and the restricted list carries + // no user SID -- so the user's grants stop counting, the package SID's + // (stamped above) start, and BUILTIN\\Users keeps the toolchain readable. + // + // gated on the probe's VERDICT, not on the API's existence: several + // insider builds accept CreateRestrictedToken and then kill every child + // during ntdll init. a host that fails the probe gets the honest weaker + // sandbox (appcontainer default-deny) rather than one that cannot start + // a child at all. + handle_guard restricted; + if (c.restricted_tokens_ok) { + HANDLE base = nullptr; + if (!::OpenProcessToken(::GetCurrentProcess(), + TOKEN_DUPLICATE | TOKEN_ASSIGN_PRIMARY | TOKEN_QUERY, + &base)) + return std::unexpected(win_error("spawn: OpenProcessToken")); + handle_guard base_guard{base}; + + // restricted list = BUILTIN\Users + Everyone, WELL-KNOWN SIDs ONLY. + // the kernel rejects a restricted list containing appcontainer-namespace + // SIDs outright (ERROR_INVALID_PARAMETER, measured: package-only and + // package+users both fail with 87) -- those S-1-15-* SIDs identify a + // package, and a restricted list is a set of ACCOUNT sids. what the + // list does instead is subtract: access requires BOTH the normal list + // and this one to grant, and neither Users nor Everyone grants read on + // the user's own files, so the user-SID grants stop counting -- the + // read wall -- while the toolchain under C:\Windows stays readable via + // its Users:(RX) ACE. + std::vector restricted_list; + std::vector sid_storage; + for (const WELL_KNOWN_SID_TYPE type : {WinBuiltinUsersSid, WinWorldSid}) { + BYTE sid_buf[SECURITY_MAX_SID_SIZE]{}; + DWORD sid_size = sizeof(sid_buf); + if (!::CreateWellKnownSid(type, nullptr, sid_buf, &sid_size)) continue; + const size_t off = sid_storage.size(); + sid_storage.resize(off + sid_size); + std::memcpy(sid_storage.data() + off, sid_buf, sid_size); + } + // pointers AFTER the storage is final (vector reallocation would + // invalidate earlier ones -- see make_container for the same rule). + size_t off = 0; + while (off < sid_storage.size()) { + SID_AND_ATTRIBUTES a{}; + a.Sid = sid_storage.data() + off; + // the restricted list ignores Attributes; SE_GROUP_ENABLED is + // group-list vocabulary, and a nonzero value here is exactly the + // kind of ERROR_INVALID_PARAMETER CreateRestrictedToken fails + // with -- measured, not guessed. + a.Attributes = 0; + restricted_list.push_back(a); + off += ::GetSidLengthRequired( + static_cast(*::GetSidSubAuthorityCount( + reinterpret_cast(sid_storage.data() + off)))); + } + if (restricted_list.empty()) { + // no well-known SID derivable (should not happen on windows); the + // read wall is unbuildable, degrade to appcontainer-only. + c.restricted_tokens_ok = false; + } + + HANDLE tok = nullptr; + if (!::CreateRestrictedToken(base, 0, 0, nullptr, + 0, nullptr, // drop NOTHING here; privileges + // go below + static_cast(restricted_list.size()), + restricted_list.empty() ? nullptr + : restricted_list.data(), + &tok)) + return std::unexpected(win_error("spawn: CreateRestrictedToken")); + restricted = handle_guard{tok}; + + // drop every privilege the base token holds. a sandboxed compiler + // needs none; SeShutdownPrivilege et al. surviving the wall is a + // privilege a policy never asked for. + DWORD len = 0; + ::GetTokenInformation(tok, TokenPrivileges, nullptr, 0, &len); + if (len) { + std::vector pb(len); + auto* privs = reinterpret_cast(pb.data()); + if (::GetTokenInformation(tok, TokenPrivileges, privs, len, &len) && + privs->PrivilegeCount > 0) { + // CreateRestrictedToken(0 privileges-to-delete) keeps them; + // AdjustTokenPrivileges on the new token removes all. + ::AdjustTokenPrivileges(tok, TRUE, nullptr, 0, nullptr, nullptr); + } + } + } + + // ── 8. command line + environment ───────────────────────────────── + // argv[0] is the program NAME (the posix convention all three backends + // share); the command line is the program followed by argv[1..]. pushing + // argv[0] as well would run `prog prog arg` on every call. + std::string cmdline = quote_arg(req.program); + for (const char* const* a = req.argv; *a; ++a) { + if (a == req.argv) continue; // argv[0]: the name, already in front + cmdline += " "; + cmdline += quote_arg(*a); + } + const std::wstring wcmdline = widen(cmdline); + const std::wstring wworkdir = req.workdir.empty() ? std::wstring{} : widen(req.workdir); + + std::vector env_block = build_env_block(req.envp); + + // ── 7. create, confined before the first instruction ────────────────── + STARTUPINFOEXW si{}; + si.StartupInfo.cb = sizeof(si); + si.StartupInfo.dwFlags = STARTF_USESTDHANDLES; + si.StartupInfo.hStdInput = child_std[0]; + si.StartupInfo.hStdOutput = child_std[1]; + si.StartupInfo.hStdError = child_std[2]; + + DWORD flags = CREATE_SUSPENDED | EXTENDED_STARTUPINFO_PRESENT | + CREATE_UNICODE_ENVIRONMENT | CREATE_NO_WINDOW; + PROCESS_INFORMATION pi{}; + // the restricted token is the primary token when the host passed the + // probe: the child runs with the restricted list from its first + // instruction, same rule as the posix backends' post-fork seccomp + // install -- the wall is up before the program can act on anything. + // without it (probe said no), plain CreateProcessW carries the + // appcontainer attribute, which is the wall that still holds. + const BOOL created = + restricted + ? ::CreateProcessAsUserW(restricted.get(), nullptr, + const_cast(wcmdline.c_str()), + nullptr, nullptr, TRUE, flags, + env_block.empty() ? nullptr : env_block.data(), + wworkdir.empty() ? nullptr : wworkdir.c_str(), + &si.StartupInfo, &pi) + : ::CreateProcessW(nullptr, + const_cast(wcmdline.c_str()), + nullptr, nullptr, TRUE, flags, + env_block.empty() ? nullptr : env_block.data(), + wworkdir.empty() ? nullptr : wworkdir.c_str(), + &si.StartupInfo, &pi); + // env-gated trace (CLAYBIN_TRACE=1): the 0xc0000142 isolation work needs + // the real decision facts per spawn, without a rebuild of the consumer. + // stderr, one line, after the call so the verdict rides along. + const bool trace = [] { + char b[8]{}; + const DWORD n = ::GetEnvironmentVariableA("CLAYBIN_TRACE", b, sizeof b); + return n > 0 && n < sizeof b && b[0] == '1'; + }(); + if (trace) + std::fprintf(stderr, "[claybin] spawn: api=%s ac=%d rt_compiled=%d rt_used=%d caps=%zu " + "mem=%llu procs=%u wx=%d extpts=%d childpol=%d " + "workdir='%s' env=%zu flags=0x%lx created=%d\n", + restricted ? "AsUser" : "W", + c.use_app_container, c.restricted_tokens_ok, + static_cast(static_cast(restricted)), + cc.caps.size(), + static_cast(c.memory_limit_bytes), + c.active_process_limit, + static_cast(c.disable_dynamic_code), + static_cast(c.disable_extension_points), + static_cast(c.disable_child_processes), + req.workdir.c_str(), + env_block.size(), + static_cast(flags), + static_cast(created)); + if (!created) { + return std::unexpected(win_error(restricted ? "spawn: CreateProcessAsUserW" + : "spawn: CreateProcessW")); + } + + // ── 8. into the job BEFORE it runs, then go ─────────────────────────── + if (!::AssignProcessToJobObject(job.get(), pi.hProcess)) { + const Error e = win_error("spawn: AssignProcessToJobObject"); + ::TerminateProcess(pi.hProcess, 1); + ::CloseHandle(pi.hThread); + ::CloseHandle(pi.hProcess); + return std::unexpected(e); + } + ::ResumeThread(pi.hThread); + ::CloseHandle(pi.hThread); + // BOTH handles go to the caller: the process handle is the waitable exit + // and the reap source, the job is the kill switch and the limit boundary. + out.pid = static_cast(pi.dwProcessId); + out.process = pi.hProcess; + out.job = job.release(); // ownership moved to the caller + return out; +} + +// the public entry point: first call probes for real, every later call in +// this process gets that answer. see the block comment above. +HostCapabilities probe_host() { return cached_host_probe(); } + +} // namespace clay::windows + +#else // !_WIN32 + +namespace clay::windows { +HostCapabilities probe_host() { return HostCapabilities::none(); } +} // namespace clay::windows + +#endif // _WIN32 diff --git a/tests/windows_live_check.cpp b/tests/windows_live_check.cpp new file mode 100644 index 0000000..a08db1e --- /dev/null +++ b/tests/windows_live_check.cpp @@ -0,0 +1,304 @@ +// a live check of the windows apply path. unlike windows_backend_test (pure +// translation, runs everywhere) this one actually CREATES an appcontainer +// profile, stamps real ACLs and spawns a real confined child, so it only means +// anything on a windows host. +// +// separate from the backend test for the same reason the macos live check is: +// it needs real children with real exit codes, and a test that silently skips +// on every CI runner should be obviously the one doing the skipping. +#include "harness.hpp" + +#include "claybin/windows/backend.hpp" +#include "claybin/policy/shapes.hpp" + +#if defined(_WIN32) + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include + +#include +#include + +#endif + +using namespace clay; +using namespace clay::test; + +#if defined(_WIN32) +namespace { + +// the same RAII idea spawn.cpp uses locally: the test owns the handles it is +// handed, and closes them exactly once, at scope exit. +struct hguard { + void* h{nullptr}; + explicit hguard(void* v) : h(v) {} + ~hguard() { if (h) ::CloseHandle(h); } + hguard(const hguard&) = delete; + hguard& operator=(const hguard&) = delete; +}; + +int run_in(const windows::Compiled& c, const char* cmd) { + const char* argv[] = {"cmd.exe", "/d", "/s", "/c", cmd, nullptr}; + windows::SpawnRequest req; + req.program = "cmd.exe"; + req.argv = argv; + req.stdout_fd = windows::SpawnRequest::kDevNull; + req.stderr_fd = windows::SpawnRequest::kDevNull; + req.workdir = "C:\\_Workspace_\\claybin\\build"; + + auto s = windows::spawn(c, req); + if (!s) return -1; + + // the waitable exit is the process handle; the job the kill switch that + // closes at scope exit, which is what takes a still-running guest down. + hguard proc{s->process}; + hguard job{s->job}; + if (::WaitForSingleObject(static_cast(proc.h), 20000) != WAIT_OBJECT_0) + return -1; + DWORD code = 0; + if (!::GetExitCodeProcess(static_cast(proc.h), &code)) return -1; + return static_cast(code); +} + +// the agentty shape: stdout/stderr are ANONYMOUS PIPE handles, not the NUL +// device. insider builds are known to drift exactly here -- a guest whose +// console must attach while its inherited handles are pipes dies during dll +// init (0xc0000142) even though the same guest with NUL handles runs. this +// variant exists to measure that difference on THIS host. +int run_in_pipe(const windows::Compiled& c, const char* cmd) { + SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; + HANDLE r = nullptr, w = nullptr; + if (!::CreatePipe(&r, &w, &sa, 0)) return -1; + hguard rg{r}, wg{w}; + + const char* argv[] = {"cmd.exe", "/d", "/s", "/c", cmd, nullptr}; + windows::SpawnRequest req; + req.program = "cmd.exe"; + req.argv = argv; + req.stdout_fd = static_cast(reinterpret_cast(w)); + req.stderr_fd = static_cast(reinterpret_cast(w)); + req.workdir = "C:\\_Workspace_\\claybin\\build"; + + auto s = windows::spawn(c, req); + if (!s) return -1; + hguard proc{s->process}; + hguard job{s->job}; + wg.h = nullptr; // our write end goes, or the reader never sees EOF + if (::WaitForSingleObject(static_cast(proc.h), 20000) != WAIT_OBJECT_0) + return -1; + DWORD code = 0; + if (!::GetExitCodeProcess(static_cast(proc.h), &code)) return -1; + return static_cast(code); +} + +struct scope_exit { + void (*fn)() = nullptr; + ~scope_exit() { if (fn) fn(); } +}; + +} // namespace +#endif + +int main() { +#if !defined(_WIN32) + return skip("windows_live_check", "not Windows"); +#else + namespace fs = std::filesystem; + + const auto host = windows::probe_host(); + if (!host.app_container) + return skip("windows_live_check", "appcontainer profiles unavailable to this token"); + + // scratch dir for the write grant: a fresh dir we label and ACE ourselves, + // deleted afterwards. the workspace-of-the-moment, in miniature. + const fs::path scratch = + fs::temp_directory_path() / "claybin_live_scratch"; + std::error_code ec; + fs::remove_all(scratch, ec); + fs::create_directories(scratch, ec); + if (ec) return skip("windows_live_check", "cannot create scratch dir"); + const std::string scratch_s = scratch.string(); + scope_exit cleanup{[] { + std::error_code e2; + fs::remove_all(fs::temp_directory_path() / "claybin_live_scratch", e2); + }}; + + // a policy that can run a shell: read the system, write only to the + // working dir (the scratch dir is where the RUNNER would place the + // workspace), no network. cmd.exe needs fork/exec equivalents to run + // anything, which is itself worth asserting: a sandbox that forbids them + // looks identical to a broken one. + const std::string workdir_s = "C:\\_Workspace_\\claybin\\build"; + auto pol = Policy{} + .read("C:\\Windows") + .execute("C:\\Windows\\System32") + .read_write(workdir_s) + .allow_fork() + .allow_exec() + .seal(); + + // ACL mutation is opt-in on windows (it mutates host objects), and this + // test is exactly the thing that was opted into. + windows::Options opts; + opts.allow_host_acl_mutation = true; + + auto c = windows::compile(pol, host, opts); + CHECK(c.has_value()); + if (!c) return finish("windows_live_check"); + + // -- the sandbox does not break the program ---------------------------- + // first, and most important: a sandbox that denies everything passes + // every "is it blocked" test and is still useless. the guest must RUN. + CHECK_EQ(run_in(*c, "exit 0"), 0); + + // -- the write wall holds ---------------------------------------------- + // writing inside the grant works -- the file must land IN the granted + // workdir, which is exactly what run_in pins as the child's cwd. + CHECK_EQ(run_in(*c, "echo ok > claybin_live_ok.txt"), 0); + CHECK(fs::exists(fs::path{workdir_s} / "claybin_live_ok.txt")); + // ...and writing outside it does not. C:\\Windows is readable, and its + // ACL is owned by TrustedInstaller: a lowbox process could not write + // there even without us, which is the wall doing its job through both + // mechanisms at once. + CHECK(run_in(*c, "echo no > C:\\Windows\\claybin_should_not_exist.txt") != 0); + CHECK(!fs::exists("C:\\Windows\\claybin_should_not_exist.txt")); + + // -- the read wall holds ----------------------------------------------- + // a policy with no grants beyond the system roots: the appcontainer token + // keeps the user SID, so what the read wall can and cannot do depends on + // the host's restricted-token verdict, which probe_host measured by + // RUNNING a child. this host (an insider build) refuses restricted + // tokens, so the wall here is appcontainer default-deny: arbitrary + // absolute paths outside the grants are unreadable, but a path in the + // user profile that carries the user's own DACL still is. the test pins + // what IS true rather than what should be: probing a location with no + // grant and no package ACE must not succeed. + { + auto ro = Policy{} + .read("C:\\Windows") + .execute("C:\\Windows\\System32") + .allow_fork() + .allow_exec() + .seal(); + auto rc = windows::compile(ro, host, opts); + CHECK(rc.has_value()); + if (rc) { + // reading something granted must work + CHECK_EQ(run_in(*rc, "type C:\\Windows\\win.ini >nul"), 0); + // a made-up path outside every grant must not + CHECK(run_in(*rc, "type C:\\claybin_should_not_exist.txt") != 0); + } + } + + // -- the job: memory cap is a hard wall -------------------------------- + { + windows::Compiled tight = *c; + tight.memory_limit_bytes = 8 * 1024 * 1024; // 8 MiB + // powershell cannot even start inside 8 MiB; the command ends under + // the cap with a nonzero exit. `& exit 0` would mask the outcome, + // so the failure code IS the observed exit. + const int rc = run_in(tight, + "powershell -NoProfile -Command \"$a='x'*20000000\" >nul 2>&1"); + CHECK(rc != 0); + } + + // -- network capabilities: measure what THIS host grants a guest ------- + // createAppContainerProfile with capability sets is exactly where + // insider builds drift: a set that fails here fails at SPAWN TIME with + // a child that dies during dll init (0xc0000142), which no compile-time + // check can see. the probe runs the agentty shape (internetClient + + // privateNetworkClientServer, the two entries of kNetworkCapabilities) + // and the single-cap shape, and pins what actually runs. + { + // the agentty shape: allow_network + both canonical caps. + auto net_pol = Policy{} + .read("C:\\Windows") + .execute("C:\\Windows\\System32") + .read_write(workdir_s) + .allow_fork() + .allow_exec() + .connect("", 0) // Full: both kNetworkCapabilities + .seal(); + auto nc = windows::compile(net_pol, host, opts); + CHECK(nc.has_value()); + if (nc) { + const int rc2 = run_in(*nc, "exit 0"); + std::fprintf(stderr, "net-caps guest (both caps): exit %d\n", rc2); + CHECK_EQ(rc2, 0); + } + // the single-cap shape: outbound only, no loopback/LAN grant. + auto one_pol = Policy{} + .read("C:\\Windows") + .execute("C:\\Windows\\System32") + .read_write(workdir_s) + .allow_fork() + .allow_exec() + .connect("", 443) // Ports: narrow net grant + .seal(); + auto oc = windows::compile(one_pol, host, opts); + CHECK(oc.has_value()); + if (oc) { + const int rc3 = run_in(*oc, "exit 0"); + std::fprintf(stderr, "net-caps guest (narrow): exit %d\n", rc3); + CHECK_EQ(rc3, 0); + } + } + + // -- pipes: the agentty exec chain hands stdout/stderr as PIPE handles -- + // every check above ran with NUL-device handles. the full exec chain in + // agentty hands anonymous pipes, which is where the 0xc0000142 deaths + // were observed. pin both shapes: NUL and pipe must agree. + { + const int rp = run_in_pipe(*c, "echo piped"); + std::fprintf(stderr, "pipe-handle guest: exit %d\n", rp); + CHECK_EQ(rp, 0); + } + + // -- the agentty shape, in full: ACL-stamped grants, own profile name -- + // agentty's adapter opts into allow_host_acl_mutation and spawns under + // the "agentty" profile. anything that only bites in that combination + // (a stale ACE, a stamp on a system dir) is invisible to the checks + // above, so it is pinned here with the real Options. + { + ::clay::windows::Options ag_opts; + ag_opts.profile_name = "agentty.livecheck"; + ag_opts.allow_host_acl_mutation = true; + auto ag_pol = Policy{} + .read("C:\\Windows") + .execute("C:\\Windows\\System32") + .read_write(workdir_s) + .allow_fork() + .allow_exec() + .connect("", 0) + .seal(); + auto ac = windows::compile(ag_pol, host, ag_opts); + CHECK(ac.has_value()); + if (ac) { + const int ra = run_in_pipe(*ac, "echo agentty-shape"); + std::fprintf(stderr, "agentty-shape guest (acl stamps, own profile): exit %d\n", ra); + CHECK_EQ(ra, 0); + } + ::DeleteAppContainerProfile(L"agentty.livecheck"); + + // the exact agentty PROFILE NAME: the chain in agentty dies with + // 0xc0000142 while every check here runs -- the last remaining delta + // was the profile name. if "agentty" dies where "agentty.livecheck" + // lives, the name (or its on-disk state) is the poison. + { + ag_opts.profile_name = "agentty"; + auto ac2 = windows::compile(ag_pol, host, ag_opts); + CHECK(ac2.has_value()); + if (ac2) { + const int rap = run_in_pipe(*ac2, "echo agentty-name"); + std::fprintf(stderr, "agentty-profile-name guest: exit %d\n", rap); + CHECK_EQ(rap, 0); + } + } + } + + return finish("windows_live_check"); +#endif +} diff --git a/tools/caps_print.cpp b/tools/caps_print.cpp new file mode 100644 index 0000000..04ab759 --- /dev/null +++ b/tools/caps_print.cpp @@ -0,0 +1,12 @@ +// prints the full HostCapabilities verdict on this host, one line per field. +#include +#include "claybin/windows/backend.hpp" + +int main() { + for (int round = 1; round <= 3; ++round) { + const auto h = clay::windows::probe_host(); + std::printf("round %d: ac=%d rt=%d mit=%d\n", round, + h.app_container, h.restricted_tokens, h.mitigation_policies); + } + return 0; +} diff --git a/tools/kill_probe.cpp b/tools/kill_probe.cpp new file mode 100644 index 0000000..7e6d3c3 --- /dev/null +++ b/tools/kill_probe.cpp @@ -0,0 +1,161 @@ +// kill_probe — measures, on THIS host, how a sandboxed (appcontainer) child +// dies when we ask it to: +// +// phase 1 spawn `ping -n 21` (a grandchild of cmd) under the appcontainer, +// adopt it into jaal's windows_process, watch the exit, and at +// ~300 ms call stop(graceful, tree). jaal's windows backend maps +// graceful to TerminateProcess on the LEADER (adopted processes +// carry no job handle), so the question is: does the leader die, +// how fast does the exit fire, and does ping outlive it? +// phase 2 same spawn, but TerminateJobObject via claybin's job handle — +// the kill_tree() hook the exec loop falls back to. +// +// Build (MSYS2 clang64, from the claybin root): +// clang++ -std=c++23 -O1 -Iinclude -I../maya/third_party/jaal/include +// tools/kill_probe.cpp src/windows/spawn.cpp +// ../maya/third_party/jaal/src/platform/windows/{process,wait_reactor,handle}.cpp +// -o build/kill_probe.exe -luserenv -lwinmm +#include +#include +#include + +#ifndef WIN32_LEAN_AND_MEAN +#define WIN32_LEAN_AND_MEAN +#endif +#include +#include + +#include +#include + +#include +#include + +using namespace clay; +using clk = std::chrono::steady_clock; +static clk::time_point t0; + +static long long ms_since(clk::time_point t) { + return std::chrono::duration_cast(clk::now() - t).count(); +} + +int main() { + namespace pf = ::jaal::platform; + const auto host = windows::probe_host(); + std::printf("probe: ac=%d rt=%d mit=%d\n", host.app_container, + host.restricted_tokens, host.mitigation_policies); + + for (int phase = 1; phase <= 2; ++phase) { + auto pol = Policy{} + .read("C:\\Windows") + .execute("C:\\Windows\\System32") + .read_write("C:\\_Workspace_\\claybin\\build") + .allow_fork() + .allow_exec() + .connect("", 0) + .seal(); + windows::Options opts; + opts.profile_name = "claybin.killprobe"; + opts.allow_host_acl_mutation = true; + auto c = windows::compile(pol, host, opts); + if (!c) { + const std::string m{c.error().mechanism}; + std::printf("compile failed: %s\n", m.c_str()); + return 1; + } + + SECURITY_ATTRIBUTES sa{sizeof(sa), nullptr, TRUE}; + HANDLE r = nullptr, w = nullptr; + if (!::CreatePipe(&r, &w, &sa, 0)) return 1; + + const char* argv[] = {"cmd.exe", "/d", "/s", "/c", + "ping -n 21 127.0.0.1 >nul& echo done", nullptr}; + windows::SpawnRequest req; + req.program = "cmd.exe"; + req.argv = argv; + req.stdout_fd = static_cast(reinterpret_cast(w)); + req.stderr_fd = static_cast(reinterpret_cast(w)); + req.workdir = "C:\\_Workspace_\\claybin\\build"; + auto s = windows::spawn(*c, req); + ::CloseHandle(w); + if (!s) { std::printf("spawn failed\n"); return 1; } + const HANDLE job = static_cast(s->job); + const auto t_start = clk::now(); + + pf::windows_process::adopted_child a; + a.pid = static_cast(s->pid); + a.process = s->process; // ownership moves + s->process = nullptr; + a.stdout_pipe = r; // read end moves too + auto p = pf::windows_process::adopt(std::move(a)); + if (!p) { std::printf("adopt failed\n"); return 1; } + + auto reactor = pf::wait_reactor::create(); + auto exit_reg = reactor->watch(p->exit_handle().get(), pf::interest::read, 1); + const HANDLE out_h = static_cast(p->stdout_handle()->get()); + auto out_reg = reactor->watch(out_h, pf::interest::read, 2); + + const auto kill_at = t_start + std::chrono::milliseconds{300}; + bool killed = false, exited = false; + long long exit_ms = -1; + char buf[4096]; + std::string out; + + for (;;) { + const auto now = clk::now(); + auto next = now + std::chrono::hours{24}; + if (!killed) next = std::min(next, kill_at); + const auto wait_ms = std::chrono::duration_cast( + next > now ? next - now : std::chrono::milliseconds::zero()); + auto res = reactor->wait(wait_ms); + const auto woke = clk::now(); + std::printf(" [%lldms] wait returned after %lldms events=%u timeout=%d\n", + ms_since(t_start), + std::chrono::duration_cast(woke - now).count(), + res ? res->count : 0u, res ? res->timeout : 0); + if (res) { + for (std::uint8_t i = 0; i < res->count; ++i) { + const auto& e = res->ready[i]; + if (e.token == 1) { + exited = true; + exit_ms = ms_since(t_start); + } else if (e.token == 2) { + bool eof = false; + const auto n = pf::read_some(p->stdout_handle().value(), buf, sizeof buf, eof); + if (n > 0) out.append(buf, n); + std::printf(" [%lldms] stdout %zu bytes eof=%d\n", + ms_since(t_start), n, eof); + } + } + } + if (exited) break; + const auto t = clk::now(); + if (!killed && t >= kill_at) { + killed = true; + if (phase == 1) { + std::printf(" [%lldms] graceful stop (TerminateProcess on leader)\n", + ms_since(t_start)); + const auto st = p->stop(pf::stop_mode::graceful, pf::stop_scope::tree); + std::printf(" [%lldms] graceful returned ok=%d\n", + ms_since(t_start), static_cast(static_cast(st))); + } else { + std::printf(" [%lldms] TerminateJobObject\n", ms_since(t_start)); + ::TerminateJobObject(job, 1); + } + continue; + } + if (killed && t >= kill_at + std::chrono::seconds{8}) { + std::printf(" [%lldms] GIVING UP: exit never fired 8s after the kill\n", + ms_since(t_start)); + break; + } + } + std::printf("phase %d: exit fired at %lldms output='%s' (expected: kill lands ~300ms,\n" + " ping's natural end would be ~20000ms)\n\n", + phase, exit_ms, out.c_str()); + (void)p->reap(); + ::CloseHandle(job); + ::DeleteAppContainerProfile(L"claybin.killprobe"); + } + return 0; +}