"""AppContainer profiles: privilege reduction on Windows without elevation. What an AppContainer buys, and why it is the right primitive here ---------------------------------------------------------------- A Job Object controls how much a child may consume. It controls nothing about what a child may reach: the process still runs on the operator's token and can read every file that token can read. That is the gap this module closes. An AppContainer (a LowBox token) is default-deny for the filesystem, the registry, other applications and the network. Access is granted by adding an explicit ACE for the container's SID to the specific objects it should reach. Integrity is always Low, and child processes inherit the token, so a request cannot escape by spawning. **Zero capabilities means no network, full stop.** Not a firewall rule, not a filter that has to be maintained: the token simply does not carry ``WinCapabilityInternetClientSid``, so there is no client capability to use. It needs no administrator rights and no local user account, which is the deciding property for this project. The two production Windows sandboxes worth comparing against, OpenAI's Codex and Anthropic's ``srt``, both use restricted tokens and both require a one-time elevated install that creates dedicated local accounts. A volunteer lending a spare desktop should not have to do that. Two consequences worth stating before someone hits them ------------------------------------------------------- **Loopback is blocked inside an AppContainer.** Deliberately, by Windows. Unblocking it means ``CheckNetIsolation LoopbackExempt``, which is a machine-wide change this code will not make on somebody else's computer. Host to sandbox communication must therefore use inherited pipes rather than a TCP port on ``127.0.0.1``. **Windows Sandbox and Server Silos are not alternatives.** Windows Sandbox is Pro and above only, allows one instance at a time, takes seconds to start and has no creation API. Server Silos need the container stack and administrator rights. Neither can be a per-request mechanism on a volunteer's machine. Execution status ---------------- Written against the documented Win32 API and **not yet executed**: this development environment has no Windows kernel. Every entry point therefore returns a reason rather than raising when the API is unavailable, and ``applied`` is set only where a call has actually returned success. Nothing here may be described as working until ``tests/test_request_sandbox.py`` has run on Windows. """ from __future__ import annotations import ctypes import sys from collections.abc import Sequence from ctypes import wintypes from pathlib import Path from typing import Any from .base import RestrictionReport MECHANISM = "windows-appcontainer" #: Error returned by ``CreateAppContainerProfile`` when the name already exists. HRESULT_ALREADY_EXISTS = -2147024713 # 0x800700B0 # Access-control constants used when granting the container its scratch area. GRANT_ACCESS = 1 TRUSTEE_IS_SID = 0 TRUSTEE_IS_GROUP = 2 SE_FILE_OBJECT = 1 DACL_SECURITY_INFORMATION = 0x00000004 SUB_CONTAINERS_AND_OBJECTS_INHERIT = 0x00000003 GENERIC_ALL = 0x10000000 class _Profile: """One request's AppContainer profile and the SID it resolved to. Closing deletes the profile. A profile left behind is not a security problem, but thousands of them are a mess on somebody else's machine, and the whole point of a per-request sandbox is that nothing outlives the request. """ def __init__(self, name: str, sid: Any) -> None: self.name = name self.sid = sid self._closed = False def close(self) -> None: if self._closed: return self._closed = True try: userenv = ctypes.WinDLL("userenv", use_last_error=True) userenv.DeleteAppContainerProfile(ctypes.c_wchar_p(self.name)) except Exception: return finally: if self.sid: try: ctypes.windll.kernel32.LocalFree(self.sid) except Exception: pass def available() -> bool: """Can this host create AppContainer profiles at all?""" if sys.platform != "win32": return False try: ctypes.WinDLL("userenv", use_last_error=True) except OSError: return False return True def describe_appcontainer_support() -> RestrictionReport: """State the capability without creating anything. Never returns ``applied=True``. Support for an API is not the same claim as a sandbox having been built, and this project does not let the first stand in for the second. """ if sys.platform != "win32": return RestrictionReport( platform=sys.platform, applied=False, mechanism=MECHANISM, reason="AppContainer is a Windows mechanism and this host is not Windows", ) if not available(): return RestrictionReport( platform=sys.platform, applied=False, mechanism=MECHANISM, reason="userenv.dll could not be loaded, so no profile can be created", ) return RestrictionReport( platform=sys.platform, applied=False, mechanism=MECHANISM, reason=( "the API is present and needs no elevation, but no profile has been " "created yet, so no confinement is claimed" ), ) def create_profile( request_id: str, *, grant_paths: Sequence[Path] = () ) -> tuple[_Profile | None, str | None]: """Create a zero-capability profile for one request. Returns ``(profile, None)`` on success and ``(None, reason)`` otherwise. It does not raise: a worker that cannot confine a request should report that honestly and let the caller decide, not fail the user's run over it. ``grant_paths`` receives an inheritable full-access ACE for the container SID. Keep it to the request's own scratch directory: every path granted here is a path the request can read and write, and the shared read-only runtime is stamped once at install time against a fixed SID rather than per request, because stamping nested trees per request is the dominant cost in this design. """ if sys.platform != "win32": return None, "AppContainer is unavailable: this host is not Windows" if not available(): return None, "AppContainer is unavailable: userenv.dll could not be loaded" # Profile names are bounded and must be filesystem safe. The request id is # already a hex token, but it is normalised anyway rather than trusted. safe = "".join(character for character in request_id if character.isalnum())[:48] name = f"distinct-{safe or 'request'}" sid = ctypes.c_void_p() try: userenv = ctypes.WinDLL("userenv", use_last_error=True) result = userenv.CreateAppContainerProfile( ctypes.c_wchar_p(name), ctypes.c_wchar_p(name), ctypes.c_wchar_p("distinct sandboxed request"), None, # no capabilities: no network, no broker, nothing 0, ctypes.byref(sid), ) if result == HRESULT_ALREADY_EXISTS: # A profile survived a previous crash. Derive its SID rather than # failing: the name is unique per request, so this is recovery, not # reuse across requests. result = userenv.DeriveAppContainerSidFromAppContainerName( ctypes.c_wchar_p(name), ctypes.byref(sid) ) if result != 0: return None, f"CreateAppContainerProfile failed with HRESULT {result:#010x}" except (AttributeError, OSError) as exc: return None, f"AppContainer profile creation failed: {type(exc).__name__}: {exc}" profile = _Profile(name, sid) for path in grant_paths: granted, reason = grant_path(profile, path) if not granted: profile.close() return None, reason return profile, None def grant_path(profile: _Profile, path: Path) -> tuple[bool, str | None]: """Add an inheritable full-access ACE for the container SID to ``path``. Without this the container can write nowhere at all, which would be secure and useless: a request has to be able to produce an artifact. """ try: advapi = ctypes.WinDLL("advapi32", use_last_error=True) target = ctypes.c_wchar_p(str(Path(path).resolve())) old_dacl = ctypes.c_void_p() descriptor = ctypes.c_void_p() status = advapi.GetNamedSecurityInfoW( target, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, None, None, ctypes.byref(old_dacl), None, ctypes.byref(descriptor), ) if status != 0: return False, f"GetNamedSecurityInfo failed with error {status}" access = _EXPLICIT_ACCESS() access.grfAccessPermissions = GENERIC_ALL access.grfAccessMode = GRANT_ACCESS access.grfInheritance = SUB_CONTAINERS_AND_OBJECTS_INHERIT access.Trustee.pMultipleTrustee = None access.Trustee.MultipleTrusteeOperation = 0 access.Trustee.TrusteeForm = TRUSTEE_IS_SID access.Trustee.TrusteeType = TRUSTEE_IS_GROUP access.Trustee.ptstrName = ctypes.cast(profile.sid, ctypes.c_void_p) new_dacl = ctypes.c_void_p() status = advapi.SetEntriesInAclW( 1, ctypes.byref(access), old_dacl, ctypes.byref(new_dacl) ) if status != 0: return False, f"SetEntriesInAcl failed with error {status}" status = advapi.SetNamedSecurityInfoW( target, SE_FILE_OBJECT, DACL_SECURITY_INFORMATION, None, None, new_dacl, None ) if status != 0: return False, f"SetNamedSecurityInfo failed with error {status}" try: ctypes.windll.kernel32.LocalFree(new_dacl) ctypes.windll.kernel32.LocalFree(descriptor) except Exception: pass return True, None except (AttributeError, OSError) as exc: return False, f"granting the sandbox its scratch directory failed: {exc}" class _TRUSTEE(ctypes.Structure): _fields_ = [ ("pMultipleTrustee", ctypes.c_void_p), ("MultipleTrusteeOperation", wintypes.DWORD), ("TrusteeForm", wintypes.DWORD), ("TrusteeType", wintypes.DWORD), ("ptstrName", ctypes.c_void_p), ] class _EXPLICIT_ACCESS(ctypes.Structure): _fields_ = [ ("grfAccessPermissions", wintypes.DWORD), ("grfAccessMode", wintypes.DWORD), ("grfInheritance", wintypes.DWORD), ("Trustee", _TRUSTEE), ]