Compare commits
22 commits
main
...
studio-san
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2883b9a970 | ||
|
|
6e09a4683d | ||
|
|
58c02e9a5d | ||
|
|
88f39d6f01 | ||
|
|
4ec9db68ad | ||
|
|
937cc05b0a | ||
|
|
339dded90a | ||
|
|
2e91a44202 | ||
|
|
f5b9e1a4ab | ||
|
|
8d50555a2d | ||
|
|
830af5f627 | ||
|
|
8d99c172a1 | ||
|
|
e7aed125d6 | ||
|
|
fda0c11682 | ||
|
|
10f6db4f3e | ||
|
|
9ddd563fd2 | ||
|
|
adf2a468bd | ||
|
|
9471bc12d3 | ||
|
|
04434151f9 | ||
|
|
c2903da6a5 | ||
|
|
57a73f34d5 | ||
|
|
61523b73f1 |
2 changed files with 241 additions and 0 deletions
|
|
@ -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/<parent>/environ secret-recovery path first; if it
|
||||
# cannot be applied, fail closed rather than leak the parent environ.
|
||||
|
|
|
|||
116
studio/backend/tests/test_sandbox_path_check.py
Normal file
116
studio/backend/tests/test_sandbox_path_check.py
Normal file
|
|
@ -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
|
||||
Loading…
Add table
Add a link
Reference in a new issue