From db76fc27e55d5b44df05b2913a79081cef374fd9 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 13 Jul 2026 10:23:20 +0000 Subject: [PATCH 1/3] Studio: OS-level sandbox for python and terminal tool execution (bubblewrap + Seatbelt) Wrap the Studio backend's python and terminal tool subprocesses in an OS-level sandbox: bubblewrap on Linux, Seatbelt (sandbox-exec) on macOS. This confines LLM-driven tool execution to the per-session workdir with a read-only view of the OS and interpreter, a fresh /tmp, and no network, instead of running with the full privileges of the Studio user. This ports the sandbox core from PR #5468 onto current main and redoes the integration against today's tool-execution path. The existing in-process guard (code-safety AST scan, command blocklist, credential-free env, rlimits, /proc environ hardening) is unchanged and becomes a defense-in-depth layer: the OS sandbox is the primary boundary when available, and execution falls back to the existing guarded path (with a warning) when it is not. core/inference/sandbox.py (new): - sandbox_available(): cached, thread-safe, three-way probe (ok / fail / transient-timeout) that confirms the primitive can actually apply in this process context, not just that the binary exists. A transient timeout is not cached, so a one-off slow probe does not disable the sandbox for the whole process lifetime. - build_sandbox_argv(inner_argv, workdir): the Linux bwrap argv (--unshare-all, fresh /proc /dev /tmp, read-only OS + interpreter binds, rw workdir bind, narrowed /etc) or the macOS Seatbelt profile (deny default, narrow read allow, deny network, deny Keychain / LaunchServices browser-escape). NPROC is reapplied inside the Linux user namespace by a small inner wrapper. core/inference/tools.py: - _python_exec / _bash_exec wrap the interpreter / shell argv with build_sandbox_argv when sandbox_available() and not Bypass Permissions. - _sandbox_preexec is split into _sandbox_preexec_impl(apply_no_new_privs, apply_nproc); the Linux bwrap path uses _sandbox_preexec_for_bwrap, which skips PR_SET_NO_NEW_PRIVS (breaks the setuid bwrap helper) and the per-real-UID RLIMIT_NPROC (can EAGAIN bwrap's fork on busy hosts). macOS keeps the full pre-exec. - _get_workdir canonicalizes the workdir with realpath so the child's cwd and the bwrap bind always match on symlinked-$HOME hosts. - UNSLOTH_STUDIO_SANDBOX_STRICT=1 (opt-in) makes execution fail closed when the sandbox cannot be applied; the default stays fail-open so locked-down installs keep working. run.py warms the availability probe at startup. install.sh installs bubblewrap best-effort on Linux (never fatal; missing bwrap only downgrades to the in-process guard). CI installs bubblewrap, relaxes the Ubuntu 24.04 userns AppArmor restriction, and gates the enforcement tests on a probe so they run where the sandbox applies and skip (green) where it cannot. Co-authored-by: Nilay <118994073+NilayYadav@users.noreply.github.com> --- .github/workflows/studio-backend-ci.yml | 20 + install.sh | 24 + studio/backend/core/inference/sandbox.py | 610 +++++++++++++++++ studio/backend/core/inference/tools.py | 154 ++++- studio/backend/run.py | 14 + .../backend/tests/test_bypass_permissions.py | 24 +- studio/backend/tests/test_sandbox.py | 622 ++++++++++++++++++ 7 files changed, 1448 insertions(+), 20 deletions(-) create mode 100644 studio/backend/core/inference/sandbox.py create mode 100644 studio/backend/tests/test_sandbox.py diff --git a/.github/workflows/studio-backend-ci.yml b/.github/workflows/studio-backend-ci.yml index 3022127a2b..177f22f183 100644 --- a/.github/workflows/studio-backend-ci.yml +++ b/.github/workflows/studio-backend-ci.yml @@ -80,6 +80,26 @@ jobs: pip install --index-url https://download.pytorch.org/whl/cpu --extra-index-url https://pypi.org/simple 'torch>=2.4,<2.11' pip install 'transformers>=4.51,<5.5' + - name: Enable and probe the OS sandbox (bubblewrap) + # Install bubblewrap and confirm it can actually build a namespace on + # this runner. Ubuntu 24.04 restricts unprivileged user namespaces via + # AppArmor, so relax that knob first (best effort). The enforcement + # tests in tests/test_sandbox.py only fail-closed when + # UNSLOTH_STUDIO_SANDBOX_CI_ENFORCE=1, which is exported below only when + # this probe confirms the sandbox applies here; otherwise those tests + # self-skip and the run stays green with a warning. + run: | + sudo apt-get update -y + sudo apt-get install -y bubblewrap + sudo sysctl -w kernel.apparmor_restrict_unprivileged_userns=0 2>/dev/null || true + if bwrap --ro-bind / / --unshare-all --die-with-parent /usr/bin/true 2>/tmp/bwrap_probe.txt; then + echo "UNSLOTH_STUDIO_SANDBOX_CI_ENFORCE=1" >> "$GITHUB_ENV" + echo "bwrap probe OK: OS-sandbox enforcement tests will run" + else + echo "::warning::bwrap probe failed on this runner; OS-sandbox enforcement tests will skip" + cat /tmp/bwrap_probe.txt || true + fi + - name: Backend tests working-directory: studio/backend # Locally validated against this dep set: 831 passed, 5 skipped, 35 deselected. diff --git a/install.sh b/install.sh index 3bf6fd1855..8484be5e0b 100755 --- a/install.sh +++ b/install.sh @@ -1732,6 +1732,30 @@ case "$OS" in else step "deps" "all system dependencies found" fi + + # OS-level sandbox (bubblewrap): best effort, never fatal. The studio + # backend wraps python/terminal tool execution in bwrap when it is + # present; when it is absent tool execution falls back to the in-process + # guard and logs a warning (set UNSLOTH_STUDIO_SANDBOX_STRICT=1 to refuse + # instead of falling back). Missing bwrap must not block install, so this + # is intentionally outside the required-dependency handling above. + if command -v bwrap >/dev/null 2>&1; then + step "sandbox" "bubblewrap found (OS-level tool sandbox enabled)" + elif command -v apt-get >/dev/null 2>&1; then + apt-get install -y bubblewrap /dev/null 2>&1 || true + if ! command -v bwrap >/dev/null 2>&1 && command -v sudo >/dev/null 2>&1; then + sudo -n apt-get install -y bubblewrap /dev/null 2>&1 || true + fi + if command -v bwrap >/dev/null 2>&1; then + step "sandbox" "installed bubblewrap (OS-level tool sandbox enabled)" + else + step "sandbox" "bubblewrap not installed; tool execution uses the in-process guard only" "$C_WARN" + substep "Optional: sudo apt-get install -y bubblewrap (enables the OS-level sandbox)" + fi + else + step "sandbox" "bubblewrap not found; tool execution uses the in-process guard only" "$C_WARN" + substep "Optional: install 'bubblewrap' with your package manager to enable the OS-level sandbox" + fi ;; esac diff --git a/studio/backend/core/inference/sandbox.py b/studio/backend/core/inference/sandbox.py new file mode 100644 index 0000000000..eecb7babf9 --- /dev/null +++ b/studio/backend/core/inference/sandbox.py @@ -0,0 +1,610 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +OS-level sandbox wrapper for tool execution. +""" + +import os +import shutil +import site +import subprocess +import sys +import threading + +from loggers import get_logger + +logger = get_logger(__name__) + +_SANDBOX_EXEC = "/usr/bin/sandbox-exec" +_BWRAP_PROBE_BIN = shutil.which("true") or "/usr/bin/true" + +_sandbox_available_cache: bool | None = None +# Absolute path to ``bwrap``, resolved once at probe time so the runtime +# sandbox argv doesn't depend on the child's PATH (``_build_safe_env`` +# strips PATH down to a fixed allow-list that won't cover Nix-style or +# custom-prefix installs). +_linux_bwrap_path: str | None = None +# Guards probe + cache so concurrent first-callers see a consistent +# (cache, bwrap_path) snapshot rather than racing on partial writes. +_sandbox_probe_lock = threading.Lock() + +# Extra macOS exec/read prefixes Studio actually puts on PATH via +# _build_safe_env (Homebrew on Intel + Apple Silicon). Without these +# in the Seatbelt profile, a tool like `bash_exec("uv --version")` or +# even `bash` itself resolving to /usr/local/bin/bash fails with +# Operation not permitted on common dev macs. +_MACOS_EXTRA_EXEC_PREFIXES = ( + "/usr/local/bin", + "/usr/local/lib", + "/usr/local/sbin", + "/usr/local/opt", + "/usr/local/Cellar", + "/opt/homebrew/bin", + "/opt/homebrew/lib", + "/opt/homebrew/sbin", + "/opt/homebrew/opt", + "/opt/homebrew/Cellar", +) + + +class _ProbeResult: + """Three-way probe result: True / False / transient timeout. + + The caller treats a transient timeout as "do not cache; let the + next caller re-probe". A definite True or False (binary missing, + bwrap setuid helper denied, kernel userns refusal) is cacheable + for the lifetime of the process. + """ + + __slots__ = ("ok", "transient") + + def __init__( + self, + ok: bool, + transient: bool = False, + ): + self.ok = ok + self.transient = transient + + +def _probe(argv: list[str], label: str) -> _ProbeResult: + """Run *argv*; return _ProbeResult(ok=..., transient=...). + + ``transient=True`` means the answer might change next time (e.g. + timed out under IO load); the caller should NOT cache the False. + """ + try: + proc = subprocess.run( + argv, + stdout = subprocess.PIPE, + stderr = subprocess.PIPE, + timeout = 5, + ) + except subprocess.TimeoutExpired as e: + # Slow runner / loaded box / cold filesystem. Don't pin the + # answer to False forever; let the next caller re-probe. + logger.warning("%s probe timed out (%s); will retry on next tool call", label, e) + return _ProbeResult(ok = False, transient = True) + except OSError as e: + logger.warning("%s probe failed (%s); tool execution will run unsandboxed", label, e) + return _ProbeResult(ok = False) + if proc.returncode != 0: + stderr_tail = proc.stderr.decode("utf-8", errors = "replace").strip()[-200:] + logger.warning( + "%s present but probe returned %s; tool execution will run unsandboxed. stderr: %s", + label, + proc.returncode, + stderr_tail, + ) + return _ProbeResult(ok = False) + return _ProbeResult(ok = True) + + +def _macos_probe() -> _ProbeResult: + if not os.path.exists(_SANDBOX_EXEC): + logger.warning("macOS sandbox unavailable (sandbox-exec missing)") + return _ProbeResult(ok = False) + return _probe( + [_SANDBOX_EXEC, "-p", "(version 1)(allow default)", "/usr/bin/true"], + "macOS sandbox-exec", + ) + + +def _linux_probe() -> _ProbeResult: + """Smoke-test that ``bwrap`` can apply a minimal sandbox here. + + Catches the cases where the kernel refuses to create unprivileged + user namespaces — surfacing at startup instead of first use + """ + global _linux_bwrap_path + bwrap = shutil.which("bwrap") + if bwrap is None: + logger.warning("bwrap not found on PATH; tool execution will run unsandboxed") + return _ProbeResult(ok = False) + result = _probe( + [ + bwrap, + "--ro-bind", + "/", + "/", + "--unshare-all", + "--die-with-parent", + _BWRAP_PROBE_BIN, + ], + "Linux bwrap", + ) + if result.ok: + _linux_bwrap_path = bwrap + return result + + +def sandbox_available() -> bool: + """True iff the platform's sandbox can be applied in this process context. + + Existence of the binary alone is not enough: a nested-sandboxed + parent may have ``sandbox-exec`` / ``bwrap`` present but be unable + to apply additional policies. Confirm by spawning a no-op sandboxed + ``/usr/bin/true`` once at first call and caching the result. + + Thread-safe: the run.py background probe and concurrent tool calls + can hit this entry point at the same time. The lock prevents a + slow-failing probe from overwriting a fast-succeeding probe (or + vice versa) and ensures _linux_bwrap_path is set before any caller + observes _sandbox_available_cache=True. + + A transient probe TIMEOUT (slow runner, cold filesystem, loaded + host) is NOT cached: the next caller re-probes. Without this, a + one-off timeout would pin the answer to "unavailable" for the + entire Studio process lifetime even after the underlying load + cleared. + """ + global _sandbox_available_cache + if _sandbox_available_cache is not None: + return _sandbox_available_cache + + with _sandbox_probe_lock: + if _sandbox_available_cache is not None: + return _sandbox_available_cache + + if sys.platform == "darwin": + result = _macos_probe() + label = "macOS Seatbelt" + elif sys.platform == "linux": + result = _linux_probe() + label = "Linux bubblewrap" + else: + result = _ProbeResult(ok = False) + label = "no sandbox primitive for this platform" + + ok = result.ok + if not result.transient: + _sandbox_available_cache = ok + if ok: + logger.info("%s sandbox available; tool execution sandboxed", label) + elif sys.platform not in ("darwin", "linux"): + logger.warning("%s; tool execution will run unsandboxed", label) + return ok + + +def _safe_subpath(p: str) -> str: + """Reject paths that cannot be safely embedded in a Seatbelt literal. + + Seatbelt string literals use ``"..."`` with ``\\`` escapes; a path + containing ``"``, ``\\``, a newline, or a NUL byte could close the + string and inject Scheme into the profile. macOS paths in practice + contain none of these, so rejecting them is safer than escaping. + """ + if any(c in p for c in ('"', "\\", "\n", "\r", "\x00")): + raise ValueError(f"path unsafe for Seatbelt profile: {p!r}") + return p + + +def _editable_source_paths() -> list[str]: + """Source dirs registered by PEP 660 editable installs. + + Read from the parent's ``sys.modules``; valid for the child only + while it shares ``sys.executable`` with the parent. + """ + paths: list[str] = [] + for name, mod in list(sys.modules.items()): + if not (name.startswith("__editable___") and name.endswith("_finder")): + continue + paths.extend(getattr(mod, "MAPPING", {}).values()) + for ns_paths in getattr(mod, "NAMESPACES", {}).values(): + paths.extend(ns_paths) + return paths + + +def _exec_chain_symlinks(executable: str) -> list[str]: + """Symlinks encountered while resolving *executable* to its real binary. + + Returned paths are the symlinks themselves (not their targets). The + Linux bwrap argv binds each one so that, inside the sandbox, the + kernel can follow the chain during ``execve`` — otherwise it hits + ``ENOENT`` on an intermediate symlink we never mounted. + """ + out: list[str] = [] + seen_links: set[str] = set() + current = executable + for _ in range(40): # cycle guard against pathological symlink loops + parts = current.split(os.sep) + prefix = "/" + for p in parts[1:]: + prefix = os.path.normpath(os.path.join(prefix, p)) + if prefix in seen_links: + continue + try: + if os.path.islink(prefix): + seen_links.add(prefix) + out.append(prefix) + except OSError as e: + logger.debug("exec-chain islink(%s) failed: %s", prefix, e) + try: + if not os.path.islink(current): + break + target = os.readlink(current) + except OSError as e: + logger.debug("exec-chain readlink(%s) failed: %s", current, e) + break + if not target.startswith(os.sep): + target = os.path.normpath(os.path.join(os.path.dirname(current), target)) + if target == current: + break + current = target + return out + + +def _python_read_paths() -> list[str]: + """Real dirs the Python interpreter needs to read at runtime. + + Returns ``sys.prefix``, ``sys.base_prefix``, system site-packages, + user site-packages, and editable-install source dirs — all + realpath-normalized, deduplicated, and filtered to existing dirs. + Used by both the macOS Seatbelt profile and the Linux bwrap argv. + """ + candidates: list[str] = [sys.prefix, sys.base_prefix] + candidates.extend(site.getsitepackages()) + # user-site is under real $HOME; exposing it defeats the deny-$HOME stance. + if os.environ.get("UNSLOTH_STUDIO_SANDBOX_ALLOW_USER_SITE") == "1": + user_site = site.getusersitepackages() + if user_site: + candidates.append(user_site) + candidates.extend(_editable_source_paths()) + + seen: set[str] = set() + out: list[str] = [] + for p in candidates: + if not p: + continue + rp = os.path.realpath(p) + if rp in seen or not os.path.isdir(rp): + continue + seen.add(rp) + out.append(rp) + return out + + +def _macos_seatbelt_profile(workdir: str) -> str: + """Build a Seatbelt profile string for ``sandbox-exec -p``.""" + py_subpaths = [f'(subpath "{_safe_subpath(p)}")' for p in _python_read_paths()] + + wd = _safe_subpath(os.path.realpath(workdir)) + py_block = "\n ".join(py_subpaths) + # Optional Homebrew prefixes (Intel + Apple Silicon). Skipped when + # the directory doesn't exist so the profile stays minimal on macs + # without Homebrew. Without this, _build_safe_env's PATH includes + # /usr/local/bin but Seatbelt blocks exec/read there, and a stock + # `bash` that resolves to /usr/local/bin/bash fails. + homebrew_subpaths = [ + f'(subpath "{_safe_subpath(p)}")' for p in _MACOS_EXTRA_EXEC_PREFIXES if os.path.isdir(p) + ] + workdir_subpath = f'(subpath "{wd}")' + # Paths the kernel needs mmap(PROT_EXEC) on so the loader can map + # binaries and dylibs as code. Narrower than the full read allow + # because most things we permit reads of are data, not executables. + # workdir is included so a tool that compiles + dlopens a local + # .dylib in its session folder works on macOS, matching how the + # Linux side allows exec from the bind-mounted workdir. + executable_map_block = "\n ".join( + [ + '(subpath "/usr/lib")', + '(subpath "/usr/bin")', + '(subpath "/bin")', + '(subpath "/System/Library/Frameworks")', + '(subpath "/System/Library/PrivateFrameworks")', + '(subpath "/System/Cryptexes")', + '(subpath "/System/Volumes/Preboot/Cryptexes")', + '(subpath "/Library/Frameworks")', + *py_subpaths, + *homebrew_subpaths, + workdir_subpath, + ] + ) + + # Same symmetry on process-exec: tools may need to run ./run.sh + # they just generated inside the workdir; Linux already allows that + # via the workdir bind. + process_exec_block = "\n ".join( + [ + '(subpath "/usr/lib")', + '(subpath "/usr/bin")', + '(subpath "/bin")', + '(subpath "/System/Library/Frameworks")', + '(subpath "/System/Library/PrivateFrameworks")', + '(subpath "/System/Cryptexes")', + '(subpath "/System/Volumes/Preboot/Cryptexes")', + '(subpath "/Library/Frameworks")', + *py_subpaths, + *homebrew_subpaths, + workdir_subpath, + ] + ) + + homebrew_read_block = "\n " + "\n ".join(homebrew_subpaths) if homebrew_subpaths else "" + + return f"""(version 1) +(deny default) + +(allow process-fork) +(allow process-exec + {process_exec_block} +) +(allow signal (target self)) +(allow process-info-pidinfo (target self)) +(allow process-info-pidfdinfo (target self)) +(allow sysctl-read) +(allow ipc-posix-shm) +(allow file-read-metadata) + +(allow file-read* + ; (literal "/") is required: dyld and many runtime resolvers stat + ; the root directory itself, which is NOT matched by (subpath "/X"). + (literal "/") + ; --- Execution surface --- + (subpath "/usr/lib") + (subpath "/usr/bin") + (subpath "/bin") + ; Narrow /System: only the framework + dyld surfaces the loader needs. + ; Avoids exposing /System/Applications/* (~all installed system apps and + ; their localized resources) and /System/iOSSupport to the LLM. + (subpath "/System/Library/Frameworks") + (subpath "/System/Library/PrivateFrameworks") + (subpath "/System/Library/dyld") + (subpath "/System/Cryptexes") + (subpath "/System/Volumes/Preboot/Cryptexes") + (subpath "/Library/Frameworks") + ; --- Runtime data libraries actually consult --- + (subpath "/usr/share/zoneinfo") ; tzdata for datetime + (subpath "/usr/share/icu") ; ICU data + (subpath "/private/var/db/dyld") + (subpath "/private/var/db/timezone") + ; Narrow /private/etc to runtime essentials; deny passwd/shadow/sudoers etc. + (literal "/private/etc/hosts") + (literal "/private/etc/resolv.conf") + (literal "/private/etc/nsswitch.conf") + (literal "/private/etc/localtime") + (literal "/private/etc/protocols") + (literal "/private/etc/services") + (subpath "/private/etc/ssl") + (subpath "/private/etc/ca-certificates") + (literal "/dev/null") + (literal "/dev/zero") + (literal "/dev/random") + (literal "/dev/urandom") + (literal "/dev/dtracehelper") + (literal "/dev/autofs_nowait") + {py_block}{homebrew_read_block} +) + +; Required for mmap(PROT_EXEC) on dylibs — without this Python cannot +; load libpython, libsystem_*, or any C-extension .so. Also required +; for /bin/bash and /usr/bin/* under the terminal tool. +(allow file-map-executable + {executable_map_block} +) + +(allow file-read* (subpath "{wd}")) +(allow file-write* (subpath "{wd}")) +(allow file-ioctl (subpath "{wd}")) + +; coreservices.launchservicesd + lsd.mapdb are intentionally NOT allowed: +; together with (allow process-exec /usr/bin) they let a tool run +; `open URL` and have LaunchServices spawn a browser outside the +; sandbox, bypassing (deny network-outbound). +; SecurityServer is also NOT allowed: with `/usr/bin/security` reachable +; it would expose Keychain reads of stored credentials. +(allow mach-lookup + (global-name "com.apple.trustd.agent") + (global-name "com.apple.trustd") + (global-name "com.apple.system.opendirectoryd.libinfo") + (global-name "com.apple.system.opendirectoryd.membership") + (global-name "com.apple.system.logger") + (global-name "com.apple.system.notification_center") + (global-name "com.apple.system.DirectoryService.libinfo_v1") +) + +(deny network-outbound) +(deny network-inbound) +(deny network-bind) +""" + + +_LINUX_NPROC_WRAPPER_TEMPLATE = ( + "import os, resource, sys\n" + "try:\n" + " nproc = {nproc}\n" + " _soft, hard = resource.getrlimit(resource.RLIMIT_NPROC)\n" + " target = nproc if hard == resource.RLIM_INFINITY else min(nproc, hard)\n" + " resource.setrlimit(resource.RLIMIT_NPROC, (target, target))\n" + "except (ValueError, OSError, AttributeError):\n" + " pass\n" + "os.execvp(sys.argv[1], sys.argv[1:])\n" +) + + +_NPROC_DEFAULT = 10000 +# Hard floor: Python's own startup needs a handful of threads for the +# GC and signal handlers; multiprocessing needs at least two. A value +# of 0 or 1 would brick the inner interpreter before the LLM-supplied +# code even ran. 64 is well below any realistic legitimate need and +# well above the kernel minimum. +_NPROC_FLOOR = 64 + + +def _resolve_nproc_limit() -> int: + """Read UNSLOTH_STUDIO_SANDBOX_NPROC on the host; default 10000. + + ``_build_safe_env`` is a strict whitelist, so the env var is not + propagated into the sandbox. Bake the value into the wrapper at + argv-build time so the operator's override still takes effect + inside the namespace. + + Values below ``_NPROC_FLOOR`` are silently clamped up; a value of + 0 would otherwise prevent the inner Python wrapper itself from + spawning the LLM-controlled child. + """ + try: + value = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_NPROC", str(_NPROC_DEFAULT))) + except ValueError: + return _NPROC_DEFAULT + if value < _NPROC_FLOOR: + logger.warning( + "UNSLOTH_STUDIO_SANDBOX_NPROC=%s below floor %s; clamping", + value, + _NPROC_FLOOR, + ) + return _NPROC_FLOOR + return value + + +# Import-time sanity: catch the case where a maintainer accidentally +# adds a literal `{` to the template (e.g. a dict literal) which would +# turn .format() into a KeyError at every tool call. +assert "12345" in _LINUX_NPROC_WRAPPER_TEMPLATE.format( + nproc = 12345 +), "_LINUX_NPROC_WRAPPER_TEMPLATE does not format cleanly" + + +def _linux_inner_rlimit_wrapper(inner_argv: list[str]) -> list[str]: + """Wrap ``inner_argv`` with a tiny Python that sets RLIMIT_NPROC. + + Why: ``_sandbox_preexec_for_bwrap`` cannot call ``setrlimit(NPROC)`` + on the parent because that limit is per-real-UID and bwrap's setuid + helper would EAGAIN on busy multi-tenant hosts where the operator + already runs many processes. Inside the bwrap user namespace the + counter is per-mapped-UID (typically ``nobody``), so applying NPROC + there does not collide with the host UID's process count. The + wrapper runs in the namespace, clamps NPROC to the configured value + (or the inherited hard cap, whichever is smaller), then ``execvp``s + the original argv so the LLM-controlled command runs with the cap. + """ + exe = os.path.abspath(os.path.normpath(sys.executable)) + script = _LINUX_NPROC_WRAPPER_TEMPLATE.format(nproc = _resolve_nproc_limit()) + return [exe, "-c", script, *inner_argv] + + +def _linux_bwrap_argv(inner_argv: list[str], workdir: str) -> list[str]: + """Build a ``bwrap`` argv for the Linux sandbox. + + Deny by omission: the child sees only what we bind-mount. ``net`` + is unshared without loopback, so all network is denied. ``/tmp`` + is a fresh tmpfs so writes don't leak to the host. The inner argv + is wrapped with a small Python that re-applies RLIMIT_NPROC inside + the userns (see :func:`_linux_inner_rlimit_wrapper`). + """ + wd = os.path.realpath(workdir) + top_ro_dirs = ("/usr", "/bin", "/sbin", "/lib", "/lib64") + # Narrow /etc to runtime essentials; deny sshd_config, machine-id, etc. + etc_ro_entries = ( + "/etc/hosts", + "/etc/resolv.conf", + "/etc/nsswitch.conf", + "/etc/localtime", + "/etc/ld.so.cache", + "/etc/ld.so.conf", + "/etc/ld.so.conf.d", + "/etc/ssl", + "/etc/ca-certificates", + "/etc/pki", + ) + + assert _linux_bwrap_path is not None, "bwrap path unset despite successful probe" + args: list[str] = [ + _linux_bwrap_path, + "--die-with-parent", + "--new-session", + "--unshare-all", + "--proc", + "/proc", + "--dev", + "/dev", + "--tmpfs", + "/tmp", + ] + # -try variants skip missing paths so the same argv works on + # usrmerge distros (/lib, /lib64 are symlinks into /usr or absent). + for d in top_ro_dirs: + args.extend(["--ro-bind-try", d, d]) + for d in etc_ro_entries: + args.extend(["--ro-bind-try", d, d]) + + def _is_under_top_ro(path: str) -> bool: + return any(path == top or path.startswith(top + os.sep) for top in top_ro_dirs) + + # _python_read_paths() already realpaths, filters non-dirs, dedupes, + # and includes editable-install source dirs (so `pip install -e .` + # repos like unsloth remain readable inside the sandbox). + for rp in _python_read_paths(): + if _is_under_top_ro(rp): + continue + args.extend(["--ro-bind-try", rp, rp]) + + # Bind exec-chain symlinks whose parent isn't already covered by + # an existing bind — binding into a read-only mount fails; symlinks + # under an existing bind are already reachable via path inheritance. + bind_flags = ("--ro-bind", "--ro-bind-try", "--bind", "--bind-try") + bound_dests = [ + args[i + 2] for i, arg in enumerate(args) if arg in bind_flags and i + 2 < len(args) + ] + + bound_links: set[str] = set() + # Normalize sys.executable so a launcher path containing `..` (e.g. + # `../.venv/bin/python`) is resolved before walking the symlink + # chain; an unresolved `..` segment would land outside the bind set + # and the bwrap child would fail to exec. + exe = os.path.abspath(os.path.normpath(sys.executable)) + for sym in _exec_chain_symlinks(exe): + if sym in bound_links or _is_under_top_ro(sym): + continue + parent = os.path.dirname(sym) + if any(parent == b or parent.startswith(b + os.sep) for b in bound_dests): + continue + bound_links.add(sym) + args.extend(["--ro-bind-try", sym, sym]) + + args.extend(["--bind", wd, wd]) + + args.append("--") + args.extend(_linux_inner_rlimit_wrapper(inner_argv)) + return args + + +def build_sandbox_argv(inner_argv: list[str], workdir: str) -> list[str]: + """Return an argv that runs *inner_argv* under the platform sandbox. + + Caller MUST gate with :func:`sandbox_available`; reaching the final + AssertionError indicates the gate was bypassed. + """ + if not inner_argv: + raise ValueError("inner_argv must be non-empty") + + if sys.platform == "darwin": + profile = _macos_seatbelt_profile(workdir) + return [_SANDBOX_EXEC, "-p", profile, *inner_argv] + if sys.platform == "linux": + return _linux_bwrap_argv(inner_argv, workdir) + raise AssertionError( + f"build_sandbox_argv called on unsupported platform {sys.platform!r}; " + "callers must gate with sandbox_available()" + ) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 82c50933fc..eacd3cfd55 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -36,6 +36,7 @@ from core.inference.mcp_client import ( record_probe_failure, stdio_mcp_enabled, ) +from core.inference.sandbox import build_sandbox_argv, sandbox_available from storage import mcp_servers_db from loggers import get_logger @@ -506,9 +507,18 @@ def _build_bypass_env(workdir: str) -> dict[str, str]: return env -def _sandbox_preexec(): +def _sandbox_preexec_impl(apply_no_new_privs: bool, apply_nproc: bool = True): """Best-effort sandbox setup for sandboxed subprocesses (modules are - resolved at import time so the forked child runs no imports).""" + resolved at import time so the forked child runs no imports). + + ``apply_no_new_privs`` and ``apply_nproc`` are False on the Linux bwrap + path. PR_SET_NO_NEW_PRIVS set before execve breaks a setuid ``bwrap`` + helper (it cannot raise privileges to set up the namespace); bwrap + reapplies no-new-privs to the inner payload itself. RLIMIT_NPROC is + per-real-UID, so capping it on the parent can EAGAIN bwrap's own helper + fork on a busy multi-tenant host; the cap is reapplied inside the user + namespace (per mapped UID) by the inner rlimit wrapper in sandbox.py. + """ try: os.setsid() except OSError: @@ -520,27 +530,30 @@ def _sandbox_preexec(): pass if _libc is not None: - try: - _libc.prctl(38, 1, 0, 0, 0) # PR_SET_NO_NEW_PRIVS - except (OSError, AttributeError): - pass + if apply_no_new_privs: + try: + _libc.prctl(38, 1, 0, 0, 0) # PR_SET_NO_NEW_PRIVS + except (OSError, AttributeError): + pass try: _libc.prctl(1, 9, 0, 0, 0) # PR_SET_PDEATHSIG = SIGKILL except (OSError, AttributeError): pass - # CLONE_NEWNET not applied: with userns enabled it blocks all egress, - # including allowlisted hosts. Network policy is enforced by the AST - # host check and the bash blocklist. + # CLONE_NEWNET not applied here: when the OS sandbox is available it is + # the network boundary (bwrap --unshare-all / Seatbelt deny network). + # On the unsandboxed fallback path the AST host check and the bash + # blocklist enforce network policy instead. if _resource is not None: - # RLIMIT_NPROC is per-real-UID, so the cap is well above normal usage. - try: - nproc = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_NPROC", "10000")) - _resource.setrlimit(_resource.RLIMIT_NPROC, (nproc, nproc)) - except (ValueError, OSError, AttributeError): - pass + if apply_nproc: + # RLIMIT_NPROC is per-real-UID, so the cap is well above normal usage. + try: + nproc = int(os.environ.get("UNSLOTH_STUDIO_SANDBOX_NPROC", "10000")) + _resource.setrlimit(_resource.RLIMIT_NPROC, (nproc, nproc)) + except (ValueError, OSError, AttributeError): + pass try: _resource.setrlimit(_resource.RLIMIT_FSIZE, (100 * 1024 * 1024, 100 * 1024 * 1024)) except (ValueError, OSError): @@ -567,6 +580,27 @@ def _sandbox_preexec(): pass +def _sandbox_preexec(): + """Pre-exec for the direct (unsandboxed) launch path: full hardening. + + Used when the OS sandbox is unavailable (bwrap/sandbox-exec missing or the + kernel refuses userns) and on macOS under Seatbelt, where sandbox-exec is + not setuid so PR_SET_NO_NEW_PRIVS and the per-UID NPROC cap apply cleanly. + """ + _sandbox_preexec_impl(apply_no_new_privs = True, apply_nproc = True) + + +def _sandbox_preexec_for_bwrap(): + """Pre-exec for the Linux bwrap path. + + Skips PR_SET_NO_NEW_PRIVS (set before execve it breaks the setuid bwrap + helper; bwrap applies no-new-privs inside the namespace) and RLIMIT_NPROC + (per-real-UID, it can EAGAIN bwrap's own fork on busy hosts; sandbox.py's + inner wrapper reapplies the cap per mapped UID inside the namespace). + """ + _sandbox_preexec_impl(apply_no_new_privs = False, apply_nproc = False) + + def _bypass_preexec(): """Minimal pre-exec for bypass exec: os.setsid() only. @@ -633,6 +667,49 @@ def _get_shell_cmd(command: str) -> list[str]: return ["bash", "-c", command] +def _normalized_sys_executable() -> str: + """Return ``sys.executable`` with redundant ``..`` segments collapsed. + + Studio is sometimes launched as ``../.venv/bin/python``, which puts a + literal ``..`` in ``sys.executable``. The Linux bwrap argv bind-mounts only + the realpath chain of the interpreter plus the venv tree, so the bwrap child + cannot resolve the unresolved parent segment and fails with ``execvp ... No + such file or directory``. ``abspath(normpath(...))`` collapses ``..`` while + preserving the venv launcher path. ``realpath`` would resolve ``bin/python`` + to the base interpreter outside the venv root, which the bind set does not + cover, so the venv site-packages would not be visible inside the sandbox. + """ + return os.path.abspath(os.path.normpath(sys.executable)) + + +_TRUTHY_ENV_VALUES = frozenset({"1", "true", "yes", "on"}) + + +# Sentinel returned by the tool entry points when the operator asked for strict +# sandboxing and the OS primitive cannot be applied. Surfaces as the tool output +# so the LLM (and the user) see why the call refused. +_SANDBOX_REQUIRED_UNAVAILABLE_MSG = ( + "Execution blocked: UNSLOTH_STUDIO_SANDBOX_STRICT=1 is set but the OS " + "sandbox is unavailable. Install / enable bubblewrap on Linux " + "(apt install bubblewrap, ensure unprivileged user namespaces are " + "permitted) or sandbox-exec on macOS, or unset " + "UNSLOTH_STUDIO_SANDBOX_STRICT to allow unsandboxed execution." +) + + +def _strict_sandbox_required() -> bool: + """True iff the operator wants tool execution to fail closed. + + Opt-in: the default is the original fail-open behavior so installs without + bubblewrap (locked-down kernels, nested containers, hosts without bwrap) + keep working. Operators who require the OS boundary set + UNSLOTH_STUDIO_SANDBOX_STRICT=1. Accepts the usual case-insensitive truthy + values (1 / true / yes / on). + """ + value = os.environ.get("UNSLOTH_STUDIO_SANDBOX_STRICT", "").strip().lower() + return value in _TRUTHY_ENV_VALUES + + # Per-session working directories so each chat thread gets its own sandbox. # Falls back to ~/studio_sandbox/_default for callers without a session_id. _workdirs: dict[str, str] = {} @@ -690,6 +767,11 @@ def _get_workdir(session_id: str | None = None) -> str: workdir = os.path.join(sandbox_root, "_invalid") else: workdir = os.path.join(sandbox_root, "_default") + # Canonicalize: the Linux sandbox bind-mounts os.path.realpath(workdir) + # while the child is launched with cwd=workdir. If $HOME is a symlink + # the two diverge and the sandboxed child's chdir fails on a path that + # was never bound. Realpath here so cwd and the bind always match. + workdir = os.path.realpath(workdir) os.makedirs(workdir, exist_ok = True) try: os.chmod(sandbox_root, 0o700) @@ -2660,6 +2742,17 @@ def _python_exec( # Match the sandboxed Python path without changing bypass shell I/O. safe_env = dict(safe_env) safe_env["PYTHONIOENCODING"] = "utf-8" + # Decide whether to OS-confine this run. Bypass Permissions + # (disable_sandbox) is an explicit operator opt-out and is never + # OS-sandboxed. Otherwise wrap the interpreter in the platform sandbox + # when available; if the operator required it (strict mode) but it is + # not available, refuse rather than run unconfined. + inner_argv = [_normalized_sys_executable(), tmp_path] + sandboxed = (not disable_sandbox) and sandbox_available() + if not disable_sandbox and not sandboxed and _strict_sandbox_required(): + return _SANDBOX_REQUIRED_UNAVAILABLE_MSG + argv = build_sandbox_argv(inner_argv, workdir) if sandboxed else inner_argv + popen_kwargs = dict( stdout = subprocess.PIPE, stderr = subprocess.STDOUT, @@ -2672,11 +2765,19 @@ def _python_exec( env = safe_env, ) if sys.platform != "win32": - popen_kwargs["preexec_fn"] = _bypass_preexec if disable_sandbox else _sandbox_preexec + if disable_sandbox: + popen_kwargs["preexec_fn"] = _bypass_preexec + elif sandboxed and sys.platform == "linux": + # bwrap applies no-new-privs and the NPROC cap inside its own + # namespace; applying them on the parent breaks the setuid + # helper / EAGAINs its fork (see sandbox.py). + popen_kwargs["preexec_fn"] = _sandbox_preexec_for_bwrap + else: + popen_kwargs["preexec_fn"] = _sandbox_preexec else: popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW - proc = subprocess.Popen([sys.executable, tmp_path], **popen_kwargs) + proc = subprocess.Popen(argv, **popen_kwargs) # Spawn cancel watcher if we have a cancel event if cancel_event is not None: @@ -2765,6 +2866,16 @@ def _bash_exec( try: workdir = _get_workdir(session_id) safe_env = _build_bypass_env(workdir) if disable_sandbox else _build_safe_env(workdir) + + # Same sandbox decision as _python_exec: bypass runs unconfined, else + # wrap the shell in the platform sandbox when available, and honor + # strict mode when the primitive is missing. + inner_argv = _get_shell_cmd(command) + sandboxed = (not disable_sandbox) and sandbox_available() + if not disable_sandbox and not sandboxed and _strict_sandbox_required(): + return _SANDBOX_REQUIRED_UNAVAILABLE_MSG + argv = build_sandbox_argv(inner_argv, workdir) if sandboxed else inner_argv + popen_kwargs = dict( stdout = subprocess.PIPE, stderr = subprocess.STDOUT, @@ -2773,11 +2884,16 @@ def _bash_exec( env = safe_env, ) if sys.platform != "win32": - popen_kwargs["preexec_fn"] = _bypass_preexec if disable_sandbox else _sandbox_preexec + if disable_sandbox: + popen_kwargs["preexec_fn"] = _bypass_preexec + elif sandboxed and sys.platform == "linux": + popen_kwargs["preexec_fn"] = _sandbox_preexec_for_bwrap + else: + popen_kwargs["preexec_fn"] = _sandbox_preexec else: popen_kwargs["creationflags"] = subprocess.CREATE_NO_WINDOW - proc = subprocess.Popen(_get_shell_cmd(command), **popen_kwargs) + proc = subprocess.Popen(argv, **popen_kwargs) if cancel_event is not None: watcher = threading.Thread( diff --git a/studio/backend/run.py b/studio/backend/run.py index 3efeac960e..d0d279ce04 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -1356,6 +1356,20 @@ def run_server( (time.perf_counter() - boot_started) * 1000, ) + # Warm the OS-sandbox probe in the background so the "tool execution + # sandboxed / unsandboxed" status is logged at startup instead of being + # deferred to the first tool call, and so that first call does not pay the + # probe's timeout. Best effort: a failure here must never block startup. + def _warm_sandbox_probe(): + try: + from core.inference.sandbox import sandbox_available + + sandbox_available() + except Exception as exc: # never let the probe crash server startup + logger.debug("sandbox availability probe failed at startup: %s", exc) + + Thread(target = _warm_sandbox_probe, daemon = True).start() + _write_pid_file() import atexit diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py index d92509a5fe..d5450857bd 100644 --- a/studio/backend/tests/test_bypass_permissions.py +++ b/studio/backend/tests/test_bypass_permissions.py @@ -120,11 +120,33 @@ def captured_popen(monkeypatch): @_POSIX_ONLY -def test_python_sandboxed_uses_sandbox_preexec_and_safe_env(captured_popen, monkeypatch): +def test_python_unsandboxed_fallback_uses_sandbox_preexec_and_safe_env(captured_popen, monkeypatch): + # When the OS sandbox is unavailable, the non-bypass python path falls back + # to a direct interpreter launch with the full-hardening pre-exec and the + # credential-free environment. + monkeypatch.setattr(tools, "sandbox_available", lambda: False) monkeypatch.setenv("HF_TOKEN", "secret-abc") _python_exec("print(1)", None, 5, "t", disable_sandbox = False) assert captured_popen["kwargs"]["preexec_fn"] is tools._sandbox_preexec assert "HF_TOKEN" not in captured_popen["kwargs"]["env"] + # Not OS-wrapped: the interpreter runs directly. + assert captured_popen["cmd"][0] == tools._normalized_sys_executable() + + +@pytest.mark.skipif(sys.platform != "linux", reason = "bwrap pre-exec is Linux-only") +def test_python_sandboxed_uses_bwrap_preexec_and_wraps_argv(captured_popen, monkeypatch): + # When bubblewrap is available on Linux, the non-bypass python path wraps the + # interpreter in the bwrap argv and uses the bwrap-specific pre-exec (which + # skips no-new-privs / NPROC on the host parent so the setuid helper works). + monkeypatch.setattr(tools, "sandbox_available", lambda: True) + monkeypatch.setattr( + tools, "build_sandbox_argv", lambda inner, wd: ["/usr/bin/bwrap", "--", *inner] + ) + monkeypatch.setenv("HF_TOKEN", "secret-abc") + _python_exec("print(1)", None, 5, "t", disable_sandbox = False) + assert captured_popen["kwargs"]["preexec_fn"] is tools._sandbox_preexec_for_bwrap + assert "HF_TOKEN" not in captured_popen["kwargs"]["env"] + assert captured_popen["cmd"][0] == "/usr/bin/bwrap" @_POSIX_ONLY diff --git a/studio/backend/tests/test_sandbox.py b/studio/backend/tests/test_sandbox.py new file mode 100644 index 0000000000..81389460ef --- /dev/null +++ b/studio/backend/tests/test_sandbox.py @@ -0,0 +1,622 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +""" +End-to-end tests for the OS-level sandbox wired into ``_python_exec`` +and ``_bash_exec``. + +The platform-agnostic tests (workdir write, $HOME read deny, bash $HOME +read deny, network deny) run on both macOS (Seatbelt) and Linux +(bubblewrap) — same security claims, different mechanisms. The +``/System/Applications``-enumeration test is darwin-specific because +that path only exists on macOS. + +These tests are the only layer that proves the sandbox does what it +claims — anything that only inspects the profile string is checking +typography, not enforcement. +""" + +import importlib.util +import os +import shlex +import sys +import uuid +from pathlib import Path + +import pytest + +_BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(_BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(_BACKEND_ROOT)) + +pytestmark = pytest.mark.skipif( + sys.platform not in ("darwin", "linux"), + reason = "sandbox tests run on macOS and Linux only", +) + + +def _load_sandbox_module(): + # Bypass core.inference.__init__ (pulls orchestrator/fastapi/structlog). + path = _BACKEND_ROOT / "core" / "inference" / "sandbox.py" + spec = importlib.util.spec_from_file_location("_studio_sandbox_under_test", path) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + return module + + +_TRUTHY_CI_VALUES = frozenset({"1", "true", "yes", "on"}) + + +@pytest.fixture +def sandboxed_workdir(tmp_path, monkeypatch): + """Point tool execution's workdir lookup at a pytest tmp_path.""" + sandbox = _load_sandbox_module() + + if not sandbox.sandbox_available(): + # The CI workflow sets UNSLOTH_STUDIO_SANDBOX_CI_ENFORCE=1 only after a + # bwrap probe confirms the runner can actually apply the sandbox. When + # that flag is set, an unavailable sandbox here is a real regression, so + # fail. Otherwise (local dev, or a runner that genuinely cannot create + # unprivileged user namespaces) skip rather than turn the run red. + if os.environ.get("UNSLOTH_STUDIO_SANDBOX_CI_ENFORCE", "").strip().lower() in _TRUTHY_CI_VALUES: + pytest.fail( + "sandbox unavailable but UNSLOTH_STUDIO_SANDBOX_CI_ENFORCE=1: the " + "CI runner confirmed bubblewrap/sandbox-exec works, so this " + "enforcement test must run rather than skip" + ) + pytest.skip("sandbox unavailable (binary missing or cannot apply policy)") + + from core.inference import tools + + sid = "_sbtest" + monkeypatch.setitem(tools._workdirs, sid, str(tmp_path)) + yield sid, str(tmp_path) + + +@pytest.fixture +def home_sentinel(tmp_path_factory): + """Yield a sentinel file path + secret, kept outside the sandbox workdir. + + The sentinel proves *negative*: if the sandboxed code reads the + file, the secret appears in the tool output. Placed under a + pytest tmp_path so the test is hermetic and works on rootless CI + where the real $HOME is read-only. + """ + secret = f"SECRET-{uuid.uuid4().hex}" + sentinel_dir = tmp_path_factory.mktemp("studio_sandbox_sentinel") + path = str(sentinel_dir / f"sentinel_{uuid.uuid4().hex}.txt") + Path(path).write_text(secret) + try: + yield path, secret + finally: + if os.path.exists(path): + os.unlink(path) + + +def _run_python(code: str, sid: str) -> str: + from core.inference.tools import _python_exec + return _python_exec(code, session_id = sid, timeout = 30) + + +def _run_bash(command: str, sid: str) -> str: + from core.inference.tools import _bash_exec + return _bash_exec(command, session_id = sid, timeout = 30) + + +def test_workdir_write_succeeds(sandboxed_workdir): + sid, wd = sandboxed_workdir + code = 'from pathlib import Path\nPath("hi.txt").write_text("ok")\nprint("done")\n' + out = _run_python(code, sid) + assert "done" in out, out + assert os.path.exists(os.path.join(wd, "hi.txt")) + + +def test_home_read_denied(sandboxed_workdir, home_sentinel): + sid, _ = sandboxed_workdir + path, secret = home_sentinel + code = ( + f"try:\n" + f" with open({path!r}) as f: print('LEAKED:', f.read())\n" + f"except (PermissionError, FileNotFoundError, OSError) as e:\n" + f" print('DENIED:', type(e).__name__)\n" + ) + out = _run_python(code, sid) + assert secret not in out, out + assert "LEAKED:" not in out, out + assert "DENIED:" in out, out + + +def test_bash_home_read_denied(sandboxed_workdir, home_sentinel): + """The terminal tool must enforce the same $HOME-denial as the python tool.""" + sid, _ = sandboxed_workdir + path, secret = home_sentinel + out = _run_bash(f"/bin/cat {shlex.quote(path)}", sid) + assert secret not in out, out + # Confirm cat actually ran and was denied, not silently no-op'd. + assert any( + s in out for s in ("Permission denied", "Operation not permitted", "No such file") + ), out + + +def test_network_denied(sandboxed_workdir): + """Hit a routable IP so the test does not depend on DNS or external service. + + Host is assembled at runtime so the static AST allowlist does not + pre-block it; only the sandbox can deny the egress. Imports socket + BEFORE the try block so a broken-socket-module false-positive cannot + silently pass the denial assertion. + """ + sid, _ = sandboxed_workdir + code = ( + "import socket\n" + "try:\n" + " host = '.'.join(['8', '8', '8', '8'])\n" + " s = socket.create_connection((host, 80), timeout=5)\n" + " s.close()\n" + " print('LEAKED')\n" + "except OSError as e:\n" + " print('DENIED:', type(e).__name__, str(e)[:200])\n" + ) + out = _run_python(code, sid) + assert "LEAKED" not in out, out + assert "DENIED:" in out, out + # The sandbox-induced denial must mention a network/permission error + # rather than a Python-side import/syntax failure. + assert any( + token in out + for token in ( + "Network is unreachable", + "Operation not permitted", + "Permission denied", + "Address family not supported", + "Errno", + ) + ), out + + +def test_sandbox_off_actually_leaks(tmp_path, monkeypatch, home_sentinel): + """Control test: with the sandbox disabled, the sentinel IS readable. + + Without this, ``test_bash_home_read_denied`` would pass even if the + sandbox silently no-op'd (binary missing, probe failed) — proving + only that the sentinel UUID doesn't appear by chance, not that the + sandbox is the thing blocking it. + """ + from core.inference import tools + + monkeypatch.setattr(tools, "sandbox_available", lambda: False) + sid = "_sbtest_off" + monkeypatch.setitem(tools._workdirs, sid, str(tmp_path)) + + path, secret = home_sentinel + out = _run_bash(f"/bin/cat {shlex.quote(path)}", sid) + assert secret in out, out + + +@pytest.mark.skipif( + sys.platform != "darwin", + reason = "/System/Applications is a macOS path", +) +def test_system_applications_enumeration_denied(sandboxed_workdir): + """Pin the macOS narrowing: /System/Applications should not be readable. + + v1 of the macOS profile allowed all of /System; v2 narrowed it to + Frameworks + dyld only. The Frameworks dir must remain readable + (loading still works), while /System/Applications and + /System/iOSSupport must not. + """ + sid, _ = sandboxed_workdir + out = _run_bash("ls /System/Applications 2>&1; ls /System/iOSSupport 2>&1", sid) + assert "Operation not permitted" in out, out + out_fw = _run_bash("ls /System/Library/Frameworks | head -1", sid) + assert "Operation not permitted" not in out_fw, out_fw + assert ".framework" in out_fw, out_fw + + +# --------------------------------------------------------------------------- +# Profile / argv construction tests. These run on any macOS or Linux host +# regardless of whether the sandbox can be applied in this process context. +# --------------------------------------------------------------------------- + + +@pytest.mark.skipif(sys.platform != "darwin", reason = "Seatbelt is macOS-only") +def test_macos_profile_omits_dev_tty(tmp_path): + sandbox = _load_sandbox_module() + profile = sandbox._macos_seatbelt_profile(str(tmp_path)) + assert "/dev/tty" not in profile, profile + + +@pytest.mark.skipif(sys.platform != "darwin", reason = "Seatbelt is macOS-only") +def test_macos_profile_narrows_private_etc(tmp_path): + sandbox = _load_sandbox_module() + profile = sandbox._macos_seatbelt_profile(str(tmp_path)) + assert '(subpath "/private/etc")' not in profile, profile + for required in ( + '(literal "/private/etc/hosts")', + '(literal "/private/etc/resolv.conf")', + '(subpath "/private/etc/ssl")', + ): + assert required in profile, required + for forbidden in ( + "/private/etc/passwd", + "/private/etc/shadow", + "/private/etc/sudoers", + ): + assert forbidden not in profile, forbidden + + +@pytest.mark.skipif(sys.platform != "darwin", reason = "Seatbelt is macOS-only") +def test_macos_profile_constrains_process_exec(tmp_path): + sandbox = _load_sandbox_module() + profile = sandbox._macos_seatbelt_profile(str(tmp_path)) + assert "(allow process-exec)" not in profile + assert "(allow process-exec\n" in profile + + +@pytest.mark.skipif(sys.platform != "linux", reason = "bwrap argv is Linux-only") +def test_linux_argv_narrows_etc(tmp_path, monkeypatch): + sandbox = _load_sandbox_module() + monkeypatch.setattr(sandbox, "_linux_bwrap_path", "/usr/bin/bwrap") + argv = sandbox._linux_bwrap_argv(["/usr/bin/true"], str(tmp_path)) + bound_targets = set() + bind_flags = ("--ro-bind", "--ro-bind-try", "--bind", "--bind-try") + for i, token in enumerate(argv): + if token in bind_flags and i + 2 < len(argv): + bound_targets.add(argv[i + 2]) + assert "/etc" not in bound_targets + for required in ("/etc/hosts", "/etc/resolv.conf", "/etc/ssl"): + assert required in bound_targets, (required, bound_targets) + + +@pytest.mark.skipif(sys.platform != "linux", reason = "bwrap argv is Linux-only") +def test_linux_argv_asserts_when_bwrap_path_unset(tmp_path, monkeypatch): + sandbox = _load_sandbox_module() + monkeypatch.setattr(sandbox, "_linux_bwrap_path", None) + with pytest.raises(AssertionError): + sandbox._linux_bwrap_argv(["/usr/bin/true"], str(tmp_path)) + + +def test_python_read_paths_excludes_user_site_by_default(monkeypatch, tmp_path): + sandbox = _load_sandbox_module() + import site + + fake_user_site = tmp_path / "fake_user_site_default" + fake_user_site.mkdir() + monkeypatch.setattr(site, "getusersitepackages", lambda: str(fake_user_site)) + monkeypatch.delenv("UNSLOTH_STUDIO_SANDBOX_ALLOW_USER_SITE", raising = False) + paths = sandbox._python_read_paths() + assert os.path.realpath(str(fake_user_site)) not in paths + + +def test_python_read_paths_includes_user_site_when_opted_in(monkeypatch, tmp_path): + sandbox = _load_sandbox_module() + import site + + fake_user_site = tmp_path / "fake_user_site_opt_in" + fake_user_site.mkdir() + monkeypatch.setattr(site, "getusersitepackages", lambda: str(fake_user_site)) + monkeypatch.setenv("UNSLOTH_STUDIO_SANDBOX_ALLOW_USER_SITE", "1") + paths = sandbox._python_read_paths() + assert os.path.realpath(str(fake_user_site)) in paths + + +def test_bwrap_probe_bin_exists_and_executable(): + sandbox = _load_sandbox_module() + bin_path = sandbox._BWRAP_PROBE_BIN + assert os.path.exists(bin_path), bin_path + assert os.access(bin_path, os.X_OK), bin_path + + +def test_build_sandbox_argv_rejects_empty_inner(tmp_path): + sandbox = _load_sandbox_module() + with pytest.raises(ValueError): + sandbox.build_sandbox_argv([], str(tmp_path)) + + +def test_build_sandbox_argv_asserts_on_unsupported_platform(tmp_path, monkeypatch): + sandbox = _load_sandbox_module() + monkeypatch.setattr(sandbox.sys, "platform", "freebsd14") + with pytest.raises(AssertionError): + sandbox.build_sandbox_argv(["/usr/bin/true"], str(tmp_path)) + + +# --------------------------------------------------------------------------- +# Workdir realpath: bwrap binds os.path.realpath(workdir); tmp_path inside +# inner_argv must resolve to the same bound path when $HOME is symlinked. +# --------------------------------------------------------------------------- + + +def test_get_workdir_returns_realpath_when_home_is_symlinked(tmp_path, monkeypatch): + real_home = tmp_path / "real_home" + real_home.mkdir() + home_symlink = tmp_path / "home_symlink" + os.symlink(real_home, home_symlink) + monkeypatch.setattr( + os.path, + "expanduser", + lambda p: str(home_symlink) if p == "~" else p, + ) + + from core.inference import tools + + tools._workdirs.pop("_realpath_test", None) + wd = tools._get_workdir("_realpath_test") + try: + assert os.path.realpath(wd) == wd, wd + assert str(home_symlink) not in wd, wd + assert str(real_home) in wd, wd + finally: + tools._workdirs.pop("_realpath_test", None) + + +def test_get_workdir_idempotent(tmp_path, monkeypatch): + monkeypatch.setattr( + os.path, + "expanduser", + lambda p: str(tmp_path) if p == "~" else p, + ) + + from core.inference import tools + + tools._workdirs.pop("_idem_test", None) + first = tools._get_workdir("_idem_test") + second = tools._get_workdir("_idem_test") + try: + assert first == second + finally: + tools._workdirs.pop("_idem_test", None) + + +# --------------------------------------------------------------------------- +# Strict-mode opt-in: when UNSLOTH_STUDIO_SANDBOX_STRICT=1 and the OS +# sandbox cannot be applied, tool execution must refuse rather than run +# unsandboxed. +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize("strict_value", ["1", "true", "TRUE", "yes", "On"]) +def test_strict_mode_refuses_when_sandbox_unavailable(tmp_path, monkeypatch, strict_value): + from core.inference import tools + + monkeypatch.setattr(tools, "sandbox_available", lambda: False) + monkeypatch.setenv("UNSLOTH_STUDIO_SANDBOX_STRICT", strict_value) + sid = f"_strict_refuse_{strict_value.lower()}" + monkeypatch.setitem(tools._workdirs, sid, str(tmp_path)) + + py_out = tools._python_exec("print('would have leaked')", session_id = sid) + assert "Execution blocked" in py_out, py_out + assert "UNSLOTH_STUDIO_SANDBOX_STRICT" in py_out, py_out + + bash_out = tools._bash_exec("echo would have leaked", session_id = sid) + assert "Execution blocked" in bash_out, bash_out + assert "would have leaked" not in bash_out, bash_out + + +def test_strict_mode_refuses_on_unsupported_platform(tmp_path, monkeypatch): + """Strict mode must cover every platform, not just darwin/linux. + + An operator opting into fail-closed expects refusal everywhere, + including Windows or future OS targets where the sandbox primitive + does not exist. + """ + from core.inference import tools + + monkeypatch.setattr(tools, "sandbox_available", lambda: False) + monkeypatch.setenv("UNSLOTH_STUDIO_SANDBOX_STRICT", "1") + monkeypatch.setattr(tools.sys, "platform", "freebsd14") + sid = "_strict_refuse_unsupported" + monkeypatch.setitem(tools._workdirs, sid, str(tmp_path)) + + py_out = tools._python_exec("print('would have leaked')", session_id = sid) + assert "Execution blocked" in py_out, py_out + bash_out = tools._bash_exec("echo would have leaked", session_id = sid) + assert "Execution blocked" in bash_out, bash_out + assert "would have leaked" not in bash_out, bash_out + + +def test_strict_mode_off_falls_back_unsandboxed(tmp_path, monkeypatch): + from core.inference import tools + + monkeypatch.setattr(tools, "sandbox_available", lambda: False) + monkeypatch.delenv("UNSLOTH_STUDIO_SANDBOX_STRICT", raising = False) + sid = "_strict_off" + monkeypatch.setitem(tools._workdirs, sid, str(tmp_path)) + + out = tools._python_exec("print('hello-unsandboxed')", session_id = sid) + assert "hello-unsandboxed" in out, out + + +# --------------------------------------------------------------------------- +# Interpreter path normalization: a launcher path with `..` in sys.executable +# must be collapsed before it reaches bwrap, which binds only the realpath +# chain of the interpreter and cannot execvp through an unbound parent segment. +# --------------------------------------------------------------------------- + + +def test_normalized_sys_executable_collapses_dotdot(monkeypatch): + """A launcher path with `..` in sys.executable must be resolved + before passing to bwrap, which cannot execvp through `unsloth/..` + when the parent directory is not bind-mounted.""" + from core.inference import tools + + monkeypatch.setattr( + tools.sys, + "executable", + "/mnt/disks/unsloth/../.venv/bin/python", + ) + assert tools._normalized_sys_executable() == "/mnt/disks/.venv/bin/python" + + +@pytest.mark.skipif(sys.platform != "linux", reason = "Linux bwrap path only") +def test_linux_bwrap_argv_wraps_inner_argv_with_nproc_setter(monkeypatch): + """The bwrap argv must wrap inner_argv with a small Python that + re-applies RLIMIT_NPROC inside the userns. Without this, the + LLM-controlled child inherits the host's unlimited NPROC because + _sandbox_preexec_for_bwrap skips NPROC on the host parent.""" + sandbox = _load_sandbox_module() + + monkeypatch.setattr(sandbox, "_linux_bwrap_path", "/usr/bin/bwrap") + monkeypatch.setenv("UNSLOTH_STUDIO_SANDBOX_NPROC", "77") + argv = sandbox._linux_bwrap_argv(["/usr/bin/python3", "-c", "print(1)"], "/tmp") + sep = argv.index("--") + inner = argv[sep + 1 :] + # Inner argv now starts with a python wrapper, not the user's argv. + assert inner[0].endswith("python") or inner[0].endswith("python3") + assert inner[1] == "-c" + assert "RLIMIT_NPROC" in inner[2] + assert "execvp" in inner[2] + # The override must be baked into the script: _build_safe_env strips + # env vars, so reading UNSLOTH_STUDIO_SANDBOX_NPROC at runtime inside + # the namespace would always see the default. + assert "nproc = 77" in inner[2] + # Original argv is appended after the wrapper. + assert inner[-3:] == ["/usr/bin/python3", "-c", "print(1)"] + + +@pytest.mark.skipif(sys.platform != "linux", reason = "Linux bwrap path only") +def test_linux_bwrap_nproc_falls_back_to_default_when_env_invalid(monkeypatch): + sandbox = _load_sandbox_module() + + monkeypatch.setattr(sandbox, "_linux_bwrap_path", "/usr/bin/bwrap") + monkeypatch.setenv("UNSLOTH_STUDIO_SANDBOX_NPROC", "not-a-number") + argv = sandbox._linux_bwrap_argv(["/usr/bin/python3", "-c", "1"], "/tmp") + inner = argv[argv.index("--") + 1 :] + assert "nproc = 10000" in inner[2] + + +# --------------------------------------------------------------------------- +# macOS Seatbelt symmetry: workdir must appear in process-exec and +# file-map-executable so tools can run / dlopen a freshly written file in +# the session folder, matching the Linux side which bind-mounts the workdir. +# --------------------------------------------------------------------------- + + +def _slice_top_form(text: str, opener: str) -> str: + """Return the substring of *text* spanning the top-level Scheme form + that begins with *opener*. Counts parens so it skips past nested + `(subpath ...)` entries inside the form.""" + start = text.index(opener) + depth = 0 + for i in range(start, len(text)): + c = text[i] + if c == "(": + depth += 1 + elif c == ")": + depth -= 1 + if depth == 0: + return text[start : i + 1] + raise AssertionError(f"unterminated form starting with {opener!r}") + + +@pytest.mark.skipif(sys.platform != "darwin", reason = "Seatbelt is macOS-only") +def test_macos_profile_allows_workdir_exec(tmp_path): + sandbox = _load_sandbox_module() + # _macos_seatbelt_profile realpaths the workdir before embedding it, + # so the test must also realpath because macOS /var is symlinked to + # /private/var (and pytest tmp_path lives under /var). + wd = os.path.realpath(str(tmp_path)) + profile = sandbox._macos_seatbelt_profile(str(tmp_path)) + # Workdir should appear inside both (allow process-exec ...) and + # (allow file-map-executable ...), not just file-read*/file-write*. + process_exec_form = _slice_top_form(profile, "(allow process-exec") + file_map_form = _slice_top_form(profile, "(allow file-map-executable") + assert wd in process_exec_form, process_exec_form + assert wd in file_map_form, file_map_form + + +# --------------------------------------------------------------------------- +# Probe lock: concurrent sandbox_available() callers see the same answer +# even when probing races; the run.py background probe must not lose a +# successful detection to a duplicate concurrent call. +# --------------------------------------------------------------------------- + + +def test_sandbox_available_concurrent_calls_consistent(monkeypatch): + sandbox = _load_sandbox_module() + monkeypatch.setattr(sandbox, "_sandbox_available_cache", None) + monkeypatch.setattr(sandbox, "_linux_bwrap_path", None) + + import threading + + results: list[bool] = [] + barrier = threading.Barrier(8) + + def worker(): + barrier.wait() + results.append(sandbox.sandbox_available()) + + threads = [threading.Thread(target = worker) for _ in range(8)] + for t in threads: + t.start() + for t in threads: + t.join() + + # All callers must agree, and on Linux the bwrap path must be set + # whenever sandbox_available() reports True. + assert len(set(results)) == 1, results + if results[0] and sys.platform == "linux": + assert sandbox._linux_bwrap_path is not None + + +# --------------------------------------------------------------------------- +# Profile-injection guard, NPROC clamping, and transient-probe caching. +# --------------------------------------------------------------------------- + + +def test_safe_subpath_rejects_seatbelt_injection_chars(): + """Paths containing Seatbelt string-literal delimiters must be + rejected. Without this guard a workdir containing a `"` could + close the profile string and inject Scheme into the policy.""" + sandbox = _load_sandbox_module() + for bad_char in ('"', "\\", "\n", "\r", "\x00"): + with pytest.raises(ValueError): + sandbox._safe_subpath(f"/tmp/x{bad_char}y") + # Sanity: normal paths still pass. + assert sandbox._safe_subpath("/tmp/normal/path") == "/tmp/normal/path" + + +def test_resolve_nproc_limit_clamps_below_floor(monkeypatch): + """A value of 0 would brick the inner wrapper itself; clamp to + the floor so the sandboxed interpreter can at least start.""" + sandbox = _load_sandbox_module() + + monkeypatch.setenv("UNSLOTH_STUDIO_SANDBOX_NPROC", "0") + assert sandbox._resolve_nproc_limit() == sandbox._NPROC_FLOOR + monkeypatch.setenv("UNSLOTH_STUDIO_SANDBOX_NPROC", "5") + assert sandbox._resolve_nproc_limit() == sandbox._NPROC_FLOOR + monkeypatch.setenv("UNSLOTH_STUDIO_SANDBOX_NPROC", "9999") + assert sandbox._resolve_nproc_limit() == 9999 + + +def test_sandbox_unavailable_does_not_cache_on_transient_timeout(monkeypatch): + """A probe TimeoutExpired must NOT pin the cache to False; the + next caller has to re-probe so a one-off slow runner doesn't + disable the sandbox for the rest of the process lifetime.""" + import subprocess + + sandbox = _load_sandbox_module() + monkeypatch.setattr(sandbox, "_sandbox_available_cache", None) + monkeypatch.setattr(sandbox, "_linux_bwrap_path", None) + + call_count = {"n": 0} + + def fake_run(*args, **kwargs): + call_count["n"] += 1 + if call_count["n"] == 1: + raise subprocess.TimeoutExpired(cmd = args[0], timeout = 5) + # On the second call, return a "success" CompletedProcess. + return subprocess.CompletedProcess(args = args[0], returncode = 0, stdout = b"", stderr = b"") + + monkeypatch.setattr(sandbox.subprocess, "run", fake_run) + monkeypatch.setattr(sandbox.shutil, "which", lambda _: "/usr/bin/bwrap") + monkeypatch.setattr(sandbox.os.path, "exists", lambda p: p == sandbox._SANDBOX_EXEC) + + # First call: probe times out, returns False, but cache must stay None. + first = sandbox.sandbox_available() + assert first is False + assert sandbox._sandbox_available_cache is None, "transient timeout was cached" + # Second call: probe succeeds, value is cached. + second = sandbox.sandbox_available() + assert second is True + assert sandbox._sandbox_available_cache is True From a67dd44fae328a96e7e6587625ea1f77aec89335 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 13 Jul 2026 10:24:58 +0000 Subject: [PATCH 2/3] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/run.py | 1 - studio/backend/tests/test_sandbox.py | 5 ++++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/studio/backend/run.py b/studio/backend/run.py index d0d279ce04..8e4f34f45c 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -1363,7 +1363,6 @@ def run_server( def _warm_sandbox_probe(): try: from core.inference.sandbox import sandbox_available - sandbox_available() except Exception as exc: # never let the probe crash server startup logger.debug("sandbox availability probe failed at startup: %s", exc) diff --git a/studio/backend/tests/test_sandbox.py b/studio/backend/tests/test_sandbox.py index 81389460ef..3724704ae0 100644 --- a/studio/backend/tests/test_sandbox.py +++ b/studio/backend/tests/test_sandbox.py @@ -59,7 +59,10 @@ def sandboxed_workdir(tmp_path, monkeypatch): # that flag is set, an unavailable sandbox here is a real regression, so # fail. Otherwise (local dev, or a runner that genuinely cannot create # unprivileged user namespaces) skip rather than turn the run red. - if os.environ.get("UNSLOTH_STUDIO_SANDBOX_CI_ENFORCE", "").strip().lower() in _TRUTHY_CI_VALUES: + if ( + os.environ.get("UNSLOTH_STUDIO_SANDBOX_CI_ENFORCE", "").strip().lower() + in _TRUTHY_CI_VALUES + ): pytest.fail( "sandbox unavailable but UNSLOTH_STUDIO_SANDBOX_CI_ENFORCE=1: the " "CI runner confirmed bubblewrap/sandbox-exec works, so this " From 5d25ef34393181c5f9763e030ff30b10c98d5913 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Mon, 13 Jul 2026 13:10:43 +0000 Subject: [PATCH 3/3] sandbox: degrade gracefully when site.getsitepackages is unavailable _python_read_paths runs in the sandboxed exec path with no fallback, so an AttributeError or unexpected exception from site.getsitepackages / getusersitepackages (older virtualenv site.py, embedded / frozen Python) would make every sandboxed tool call return an execution error. Guard both calls and continue: sys.prefix / sys.base_prefix are bound regardless, so a venv's site-packages under the prefix stays visible even when these helpers do not resolve. Regression: test_python_read_paths_survives_missing_site_helpers. --- studio/backend/core/inference/sandbox.py | 17 +++++++++++++++-- studio/backend/tests/test_sandbox.py | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 2 deletions(-) diff --git a/studio/backend/core/inference/sandbox.py b/studio/backend/core/inference/sandbox.py index eecb7babf9..12cd09b336 100644 --- a/studio/backend/core/inference/sandbox.py +++ b/studio/backend/core/inference/sandbox.py @@ -264,10 +264,23 @@ def _python_read_paths() -> list[str]: Used by both the macOS Seatbelt profile and the Linux bwrap argv. """ candidates: list[str] = [sys.prefix, sys.base_prefix] - candidates.extend(site.getsitepackages()) + # site.getsitepackages / getusersitepackages are absent (older virtualenv + # site.py) or can raise (embedded / frozen builds) in some environments. + # This runs in the sandboxed exec path, so degrade gracefully instead of + # failing the tool call: sys.prefix / sys.base_prefix are bound regardless, + # so a venv's site-packages under the prefix stays visible even if these do + # not resolve. + try: + candidates.extend(site.getsitepackages()) + except Exception as e: # noqa: BLE001 - best-effort; never break tool exec + logger.debug("site.getsitepackages() unavailable: %s", e) # user-site is under real $HOME; exposing it defeats the deny-$HOME stance. if os.environ.get("UNSLOTH_STUDIO_SANDBOX_ALLOW_USER_SITE") == "1": - user_site = site.getusersitepackages() + try: + user_site = site.getusersitepackages() + except Exception as e: # noqa: BLE001 - best-effort; never break tool exec + logger.debug("site.getusersitepackages() unavailable: %s", e) + user_site = None if user_site: candidates.append(user_site) candidates.extend(_editable_source_paths()) diff --git a/studio/backend/tests/test_sandbox.py b/studio/backend/tests/test_sandbox.py index 3724704ae0..f5770546e1 100644 --- a/studio/backend/tests/test_sandbox.py +++ b/studio/backend/tests/test_sandbox.py @@ -304,6 +304,25 @@ def test_python_read_paths_includes_user_site_when_opted_in(monkeypatch, tmp_pat assert os.path.realpath(str(fake_user_site)) in paths +def test_python_read_paths_survives_missing_site_helpers(monkeypatch): + """site.getsitepackages / getusersitepackages are absent or raise in some + Python environments (older virtualenv site.py, embedded / frozen builds). + _python_read_paths runs in the sandboxed exec path, so it must degrade + gracefully rather than raise: sys.prefix must still appear.""" + sandbox = _load_sandbox_module() + import site + + def _boom(): + raise AttributeError("getsitepackages not defined in this environment") + + monkeypatch.setattr(site, "getsitepackages", _boom) + monkeypatch.setattr(site, "getusersitepackages", _boom) + monkeypatch.setenv("UNSLOTH_STUDIO_SANDBOX_ALLOW_USER_SITE", "1") + # Must not raise, and the interpreter prefix (always bindable) is retained. + paths = sandbox._python_read_paths() + assert os.path.realpath(sys.prefix) in paths + + def test_bwrap_probe_bin_exists_and_executable(): sandbox = _load_sandbox_module() bin_path = sandbox._BWRAP_PROBE_BIN