Harden sandbox: home-rooted PATH, git write targets, args= shell child, posix_spawn, python launchers, command globs, pickle loaders

Close seven static-classifier bypasses in studio/backend/core/inference/tools.py:

- Home-rooted PATH: in the sandbox HOME and the child cwd ARE the session
  workdir, so a ~ / ~user or $HOME / $PWD (${HOME} / ${PWD}) PATH entry lets a
  bare command resolve to a workdir shebang. _path_value_is_unsafe now flags
  those while keeping absolute, $PATH, and other $VAR (assumed absolute) entries
  allowed, so PATH=~/bin evil / env={'PATH': '~/bin'} block.
- git write targets: git is a native child the runtime backstop cannot see, so
  git init /tmp/x, git clone url /tmp/x, git init ../x, and git -C /outside /
  --git-dir= / --work-tree= / --separate-git-dir= write outside the workdir.
  Flag a git path operand or dir-option value that escapes the workdir; all
  workdir-relative git usage (status, log, clone url, -C sub) stays allowed.
- args= shell child: the argv sequence can be passed through the public args=
  keyword, which left _is_shell_child false and accepted a BASH_ENV / opaque env
  for a bash child. Resolve the argv from node.args[0] OR the args= kwarg for
  both the executable= reconstruction and the shell-child env check.
- posix_spawn: os.posix_spawn(path, argv, env) executes path while argv[0] is
  cosmetic, but it never entered the exec/spawn argv reconstruction, so a
  literal-env form (env=() / a byte list) ran a mutating tail (sed -i /tmp/out)
  unguarded. Widen the reconstruction to os.posix_spawn / os.posix_spawnp.
- Python launcher scripts: pip / pytest / ipython console scripts start a fresh
  unguarded interpreter (the same escape as the already-blocked bare python), so
  subprocess.run(['pytest', 'evil.py']) / pip install <local sdist> could run
  workdir code. Deny the well-known launcher entry points.
