distinct / distinct_agent /isolation /windows_appcontainer.py
aaxaxax's picture
initial commit
2aa8b3a
Raw
History Blame
10.8 kB
"""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),
]