Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
11 changes: 8 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
69 changes: 65 additions & 4 deletions docs/platforms.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
|---|---|---|---|
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 `<atomic>` 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.
84 changes: 84 additions & 0 deletions include/claybin/windows/backend.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -128,6 +135,83 @@ Result<Compiled> compile(const Policy<Sealed>& 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<Spawned> spawn(Compiled c, const SpawnRequest& req);

} // namespace clay::windows
Loading