- Command-name globs: /bin/s? / touc? / /bin/[bd]ash expand to a shell / writer
  before command lookup while the scanner compares the literal basename. Fail
  closed on * / ? / [ ] glob metacharacters in a command word (a bare [ is the
  test builtin and stays allowed).
- Pickle-backed loaders: torch.load(weights_only=False), joblib.load, and
  numpy.load(allow_pickle=True) run a reduce payload. Flag the unsafe forms
  while the safe defaults (torch.load(f), torch.load(f, weights_only=True),
  numpy.load(f)) stay allowed.

Regression coverage: TestRound35Bypasses in tests/test_sandbox_tools.py.
This commit is contained in:
danielhanchen 2026-07-10 11:13:59 +00:00
commit 3bad13b58d
2 changed files with 313 additions and 19 deletions

View file

@ -148,6 +148,25 @@ _INTERPRETER_COMMANDS = frozenset(
"nawk",
}
)
# Python console-script entry points that START A FRESH, UNGUARDED Python interpreter (their
# shebang is the same interpreter whose bin dir the safe env prepends). Running a workdir file
# through one -- subprocess.run(['pytest', 'test_evil.py']) / pip install <local sdist> -- is
# the same child-process escape as a bare `python foo.py`, which is already blocked above, so
# the launcher entry points are denied for consistency. In-workdir Python belongs in the
# guarded python_execute tool. (This is deliberately tight to well-known launchers; a broader
# allowlisted-tooling relaxation is tracked separately.)
_PYTHON_LAUNCHER_COMMANDS = frozenset(
{
"pip",
"pip2",
"pip3",
"pipx",
"pytest",
"py.test",
"ipython",
"ipython3",
}
)
# File-creating / writing coreutils. Same rationale as the interpreters: a spawned child
# runs without the in-process realpath backstop, so subprocess.run(['touch', '/tmp/x']),
# tee, cp, mv, ... write / create / delete outside the session workdir. In-workdir file
@ -198,7 +217,12 @@ _CHILD_WRITE_COMMANDS = frozenset(
"mktemp",
}
)
_BLOCKED_COMMANDS_COMMON = _BLOCKED_COMMANDS_COMMON | _INTERPRETER_COMMANDS | _CHILD_WRITE_COMMANDS
_BLOCKED_COMMANDS_COMMON = (
_BLOCKED_COMMANDS_COMMON
| _INTERPRETER_COMMANDS
| _PYTHON_LAUNCHER_COMMANDS
| _CHILD_WRITE_COMMANDS
)
_BLOCKED_COMMANDS_WIN = frozenset(
{
"rmdir",
@ -264,20 +288,53 @@ def _is_local_executable_path(tok: str) -> bool:
def _path_value_is_unsafe(value: str) -> bool:
"""True when a PATH search list would let a BARE (no-slash) command resolve to a workdir
executable: any entry that is ``.``, empty (``:`` = cwd), or a relative directory. An
absolute (``/...``), ``~``-rooted, or variable (``$PATH`` / ``%VAR%``) entry is safe. Used
so a ``PATH=. evil`` prefix / ``env={'PATH': '.'}`` cannot smuggle an unguarded shebang
executable: any entry that is ``.``, empty (``:`` = cwd), a relative directory, or one that
expands to the session workdir. In the sandbox ``HOME`` and the child cwd ARE the workdir,
so ``~`` / ``~user`` and ``$HOME`` / ``$PWD`` (``${HOME}`` / ``${PWD}``) are unsafe. An
absolute (``/...``), ``%VAR%``, or other ``$VAR`` entry (``$PATH``, ``$CONDA_PREFIX/bin``,
assumed to expand to a trusted absolute path) is safe. Used so a ``PATH=. evil`` /
``PATH=~/bin evil`` prefix / ``env={'PATH': '~/bin'}`` cannot smuggle an unguarded shebang
past the bare-name PATH exemption in _is_local_executable_path."""
for entry in value.replace("\\", "/").split(":"):
e = entry.strip()
if e in ("", "."):
return True
if e.startswith(("/", "~", "$", "%")):
# ~ / ~user expand to HOME, which is the session workdir in the sandbox.
if e.startswith("~"):
return True
if e.startswith("$"):
name = e[1:]
if name.startswith("{"):
name = name[1:]
name = re.split(r"[/}]", name, maxsplit = 1)[0]
if name in ("HOME", "PWD"):
return True # expands to the session workdir
continue # $PATH / other vars: assume a trusted absolute expansion
if e.startswith(("/", "%")):
continue
return True # a relative directory (relbin, ./tools)
return False
def _arg_escapes_workdir(tok: str) -> bool:
"""True when a path-like argument can point OUTSIDE the session workdir: an absolute path
(``/tmp/x``), a ``~`` / ``~user`` home path (home == workdir, but a shell child follows the
real HOME), or any path with a ``..`` component that can traverse above the workdir. A
workdir-relative name (``sub/out``, ``repo``) stays inside and returns False. Used to confine
file-creating child commands (git init/clone <dir>, ...) that the runtime guard cannot see."""
t = tok.replace("\\", "/")
if t.startswith("/") or t.startswith("~"):
return True
return ".." in t.split("/")
# git subcommands / global options that CREATE files or repositories at an arbitrary path in an
# unguarded child (the runtime realpath backstop never sees a native git process). A path
# operand or -C / --git-dir / --work-tree / --separate-git-dir value that escapes the workdir
# lets git write outside the session (git init /tmp/x, git clone url /tmp/x, git -C /outside ...).
_GIT_DIR_OPTIONS = frozenset({"--git-dir", "--work-tree", "--separate-git-dir"})
# The only shell redirection targets trusted without a realpath check: standard device
# sinks that cannot escape the workdir. Every other target (relative or absolute) fails
# closed, because the unguarded child follows symlinks and resolves relative names against a
@ -999,6 +1056,13 @@ def _find_blocked_commands(command: str) -> set[str]:
# regex below misses the wrapper case because $CMD is not right after a separator.
if "$" in token or "`" in token:
blocked.add("command-expansion")
# Glob metacharacters in a command NAME (/bin/s?, touc?, /bin/[bd]ash) are expanded by
# the shell to a matching path BEFORE command lookup, so the literal basename compared
# against the blocklist (s?, touc?) never matches the shell / writer it resolves to.
# The resolved binary cannot be proven safe, so fail closed. A bare `[` is the test
# builtin (not a glob), so exclude it.
if "*" in token or "?" in token or (token != "[" and "[" in token):
blocked.add("command-glob")
if base in _BLOCKED_COMMANDS or _is_versioned_interpreter(base):
blocked.add(base)
# The `.` builtin is bash's `source`: `. evil.sh` runs an unscanned script in the
@ -1259,6 +1323,35 @@ def _find_blocked_commands(command: str) -> set[str]:
if _t.startswith("alias.") and _k + 1 < len(_seg) and _seg[_k + 1].startswith("!"):
blocked.add("git-shell-alias")
break
# git init /tmp/x, git clone url /tmp/x, git worktree add /tmp/x, git -C /outside ...
# all create / operate on files outside the workdir in an unguarded native git child.
# Flag a path OPERAND (bare, non-flag) or a -C / --git-dir / --work-tree value that
# escapes the workdir. Workdir-relative git usage (git init, git clone url, git -C sub)
# and non-path operands (a clone URL, a config name=value) stay allowed.
_gk = 0
while _gk < len(_seg):
_gt = _seg[_gk]
if _gt == "-C" and _gk + 1 < len(_seg):
if _arg_escapes_workdir(_seg[_gk + 1]):
blocked.add("git-write-outside")
_gk += 2
continue
if _gt in _GIT_DIR_OPTIONS and _gk + 1 < len(_seg):
if _arg_escapes_workdir(_seg[_gk + 1]):
blocked.add("git-write-outside")
_gk += 2
continue
_oeq = None
for _opt in _GIT_DIR_OPTIONS:
if _gt.startswith(_opt + "="):
_oeq = _gt.split("=", 1)[1]
break
if _oeq is not None:
if _arg_escapes_workdir(_oeq):
blocked.add("git-write-outside")
elif not _gt.startswith("-") and _arg_escapes_workdir(_gt):
blocked.add("git-write-outside")
_gk += 1
# alias x='touch /tmp/p'; ...; x (with expand_aliases) runs the alias BODY at execution
# time, but the command word `x` is unknown to the scanner. Scan the body of each alias
@ -3497,6 +3590,46 @@ _UNPICKLER_MODULES = frozenset({"pickle", "_pickle", "dill", "cloudpickle"})
_YAML_SAFE_LOADERS = frozenset({"SafeLoader", "CSafeLoader", "BaseLoader"})
_YAML_LOAD_METHODS = frozenset({"load", "load_all"})
# Pickle-backed loaders whose reduce payload executes arbitrary code, gated by a keyword like
# yaml.load: torch.load runs a pickle unless weights_only is True (torch>=2.6 default), numpy.load
# only unpickles with allow_pickle=True, and joblib.load is always pickle-backed. Tracked
# separately from _CODE_DESERIALIZE_SINKS because the safe default forms must stay allowed.
_PICKLE_LOADER_MODULES = frozenset({"torch", "numpy", "joblib"})
def _kw_constant_truthy(node, name):
"""The literal truthiness of keyword ``name`` in a call: True/False when it is a constant,
None when absent or non-constant. Used for the weights_only / allow_pickle gates."""
for kw in node.keywords:
if kw.arg == name:
if isinstance(kw.value, ast.Constant):
return bool(kw.value.value)
return None
return None
def _kw_present(node, name):
"""True when keyword ``name`` is passed in the call (any value)."""
return any(kw.arg == name for kw in node.keywords)
def _pickle_loader_is_unsafe(fq, node):
"""True when a torch.load / numpy.load / joblib.load call runs an UNVERIFIED pickle payload:
joblib.load always does; torch.load when weights_only is EXPLICITLY not-True (torch>=2.6
defaults it to True, so the bare torch.load(f) form relies on that safe default and stays
allowed); numpy.load only when allow_pickle is a constant True. So the safe forms
(torch.load(f), torch.load(f, weights_only=True), numpy.load(f)) return False."""
if fq == "joblib.load":
return True
if fq == "torch.load":
return (
_kw_present(node, "weights_only")
and _kw_constant_truthy(node, "weights_only") is not True
)
if fq == "numpy.load":
return _kw_constant_truthy(node, "allow_pickle") is True
return False
def _yaml_loader_class_name(value):
"""Terminal attribute/name of a Loader= argument (yaml.SafeLoader -> SafeLoader)."""
@ -5368,6 +5501,10 @@ def _check_signal_escape_patterns(
# `from pickle import Unpickler [as X]`: Unpickler(f).load() reaches the same reduce
# path as pickle.load; track the ctor alias so the .load() method call is flagged.
self.unpickler_aliases: set[str] = set()
# import torch / numpy as np / joblib -> {alias: module}; from joblib import load ->
# {load: "joblib.load"}. Conditional pickle-backed loaders (see _pickle_loader_is_unsafe).
self.pickle_loader_module_aliases: dict[str, str] = {}
self.pickle_loader_func_aliases: dict[str, str] = {}
# import types as t -> {"types", "t"}; from types import FunctionType as F -> {"F"}.
# FunctionType(code, globals)() runs a code object WITHOUT eval/exec, so a
# dynamic compile() result reaches execution through it (see visit_Call).
@ -5442,6 +5579,11 @@ def _check_signal_escape_patterns(
self.gc_aliases.add(alias.asname or "gc")
if alias.name in _DESERIALIZE_MODULES:
self.deserialize_module_aliases[alias.asname or alias.name] = alias.name
# import torch / import numpy as np / import joblib: the top-level name (or its
# alias) is the receiver for torch.load / np.load / joblib.load.
_pl_top = alias.name.split(".")[0]
if _pl_top in _PICKLE_LOADER_MODULES and "." not in (alias.asname or alias.name):
self.pickle_loader_module_aliases[alias.asname or alias.name] = _pl_top
self.generic_visit(node)
def visit_ImportFrom(self, node):
@ -5494,6 +5636,14 @@ def _check_signal_escape_patterns(
self.yaml_load_aliases[alias.asname or alias.name] = alias.name
elif alias.name == "Unpickler" and node.module in _UNPICKLER_MODULES:
self.unpickler_aliases.add(alias.asname or alias.name)
elif node.module in _PICKLE_LOADER_MODULES:
# from joblib import load / from torch import load: the bare-name form of the
# conditional pickle-backed loader; the safe-flag gate is applied at the call.
for alias in node.names:
if alias.name == "load":
self.pickle_loader_func_aliases[alias.asname or alias.name] = (
f"{node.module}.load"
)
elif node.module == "types":
for alias in node.names:
if alias.name == "FunctionType":
@ -6082,6 +6232,12 @@ def _check_signal_escape_patterns(
)
blocked_in_args = _check_args_for_blocked(all_call_args, _shell_maybe_true)
# The argv sequence can be given positionally (run(['bash', ...])) or through the
# public args= keyword (run(args=['bash', ...])), which this analyzer already
# collects in _CMD_KWARGS. Resolve either form so the executable= and shell-child
# checks below are not bypassed by moving the command into args=.
_argv0_node = node.args[0] if node.args else expanded_kwargs.get("args")
# subprocess(..., executable=PROG) makes PROG the real program while the argv
# TAIL still supplies its flags/args, so scanning executable and argv separately
# misses run(['x', '-i', 's/a/b/', '/f'], executable='/usr/bin/sed') (child runs
@ -6091,10 +6247,9 @@ def _check_signal_escape_patterns(
if (
_exe is not None
and not _shell_maybe_true
and node.args
and isinstance(node.args[0], (ast.List, ast.Tuple))
and isinstance(_argv0_node, (ast.List, ast.Tuple))
):
_tail = [_extract_string_from_node(e) for e in node.args[0].elts[1:]]
_tail = [_extract_string_from_node(e) for e in _argv0_node.elts[1:]]
_combined = [_exe] + _tail
if all(_c is not None for _c in _combined):
blocked_in_args = blocked_in_args | _find_blocked_commands(
@ -6111,12 +6266,8 @@ def _check_signal_escape_patterns(
_env_node = expanded_kwargs.get("env")
if _env_node is not None:
_is_shell_child = _shell_maybe_true
if (
not _is_shell_child
and node.args
and isinstance(node.args[0], (ast.List, ast.Tuple))
):
_elts0 = [_extract_string_from_node(_e) for _e in node.args[0].elts]
if not _is_shell_child and isinstance(_argv0_node, (ast.List, ast.Tuple)):
_elts0 = [_extract_string_from_node(_e) for _e in _argv0_node.elts]
_ci0 = _blocked_in_argv(_elts0)[1]
if _ci0 is not None and _ci0 < len(_elts0) and _elts0[_ci0]:
_is_shell_child = (
@ -6167,11 +6318,17 @@ def _check_signal_escape_patterns(
blocked_in_args = blocked_in_args | {"shell-startup-env:non-literal"}
# os.execl(path, a0, a1, ...) / os.execv(path, [a0, ...]) / os.spawnl(mode,
# path, a0, ...) spread the child's argv across separate positional args (or a
# single list), so scanning each string alone misses a mutating tail like
# `sed -i ...`. Reconstruct the executed command line (program path + argv[1:],
# since argv[0] is the cosmetic name) and run the full scanner over it.
if shell_func.startswith("os.exec") or shell_func.startswith("os.spawn"):
# path, a0, ...) / os.posix_spawn(path, argv, env) spread the child's argv across
# separate positional args (or a single list), so scanning each string alone
# misses a mutating tail like `sed -i ...`. posix_spawn(p) executes `path` while
# argv[0] is only cosmetic, so a literal-env form (env=() / a byte list) otherwise
# slips the non-literal-env fallback. Reconstruct the executed command line
# (program path + argv[1:], since argv[0] is cosmetic) and run the full scanner.
if (
shell_func.startswith("os.exec")
or shell_func.startswith("os.spawn")
or shell_func.startswith("os.posix_spawn")
):
_name = shell_func.split(".", 1)[1]
if _name.startswith("spawn"): # spawn*(mode, path, ...)
_path_node = node.args[1] if len(node.args) > 1 else None
@ -6473,12 +6630,30 @@ def _check_signal_escape_patterns(
and self._is_unpickler_ctor(_ecf.value)
):
_deser_fq = "pickle.Unpickler.load"
# torch.load(f, weights_only=False) / np.load(f, allow_pickle=True) /
# joblib.load(f) run a pickle reduce payload; flag only the unsafe forms so
# the safe defaults (torch.load(f), np.load(f)) stay allowed.
if (
_deser_fq is None
and _ecf.attr == "load"
and isinstance(_ecf.value, ast.Name)
):
_pcanon = self.pickle_loader_module_aliases.get(_ecf.value.id)
if _pcanon is not None:
_plfq = f"{_pcanon}.load"
if _pickle_loader_is_unsafe(_plfq, node):
_deser_fq = _plfq
if _deser_fq is None and isinstance(_ecf, ast.Name):
# from yaml import load; load(data): apply the same safe-loader check to the
# bare-name alias so importing the function directly is not a bypass.
_ym = self.yaml_load_aliases.get(_ecf.id)
if _ym is not None and not _yaml_call_has_safe_loader(node):
_deser_fq = "yaml." + _ym
# from joblib import load; load(f): the bare-name conditional pickle loader.
if _deser_fq is None:
_plf = self.pickle_loader_func_aliases.get(_ecf.id)
if _plf is not None and _pickle_loader_is_unsafe(_plf, node):
_deser_fq = _plf
if _analyzer_on and _deser_fq is not None:
dynamic_desc = f"{_deser_fq}() deserializes an unverifiable code payload"
elif is_dynamic_import:

View file

@ -3662,3 +3662,122 @@ class TestRound34Bypasses:
)
def test_round34_benign_allowed(self, code):
_ok(code)
class TestRound35Bypasses:
"""Thirty-fifth-round Codex findings: ~ / $HOME / $PWD-rooted PATH entries (home == workdir),
git write subcommands / -C targeting outside the workdir, the args= keyword hiding a shell
child from the BASH_ENV check, os.posix_spawn skipping the exec/spawn argv reconstruction,
Python launcher console scripts (pip / pytest) starting an unguarded interpreter, glob
metacharacters in a command name, and the pickle-backed torch/joblib/numpy loaders."""
@pytest.mark.parametrize(
"code",
[
# ~ / $HOME / $PWD expand to the session workdir, so a bare command resolves there.
"import os\nos.system('PATH=~/bin evil')",
"import os\nos.system('PATH=$HOME/bin evil')",
"import os\nos.system('PATH=$PWD/bin evil')",
"import subprocess\nsubprocess.run(['evil'], env={'PATH': '~/bin'})",
],
)
def test_home_rooted_path_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# git init/clone/-C at an absolute / .. / ~ path writes outside the workdir.
"import os\nos.system('git init /tmp/x')",
"import subprocess\nsubprocess.run(['git', 'init', '/tmp/x'])",
"import subprocess\nsubprocess.run(['git', 'clone', 'https://github.com/a/b', '/tmp/x'])",
"import os\nos.system('git init ../outside')",
"import subprocess\nsubprocess.run(['git', '-C', '/tmp/repo', 'init'])",
"import os\nos.system('git --git-dir=/tmp/g --work-tree=/tmp/w checkout .')",
],
)
def test_git_write_outside_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# A shell child whose command is in the args= keyword must still get the BASH_ENV /
# opaque-env check (a non-literal env may carry a startup script).
"import subprocess\nsubprocess.run(args=['bash', '-c', 'echo ok'], env={'BASH_ENV': 'e.sh'})",
"import subprocess\nsubprocess.run(args=['bash', '-c', 'echo ok'], env=custom)",
],
)
def test_args_kwarg_shell_child_env_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# posix_spawn(path, argv, env) executes `path`; argv[0] is cosmetic. Reconstruct
# path + argv[1:] so a mutating tail is caught even with a literal env (env=() / list).
"import os\nos.posix_spawn('/usr/bin/sed', ['x', '-i', 's/a/b/', '/tmp/out'], ())",
"import os\nos.posix_spawnp('sed', ['x', '-i', 's/a/b/', '/tmp/out'], [b'PATH=/usr/bin'])",
],
)
def test_posix_spawn_reconstruction_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# pip / pytest console scripts start a fresh unguarded Python interpreter.
"import subprocess\nsubprocess.run(['pytest', 'test_evil.py'])",
"import os\nos.system('pip install requests')",
"import os\nos.system('pytest test_evil.py')",
"import os\nos.system('ipython evil.py')",
],
)
def test_python_launcher_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# Glob metacharacters in a command NAME are expanded to a matching path before lookup.
"import os\nos.system('/bin/s? -c \"echo hi\"')",
"import os\nos.system('/usr/bin/touc? /tmp/x')",
"import os\nos.system('/bin/[bd]ash -c id')",
],
)
def test_command_name_glob_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# Pickle-backed loaders run a reduce payload; flag the unsafe forms.
"import torch\ntorch.load('m.pt', weights_only=False)",
"import joblib\njoblib.load('m.pkl')",
"from joblib import load\nload('m.pkl')",
"import numpy as np\nnp.load('a.npy', allow_pickle=True)",
],
)
def test_pickle_backed_loaders_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# $VAR (non-HOME) / absolute PATH entries, workdir-relative git, args= without a
# shell child, safe loader defaults, glob in ARGUMENT position must all still pass.
"import os\nos.system('PATH=/opt/conda/bin:$PATH ls -la')",
"import os\nos.system('PATH=$CONDA_PREFIX/bin:$PATH true')",
"import os\nos.system('git status')",
"import os\nos.system('git clone https://github.com/x/y')",
"import os\nos.system('git -C sub log --oneline')",
"import subprocess\nsubprocess.run(args=['echo', 'ok'], env=custom)",
"import torch\ntorch.load('m.pt')",
"import torch\ntorch.load('m.pt', weights_only=True)",
"import numpy as np\nnp.load('a.npy')",
"import os\nos.system('ls *.py')",
"import os\nos.system('[ -f x ] && echo hi')",
],
)
def test_round35_benign_allowed(self, code):
_ok(code)