unsloth/studio/backend/tests/test_sandbox_filesystem.py
danielhanchen 016de7790d Studio sandbox: enforce filesystem confinement at runtime, drop the static path resolver
The static filesystem write-confinement (the LOCAL/ESCAPE/UNKNOWN path resolver
_resolve_path / _resolve_path_call plus the _FS_* mutating-op inventory) was the
largest and most complex part of the classifier, and for writes it duplicated the
runtime realpath backstop, which is strictly more robust: it resolves the true
realpath at the syscall boundary, so it also catches dynamic paths, pre-existing
symlinks, and library writers the static pass could not prove.

Make the runtime backstop the single filesystem-write boundary and delete the
static resolver:

- Harden the backstop to close the gaps the static layer used to cover: guard the
  low-level os.open (any mutating flag confines the target; a mutating dir_fd fails
  closed) and io.open (which also carries pathlib.Path.open('w')), and add
  os.mknod / lchmod / lchown / chflags and shutil.chown / copymode / copystat to
  the wrapped set. Native-C writers (cv2.imwrite) and the realpath TOCTOU window
  remain documented residuals that only OS-level isolation can close.
- Remove _resolve_path, _resolve_path_call, _resolve_join, _classify_path_string,
  _is_pathlib_expr and the _FS_* / _PATHLIB_CTORS / _PATH_DEPTH_CAP constants, and
  the write half of the filesystem visitor plus the FS_READ_STRICT knob.
- Reads are not confined by the backstop, so keep a small static sensitive-read
  scanner (_is_sensitive_abs_path) that still blocks host-secret reads via a
  sensitive absolute / ~ literal in any call arg (covers open, os.open, and library
  loaders such as pandas.read_csv('/etc/passwd')) and .. / ~ traversal on the
  dedicated open/read callees.

Net: about 300 fewer lines in tools.py and one fewer concept to audit; static
analysis now scopes to exec, shell, network and sensitive-reads while writes are
confined at runtime. Rework the filesystem tests around the new contract and add
os.open / io.open / Path.open / dir_fd escape cases to the backstop suite.
2026-07-09 12:35:29 +00:00

101 lines
3.7 KiB
Python

# SPDX-License-Identifier: AGPL-3.0-only
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved.
"""Stage 3: static sensitive-read scanner in the sandbox classifier.
Filesystem WRITE confinement is enforced at runtime by the realpath backstop (see
test_sandbox_runtime_backstop.py), which is strictly more robust than static path
proving. This static pass only blocks host-secret READS, which the backstop leaves
unpatched, so writes/deletes must pass the static gate and be confined at runtime.
"""
import sys
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))
from core.inference.tools import _check_code_safety
def _blocked(code):
assert _check_code_safety(code) is not None, code
def _ok(code):
assert _check_code_safety(code) is None, code
class TestSensitiveReadBlocked:
"""Host-secret reads must block statically (the runtime backstop skips reads)."""
@pytest.mark.parametrize(
"code",
[
'open("/etc/passwd").read()',
'open("/etc/shadow").read()',
'open("../../etc/passwd").read()',
'open("~/.ssh/id_rsa").read()',
'open("/proc/self/environ").read()',
'open("~/.aws/credentials").read()',
# library loaders that internally open() the path
'import numpy as np; np.load("/etc/shadow")',
'import pandas as pd; pd.read_csv("/etc/passwd")',
'from pathlib import Path; Path("/root/.ssh/id_rsa").read_text()',
# a sensitive path anywhere (incl. write targets) is caught by the
# callee-independent scan, which is fine (also runtime-confined)
'import os; os.rename("data.csv", "/root/data.csv")',
],
)
def test_block(self, code):
_blocked(code)
class TestWritesPassStaticGate:
"""Writes/deletes/renames to non-secret paths are no longer statically blocked;
the runtime realpath backstop confines them. They must pass the static gate so
benign in-workdir I/O is never over-blocked."""
@pytest.mark.parametrize(
"code",
[
'import shutil; shutil.rmtree("/home/user")',
'import os; os.remove("../secret.txt")',
'open("/etc/cron.d/x", "w").write("* * * * *")',
'import os; os.symlink("/etc", "link")',
'import os; os.chmod("/usr/bin/python", 0o777)',
'open(f"/var/log/{name}", "w")',
'import tempfile; tempfile.mkstemp(dir="/tmp")',
'import numpy as np; np.save("/etc/x.npy", a)',
'import os; os.makedirs("/opt/evil")',
'import os; os.rename("data.csv", "backup/data.csv")',
],
)
def test_static_allow(self, code):
_ok(code)
class TestBenignFilesystemAllowed:
@pytest.mark.parametrize(
"code",
[
'open("out.txt", "w").write("hi")',
'from pathlib import Path; (Path("results") / "m.json").write_text(s)',
'import os; os.makedirs("run/ckpt", exist_ok=True)',
'import shutil; shutil.copy("a.csv", "b.csv")',
'import numpy as np; np.save("emb.npy", arr)',
'import pandas as pd; df.to_parquet("out/data.parquet")',
'import json; json.dump(d, open("r.json", "w"))',
"import tempfile; f = tempfile.NamedTemporaryFile()",
'import pandas as pd; pd.read_csv("/data/train.csv")',
"open(fname).read()",
'p = "ckpt.pt"\nimport torch\ntorch.save(m, p)',
'df.to_csv("results/summary.csv")',
'open("data_" + str(i) + ".csv").read()',
],
)
def test_allow(self, code):
_ok(code)