diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index bc9ffe85c2..a2d2556a4d 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -5542,6 +5542,120 @@ def _is_outside_workdir(abs_path: str, workdir: str | None = None) -> bool: return rp != root and not rp.startswith(root + os.sep) +# Device paths that shell redirection and common tooling rely on; they are not +# filesystem escapes, so the out-of-workdir scan skips them. +_ALLOWED_ABS_PATHS = frozenset( + { + "/dev/null", + "/dev/zero", + "/dev/full", + "/dev/tty", + "/dev/stdin", + "/dev/stdout", + "/dev/stderr", + "/dev/random", + "/dev/urandom", + } +) + + +def _sensitive_prefixes() -> tuple[str, ...]: + """Realpath'd system roots holding host config, credentials, other users' + files or kernel state. Reads under these (outside the workdir) are blocked; + ephemeral scratch like /tmp and $TMPDIR is deliberately not listed.""" + roots = ["/etc", "/root", "/home", "/proc", "/sys", "/boot"] + try: + home = os.path.expanduser("~") + if home and home != "~": + roots.append(home) + except (OSError, ValueError, KeyError): + pass + resolved: list[str] = [] + for r in roots: + try: + rp = os.path.realpath(r) + except (OSError, ValueError): + continue + if rp and rp != os.sep: + resolved.append(rp) + return tuple(dict.fromkeys(resolved)) + + +_SENSITIVE_PREFIXES = _sensitive_prefixes() + + +def _is_sensitive_outside_workdir(abs_path: str, workdir: str) -> bool: + """True when ``abs_path`` resolves under a sensitive system prefix and is not + inside the session workdir.""" + if not _is_outside_workdir(abs_path, workdir): + return False + try: + rp = os.path.realpath(abs_path) + except (OSError, ValueError): + return False + return any(rp == p or rp.startswith(p + os.sep) for p in _SENSITIVE_PREFIXES) + + +def _sensitive_paths(command: str, workdir: str) -> list[str]: + """Best-effort keyword scan for path arguments in ``command`` that resolve to + a sensitive out-of-workdir location (host config, credentials, other users' + files, kernel state). + + A lightweight, additive defence-in-depth check: it inspects literal path-like + tokens (absolute ``/`` or ``~`` paths, explicit relative paths, an option's + attached ``--flag=/path`` value, and ``$HOME``/``${VAR}`` references) and + reports those landing under a sensitive prefix while outside the session + workdir. Ephemeral scratch (/tmp, $TMPDIR) and neutral paths are allowed; the + kernel-level filesystem sandbox is the real boundary, so this is best effort + (no full shell expansion, command substitution, globbing or Windows paths). + Fails open on anything it cannot parse. Returns blocked realpaths (deduped). + """ + try: + tokens = shlex.split(command, posix = True) + except ValueError: + return [] + blocked: list[str] = [] + seen: set[str] = set() + for token in tokens: + # Strip a leading shell redirection operator glued to the path (>, >>, + # 2>, 2>>, &>, &>>, <) so e.g. 2>>/etc/x and &>/etc/x are still checked. + tok = re.sub(r"^[0-9&]*[<>]+", "", token) + if not tok: + continue + if tok.startswith("-"): + # An option carrying a path value: --file=/etc/x, -o=/etc/x, -o/etc/x. + if "=" in tok: + tok = tok.split("=", 1)[1] + elif "/" in tok: + tok = tok[tok.index("/") :] + else: + continue # a bare flag carries no path + if not tok: + continue + if "://" in tok: + continue + # Best-effort env expansion so $HOME / ${VAR} paths are checked; this is + # not a full shell (no command substitution or globbing). + if "$" in tok: + tok = os.path.expandvars(tok) + if tok.startswith("~"): + candidate = os.path.expanduser(tok) + elif tok.startswith("/"): + if tok in _ALLOWED_ABS_PATHS: + continue + candidate = tok + elif "/" in tok: + candidate = os.path.join(workdir, tok) + else: + continue + if _is_sensitive_outside_workdir(candidate, workdir): + resolved = os.path.realpath(candidate) + if resolved not in seen: + seen.add(resolved) + blocked.append(resolved) + return blocked + + def _missing_path_hint(output: str, workdir: str | None = None) -> str: """Model-visible healing when an execution fails on an absolute path missing in the sandbox (a code-interpreter habit path, or one invented from the CWD). @@ -5829,6 +5943,17 @@ def _bash_exec( blocked = _find_blocked_commands(command) if blocked: return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}" + # Defence in depth: reject file arguments that resolve to a sensitive + # location (host config, credentials, other users' files, kernel state) + # outside the session workdir. Ephemeral scratch like /tmp is allowed, + # and bypass sessions skip this along with the blocklist above. + sensitive = _sensitive_paths(command, _get_workdir(session_id)) + if sensitive: + return ( + "Blocked for safety: protected path(s) outside the sandbox working " + f"directory: {', '.join(sensitive)}. Read and write files with " + "relative paths in the working directory instead." + ) elif not _harden_parent_against_proc_env_leak(): # Close the /proc//environ secret-recovery path first; if it # cannot be applied, fail closed rather than leak the parent environ. diff --git a/studio/backend/tests/test_sandbox_path_check.py b/studio/backend/tests/test_sandbox_path_check.py new file mode 100644 index 0000000000..a312f6d123 --- /dev/null +++ b/studio/backend/tests/test_sandbox_path_check.py @@ -0,0 +1,116 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Tests for the sensitive-path check on the terminal tool (#7242). + +A lightweight, additive keyword scan that rejects shell path arguments resolving +to a sensitive out-of-workdir location (host config, credentials, other users' +files, kernel state). Ephemeral scratch like /tmp is allowed; it is defence in +depth, not the real boundary (the kernel filesystem sandbox is), and is skipped +when the sandbox is disabled (Bypass Permissions). +""" + +from __future__ import annotations + +import os +import sys +from pathlib import Path + +_BACKEND_DIR = str(Path(__file__).resolve().parent.parent) +if _BACKEND_DIR not in sys.path: + sys.path.insert(0, _BACKEND_DIR) + +from core.inference.tools import _bash_exec, _sensitive_paths + + +def test_sensitive_absolute_path_is_flagged(tmp_path): + wd = str(tmp_path) + # /etc is a sensitive prefix, so the check catches an out-of-workdir read. + assert _sensitive_paths("cat /etc/hostname", wd) == ["/etc/hostname"] + + +def test_home_credential_path_is_flagged(tmp_path): + wd = str(tmp_path) + # ~ expands to the real home (a sensitive prefix), so ~/.ssh/id_rsa is caught. + flagged = _sensitive_paths("cat ~/.ssh/id_rsa", wd) + assert flagged and flagged[0].endswith(".ssh/id_rsa") + + +def test_scratch_and_neutral_paths_are_allowed(tmp_path): + wd = str(tmp_path) + # Ephemeral scratch and neutral mounts are not sensitive: allowed so normal + # tooling (and the timeout/cancel/hint sandbox tests) keep working. + assert _sensitive_paths("touch /tmp/marker", wd) == [] + assert _sensitive_paths("cat /mnt/data/definitely_missing.txt", wd) == [] + + +def test_paths_inside_workdir_are_allowed(tmp_path): + wd = str(tmp_path) + (tmp_path / "data.csv").write_text("x") + assert _sensitive_paths("cat data.csv", wd) == [] + assert _sensitive_paths("cat sub/dir/data.csv", wd) == [] + assert _sensitive_paths(f"cat {wd}/data.csv", wd) == [] + + +def test_traversal_into_sensitive_prefix_is_flagged(tmp_path): + # A workdir nested under /etc would let ../ climb into the sensitive prefix; + # a traversal that lands in a sensitive location must be caught. Simulate the + # generic case: an explicit sensitive target after a traversal token. + wd = str(tmp_path / "session") + os.makedirs(wd) + # Traversal to a non-sensitive sibling scratch is intentionally allowed. + assert _sensitive_paths("cat ../peer.txt", wd) == [] + # But an absolute sensitive read is still blocked. + assert _sensitive_paths("grep secret /etc/shadow", wd) == ["/etc/shadow"] + + +def test_option_attached_path_value_is_flagged(tmp_path): + wd = str(tmp_path) + # --flag=/path and glued short options carry a path the plain flag skip missed. + assert _sensitive_paths("grep x --file=/etc/shadow", wd) == ["/etc/shadow"] + assert _sensitive_paths("tool -o/etc/passwd", wd) == ["/etc/passwd"] + # A neutral attached value stays allowed. + assert _sensitive_paths("tool --out=/tmp/ok.txt", wd) == [] + + +def test_env_var_paths_are_expanded(tmp_path, monkeypatch): + wd = str(tmp_path) + monkeypatch.setenv("NB_SECRET_DIR", "/etc") + assert _sensitive_paths("cat $NB_SECRET_DIR/shadow", wd) == ["/etc/shadow"] + assert _sensitive_paths("cat ${NB_SECRET_DIR}/shadow", wd) == ["/etc/shadow"] + + +def test_glued_ampersand_redirection_is_flagged(tmp_path): + wd = str(tmp_path) + # &> (stdout+stderr) glued to a sensitive target is stripped and checked. + assert _sensitive_paths("prog &>/etc/motd", wd) == ["/etc/motd"] + # Numeric-fd redirection to a device stays allowed. + assert _sensitive_paths("prog 2>>/dev/null", wd) == [] + + +def test_normal_commands_and_devices_are_untouched(tmp_path): + wd = str(tmp_path) + assert _sensitive_paths("echo hello", wd) == [] + assert _sensitive_paths("pip install requests", wd) == [] + # Redirection to /dev/null is not a filesystem escape. + assert _sensitive_paths("python train.py 2>/dev/null", wd) == [] + # URLs are not local filesystem paths. + assert _sensitive_paths("git clone https://github.com/a/b", wd) == [] + + +def test_bash_exec_blocks_sensitive_path(): + msg = _bash_exec("cat /etc/hostname", session_id = "pathcheck-block") + assert "outside the sandbox working directory" in msg + assert "/etc/hostname" in msg + + +def test_bash_exec_allows_normal_command(): + msg = _bash_exec("echo hello", session_id = "pathcheck-normal") + assert "outside the sandbox working directory" not in msg + assert "hello" in msg + + +def test_bypass_skips_the_sensitive_path_block(): + # Bypass Permissions skips the blocklist and this check alike. + msg = _bash_exec("cat /etc/hostname", session_id = "pathcheck-bypass", disable_sandbox = True) + assert "outside the sandbox working directory" not in msg