Compare commits
3 commits
main
...
danielhanc
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5d25ef3439 | ||
|
|
a67dd44fae | ||
|
|
db76fc27e5 |
7 changed files with 1482 additions and 20 deletions
20
.github/workflows/studio-backend-ci.yml
vendored
20
.github/workflows/studio-backend-ci.yml
vendored
|
|
@ -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.
|
||||
|
|
|
|||
24
install.sh
24
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 >/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 >/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
|
||||
|
||||
|
|
|
|||
623
studio/backend/core/inference/sandbox.py
Normal file
623
studio/backend/core/inference/sandbox.py
Normal file
|
|
@ -0,0 +1,623 @@
|
|||
# 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]
|
||||
# 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":
|
||||
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())
|
||||
|
||||
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()"
|
||||
)
|
||||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1356,6 +1356,19 @@ 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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
644
studio/backend/tests/test_sandbox.py
Normal file
644
studio/backend/tests/test_sandbox.py
Normal file
|
|
@ -0,0 +1,644 @@
|
|||
# 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_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
|
||||
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
|
||||
Loading…
Add table
Add a link
Reference in a new issue