| """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" |
|
|
| |
| HRESULT_ALREADY_EXISTS = -2147024713 |
|
|
| |
| 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" |
|
|
| |
| |
| 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, |
| 0, |
| ctypes.byref(sid), |
| ) |
| if result == HRESULT_ALREADY_EXISTS: |
| |
| |
| |
| 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), |
| ] |
|
|