Narrow the sandbox path check to sensitive prefixes only

The out-of-workspace path check blocked every path outside the workdir,
which broke legitimate sandbox behaviour: the timeout/cancel kill-grandchild
tests write a marker under /tmp, and the missing-path hint uses /mnt/data,
so both hit the block instead of their intended path. Scope the block to
sensitive system prefixes (/etc, /root, /home, /proc, /sys, /boot and the
invoking user home) so credentials and host config outside the workdir are
still rejected, while ephemeral scratch (/tmp, $TMPDIR) and neutral mounts
are allowed. Update the test to the sensitive-prefix contract.
This commit is contained in:
danielhanchen 2026-07-20 09:54:33 +00:00
commit 58c02e9a5d
2 changed files with 103 additions and 46 deletions

View file

@ -5559,22 +5559,60 @@ _ALLOWED_ABS_PATHS = frozenset(
)
def _paths_outside_workdir(command: str, workdir: str) -> list[str]:
"""Best-effort keyword scan for path arguments in ``command`` that resolve
outside ``workdir``.
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))
A lightweight, additive defence-in-depth check: it only inspects literal
path-like tokens (absolute ``/`` or ``~`` paths and explicit relative paths
containing ``/``) and reports the ones whose ``realpath`` escapes the
session workdir. It is not the real boundary (the kernel-level filesystem
sandbox is) and fails open on anything it cannot parse. Returns the escaping
resolved paths (deduped, order-preserving); empty means nothing to block.
_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 and explicit relative paths containing
``/``) 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. Fails open
on anything it cannot parse. Returns the blocked realpaths (deduped, ordered).
"""
try:
tokens = shlex.split(command, posix = True)
except ValueError:
return []
outside: list[str] = []
blocked: list[str] = []
seen: set[str] = set()
for token in tokens:
tok = _REDIR_PREFIX_RE.sub("", token)
@ -5582,7 +5620,7 @@ def _paths_outside_workdir(command: str, workdir: str) -> list[str]:
# flags and URLs are not local filesystem paths
continue
if tok.startswith("~"):
candidate = os.path.join(workdir, tok[1:].lstrip("/\\") or ".")
candidate = os.path.expanduser(tok)
elif tok.startswith("/"):
if tok in _ALLOWED_ABS_PATHS:
continue
@ -5591,12 +5629,12 @@ def _paths_outside_workdir(command: str, workdir: str) -> list[str]:
candidate = os.path.join(workdir, tok)
else:
continue
if _is_outside_workdir(candidate, workdir):
if _is_sensitive_outside_workdir(candidate, workdir):
resolved = os.path.realpath(candidate)
if resolved not in seen:
seen.add(resolved)
outside.append(resolved)
return outside
blocked.append(resolved)
return blocked
def _missing_path_hint(output: str, workdir: str | None = None) -> str:
@ -5886,14 +5924,15 @@ def _bash_exec(
blocked = _find_blocked_commands(command)
if blocked:
return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}"
# Defence in depth: reject obvious file arguments that resolve outside
# the session workdir (bypass sessions skip this along with the
# blocklist above).
outside = _paths_outside_workdir(command, _get_workdir(session_id))
if outside:
# 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: path(s) outside the sandbox working "
f"directory: {', '.join(outside)}. Read and write files with "
"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():

View file

@ -1,12 +1,13 @@
# 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 out-of-workdir path check on the terminal tool (#7242).
"""Tests for the sensitive-path check on the terminal tool (#7242).
A lightweight, additive keyword scan that rejects shell path arguments whose
realpath escapes the session working directory. It is defence in depth, not the
real boundary (the kernel filesystem sandbox is), and is skipped when the
sandbox is disabled (Bypass Permissions).
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
@ -19,44 +20,61 @@ _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, _paths_outside_workdir
from core.inference.tools import _bash_exec, _sensitive_paths
def test_absolute_path_outside_workdir_is_flagged(tmp_path):
def test_sensitive_absolute_path_is_flagged(tmp_path):
wd = str(tmp_path)
# /etc/hostname is not a blocklisted credential path, so only the new check
# catches it.
assert _paths_outside_workdir("cat /etc/hostname", wd) == ["/etc/hostname"]
# /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 _paths_outside_workdir("cat data.csv", wd) == []
assert _paths_outside_workdir("cat sub/dir/data.csv", wd) == []
assert _paths_outside_workdir(f"cat {wd}/data.csv", wd) == []
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_relative_traversal_escape_is_flagged(tmp_path):
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)
outside = _paths_outside_workdir("cat ../secret.txt", wd)
assert outside and outside[0].endswith("secret.txt")
# It resolved above the workdir.
assert not outside[0].startswith(os.path.realpath(wd) + os.sep)
# 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_normal_commands_and_devices_are_untouched(tmp_path):
wd = str(tmp_path)
assert _paths_outside_workdir("echo hello", wd) == []
assert _paths_outside_workdir("pip install requests", wd) == []
assert _sensitive_paths("echo hello", wd) == []
assert _sensitive_paths("pip install requests", wd) == []
# Redirection to /dev/null is not a filesystem escape.
assert _paths_outside_workdir("python train.py 2>/dev/null", wd) == []
assert _sensitive_paths("python train.py 2>/dev/null", wd) == []
# URLs are not local filesystem paths.
assert _paths_outside_workdir("git clone https://github.com/a/b", wd) == []
assert _sensitive_paths("git clone https://github.com/a/b", wd) == []
def test_bash_exec_blocks_out_of_workdir_path():
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
@ -68,7 +86,7 @@ def test_bash_exec_allows_normal_command():
assert "hello" in msg
def test_bypass_skips_the_out_of_workdir_block():
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