Compare commits

...
Sign in to create a new pull request.

3 commits

Author SHA1 Message Date
danielhanchen
5d25ef3439 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.
2026-07-13 13:11:44 +00:00
pre-commit-ci[bot]
a67dd44fae [pre-commit.ci] auto fixes from pre-commit.com hooks
for more information, see https://pre-commit.ci
2026-07-13 10:25:01 +00:00
danielhanchen
db76fc27e5 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>
2026-07-13 10:23:20 +00:00
7 changed files with 1482 additions and 20 deletions

View file

@ -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.

View file

@ -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

View 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()"
)

View file

@ -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(

View file

@ -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

View file

@ -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

View 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