import sys sys.dont_write_bytecode = True if not sys.dont_write_bytecode: raise RuntimeError("owned process host could not disable bytecode writes") import ctypes import errno import json import math import os import queue import signal import struct import subprocess import threading import time # Keep this module standard-library-only: it is re-executed as the isolated # containment host before any application code is allowed to run. _HOST_FLAG = "--owned-process-host" _MAX_PACKET_SIZE = 16 * 1024 * 1024 _CREATE_BREAKAWAY_FROM_JOB = 0x01000000 _TEST_FAIL_JOB_ASSIGNMENT = "OWNED_PROCESS_TEST_FAIL_JOB_ASSIGNMENT" _WINDOWS_INHERIT_LOCK = threading.Lock() _JOB_OBJECT_LIMIT_JOB_MEMORY = 0x00000200 _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 _JOB_OBJECT_CPU_RATE_CONTROL_INFORMATION = 15 _JOB_OBJECT_CPU_RATE_CONTROL_ENABLE = 0x1 _JOB_OBJECT_CPU_RATE_CONTROL_WEIGHT_BASED = 0x2 _PROCESS_MEMORY_PRIORITY = 0 _MAX_JOB_PROCESS_IDS = 4096 _MAX_REAP_BATCH = 64 _HOST_PRIVATE_ENV_KEYS = frozenset({ "SCANNER_SUPERVISED", "TRUF_MANAGED_POSTGRES_DSN", "SCANNER_DB_URL", "DATABASE_URL", "SCANNER_DASHBOARD_DB_URL", "KEYCHECK_DB_URL", "PGPASSWORD", "PGUSER", "PGDATABASE", "PGHOST", "PGHOSTADDR", "PGPORT", "PGSERVICE", "PGSERVICEFILE", "PGPASSFILE", "PGOPTIONS", "PGSSLMODE", "PGSSLKEY", "PGSSLCERT", "PGSSLROOTCERT", }) if os.name == "nt": from ctypes import wintypes class _IO_COUNTERS(ctypes.Structure): _fields_ = [ ("ReadOperationCount", ctypes.c_ulonglong), ("WriteOperationCount", ctypes.c_ulonglong), ("OtherOperationCount", ctypes.c_ulonglong), ("ReadTransferCount", ctypes.c_ulonglong), ("WriteTransferCount", ctypes.c_ulonglong), ("OtherTransferCount", ctypes.c_ulonglong), ] class _JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure): _fields_ = [ ("PerProcessUserTimeLimit", ctypes.c_longlong), ("PerJobUserTimeLimit", ctypes.c_longlong), ("LimitFlags", wintypes.DWORD), ("MinimumWorkingSetSize", ctypes.c_size_t), ("MaximumWorkingSetSize", ctypes.c_size_t), ("ActiveProcessLimit", wintypes.DWORD), ("Affinity", ctypes.c_size_t), ("PriorityClass", wintypes.DWORD), ("SchedulingClass", wintypes.DWORD), ] class _JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure): _fields_ = [ ("BasicLimitInformation", _JOBOBJECT_BASIC_LIMIT_INFORMATION), ("IoInfo", _IO_COUNTERS), ("ProcessMemoryLimit", ctypes.c_size_t), ("JobMemoryLimit", ctypes.c_size_t), ("PeakProcessMemoryUsed", ctypes.c_size_t), ("PeakJobMemoryUsed", ctypes.c_size_t), ] class _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION(ctypes.Structure): _fields_ = [ ("TotalUserTime", ctypes.c_longlong), ("TotalKernelTime", ctypes.c_longlong), ("ThisPeriodTotalUserTime", ctypes.c_longlong), ("ThisPeriodTotalKernelTime", ctypes.c_longlong), ("TotalPageFaultCount", wintypes.DWORD), ("TotalProcesses", wintypes.DWORD), ("ActiveProcesses", wintypes.DWORD), ("TotalTerminatedProcesses", wintypes.DWORD), ] class _JOBOBJECT_BASIC_PROCESS_ID_LIST(ctypes.Structure): _fields_ = [ ("NumberOfAssignedProcesses", wintypes.DWORD), ("NumberOfProcessIdsInList", wintypes.DWORD), ("ProcessIdList", ctypes.c_size_t * _MAX_JOB_PROCESS_IDS), ] class _JOBOBJECT_CPU_RATE_CONTROL_INFORMATION(ctypes.Structure): _fields_ = [ ("ControlFlags", wintypes.DWORD), ("Weight", wintypes.DWORD), ] class _MEMORY_PRIORITY_INFORMATION(ctypes.Structure): _fields_ = [("MemoryPriority", wintypes.ULONG)] class _FILETIME(ctypes.Structure): _fields_ = [ ("dwLowDateTime", wintypes.DWORD), ("dwHighDateTime", wintypes.DWORD), ] _P_BOOL = ctypes.POINTER(wintypes.BOOL) _P_DWORD = ctypes.POINTER(wintypes.DWORD) _P_FILETIME = ctypes.POINTER(_FILETIME) _KERNEL32 = ctypes.WinDLL("kernel32", use_last_error=True) _CREATE_JOB_OBJECT = _KERNEL32.CreateJobObjectW _CREATE_JOB_OBJECT.argtypes = [ctypes.c_void_p, wintypes.LPCWSTR] _CREATE_JOB_OBJECT.restype = wintypes.HANDLE _SET_INFORMATION_JOB_OBJECT = _KERNEL32.SetInformationJobObject _SET_INFORMATION_JOB_OBJECT.argtypes = [ wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ] _SET_INFORMATION_JOB_OBJECT.restype = wintypes.BOOL _QUERY_INFORMATION_JOB_OBJECT = _KERNEL32.QueryInformationJobObject _QUERY_INFORMATION_JOB_OBJECT.argtypes = [ wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, _P_DWORD, ] _QUERY_INFORMATION_JOB_OBJECT.restype = wintypes.BOOL _ASSIGN_PROCESS_TO_JOB_OBJECT = _KERNEL32.AssignProcessToJobObject _ASSIGN_PROCESS_TO_JOB_OBJECT.argtypes = [wintypes.HANDLE, wintypes.HANDLE] _ASSIGN_PROCESS_TO_JOB_OBJECT.restype = wintypes.BOOL _IS_PROCESS_IN_JOB = _KERNEL32.IsProcessInJob _IS_PROCESS_IN_JOB.argtypes = [wintypes.HANDLE, wintypes.HANDLE, _P_BOOL] _IS_PROCESS_IN_JOB.restype = wintypes.BOOL _GET_CURRENT_PROCESS = _KERNEL32.GetCurrentProcess _GET_CURRENT_PROCESS.argtypes = [] _GET_CURRENT_PROCESS.restype = wintypes.HANDLE _SET_HANDLE_INFORMATION = _KERNEL32.SetHandleInformation _SET_HANDLE_INFORMATION.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD] _SET_HANDLE_INFORMATION.restype = wintypes.BOOL _GET_HANDLE_INFORMATION = _KERNEL32.GetHandleInformation _GET_HANDLE_INFORMATION.argtypes = [wintypes.HANDLE, _P_DWORD] _GET_HANDLE_INFORMATION.restype = wintypes.BOOL _CLOSE_HANDLE = _KERNEL32.CloseHandle _CLOSE_HANDLE.argtypes = [wintypes.HANDLE] _CLOSE_HANDLE.restype = wintypes.BOOL _TERMINATE_JOB_OBJECT = _KERNEL32.TerminateJobObject _TERMINATE_JOB_OBJECT.argtypes = [wintypes.HANDLE, wintypes.UINT] _TERMINATE_JOB_OBJECT.restype = wintypes.BOOL _EXIT_PROCESS = _KERNEL32.ExitProcess _EXIT_PROCESS.argtypes = [wintypes.UINT] _EXIT_PROCESS.restype = None _GET_PROCESS_TIMES = _KERNEL32.GetProcessTimes _GET_PROCESS_TIMES.argtypes = [ wintypes.HANDLE, _P_FILETIME, _P_FILETIME, _P_FILETIME, _P_FILETIME, ] _GET_PROCESS_TIMES.restype = wintypes.BOOL _QUERY_FULL_PROCESS_IMAGE_NAME = _KERNEL32.QueryFullProcessImageNameW _QUERY_FULL_PROCESS_IMAGE_NAME.argtypes = [ wintypes.HANDLE, wintypes.DWORD, wintypes.LPWSTR, _P_DWORD, ] _QUERY_FULL_PROCESS_IMAGE_NAME.restype = wintypes.BOOL _SET_PROCESS_INFORMATION = _KERNEL32.SetProcessInformation _SET_PROCESS_INFORMATION.argtypes = [ wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ] _SET_PROCESS_INFORMATION.restype = wintypes.BOOL _GET_PROCESS_INFORMATION = _KERNEL32.GetProcessInformation _GET_PROCESS_INFORMATION.argtypes = [ wintypes.HANDLE, ctypes.c_int, ctypes.c_void_p, wintypes.DWORD, ] _GET_PROCESS_INFORMATION.restype = wintypes.BOOL else: _IO_COUNTERS = _JOBOBJECT_BASIC_LIMIT_INFORMATION = None _JOBOBJECT_EXTENDED_LIMIT_INFORMATION = None _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION = None _JOBOBJECT_BASIC_PROCESS_ID_LIST = _FILETIME = None _JOBOBJECT_CPU_RATE_CONTROL_INFORMATION = _MEMORY_PRIORITY_INFORMATION = None _KERNEL32 = None class OwnedProcessError(OSError): def __init__(self, stage, message, error_number=None, windows_error=None): super().__init__(error_number or 0, f"owned process {stage} failed: {message}") self.stage = stage self.winerror = windows_error def _write_all(fd, data): view = memoryview(data) while view: written = os.write(fd, view) if written <= 0: raise BrokenPipeError("control pipe closed") view = view[written:] def _read_exact(fd, size): chunks = [] remaining = size while remaining: chunk = os.read(fd, remaining) if not chunk: raise EOFError("pipe closed") chunks.append(chunk) remaining -= len(chunk) return b"".join(chunks) def _write_packet(fd, value): data = json.dumps(value, ensure_ascii=True, separators=(",", ":")).encode("utf-8") if len(data) > _MAX_PACKET_SIZE: raise ValueError("owned process control packet is too large") _write_all(fd, struct.pack("!I", len(data)) + data) def _read_packet(fd): size = struct.unpack("!I", _read_exact(fd, 4))[0] if size > _MAX_PACKET_SIZE: raise ValueError("owned process control packet is too large") return json.loads(_read_exact(fd, size).decode("utf-8")) def _serializable_command(args): if isinstance(args, (str, os.PathLike)): return os.fspath(args) if isinstance(args, bytes): raise TypeError("OwnedProcess does not accept bytes command arguments") values = [] for value in args: if isinstance(value, bytes): raise TypeError("OwnedProcess does not accept bytes command arguments") values.append(os.fspath(value) if isinstance(value, os.PathLike) else str(value)) return values def _serializable_environment(env): source = os.environ if env is None else env result = {} for key, value in source.items(): if isinstance(key, bytes) or isinstance(value, bytes): raise TypeError("OwnedProcess does not accept bytes environment entries") result[str(key)] = str(value) result.pop(_TEST_FAIL_JOB_ASSIGNMENT, None) return result def _validate_job_memory_limit_bytes(value): if isinstance(value, bool) or not isinstance(value, int): raise ValueError("job_memory_limit_bytes must be a non-negative integer") if value < 0: raise ValueError("job_memory_limit_bytes must be a non-negative integer") if value > (1 << (ctypes.sizeof(ctypes.c_size_t) * 8)) - 1: raise ValueError("job_memory_limit_bytes exceeds the host address size") return value def _validate_job_cpu_weight(value): if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 9: raise ValueError("job_cpu_weight must be 0 or an integer from 1 through 9") return value def _validate_process_memory_priority(value): if isinstance(value, bool) or not isinstance(value, int) or value < 0 or value > 5: raise ValueError("process_memory_priority must be 0 or an integer from 1 through 5") return value def _host_environment(): """The containment host never needs application authority credentials.""" env = os.environ.copy() for key in list(env): normalized = str(key).upper() if ( normalized.startswith("TRUF_SUPERVISOR_") or normalized.startswith("TRUF_POSTGRES_") or normalized in _HOST_PRIVATE_ENV_KEYS ): env.pop(key, None) return env def _require_isolated_host(): if not ( sys.flags.isolated and sys.flags.no_site and sys.flags.dont_write_bytecode ): raise RuntimeError("owned process host requires -I -S -B") def _status_reader(fd_owner, result_queue): try: fd = fd_owner.get_nowait() except queue.Empty: return try: result_queue.put((True, _read_packet(fd))) except BaseException as exc: result_queue.put((False, exc)) finally: try: os.close(fd) except OSError: pass def _validated_identity(value, pid): prefix = "windows-filetime:" if os.name == "nt" else "proc-start-ticks:" if not isinstance(value, dict) or type(pid) is not int or pid <= 0: raise OwnedProcessError("identity", "missing process identity") creation = value.get("creation_time") timestamp = value.get("creation_time_unix") executable = value.get("executable") try: valid_timestamp = type(timestamp) in (int, float) and math.isfinite(timestamp) and timestamp > 0 except OverflowError: valid_timestamp = False if ( type(value.get("pid")) is not int or value["pid"] != pid or not isinstance(creation, str) or not creation.startswith(prefix) or not creation[len(prefix):].isascii() or not creation[len(prefix):].isdigit() or not valid_timestamp or not isinstance(executable, str) or not os.path.isabs(executable) or "\0" in executable ): raise OwnedProcessError("identity", "incomplete or mismatched process identity") return dict(value) class OwnedProcess: """Popen-like process whose payload tree is owned by a host containment boundary.""" def __init__( self, args, bufsize=-1, executable=None, stdin=None, stdout=None, stderr=None, preexec_fn=None, close_fds=True, shell=False, cwd=None, env=None, universal_newlines=None, startupinfo=None, creationflags=0, restore_signals=True, start_new_session=False, pass_fds=(), *, user=None, group=None, extra_groups=None, encoding=None, errors=None, text=None, umask=-1, pipesize=-1, process_group=None, job_memory_limit_bytes=0, job_cpu_weight=0, process_memory_priority=0, _startup_timeout=15, ): if preexec_fn is not None: raise ValueError("OwnedProcess does not support preexec_fn") if not close_fds: raise ValueError("OwnedProcess requires close_fds=True") if startupinfo is not None: raise ValueError("OwnedProcess does not support caller startupinfo") if start_new_session: raise ValueError("OwnedProcess owns the payload session") if pass_fds: raise ValueError("OwnedProcess does not pass arbitrary file descriptors") if user is not None or group is not None or extra_groups is not None or umask != -1: raise ValueError("OwnedProcess does not support credential or umask changes") if process_group not in (None, -1): raise ValueError("OwnedProcess owns the payload process group") if int(creationflags or 0) & _CREATE_BREAKAWAY_FROM_JOB: raise ValueError("CREATE_BREAKAWAY_FROM_JOB is forbidden for owned processes") job_memory_limit_bytes = _validate_job_memory_limit_bytes(job_memory_limit_bytes) job_cpu_weight = _validate_job_cpu_weight(job_cpu_weight) process_memory_priority = _validate_process_memory_priority(process_memory_priority) self.args = args self._owner_pid = os.getpid() self._host_process = None self._control_fd = None self._control_lock = threading.Lock() self._payload_pid = None self._payload_identity = None self._host_identity = None self._job_membership_verified = False self._job_accounting = {} self._job_members = [] self._status_fd_owner = queue.SimpleQueue() self._configuration_sent = False config = { "args": _serializable_command(args), "bufsize": int(bufsize), "executable": os.fspath(executable) if executable is not None else None, "shell": bool(shell), "cwd": os.fspath(cwd) if cwd is not None else None, "env": _serializable_environment(env), "creationflags": int(creationflags or 0), "restore_signals": bool(restore_signals), "job_memory_limit_bytes": job_memory_limit_bytes, "job_cpu_weight": job_cpu_weight, "process_memory_priority": process_memory_priority, } # Windows venv executables are redirecting launchers. Retain the actual # stdlib-only observer, not a launcher with a different process identity. host_python = sys._base_executable if os.name == "nt" else sys.executable control_read, control_write = os.pipe() status_read, status_write = os.pipe() self._status_fd_owner.put(status_read) self._control_fd = control_write host_command = [ host_python, "-I", "-S", "-B", os.path.abspath(__file__), _HOST_FLAG, ] popen_options = { "stdin": stdin, "stdout": stdout, "stderr": stderr, "bufsize": bufsize, "universal_newlines": universal_newlines, "encoding": encoding, "errors": errors, "text": text, "close_fds": True, "pipesize": pipesize, "env": _host_environment(), } try: if os.name == "nt": import msvcrt control_handle = msvcrt.get_osfhandle(control_read) status_handle = msvcrt.get_osfhandle(status_write) host_command.extend([str(control_handle), str(status_handle)]) startup = subprocess.STARTUPINFO() startup.lpAttributeList = {"handle_list": [control_handle, status_handle]} popen_options["startupinfo"] = startup popen_options["creationflags"] = self._host_creationflags(config["creationflags"]) with _WINDOWS_INHERIT_LOCK: os.set_inheritable(control_read, True) os.set_inheritable(status_write, True) try: self._host_process = subprocess.Popen(host_command, **popen_options) finally: os.set_inheritable(control_read, False) os.set_inheritable(status_write, False) else: host_command.extend([str(control_read), str(status_write)]) popen_options["pass_fds"] = (control_read, status_write) # An inner observer must survive the outer payload-group stop. popen_options["start_new_session"] = True self._host_process = subprocess.Popen(host_command, **popen_options) except BaseException: self._close_fd(control_read) self._close_fd(control_write) self._cancel_status_reader() self._close_fd(status_write) self._control_fd = None raise self._close_fd(control_read) self._close_fd(status_write) try: _write_packet(self._control_fd, config) self._configuration_sent = True status = self._wait_for_startup(float(_startup_timeout)) if not isinstance(status, dict) or status.get("ok") is not True: detail = status if isinstance(status, dict) else {} raise OwnedProcessError( str(detail.get("stage") or "startup"), str(detail.get("message") or "host exited without a valid handshake"), detail.get("errno"), detail.get("winerror"), ) if status.get("job_membership_verified") is not True: raise OwnedProcessError("job_membership", "host did not prove payload containment") self._payload_pid = status.get("pid") if self._payload_pid == self._host_process.pid: raise OwnedProcessError("identity", "payload cannot be its own observer") self._payload_identity = _validated_identity(status.get("payload_identity"), self._payload_pid) self._host_identity = _validated_identity(status.get("host_identity"), self._host_process.pid) members = status.get("job_members") if not isinstance(members, list) or len(members) > _MAX_JOB_PROCESS_IDS: raise OwnedProcessError("identity", "invalid containment member list") self._job_members = [ _validated_identity(item, item.get("pid") if isinstance(item, dict) else None) for item in members ] if self._payload_identity not in self._job_members or self._host_identity not in self._job_members: raise OwnedProcessError("identity", "retained identities missing from containment members") accounting = status.get("job_accounting") if not isinstance(accounting, dict) or ( os.name != "nt" and accounting.get("child_subreaper_verified") is not True ): raise OwnedProcessError("job_membership", "missing containment accounting or subreaper proof") self._job_accounting = dict(accounting) self._job_membership_verified = True except BaseException: # An unclaimed read end can keep the host blocked on a full pipe. self._cancel_status_reader() self._request_stop() self._reap_failed_start() raise finally: self._cancel_status_reader() @staticmethod def _close_fd(fd): if fd is None: return try: os.close(fd) except OSError: pass @staticmethod def _host_creationflags(payload_flags): # Keep host visibility and scheduling aligned with the payload without # copying payload-only process-group flags. allowed = ( getattr(subprocess, "CREATE_NO_WINDOW", 0x08000000) | getattr(subprocess, "IDLE_PRIORITY_CLASS", 0x00000040) | getattr(subprocess, "BELOW_NORMAL_PRIORITY_CLASS", 0x00004000) | getattr(subprocess, "NORMAL_PRIORITY_CLASS", 0x00000020) | getattr(subprocess, "ABOVE_NORMAL_PRIORITY_CLASS", 0x00008000) | getattr(subprocess, "HIGH_PRIORITY_CLASS", 0x00000080) | getattr(subprocess, "REALTIME_PRIORITY_CLASS", 0x00000100) ) return int(payload_flags) & allowed def _cancel_status_reader(self): try: fd = self._status_fd_owner.get_nowait() except queue.Empty: return self._close_fd(fd) def _wait_for_startup(self, timeout): result_queue = queue.Queue(maxsize=1) # Either the reader claims the FD or failed construction cancels it. # Thread.start() interruption cannot make both sides own the same FD. thread = threading.Thread(target=_status_reader, args=(self._status_fd_owner, result_queue), daemon=True) thread.start() try: ok, result = result_queue.get(timeout=max(0.1, timeout)) except queue.Empty as exc: raise subprocess.TimeoutExpired(self.args, timeout) from exc if not ok: if self._host_process.poll() is not None: return { "ok": False, "stage": "startup", "message": f"host exited with code {self._host_process.returncode}", } raise result return result def _reap_failed_start(self): if self._host_process is None: return if os.name != "nt": # Keep the observer retained even if startup cleanup is interrupted. # Its exit, not a timeout or repeated Ctrl+C, acknowledges teardown. while True: try: self._host_process.wait() return except BaseException: try: time.sleep(0.05) except BaseException: pass try: self._host_process.wait(timeout=2) return except subprocess.TimeoutExpired: pass self._host_process.terminate() try: self._host_process.wait(timeout=2) except subprocess.TimeoutExpired: self._host_process.kill() self._host_process.wait() def _request_stop(self): if os.getpid() != self._owner_pid: # A forked proxy owns only its local FD, not the parent's job or lock. fd, self._control_fd = self._control_fd, None self._close_fd(fd) return with self._control_lock: fd = self._control_fd self._control_fd = None if fd is not None: try: if self._configuration_sent: # EOF alone is insufficient when a fork inherited a writer. if os.name != "nt": os.set_blocking(fd, False) os.write(fd, b"\0") except OSError: pass finally: self._close_fd(fd) @property def pid(self): return self._payload_pid @property def host_pid(self): return self._host_process.pid @property def payload_identity(self): return dict(self._payload_identity or {}) @property def host_identity(self): return dict(self._host_identity or {}) @property def job_membership_verified(self): return bool(self._job_membership_verified) def ownership_snapshot(self): return { "host_identity": self.host_identity, "payload_identity": self.payload_identity, "job_membership_verified": self.job_membership_verified, "job_accounting": dict(self._job_accounting), "active_members": [dict(item) for item in self._job_members], } @property def stdin(self): return self._host_process.stdin @property def stdout(self): return self._host_process.stdout @property def stderr(self): return self._host_process.stderr @property def returncode(self): return self._host_process.returncode def poll(self): code = self._host_process.poll() if code is not None: self._request_stop() return code def wait(self, timeout=None): try: try: return self._host_process.wait(timeout=timeout) except subprocess.TimeoutExpired as exc: raise subprocess.TimeoutExpired(self.args, timeout, output=exc.output, stderr=exc.stderr) from None finally: if self._host_process.returncode is not None: self._request_stop() def communicate(self, input=None, timeout=None): try: try: return self._host_process.communicate(input=input, timeout=timeout) except subprocess.TimeoutExpired as exc: raise subprocess.TimeoutExpired(self.args, timeout, output=exc.output, stderr=exc.stderr) from None finally: if self._host_process.returncode is not None: self._request_stop() def terminate(self): self._request_stop() def kill(self): self._request_stop() if os.name == "nt" and self._host_process.poll() is None: # Terminating only the retained host closes its sole Job handle. self._host_process.kill() def send_signal(self, sig): terminating = {signal.SIGTERM} if hasattr(signal, "SIGKILL"): terminating.add(signal.SIGKILL) if hasattr(signal, "CTRL_BREAK_EVENT"): terminating.add(signal.CTRL_BREAK_EVENT) if hasattr(signal, "CTRL_C_EVENT"): terminating.add(signal.CTRL_C_EVENT) if sig not in terminating: raise ValueError("OwnedProcess only supports containment-scoped stop signals") if hasattr(signal, "SIGKILL") and sig == signal.SIGKILL: self.kill() else: self.terminate() def __enter__(self): return self def __exit__(self, exc_type, value, traceback): if self.stdout: self.stdout.close() if self.stderr: self.stderr.close() try: if self.stdin: self.stdin.close() finally: self.wait() def __del__(self): try: self._request_stop() except BaseException: pass def run_owned(*popenargs, input=None, capture_output=False, timeout=None, check=False, **kwargs): if input is not None: if kwargs.get("stdin") is not None: raise ValueError("stdin and input arguments may not both be used") kwargs["stdin"] = subprocess.PIPE if capture_output: if kwargs.get("stdout") is not None or kwargs.get("stderr") is not None: raise ValueError("stdout/stderr and capture_output may not both be used") kwargs["stdout"] = subprocess.PIPE kwargs["stderr"] = subprocess.PIPE with OwnedProcess(*popenargs, **kwargs) as process: try: stdout, stderr = process.communicate(input, timeout=timeout) except subprocess.TimeoutExpired as exc: process.kill() exc.stdout, exc.stderr = process.communicate() raise except BaseException: process.kill() process.wait() raise code = process.poll() if check and code: raise subprocess.CalledProcessError(code, popenargs[0], output=stdout, stderr=stderr) return subprocess.CompletedProcess(popenargs[0], code, stdout, stderr) def _open_inherited_fd(value, write=False): if os.name != "nt": fd = int(value) os.set_inheritable(fd, False) return fd import msvcrt flags = os.O_WRONLY if write else os.O_RDONLY flags |= getattr(os, "O_BINARY", 0) fd = msvcrt.open_osfhandle(int(value), flags) os.set_inheritable(fd, False) return fd def _windows_job_limit_information(job_memory_limit_bytes=0): job_memory_limit_bytes = _validate_job_memory_limit_bytes(job_memory_limit_bytes) limits = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION() limits.BasicLimitInformation.LimitFlags = _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE if job_memory_limit_bytes: limits.BasicLimitInformation.LimitFlags |= _JOB_OBJECT_LIMIT_JOB_MEMORY limits.JobMemoryLimit = job_memory_limit_bytes return limits def _windows_job(job_memory_limit_bytes=0, job_cpu_weight=0): job = _CREATE_JOB_OBJECT(None, None) if not job: raise ctypes.WinError(ctypes.get_last_error()) try: handle_flag_inherit = 0x00000001 if not _SET_HANDLE_INFORMATION(job, handle_flag_inherit, 0): raise ctypes.WinError(ctypes.get_last_error()) handle_flags = wintypes.DWORD() if not _GET_HANDLE_INFORMATION(job, ctypes.byref(handle_flags)): raise ctypes.WinError(ctypes.get_last_error()) if handle_flags.value & handle_flag_inherit: raise OSError(errno.EPERM, "Job handle remained inheritable") limits = _windows_job_limit_information(job_memory_limit_bytes) if not _SET_INFORMATION_JOB_OBJECT(job, 9, ctypes.byref(limits), ctypes.sizeof(limits)): raise ctypes.WinError(ctypes.get_last_error()) if job_cpu_weight: cpu_policy = _JOBOBJECT_CPU_RATE_CONTROL_INFORMATION() cpu_policy.ControlFlags = ( _JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | _JOB_OBJECT_CPU_RATE_CONTROL_WEIGHT_BASED ) cpu_policy.Weight = int(job_cpu_weight) if not _SET_INFORMATION_JOB_OBJECT( job, _JOB_OBJECT_CPU_RATE_CONTROL_INFORMATION, ctypes.byref(cpu_policy), ctypes.sizeof(cpu_policy), ): raise ctypes.WinError(ctypes.get_last_error()) if os.getenv(_TEST_FAIL_JOB_ASSIGNMENT) == "1": raise OSError(errno.EPERM, "deliberate Job assignment failure") current_process = _GET_CURRENT_PROCESS() if not _ASSIGN_PROCESS_TO_JOB_OBJECT(job, current_process): raise ctypes.WinError(ctypes.get_last_error()) in_job = wintypes.BOOL() if not _IS_PROCESS_IN_JOB(current_process, job, ctypes.byref(in_job)): raise ctypes.WinError(ctypes.get_last_error()) if not in_job.value: raise OSError(errno.EPERM, "host Job membership verification failed") return _KERNEL32, job except BaseException: _CLOSE_HANDLE(job) raise def _windows_process_identity(handle, pid): creation = _FILETIME() ignored_exit = _FILETIME() ignored_kernel = _FILETIME() ignored_user = _FILETIME() if not _GET_PROCESS_TIMES( handle, ctypes.byref(creation), ctypes.byref(ignored_exit), ctypes.byref(ignored_kernel), ctypes.byref(ignored_user), ): raise ctypes.WinError(ctypes.get_last_error()) filetime = (int(creation.dwHighDateTime) << 32) | int(creation.dwLowDateTime) path = ctypes.create_unicode_buffer(32768) length = wintypes.DWORD(len(path)) if not _QUERY_FULL_PROCESS_IMAGE_NAME(handle, 0, path, ctypes.byref(length)): raise ctypes.WinError(ctypes.get_last_error()) return { "pid": int(pid), "creation_time": f"windows-filetime:{filetime}", "creation_time_unix": (filetime - 116444736000000000) / 10000000.0, "executable": os.path.normcase(os.path.realpath(os.path.abspath(path.value))), } def _windows_process_memory_priority(handle): policy = _MEMORY_PRIORITY_INFORMATION() if not _GET_PROCESS_INFORMATION( handle, _PROCESS_MEMORY_PRIORITY, ctypes.byref(policy), ctypes.sizeof(policy), ): raise ctypes.WinError(ctypes.get_last_error()) return int(policy.MemoryPriority) def _windows_set_process_memory_priority(handle, priority): policy = _MEMORY_PRIORITY_INFORMATION() policy.MemoryPriority = int(priority) if not _SET_PROCESS_INFORMATION( handle, _PROCESS_MEMORY_PRIORITY, ctypes.byref(policy), ctypes.sizeof(policy), ): raise ctypes.WinError(ctypes.get_last_error()) if _windows_process_memory_priority(handle) != int(priority): raise OSError(errno.EPERM, "process memory-priority verification failed") def _windows_verify_payload_job( job, payload, job_memory_limit_bytes=0, job_cpu_weight=0, process_memory_priority=0, ): payload_handle = wintypes.HANDLE(int(payload._handle)) in_job = wintypes.BOOL() if not _IS_PROCESS_IN_JOB(payload_handle, job, ctypes.byref(in_job)): raise ctypes.WinError(ctypes.get_last_error()) if not in_job.value: raise OSError(errno.EPERM, "payload is not in the exact retained Job") process_ids = _JOBOBJECT_BASIC_PROCESS_ID_LIST() returned = wintypes.DWORD() if not _QUERY_INFORMATION_JOB_OBJECT( job, 3, ctypes.byref(process_ids), ctypes.sizeof(process_ids), ctypes.byref(returned), ): raise ctypes.WinError(ctypes.get_last_error()) count = int(process_ids.NumberOfProcessIdsInList) assigned = int(process_ids.NumberOfAssignedProcesses) if count > _MAX_JOB_PROCESS_IDS or assigned > _MAX_JOB_PROCESS_IDS: raise OSError(errno.EOVERFLOW, "Job member list exceeds its configured bound") members = [int(process_ids.ProcessIdList[index]) for index in range(count)] if int(payload.pid) not in members: raise OSError(errno.EPERM, "payload PID is absent from the exact Job member list") accounting = _JOBOBJECT_BASIC_ACCOUNTING_INFORMATION() if not _QUERY_INFORMATION_JOB_OBJECT( job, 1, ctypes.byref(accounting), ctypes.sizeof(accounting), ctypes.byref(returned), ): raise ctypes.WinError(ctypes.get_last_error()) limits = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION() if not _QUERY_INFORMATION_JOB_OBJECT( job, 9, ctypes.byref(limits), ctypes.sizeof(limits), ctypes.byref(returned), ): raise ctypes.WinError(ctypes.get_last_error()) limit_flags = int(limits.BasicLimitInformation.LimitFlags) if not limit_flags & _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: raise OSError(errno.EPERM, "Job kill-on-close limit is missing") if job_memory_limit_bytes: if not limit_flags & _JOB_OBJECT_LIMIT_JOB_MEMORY: raise OSError(errno.EPERM, "Job memory limit is missing") if int(limits.JobMemoryLimit) != int(job_memory_limit_bytes): raise OSError(errno.EPERM, "Job memory limit verification failed") if job_cpu_weight: cpu_policy = _JOBOBJECT_CPU_RATE_CONTROL_INFORMATION() if not _QUERY_INFORMATION_JOB_OBJECT( job, _JOB_OBJECT_CPU_RATE_CONTROL_INFORMATION, ctypes.byref(cpu_policy), ctypes.sizeof(cpu_policy), ctypes.byref(returned), ): raise ctypes.WinError(ctypes.get_last_error()) expected_flags = ( _JOB_OBJECT_CPU_RATE_CONTROL_ENABLE | _JOB_OBJECT_CPU_RATE_CONTROL_WEIGHT_BASED ) if int(cpu_policy.ControlFlags) != expected_flags or int(cpu_policy.Weight) != int(job_cpu_weight): raise OSError(errno.EPERM, "Job CPU-weight verification failed") payload_memory_priority = _windows_process_memory_priority(payload_handle) if process_memory_priority and payload_memory_priority != int(process_memory_priority): raise OSError(errno.EPERM, "payload memory-priority verification failed") payload_identity = _windows_process_identity(payload_handle, payload.pid) host_identity = _windows_process_identity(_GET_CURRENT_PROCESS(), os.getpid()) member_identities = [host_identity] if payload_identity["pid"] != host_identity["pid"]: member_identities.append(payload_identity) return { "payload_identity": payload_identity, "host_identity": host_identity, "job_members": member_identities, "job_accounting": { "assigned_processes": assigned, "listed_processes": count, "active_processes": int(accounting.ActiveProcesses), "total_processes": int(accounting.TotalProcesses), "terminated_processes": int(accounting.TotalTerminatedProcesses), "peak_job_memory_bytes": int(limits.PeakJobMemoryUsed), "job_memory_limit_bytes": int(limits.JobMemoryLimit), "job_limit_flags": limit_flags, "job_cpu_weight": int(job_cpu_weight), "process_memory_priority": payload_memory_priority, }, } def _host_error(stage, exc): return { "ok": False, "stage": stage, "message": str(exc) or type(exc).__name__, "errno": getattr(exc, "errno", None), "winerror": getattr(exc, "winerror", None), } def _host_stdio(fd): try: os.fstat(fd) return fd except OSError: return None def _terminate_posix_group(process_group): try: os.killpg(process_group, signal.SIGKILL) except ProcessLookupError: pass def _linux_process_identity(pid): try: with open(f"/proc/{pid}/stat", "rb") as handle: head, separator, tail = handle.read().rpartition(b") ") fields = tail.split() if not separator or int(head.partition(b" (")[0]) != pid: raise ValueError("invalid proc process ID") start_ticks = int(fields[19]) if start_ticks < 0: raise ValueError("invalid proc start time") executable = os.readlink(f"/proc/{pid}/exe") if not os.path.isabs(executable): raise ValueError("nonabsolute proc executable") executable = os.path.normcase(os.path.realpath(os.path.abspath(executable))) clock_ticks = int(os.sysconf("SC_CLK_TCK")) boot_time = None with open("/proc/stat", "rb") as handle: for line in handle: if line.startswith(b"btime "): boot_time = int(line.split()[1]) break if not boot_time or boot_time < 0 or clock_ticks <= 0: raise ValueError("proc boot time or clock rate unavailable") identity = { "pid": pid, "creation_time": f"proc-start-ticks:{start_ticks}", "creation_time_unix": boot_time + start_ticks / clock_ticks, "executable": executable, } return _validated_identity(identity, pid) except (OSError, ValueError, IndexError, OverflowError) as exc: raise OwnedProcessError("identity", f"unable to inspect retained Linux process {pid}") from exc def _linux_enable_subreaper(): if sys.platform != "linux" or not all( hasattr(os, name) for name in ( "waitid", "WNOWAIT", "WEXITED", "WNOHANG", "P_ALL", "CLD_EXITED", "CLD_KILLED", "CLD_DUMPED", ) ): raise OSError(errno.ENOSYS, "owned POSIX processes require Linux subreapers and waitid") prctl = ctypes.CDLL(None, use_errno=True).prctl prctl.argtypes = [ctypes.c_int] + [ctypes.c_ulong] * 4 prctl.restype = ctypes.c_int enabled = ctypes.c_int() if prctl(36, 1, 0, 0, 0) != 0 or prctl(37, ctypes.addressof(enabled), 0, 0, 0) != 0: raise OSError(ctypes.get_errno(), "unable to establish child subreaper") if enabled.value != 1: raise OSError(errno.EPERM, "child subreaper verification failed") # An inherited SIGCHLD ignore disposition would auto-reap the group leader. signal.signal(signal.SIGCHLD, signal.SIG_DFL) if os.getpgid(0) != os.getpid() or os.getsid(0) != os.getpid(): raise OSError(errno.EPERM, "Linux observer is not in its own session") def _linux_payload_returncode(payload): # Drain adoptees while the root is alive, but bound each pass so sustained # child exits cannot starve control requests. Never reap the root here. for _ in range(_MAX_REAP_BATCH): status = os.waitid(os.P_ALL, 0, os.WEXITED | os.WNOHANG | os.WNOWAIT) if status is None: return None if status.si_code == os.CLD_EXITED: returncode = status.si_status elif status.si_code in (os.CLD_KILLED, os.CLD_DUMPED): returncode = -status.si_status else: raise OSError(errno.ECHILD, "unexpected retained child wait status") if status.si_pid == payload.pid: return returncode if status.si_pid <= 0: raise OSError(errno.ECHILD, "invalid retained child wait process") os.waitpid(status.si_pid, 0) return None def _linux_direct_children(): try: with open( f"/proc/{os.getpid()}/task/{os.getpid()}/children", "r", encoding="ascii", ) as handle: values = handle.read(1024 * 1024).split() except OSError as exc: raise OwnedProcessError("descendant_enumeration", "unable to enumerate owned Linux descendants") from exc if len(values) > _MAX_JOB_PROCESS_IDS: raise OwnedProcessError("descendant_enumeration", "owned Linux descendant count exceeds its bound") children = [] for value in values: try: pid = int(value) except ValueError as exc: raise OwnedProcessError("descendant_enumeration", "invalid owned Linux descendant identity") from exc if pid <= 0 or pid == os.getpid(): raise OwnedProcessError("descendant_enumeration", "invalid owned Linux descendant process ID") children.append(pid) return children def _linux_kill_exact_descendant(pid): try: identity = _linux_process_identity(pid) except OwnedProcessError: return pidfd = None try: if hasattr(os, "pidfd_open"): try: pidfd = os.pidfd_open(pid, 0) except OSError: pidfd = None sender = getattr(signal, "pidfd_send_signal", None) if pidfd is not None and sender is not None: sender(pidfd, signal.SIGKILL, None, 0) else: current = _linux_process_identity(pid) if ( current["creation_time"] != identity["creation_time"] or current["executable"] != identity["executable"] ): return os.kill(pid, signal.SIGKILL) try: process_group = os.getpgid(pid) except ProcessLookupError: return if process_group > 0 and process_group != os.getpgrp(): _terminate_posix_group(process_group) except ProcessLookupError: pass finally: if pidfd is not None: os.close(pidfd) def _linux_reap_adopted_batch(): reaped = 0 for _ in range(_MAX_REAP_BATCH): try: pid, _status = os.waitpid(-1, getattr(os, "WNOHANG", 1)) except InterruptedError: continue except ChildProcessError: return reaped if pid == 0: return reaped reaped += 1 return reaped def _linux_stop_and_reap(payload): if payload.returncode is None: # Keep the leader unreaped until after killpg, pinning its numeric PGID. _terminate_posix_group(payload.pid) payload.wait() while True: _linux_reap_adopted_batch() children = _linux_direct_children() if not children: # Recheck after a scheduling point so a just-killed intermediate # cannot publish an adopted child after teardown was acknowledged. time.sleep(0.01) _linux_reap_adopted_batch() if not _linux_direct_children(): try: os.waitpid(-1, getattr(os, "WNOHANG", 1)) except ChildProcessError: return continue for pid in children: _linux_kill_exact_descendant(pid) # Never exit while an escaped-session descendant remains. Each pass is # bounded; an unkillable process keeps this observer alive and prevents # the owner from mistaking host exit for successful containment teardown. time.sleep(0.01) def _exit_like_payload(returncode): if os.name == "nt": # Process teardown closes the host's sole Job handle after preserving # the payload's full DWORD exit code, killing any remaining members. _EXIT_PROCESS(int(returncode) & 0xFFFFFFFF) raise AssertionError("ExitProcess returned") if returncode < 0: signum = -int(returncode) if signum != signal.SIGKILL: signal.signal(signum, signal.SIG_DFL) signal.pthread_sigmask(signal.SIG_UNBLOCK, {signum}) os.kill(os.getpid(), signum) os._exit(int(returncode) & 0xFF) def _host_main(control_value, status_value): control_fd = _open_inherited_fd(control_value) status_fd = _open_inherited_fd(status_value, write=True) status_sent = False stage = "configuration" payload = None job_api = None job = None stop_event = threading.Event() signal_stop_requested = False def request_stop(signum=None, frame=None): nonlocal signal_stop_requested signal_stop_requested = True if os.name != "nt": signal.signal(signal.SIGTERM, request_stop) signal.signal(signal.SIGINT, request_stop) try: _require_isolated_host() config = _read_packet(control_fd) if int(config.get("creationflags") or 0) & _CREATE_BREAKAWAY_FROM_JOB: raise ValueError("CREATE_BREAKAWAY_FROM_JOB is forbidden for owned processes") job_memory_limit_bytes = _validate_job_memory_limit_bytes(config.get("job_memory_limit_bytes", 0)) job_cpu_weight = _validate_job_cpu_weight(config.get("job_cpu_weight", 0)) process_memory_priority = _validate_process_memory_priority( config.get("process_memory_priority", 0) ) if os.name == "nt": stage = "job_assignment" if process_memory_priority: _windows_set_process_memory_priority( _GET_CURRENT_PROCESS(), process_memory_priority, ) job_api, job = _windows_job(job_memory_limit_bytes, job_cpu_weight) else: stage = "containment" _linux_enable_subreaper() if signal_stop_requested or stop_event.is_set(): raise InterruptedError("parent stopped before payload launch") stage = "payload_launch" payload_options = { "stdin": _host_stdio(0), "stdout": _host_stdio(1), "stderr": _host_stdio(2), "bufsize": int(config.get("bufsize", -1)), "executable": config.get("executable"), "shell": bool(config.get("shell")), "cwd": config.get("cwd"), "env": config.get("env"), "restore_signals": bool(config.get("restore_signals", True)), "close_fds": True, } if os.name == "nt": payload_options["creationflags"] = int(config.get("creationflags") or 0) else: payload_options["start_new_session"] = True payload = subprocess.Popen(config["args"], **payload_options) if os.name == "nt": stage = "job_membership" membership = _windows_verify_payload_job( job, payload, job_memory_limit_bytes, job_cpu_weight, process_memory_priority, ) status = { "ok": True, "pid": payload.pid, "job_membership_verified": True, **membership, } else: stage = "identity" payload_identity = _linux_process_identity(payload.pid) host_identity = _linux_process_identity(os.getpid()) if os.getpgid(payload.pid) != payload.pid or os.getsid(payload.pid) != payload.pid: raise OSError(errno.EPERM, "payload is not in its owned Linux session") status = { "ok": True, "pid": payload.pid, "job_membership_verified": True, "payload_identity": payload_identity, "host_identity": host_identity, "job_members": [host_identity, payload_identity], "job_accounting": {"child_subreaper_verified": True, "listed_processes": 2}, } _write_packet(status_fd, status) status_sent = True os.close(status_fd) status_fd = None def watch_parent(): try: os.read(control_fd, 1) except OSError: pass stop_event.set() threading.Thread(target=watch_parent, daemon=True).start() while True: returncode = payload.poll() if os.name == "nt" else _linux_payload_returncode(payload) if returncode is not None: if os.name != "nt": _linux_stop_and_reap(payload) return _exit_like_payload(returncode) if signal_stop_requested or stop_event.wait(0.05): if os.name == "nt": job_api.TerminateJobObject(job, 1) job_api.ExitProcess(1) else: _linux_stop_and_reap(payload) os._exit(1) except BaseException as exc: if not status_sent and status_fd is not None: try: _write_packet(status_fd, _host_error(stage, exc)) except BaseException: pass if payload is not None and os.name != "nt": while True: try: _linux_stop_and_reap(payload) break except OSError: # Cleanup failure must leave the observer alive, not let a # caller mistake its exit for confirmed containment teardown. time.sleep(0.05) if os.name == "nt" and job is not None: job_api.ExitProcess(127) return 127 finally: if status_fd is not None: try: os.close(status_fd) except OSError: pass try: os.close(control_fd) except OSError: pass if __name__ == "__main__" and len(sys.argv) == 4 and sys.argv[1] == _HOST_FLAG: sys.exit(_host_main(sys.argv[2], sys.argv[3]))