From 4ca35ec644542e42cbdd534b0016c18dd7390943 Mon Sep 17 00:00:00 2001 From: danielhanchen Date: Thu, 9 Jul 2026 06:28:42 +0000 Subject: [PATCH] Studio sandbox: add first-class filesystem confinement Add a filesystem_violations category backed by _resolve_path, a LOCAL / ESCAPE / UNKNOWN classifier that constant-folds strings and understands os.path.join, pathlib Path()/'/'/joinpath, and f-strings with real join plus absolute-reset semantics. expanduser / expandvars / os.environ / getcwd / dynamic parts collapse to UNKNOWN. A new _FilesystemPolicyVisitor inventories destructive and mutating ops (open write/append/x/+, os remove/unlink/rmdir/rename/replace/truncate/chmod/ chown/mkdir/makedirs/mknod/symlink/link/chdir, shutil rmtree/move/copy*, pathlib write_text/write_bytes/unlink/rename/replace/mkdir/rmdir/chmod/symlink_to/touch, tempfile dir=, and a curated numpy/pandas/torch/joblib/PIL/matplotlib/cv2 writer set) and applies prove-or-block: mutating LOCAL allows, UNKNOWN/ESCAPE blocks. rename/move check src and dst; symlink/link check both target and link path; chdir must be LOCAL; tempfile dir= must be LOCAL. Reads block only on a provable escape (sensitive absolute path or ..'/~ traversal), with an FS_READ_STRICT knob for prove-or-block reads. A callee-independent literal-sensitive-path scan blocks loaders like pandas.read_csv('/etc/shadow'). Library writers block only on a provable escape so in-memory buffers are not over-blocked; the Stage 5 runtime backstop covers the dynamic residual. --- studio/backend/core/inference/tools.py | 364 +++++++++++++++++- .../backend/tests/test_sandbox_filesystem.py | 128 ++++++ 2 files changed, 490 insertions(+), 2 deletions(-) create mode 100644 studio/backend/tests/test_sandbox_filesystem.py diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 5be8b7c0c0..7f6f73ca38 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -2268,6 +2268,227 @@ def _recover_exec_payload(node, func_id, const_env, exec_aliases, compiled_env): return ("DYNAMIC", None, None) +# -------------------------------------------------------------------------- +# Stage 3: first-class filesystem confinement. +# +# A destructive/mutating op is allowed only when its path is PROVABLY inside the +# session workdir (LOCAL). A read is blocked only when it PROVABLY escapes to a +# sensitive or traversal target (ESCAPE_READ). Path resolution is a constant-fold +# extended with os.path.join / pathlib join / f-string real-join + absolute-reset +# semantics; anything host-controlled or dynamic collapses to UNKNOWN. +# -------------------------------------------------------------------------- +_PATH_DEPTH_CAP = 24 +_PATHLIB_CTORS = frozenset( + {"Path", "PurePath", "PosixPath", "WindowsPath", "PurePosixPath", "PureWindowsPath"} +) + +# Sensitive read targets: exact host-identity / credential files, credential dirs, +# and the classic /proc self-inspection paths. Substring tokens are only consulted +# for absolute or ~-rooted paths with no whitespace (avoids sentence false positives). +_SANDBOX_SENSITIVE_EXACT = frozenset( + {"/etc/passwd", "/etc/shadow", "/etc/sudoers", "/etc/gshadow", "/etc/master.passwd"} +) +_SANDBOX_SENSITIVE_DIR_PARTS = ( + "/etc/ssh/", "/root/", "/.ssh/", "/.aws/", "/.config/gcloud", "/.kube/", "/.docker/", +) +_SANDBOX_SENSITIVE_TOKENS = ( + "id_rsa", "id_ed25519", ".pem", ".netrc", "credentials", ".git-credentials", + "/.huggingface/token", ".kube/config", +) +_SANDBOX_SENSITIVE_RE = re.compile( + r"^/proc/(?:self|\d+)/(?:environ|cmdline|maps|mem|task/\d+/environ)$" +) + + +def _is_sensitive_abs_path(s): + """Provably-sensitive absolute (or ~-rooted) path, whitespace-free.""" + if not isinstance(s, str) or not s: + return False + norm = s.replace("\\", "/") + if any(ch.isspace() for ch in norm): + return False + if not (norm.startswith("/") or norm.startswith("~")): + return False + if norm in _SANDBOX_SENSITIVE_EXACT: + return True + if any(part in norm for part in _SANDBOX_SENSITIVE_DIR_PARTS): + return True + if _SANDBOX_SENSITIVE_RE.match(norm): + return True + low = norm.lower() + return any(tok in low for tok in _SANDBOX_SENSITIVE_TOKENS) + + +def _classify_path_string(s): + """LOCAL for a safe-relative path; ESCAPE for absolute / drive / ~ / `..`.""" + if isinstance(s, (bytes, bytearray)): + s = _to_text(s) + if not isinstance(s, str) or s == "": + return "ESCAPE" # empty path is not provably local -> fail closed + norm = s.replace("\\", "/") + if s[0] in ("/", "\\", "~"): + return "ESCAPE" + if len(s) >= 2 and s[1] == ":": + return "ESCAPE" + if ".." in norm.split("/"): + return "ESCAPE" + return "LOCAL" + + +def _is_pathlib_expr(node): + """Whether an expression is structurally a pathlib.Path (ctor / join / attr chain).""" + if isinstance(node, ast.Call): + f = node.func + if isinstance(f, ast.Name) and f.id in _PATHLIB_CTORS: + return True + if isinstance(f, ast.Attribute): + if f.attr in _PATHLIB_CTORS: + return True + if f.attr in ("joinpath", "with_name", "with_suffix", "absolute", "resolve", + "expanduser", "parent") and _is_pathlib_expr(f.value): + return True + return False + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Div): + return _is_pathlib_expr(node.left) or _is_pathlib_expr(node.right) + if isinstance(node, ast.Attribute): + return _is_pathlib_expr(node.value) + return False + + +def _resolve_join(components, env, depth): + """Combine component verdicts with join + absolute-reset semantics.""" + result = "LOCAL" + for c in components: + v = _resolve_path(c, env, depth + 1) + if v == "ESCAPE": + result = "ESCAPE" # absolute reset or `..` -> outside + elif v == "UNKNOWN" and result != "ESCAPE": + result = "UNKNOWN" + return result + + +def _resolve_path(node, env = None, depth = 0): + """Classify a path expression as LOCAL / ESCAPE / UNKNOWN (see Stage 3).""" + if node is None or depth > _PATH_DEPTH_CAP: + return "UNKNOWN" + + v = _const_fold(node, env) + if isinstance(v, (str, bytes, bytearray)): + return _classify_path_string(v) + + if isinstance(node, ast.Name): + rhs = (env or {}).get(node.id) + if rhs is not None: + return _resolve_path(rhs, env, depth + 1) + return "UNKNOWN" + + if isinstance(node, ast.JoinedStr): + # Not all-const (else it folded above): an absolute literal prefix escapes; + # a relative prefix + dynamic hole cannot be proven local -> UNKNOWN. + prefix = "" + for part in node.values: + if isinstance(part, ast.Constant): + prefix += str(part.value) + else: + break + if prefix: + if prefix[0] in ("/", "\\", "~"): + return "ESCAPE" + if len(prefix) >= 2 and prefix[1] == ":": + return "ESCAPE" + if ".." in prefix.replace("\\", "/").split("/"): + return "ESCAPE" + return "UNKNOWN" + + if isinstance(node, ast.BinOp): + if isinstance(node.op, ast.Add): + left = _resolve_path(node.left, env, depth + 1) + return "ESCAPE" if left == "ESCAPE" else "UNKNOWN" + if isinstance(node.op, ast.Div): + return _resolve_join([node.left, node.right], env, depth) + return "UNKNOWN" + + if isinstance(node, ast.Call): + return _resolve_path_call(node, env, depth) + + return "UNKNOWN" + + +def _resolve_path_call(node, env, depth): + f = node.func + attr = f.attr if isinstance(f, ast.Attribute) else (f.id if isinstance(f, ast.Name) else "") + + # os.path.join(...) / posixpath.join(...) + if attr == "join" and isinstance(f, ast.Attribute): + owner_fq = _fq_attr_name(f.value) + if owner_fq.endswith("path") or owner_fq in ("op",): + return _resolve_join(node.args, env, depth) + if attr == "joinpath" and isinstance(f, ast.Attribute): + return _resolve_join([f.value, *node.args], env, depth) + # Host-controlled / absolute anchors are never provably local. + if attr in ("expanduser", "expandvars", "abspath", "realpath", "getcwd", "getcwdb", + "gettempdir", "mkdtemp", "home", "cwd"): + return "UNKNOWN" + if attr == "normpath" and node.args: + v = _const_fold(node.args[0], env) + if isinstance(v, (str, bytes, bytearray)): + return _classify_path_string(os.path.normpath(_to_text(v))) + return "UNKNOWN" + # Path(...) / PurePath(...) constructors (bare or pathlib.Path). + if (isinstance(f, ast.Name) and f.id in _PATHLIB_CTORS) or \ + (isinstance(f, ast.Attribute) and f.attr in _PATHLIB_CTORS): + if len(node.args) == 1: + return _resolve_path(node.args[0], env, depth + 1) + if len(node.args) >= 2: + return _resolve_join(node.args, env, depth) + return "UNKNOWN" + return "UNKNOWN" + + +# Mutating-op inventory (fully-qualified stdlib names). +_FS_DELETE = frozenset( + {"os.remove", "os.unlink", "os.rmdir", "os.removedirs", "shutil.rmtree", + "pathlib.Path.unlink", "pathlib.Path.rmdir"} +) +_FS_META = frozenset( + {"os.chmod", "os.lchmod", "os.chown", "os.lchown", "os.chflags", "os.truncate", + "shutil.chown"} +) +_FS_MKDIR = frozenset({"os.mkdir", "os.makedirs", "os.mknod"}) +_FS_CHDIR = frozenset({"os.chdir", "os.fchdir"}) +_FS_SINGLE_MUTATE = _FS_DELETE | _FS_META | _FS_MKDIR | _FS_CHDIR +_FS_RENAME = frozenset({"os.rename", "os.renames", "os.replace", "shutil.move"}) +_FS_COPY = frozenset( + {"shutil.copy", "shutil.copy2", "shutil.copyfile", "shutil.copytree", + "shutil.copymode", "shutil.copystat"} +) +_FS_SYMLINK = frozenset({"os.symlink", "os.link"}) +_FS_TEMPFILE = frozenset( + {"tempfile.mkstemp", "tempfile.mkdtemp", "tempfile.NamedTemporaryFile", + "tempfile.TemporaryFile", "tempfile.TemporaryDirectory", + "tempfile.SpooledTemporaryFile"} +) +_FS_LIBWRITER_FQ = frozenset( + {"numpy.save", "numpy.savez", "numpy.savez_compressed", "numpy.savetxt", + "np.save", "np.savez", "np.savez_compressed", "np.savetxt", + "torch.save", "joblib.dump", "cv2.imwrite"} +) +# Method-name-keyed library writers (receiver is a df / array / image / figure). +_FS_LIBWRITER_METHODS = frozenset( + {"to_csv", "to_parquet", "to_pickle", "to_json", "to_excel", "to_feather", + "savefig", "imwrite"} +) +# pathlib mutating methods -> (needs_receiver_path, extra_arg_index_or_None, op). +# unambiguous method names fire on any pathlib-looking receiver; the ambiguous +# ones (rename/replace/mkdir/chmod) require the receiver to be a pathlib expr. +_FS_PATHLIB_MUTATE = { + "write_text": None, "write_bytes": None, "unlink": None, "rmdir": None, + "symlink_to": 0, "hardlink_to": 0, "touch": None, "rename": 0, "replace": 0, + "mkdir": None, "chmod": None, +} +_FS_PATHLIB_READ = frozenset({"read_text", "read_bytes"}) + + def _check_signal_escape_patterns(code: str, _depth: int = 0, _budget = None): """Check for patterns that could escape signal-based timeouts. Returns (safe: bool, details: dict). Vendored from unsloth_zoo.rl_environments to @@ -3432,9 +3653,148 @@ def _check_signal_escape_patterns(code: str, _depth: int = 0, _budget = None): ) self.generic_visit(node) + _fs_read_strict = os.environ.get("FS_READ_STRICT", "0") != "0" + + def _fs_block(node, description): + filesystem_violations.append( + { + "type": "filesystem_violation", + "line": getattr(node, "lineno", -1), + "description": description, + } + ) + + def _fs_mutating(node, path_node, label): + verdict = _resolve_path(path_node, _const_env) + if verdict != "LOCAL": + reason = "escapes the session workdir" if verdict == "ESCAPE" \ + else "cannot be proven to stay inside the session workdir" + _fs_block(node, f"{label}: destination path {reason} (must be a sandbox-local relative path)") + + def _fs_libwriter(node, path_node, label): + # Best-effort library writers block only on a PROVABLE escape; a dynamic + # (UNKNOWN) path is left to the runtime realpath backstop to avoid + # false-positiving on in-memory buffers. + if _resolve_path(path_node, _const_env) == "ESCAPE": + _fs_block(node, f"{label}: destination path escapes the session workdir") + + def _fs_read(node, path_node, label): + v = _const_fold(path_node, _const_env) + s = _to_text(v) if isinstance(v, (str, bytes, bytearray)) else None + if s is not None: + norm = s.replace("\\", "/") + if s[:1] == "~" or ".." in norm.split("/"): + _fs_block(node, f"{label}: read escapes the session workdir via traversal") + return + if _is_sensitive_abs_path(norm): + _fs_block(node, f"{label}: reads a sensitive host identity / credential file") + return + return + if _fs_read_strict and _resolve_path(path_node, _const_env) != "LOCAL": + _fs_block(node, f"{label}: read path cannot be proven sandbox-local (FS_READ_STRICT)") + + def _kw(node, name): + for kw in node.keywords or []: + if kw.arg == name: + return kw.value + return None + + def _open_is_write(node): + mode_node = node.args[1] if len(node.args) >= 2 else _kw(node, "mode") + if mode_node is None: + return False, "r" + v = _const_fold(mode_node, _const_env) + if isinstance(v, str): + return any(c in v for c in "wax+"), v + return True, None # dynamic mode -> treat as write (conservative) + class _FilesystemPolicyVisitor(ast.NodeVisitor): - # Stage 3 fills this in; the stub keeps Stage 2 self-contained. - pass + def visit_Call(self, node): + fq = _fq_attr_name(node.func) + f = node.func + method = f.attr if isinstance(f, ast.Attribute) else (f.id if isinstance(f, ast.Name) else "") + + # Callee-independent literal-sensitive-path scan (library loaders that + # internally open(): pandas.read_csv('/etc/shadow'), np.load('/etc/passwd')). + for arg in list(node.args) + [kw.value for kw in (node.keywords or [])]: + fv = _const_fold(arg, _const_env) + sv = _to_text(fv) if isinstance(fv, (str, bytes, bytearray)) else None + if sv is not None and _is_sensitive_abs_path(sv): + _fs_block(node, f"{sv!r} is a sensitive host identity / credential file") + break + + # builtins/io open(): write mode -> mutating; read mode -> read policy. + is_open = (isinstance(f, ast.Name) and f.id == "open") or fq in ("io.open", "os.fdopen") + if is_open and fq != "os.fdopen" and node.args: + is_write, _mode = _open_is_write(node) + if is_write: + _fs_mutating(node, node.args[0], "open(write)") + else: + _fs_read(node, node.args[0], "open(read)") + + # os.open(path, flags): write flags -> mutating; else read. + if fq == "os.open" and node.args: + flags = node.args[1] if len(node.args) >= 2 else None + flag_names = {n.attr for n in ast.walk(flags) if isinstance(n, ast.Attribute)} if flags else set() + is_write = flags is None or bool( + flag_names & {"O_WRONLY", "O_RDWR", "O_CREAT", "O_TRUNC", "O_APPEND"} + ) or not flag_names + if is_write: + _fs_mutating(node, node.args[0], "os.open(write)") + else: + _fs_read(node, node.args[0], "os.open(read)") + + if fq in _FS_SINGLE_MUTATE and node.args: + _fs_mutating(node, node.args[0], fq) + elif fq in _FS_RENAME and node.args: + # rename/move: both src (removed) and dst are mutating. + _fs_mutating(node, node.args[0], f"{fq} (source)") + if len(node.args) >= 2: + _fs_mutating(node, node.args[1], f"{fq} (destination)") + else: + dst = _kw(node, "dst") + if dst is not None: + _fs_mutating(node, dst, f"{fq} (destination)") + elif fq in _FS_COPY and node.args: + dst = node.args[1] if len(node.args) >= 2 else _kw(node, "dst") + if dst is not None: + _fs_mutating(node, dst, f"{fq} (destination)") + _fs_read(node, node.args[0], f"{fq} (source)") + elif fq in _FS_SYMLINK and node.args: + # os.symlink(src=target, dst=linkpath) / os.link: check BOTH. + _fs_mutating(node, node.args[0], f"{fq} (target)") + if len(node.args) >= 2: + _fs_mutating(node, node.args[1], f"{fq} (link path)") + elif fq in _FS_TEMPFILE: + d = _kw(node, "dir") + if d is not None and _resolve_path(d, _const_env) != "LOCAL": + _fs_block(node, f"{fq}: dir= must be a sandbox-local relative path") + elif fq in _FS_LIBWRITER_FQ and node.args: + _fs_libwriter(node, node.args[0], fq) + + # Method-keyed library writers (df.to_csv(path), img.save(path), ...). + if isinstance(f, ast.Attribute): + if method in _FS_LIBWRITER_METHODS and node.args: + _fs_libwriter(node, node.args[0], method) + elif method == "save" and node.args and fq not in _FS_LIBWRITER_FQ: + # PIL Image.save / model.save style: block only a provable escape. + _fs_libwriter(node, node.args[0], method) + + # pathlib mutating / reading methods on a Path-looking receiver. A plain + # variable receiver is left to the Stage 5 runtime realpath backstop so + # benign `p = Path("out.txt"); p.write_text(...)` is not over-blocked. + if isinstance(f, ast.Attribute) and _is_pathlib_expr(f.value) \ + and (method in _FS_PATHLIB_MUTATE or method in _FS_PATHLIB_READ): + recv = f.value + if method in _FS_PATHLIB_READ: + _fs_read(node, recv, f"pathlib.Path.{method}") + else: + _fs_mutating(node, recv, f"pathlib.Path.{method}") + extra = _FS_PATHLIB_MUTATE.get(method) + if extra is not None and len(node.args) > extra: + _fs_mutating(node, node.args[extra], f"pathlib.Path.{method} (target)") + + self.generic_visit(node) NetworkAndIoVisitor().visit(tree) diff --git a/studio/backend/tests/test_sandbox_filesystem.py b/studio/backend/tests/test_sandbox_filesystem.py new file mode 100644 index 0000000000..493394f71e --- /dev/null +++ b/studio/backend/tests/test_sandbox_filesystem.py @@ -0,0 +1,128 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. + +"""Stage 3: filesystem-confinement policy in the sandbox static classifier.""" + +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, _resolve_path +import ast + + +def _blocked(code): + assert _check_code_safety(code) is not None, code + + +def _ok(code): + assert _check_code_safety(code) is None, code + + +def _verdict(expr): + return _resolve_path(ast.parse(expr, mode="eval").body) + + +class TestPathResolver: + @pytest.mark.parametrize( + "expr, expect", + [ + ('"out.txt"', "LOCAL"), + ('"outputs/run/m.bin"', "LOCAL"), + ('"/etc/passwd"', "ESCAPE"), + ('"../secret"', "ESCAPE"), + ('"~/.bashrc"', "ESCAPE"), + ('"C:\\\\Windows"', "ESCAPE"), + ('os.path.join("out", "a.txt")', "LOCAL"), + ('os.path.join("out", "..", "etc")', "ESCAPE"), + ('os.path.join("/home/u", ".ssh", "authorized_keys")', "ESCAPE"), + ('os.path.join("sub", name)', "UNKNOWN"), + ('Path("results") / "m.json"', "LOCAL"), + ('Path("/tmp/x")', "ESCAPE"), + ('os.path.expanduser("~/.bashrc")', "UNKNOWN"), + ('f"/var/log/{name}"', "ESCAPE"), + ('f"out/{name}"', "UNKNOWN"), + ("fname", "UNKNOWN"), + ], + ) + def test_resolve(self, expr, expect): + assert _verdict(expr) == expect, expr + + +class TestMutatingBlocked: + @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; open(os.path.expanduser("~/.bashrc"), "a")', + "import os; os.remove(user_path)", + 'from pathlib import Path; Path("/tmp/x").write_text("hi")', + 'import os; os.rename("data.csv", "/root/data.csv")', + 'import os; os.symlink("/etc", "link")', + 'import os; os.chdir("/")', + 'import os; os.chmod("/usr/bin/python", 0o777)', + 'import pandas as pd; df.to_csv(os.path.join("/home/u", ".ssh", "authorized_keys"))', + '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")', + 'open("out/" + name, "w")', + ], + ) + def test_block(self, code): + _blocked(code) + + +class TestReadEscapeBlocked: + @pytest.mark.parametrize( + "code", + [ + 'open("../../etc/passwd").read()', + 'open("/etc/shadow").read()', + 'import numpy as np; np.load("/etc/shadow")', + 'import pandas as pd; pd.read_csv("/etc/passwd")', + 'open("~/.ssh/id_rsa").read()', + ], + ) + def test_block(self, code): + _blocked(code) + + +class TestFilesystemAllowed: + @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) + + +class TestReadStrictKnob: + def test_dynamic_read_allowed_by_default(self, monkeypatch): + monkeypatch.delenv("FS_READ_STRICT", raising=False) + _ok("open(fname).read()") + + def test_dynamic_read_blocked_when_strict(self, monkeypatch): + monkeypatch.setenv("FS_READ_STRICT", "1") + _blocked("open(fname).read()")