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.
101 lines
3.7 KiB
Python
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)
|