272 lines
17 KiB
Python
272 lines
17 KiB
Python
"""Capability-free Windows AppContainer process launcher.
|
|
|
|
The HTTP broker must stay outside this boundary. Only approved immutable runtime,
|
|
parser source and model directories receive read/execute access. Documents travel
|
|
through anonymous pipes, never a temporary plaintext file. No network capability
|
|
or loopback exemption is assigned. Windows supplies the package-private storage.
|
|
"""
|
|
import ctypes
|
|
import hashlib
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import threading
|
|
from pathlib import Path
|
|
from ctypes import wintypes as W
|
|
|
|
|
|
class SandboxError(RuntimeError):
|
|
pass
|
|
|
|
|
|
def _verify_read_acl(path, sid):
|
|
"""Read the DACL directly; startup never needs WRITE_DAC or icacls."""
|
|
a = ctypes.WinDLL('advapi32', use_last_error=True)
|
|
k = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
a.GetNamedSecurityInfoW.argtypes = [W.LPWSTR, W.DWORD, W.DWORD, ctypes.c_void_p, ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p), ctypes.c_void_p, ctypes.POINTER(ctypes.c_void_p)]
|
|
a.GetNamedSecurityInfoW.restype = W.DWORD
|
|
a.GetAce.argtypes = [ctypes.c_void_p, W.DWORD, ctypes.POINTER(ctypes.c_void_p)]
|
|
a.GetAce.restype = W.BOOL
|
|
a.EqualSid.argtypes = [ctypes.c_void_p, ctypes.c_void_p]; a.EqualSid.restype = W.BOOL
|
|
k.LocalFree.argtypes = [ctypes.c_void_p]; k.LocalFree.restype = ctypes.c_void_p
|
|
dacl = ctypes.c_void_p(); descriptor = ctypes.c_void_p()
|
|
if a.GetNamedSecurityInfoW(str(path), 1, 4, None, None, ctypes.byref(dacl), None, ctypes.byref(descriptor)):
|
|
raise SandboxError('Approved runtime ACL could not be inspected')
|
|
try:
|
|
if not dacl: raise SandboxError('Approved runtime has unrestricted access')
|
|
count = ctypes.c_ushort.from_address(dacl.value + 4).value
|
|
allow = 0
|
|
required = 0x1200a9 # FILE_GENERIC_READ | FILE_GENERIC_EXECUTE
|
|
for index in range(count):
|
|
ace = ctypes.c_void_p()
|
|
if not a.GetAce(dacl, index, ctypes.byref(ace)): raise SandboxError('Runtime ACL is invalid')
|
|
kind = ctypes.c_ubyte.from_address(ace.value).value
|
|
flags = ctypes.c_ubyte.from_address(ace.value + 1).value
|
|
if flags & 8: continue # INHERIT_ONLY does not apply to this object.
|
|
mask = ctypes.c_uint32.from_address(ace.value + 4).value
|
|
# Conservative: a relevant deny ACE requires administrator review.
|
|
if kind == 1 and mask & (required | 0x90000000):
|
|
raise SandboxError('Approved runtime has conflicting deny permissions')
|
|
if kind == 0 and a.EqualSid(ctypes.c_void_p(ace.value + 8), sid):
|
|
if mask & 0x10000000: mask |= 0x1f01ff
|
|
if mask & 0x80000000: mask |= 0x120089
|
|
if mask & 0x20000000: mask |= 0x1200a0
|
|
allow |= mask
|
|
if allow & required != required:
|
|
raise SandboxError('Runtime ACL provisioning is required before inspector startup')
|
|
if allow & 0x400d0116: # write/append/EA/attributes/delete/WRITE_DAC/WRITE_OWNER
|
|
raise SandboxError('AppContainer runtime access must be read-only')
|
|
finally:
|
|
k.LocalFree(descriptor)
|
|
|
|
|
|
def _api():
|
|
if sys.platform != 'win32':
|
|
raise SandboxError('AppContainer requires Windows')
|
|
k = ctypes.WinDLL('kernel32', use_last_error=True)
|
|
u = ctypes.WinDLL('userenv', use_last_error=True)
|
|
a = ctypes.WinDLL('advapi32', use_last_error=True)
|
|
for name, args, result in [
|
|
('CloseHandle', [W.HANDLE], W.BOOL),
|
|
('CreatePipe', [ctypes.POINTER(W.HANDLE), ctypes.POINTER(W.HANDLE), ctypes.c_void_p, W.DWORD], W.BOOL),
|
|
('SetHandleInformation', [W.HANDLE, W.DWORD, W.DWORD], W.BOOL),
|
|
('InitializeProcThreadAttributeList', [ctypes.c_void_p, W.DWORD, W.DWORD, ctypes.POINTER(ctypes.c_size_t)], W.BOOL),
|
|
('UpdateProcThreadAttribute', [ctypes.c_void_p, W.DWORD, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p], W.BOOL),
|
|
('DeleteProcThreadAttributeList', [ctypes.c_void_p], None),
|
|
('CreateProcessW', [W.LPCWSTR, W.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, W.BOOL, W.DWORD, ctypes.c_void_p, W.LPCWSTR, ctypes.c_void_p, ctypes.c_void_p], W.BOOL),
|
|
('WaitForSingleObject', [W.HANDLE, W.DWORD], W.DWORD),
|
|
('TerminateProcess', [W.HANDLE, W.UINT], W.BOOL),
|
|
('GetExitCodeProcess', [W.HANDLE, ctypes.POINTER(W.DWORD)], W.BOOL),
|
|
('LocalFree', [ctypes.c_void_p], ctypes.c_void_p),
|
|
('CreateJobObjectW', [ctypes.c_void_p, W.LPCWSTR], W.HANDLE),
|
|
('SetInformationJobObject', [W.HANDLE, ctypes.c_int, ctypes.c_void_p, W.DWORD], W.BOOL),
|
|
('AssignProcessToJobObject', [W.HANDLE, W.HANDLE], W.BOOL),
|
|
('ResumeThread', [W.HANDLE], W.DWORD),
|
|
]:
|
|
fn = getattr(k, name); fn.argtypes = args; fn.restype = result
|
|
u.CreateAppContainerProfile.argtypes = [W.LPCWSTR, W.LPCWSTR, W.LPCWSTR, ctypes.c_void_p, W.DWORD, ctypes.POINTER(ctypes.c_void_p)]
|
|
u.CreateAppContainerProfile.restype = ctypes.c_long
|
|
u.DeriveAppContainerSidFromAppContainerName.argtypes = [W.LPCWSTR, ctypes.POINTER(ctypes.c_void_p)]
|
|
u.DeriveAppContainerSidFromAppContainerName.restype = ctypes.c_long
|
|
a.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(W.LPWSTR)]
|
|
a.ConvertSidToStringSidW.restype = W.BOOL
|
|
a.FreeSid.argtypes = [ctypes.c_void_p]; a.FreeSid.restype = ctypes.c_void_p
|
|
return k, u, a
|
|
|
|
|
|
class AppContainer:
|
|
"""Broker-owned launcher. Caller supplies trusted deployment directories."""
|
|
def __init__(self, read_directories, *, provision=False):
|
|
self.k, u, self.a = _api()
|
|
roots_input = [Path(p).absolute() for p in read_directories]
|
|
for root in roots_input:
|
|
for component in [root, *root.parents]:
|
|
if component.lstat().st_file_attributes & 0x400:
|
|
raise SandboxError('Reparse point in approved sandbox root')
|
|
roots = tuple(sorted({str(p.resolve(strict=True)) for p in roots_input}))
|
|
if not roots or any(not Path(p).is_dir() or Path(p).parent == Path(p) for p in roots):
|
|
raise SandboxError('Invalid approved sandbox runtime directories')
|
|
self.name = 'SafetyNet.Parser.' + hashlib.sha256('\0'.join(roots).encode()).hexdigest()[:24]
|
|
self.sid = ctypes.c_void_p()
|
|
result = u.CreateAppContainerProfile(self.name, 'SafetyNet parser', 'Offline content inspection', None, 0, ctypes.byref(self.sid))
|
|
if result != 0 and u.DeriveAppContainerSidFromAppContainerName(self.name, ctypes.byref(self.sid)) != 0:
|
|
raise SandboxError('AppContainer profile could not be established')
|
|
sid_text = W.LPWSTR()
|
|
if not self.a.ConvertSidToStringSidW(self.sid, ctypes.byref(sid_text)):
|
|
raise SandboxError('AppContainer identity unavailable')
|
|
try:
|
|
self.sid_string = sid_text.value
|
|
finally:
|
|
self.k.LocalFree(sid_text)
|
|
# Package SID grants are specific to this deployment, never Everyone or
|
|
# ALL APPLICATION PACKAGES. No writable permission is added to source.
|
|
for root in roots:
|
|
# Reject junctions/symlinks before recursive grants; /L alone is not
|
|
# sufficient evidence that every possible reparse target stays local.
|
|
entries = [Path(root)]
|
|
for directory, folders, files in os.walk(root, followlinks=False):
|
|
for name in folders + files:
|
|
entry = Path(directory) / name
|
|
if entry.lstat().st_file_attributes & 0x400:
|
|
raise SandboxError('Reparse point in approved sandbox runtime')
|
|
entries.append(entry)
|
|
if provision:
|
|
completed = subprocess.run(['icacls.exe', root, '/grant:r', '*' + self.sid_string + ':(RX)', '/T', '/L', '/Q'],
|
|
capture_output=True, timeout=60, creationflags=subprocess.CREATE_NO_WINDOW)
|
|
if completed.returncode:
|
|
raise SandboxError('Approved runtime access could not be established')
|
|
for entry in entries:
|
|
_verify_read_acl(entry, self.sid)
|
|
self.roots = roots
|
|
|
|
def run(self, executable, arguments, data=b'', environment=None, timeout=30, max_output=8*1024*1024):
|
|
"""One bounded pipe exchange. Child descendants remain in broker Job."""
|
|
import msvcrt
|
|
executable = str(Path(executable).resolve(strict=True))
|
|
if not any(Path(executable).is_relative_to(Path(root)) for root in self.roots):
|
|
raise SandboxError('Executable is outside approved runtime')
|
|
class SA(ctypes.Structure):
|
|
_fields_ = [('length', W.DWORD), ('descriptor', ctypes.c_void_p), ('inherit', W.BOOL)]
|
|
class SI(ctypes.Structure):
|
|
_fields_ = [('cb', W.DWORD), ('reserved', W.LPWSTR), ('desktop', W.LPWSTR), ('title', W.LPWSTR),
|
|
('x', W.DWORD), ('y', W.DWORD), ('xs', W.DWORD), ('ys', W.DWORD), ('xc', W.DWORD), ('yc', W.DWORD),
|
|
('fill', W.DWORD), ('flags', W.DWORD), ('show', W.WORD), ('reserved2size', W.WORD),
|
|
('reserved2', ctypes.c_void_p), ('stdin', W.HANDLE), ('stdout', W.HANDLE), ('stderr', W.HANDLE)]
|
|
class SIX(ctypes.Structure):
|
|
_fields_ = [('startup', SI), ('attributes', ctypes.c_void_p)]
|
|
class PI(ctypes.Structure):
|
|
_fields_ = [('process', W.HANDLE), ('thread', W.HANDLE), ('pid', W.DWORD), ('tid', W.DWORD)]
|
|
class SC(ctypes.Structure):
|
|
_fields_ = [('sid', ctypes.c_void_p), ('capabilities', ctypes.c_void_p), ('count', W.DWORD), ('reserved', W.DWORD)]
|
|
security = SA(ctypes.sizeof(SA), None, True)
|
|
handles = []
|
|
attribute = None
|
|
process = PI()
|
|
job = None
|
|
owned_files = []
|
|
try:
|
|
ir, iw, or_, ow = W.HANDLE(), W.HANDLE(), W.HANDLE(), W.HANDLE()
|
|
for read, write in [(ir, iw), (or_, ow)]:
|
|
if not self.k.CreatePipe(ctypes.byref(read), ctypes.byref(write), ctypes.byref(security), 0):
|
|
raise SandboxError('Sandbox pipes unavailable')
|
|
handles.extend([read.value, write.value])
|
|
for parent_handle in [iw, or_]:
|
|
if not self.k.SetHandleInformation(parent_handle, 1, 0):
|
|
raise SandboxError('Sandbox pipe inheritance unavailable')
|
|
size = ctypes.c_size_t()
|
|
self.k.InitializeProcThreadAttributeList(None, 2, 0, ctypes.byref(size))
|
|
attribute = ctypes.create_string_buffer(size.value)
|
|
if not self.k.InitializeProcThreadAttributeList(attribute, 2, 0, ctypes.byref(size)):
|
|
raise SandboxError('Sandbox launch attributes unavailable')
|
|
caps = SC(self.sid, None, 0, 0)
|
|
inheritance = (W.HANDLE * 2)(ir.value, ow.value)
|
|
for key, value in [(0x20009, caps), (0x20002, inheritance)]:
|
|
if not self.k.UpdateProcThreadAttribute(attribute, 0, key, ctypes.byref(value), ctypes.sizeof(value), None, None):
|
|
raise SandboxError('Sandbox launch boundary unavailable')
|
|
startup = SIX(); startup.startup.cb = ctypes.sizeof(SIX)
|
|
startup.startup.flags = 0x100; startup.startup.stdin = ir; startup.startup.stdout = ow; startup.startup.stderr = ow
|
|
startup.attributes = ctypes.cast(attribute, ctypes.c_void_p)
|
|
# Explicit environment: no broker credential or inherited deployment secrets.
|
|
env = {'SystemRoot': os.environ.get('SystemRoot', 'C:\\Windows'), 'PYTHONDONTWRITEBYTECODE': '1', 'PYTHONIOENCODING':'utf-8'}
|
|
for key in ['USERPROFILE','LOCALAPPDATA','APPDATA','TEMP','TMP','WINDIR']:
|
|
if key in os.environ: env[key] = os.environ[key]
|
|
allowed = {'EMBEDDING_MODEL_DIR','EMBEDDING_WORKER_PATH','SEARCHI_FORCE_CPU','GGML_CPU_ONLY','GGML_DISABLE_VULKAN'}
|
|
if any(key not in allowed or not isinstance(value,str) or '\0' in value for key,value in (environment or {}).items()):
|
|
raise SandboxError('Unapproved AppContainer environment field')
|
|
env.update(environment or {})
|
|
environment_block = ctypes.create_unicode_buffer('\0'.join(f'{key}={value}' for key, value in sorted(env.items())) + '\0\0')
|
|
command = ctypes.create_unicode_buffer(subprocess.list2cmdline([executable, *arguments]))
|
|
if not self.k.CreateProcessW(executable, command, None, None, True, 0x80000|0x400|0x8000000|0x4,
|
|
environment_block, self.roots[0], ctypes.byref(startup), ctypes.byref(process)):
|
|
raise SandboxError('AppContainer process launch failed: ' + str(ctypes.get_last_error()))
|
|
# Suspended launch avoids an execution window before descendant cleanup.
|
|
class BasicLimits(ctypes.Structure):
|
|
_fields_ = [('processTime', ctypes.c_int64), ('jobTime', ctypes.c_int64), ('flags', W.DWORD),
|
|
('minWorking', ctypes.c_size_t), ('maxWorking', ctypes.c_size_t), ('processes', W.DWORD),
|
|
('affinity', ctypes.c_size_t), ('priority', W.DWORD), ('scheduling', W.DWORD)]
|
|
class Extended(ctypes.Structure):
|
|
_fields_ = [('basic', BasicLimits), ('io', ctypes.c_uint64 * 6), ('processMemory', ctypes.c_size_t), ('jobMemory', ctypes.c_size_t), ('peakProcess', ctypes.c_size_t), ('peakJob', ctypes.c_size_t)]
|
|
limits = Extended(); limits.basic.flags = 0x2000 | 0x8; limits.basic.processes = 2
|
|
job = self.k.CreateJobObjectW(None, None)
|
|
if not job or not self.k.SetInformationJobObject(job, 9, ctypes.byref(limits), ctypes.sizeof(limits)) or not self.k.AssignProcessToJobObject(job, process.process):
|
|
self.k.TerminateProcess(process.process, 1)
|
|
raise SandboxError('AppContainer descendant containment unavailable')
|
|
if self.k.ResumeThread(process.thread) == 0xffffffff:
|
|
raise SandboxError('AppContainer launch resume failed')
|
|
self.k.CloseHandle(process.thread); process.thread = None
|
|
for child_handle in [ir.value, ow.value]:
|
|
self.k.CloseHandle(child_handle); handles.remove(child_handle)
|
|
writer = os.fdopen(msvcrt.open_osfhandle(iw.value, os.O_WRONLY|os.O_BINARY), 'wb', buffering=0)
|
|
reader = os.fdopen(msvcrt.open_osfhandle(or_.value, os.O_RDONLY|os.O_BINARY), 'rb', buffering=0)
|
|
handles.remove(iw.value); handles.remove(or_.value); owned_files = [writer, reader]
|
|
chunks = []; exceeded = threading.Event(); errors = []
|
|
def write():
|
|
try:
|
|
position = 0
|
|
while position < len(data):
|
|
count = writer.write(memoryview(data)[position:])
|
|
if not count: raise OSError()
|
|
position += count
|
|
except OSError:
|
|
pass
|
|
finally:
|
|
writer.close()
|
|
def read():
|
|
total = 0
|
|
try:
|
|
while True:
|
|
chunk = reader.read(65536)
|
|
if not chunk: break
|
|
total += len(chunk)
|
|
if total > max_output:
|
|
exceeded.set(); self.k.TerminateProcess(process.process, 1); break
|
|
chunks.append(chunk)
|
|
except OSError:
|
|
errors.append(True)
|
|
threads = [threading.Thread(target=write, daemon=True), threading.Thread(target=read, daemon=True)]
|
|
for thread in threads: thread.start()
|
|
if self.k.WaitForSingleObject(process.process, int(timeout*1000)) != 0:
|
|
self.k.TerminateProcess(process.process, 1)
|
|
self.k.WaitForSingleObject(process.process, 5000)
|
|
raise SandboxError('AppContainer inspection deadline exceeded')
|
|
self.k.CloseHandle(job);job=None
|
|
for thread in threads: thread.join(2)
|
|
if exceeded.is_set() or errors or any(thread.is_alive() for thread in threads):
|
|
raise SandboxError('AppContainer response failed bounded exchange')
|
|
code = W.DWORD()
|
|
if not self.k.GetExitCodeProcess(process.process, ctypes.byref(code)):
|
|
raise SandboxError('AppContainer process status unavailable')
|
|
return code.value, b''.join(chunks)
|
|
finally:
|
|
if job: self.k.CloseHandle(job);job=None
|
|
for file in owned_files:
|
|
if not file.closed: file.close()
|
|
for handle in handles: self.k.CloseHandle(handle)
|
|
if job: self.k.CloseHandle(job)
|
|
if process.thread: self.k.CloseHandle(process.thread)
|
|
if process.process: self.k.CloseHandle(process.process)
|
|
if attribute is not None: self.k.DeleteProcThreadAttributeList(attribute)
|
|
|
|
|
|
|