Harden sandbox: env=dict/**-splat child env, aliased os.environ, git helper env vars, relative native output under an escaping cwd

Close five bypasses Codex found on the round-51 branch.

- env=dict(...) / env={**mapping} child-env mappings: the subprocess env=
  analysis only walked a literal dict and a dict() call, so a git child with
  env=dict(PATH=...) still dropped the injected hook suppression and a
  env={**{'BASH_ENV': ...}} splat hid the startup var. Flatten the mapping
  (literal dict, dict() call, and nested ** splats) into (key, value) pairs
  once via _env_mapping_pairs and run the same PATH / BASH_ENV / GIT_* policy;
  an opaque computed key on a shell child fails closed.

- aliased os.environ mutations: e = os.environ (or from os import environ as e)
  binds a new name to the same inherited environment, so a later
  e['BASH_ENV'] = ... escaped the subscript check. Track the alias
  (from-import, and a single-assignment e = os.environ / os.environb) and treat
  it as the environ mapping in _is_environ_receiver.

- git helper-command env vars: GIT_EXTERNAL_DIFF / GIT_ASKPASS / GIT_SSH /
  GIT_SSH_COMMAND / GIT_PROXY_COMMAND / GIT_EDITOR / GIT_SEQUENCE_EDITOR /
  GIT_PAGER name a program git executes; a workdir-local / ~ target
  (GIT_EXTERNAL_DIFF=./evil git diff) runs unreviewed code in the unguarded git
  child. Block an assignment-prefix helper var whose command is a local
  executable path or a ~ path; an absolute system tool (GIT_SSH=/usr/bin/ssh)
  stays allowed.

- relative native output under an escaping cwd: openssl -out FILE and a
  sqlite3 DBFILE / dot-file / -init operand only checked the literal operand,
  so a RELATIVE operand under an escaping env -C DIR (or a subprocess cwd=,
  reconstructed as env -C DIR) -- env -C /tmp openssl rand -out key,
  subprocess.run(['sqlite3','db.sqlite',...], cwd='/tmp') -- wrote outside the
  session. Scan back for an escaping chdir wrapper (_cwd_wrapper_escapes) and
  combine it with a relative operand (_operand_relative_local); a workdir-subdir
  env -C and a no-chdir relative operand stay allowed.

Regression coverage: TestRound52Bypasses in tests/test_sandbox_tools.py
(env=dict / ** splat, aliased environ, git helper env vars, relative native
output under an escaping cwd, plus a round52 benign-allowed set: non-git
env=dict, benign splat / aliased-env var, GIT_PAGER=cat, no-chdir and
workdir-subdir native output).
This commit is contained in:
danielhanchen 2026-07-10 22:24:26 +00:00
commit 10962c482c
2 changed files with 271 additions and 53 deletions

View file

@ -546,6 +546,45 @@ def _git_operand_escapes(tok: str, assigns = None) -> bool:
return _arg_escapes_workdir(tok)
def _cwd_wrapper_escapes(tokens, cmd_idx) -> bool:
"""True when an ``env -C DIR`` / ``--chdir DIR`` / ``--chdir=DIR`` / glued ``-CDIR`` wrapper
in the SAME command segment BEFORE ``cmd_idx`` changes the child's cwd to a directory that
escapes the workdir (a literal escaping ``cwd=`` on a subprocess call reaches here as the same
synthetic ``env -C <dir>`` prefix). Under such a cwd even a workdir-RELATIVE write operand
(openssl -out key, sqlite3 db.sqlite) lands outside the session. Scans back to the previous
shell separator; a workdir-local chdir (env -C sub) returns False."""
for _bk in range(cmd_idx - 1, -1, -1):
_bt = tokens[_bk]
if _bt in _SHELL_SEPARATORS or _bt in _SHELL_KEYWORDS_AS_SEP:
break
if _bt in ("-C", "--chdir") and _bk + 1 < len(tokens):
if _arg_escapes_workdir(tokens[_bk + 1]):
return True
elif _bt.startswith("--chdir=") and _arg_escapes_workdir(_bt.split("=", 1)[1]):
return True
elif _bt.startswith("-C") and len(_bt) > 2 and _arg_escapes_workdir(_bt[2:]):
return True
return False
def _operand_relative_local(tok: str) -> bool:
"""A literal RELATIVE path operand that resolves under the child cwd, so it escapes the workdir
when the cwd itself escapes (paired with _cwd_wrapper_escapes). Absolute (``/x``), home (``~``),
``$``/backtick expansions (unknown -- left to _git_operand_escapes), option flags, empty, and
the sqlite in-memory forms return False so they are handled by their own checks."""
if not tok:
return False
_u = tok
if len(_u) >= 2 and _u[0] == _u[-1] and _u[0] in ("'", '"'):
_u = _u[1:-1]
if not _u or _u[0] in ("/", "~", "-") or "$" in _u or "`" in _u:
return False
_ul = _u.lower()
if _u == ":memory:" or _ul.startswith("file::memory:") or "mode=memory" in _ul:
return False
return True
# git options whose VALUE is a path that a native git child writes to / operates in (the runtime
# realpath backstop never sees a native git process). A value that escapes the workdir lets git
# write outside the session: -C / --git-dir / --work-tree / --separate-git-dir (repo location),
@ -1697,6 +1736,18 @@ def _find_blocked_commands(command: str) -> set[str]:
# git path / config environment variables -- GIT_DIR / GIT_WORK_TREE / GIT_INDEX_FILE point
# git's writes outside the workdir, and GIT_CONFIG_* override the sandbox's env-based hook
# suppression. Handle both NAME=value and NAME+=value (append).
_GIT_EXEC_ENV_VARS = frozenset(
{
"GIT_EXTERNAL_DIFF",
"GIT_ASKPASS",
"GIT_SSH",
"GIT_SSH_COMMAND",
"GIT_PROXY_COMMAND",
"GIT_EDITOR",
"GIT_SEQUENCE_EDITOR",
"GIT_PAGER",
}
)
for _ei, _et in enumerate(tokens):
if not _ASSIGNMENT_RE.match(_et):
continue
@ -1721,6 +1772,20 @@ def _find_blocked_commands(command: str) -> set[str]:
# .git/hooks/* in an unguarded git child.
elif _an == "GIT_CONFIG" or _an.startswith("GIT_CONFIG_"):
blocked.add("git-config-env-override")
# git runs the program named by these env vars (GIT_EXTERNAL_DIFF / GIT_ASKPASS /
# GIT_SSH[_COMMAND] / GIT_PROXY_COMMAND / GIT_EDITOR / GIT_PAGER); a value pointing at a
# WORKDIR executable (GIT_EXTERNAL_DIFF=./evil git diff) runs a planted helper in an
# unguarded git child. A bare command name (GIT_PAGER=cat) or a system tool stays allowed.
elif _an in _GIT_EXEC_ENV_VARS:
_gev = _av
if len(_gev) >= 2 and _gev[0] == _gev[-1] and _gev[0] in ("'", '"'):
_gev = _gev[1:-1]
_gecmd = _gev.split()[0] if _gev.split() else ""
# A workdir-reachable helper: a local / relative executable (./evil, sub/evil) or a ~
# (HOME == workdir) path. An absolute system tool (/usr/bin/ssh) and a bare PATH name
# (cat) stay allowed -- the attacker cannot plant a file outside the workdir.
if _is_local_executable_path(_gecmd) or _gecmd.startswith("~"):
blocked.add("git-exec-env")
# git -c alias.X='!CMD' X / git config alias.X '!CMD': a git alias whose value starts with
# `!` runs CMD through an unguarded shell, but the scanner sees only `git`. Flag the shell-
@ -1942,16 +2007,19 @@ def _find_blocked_commands(command: str) -> set[str]:
for i in _cmd_word_idx:
if _token_basename(tokens[i]) != "openssl":
continue
# An env -C DIR / subprocess cwd= (reconstructed as env -C DIR) that escapes the workdir
# makes even a RELATIVE -out operand (openssl rand -out key, cwd=/tmp) land outside.
_ossl_cwd_escapes = _cwd_wrapper_escapes(tokens, i)
for k in range(i + 1, len(tokens)):
t = tokens[k]
if t in _SHELL_SEPARATORS or t in _SHELL_KEYWORDS_AS_SEP:
break
if (
t in _OPENSSL_WRITE_FLAGS
and k + 1 < len(tokens)
and _git_operand_escapes(tokens[k + 1], _local_assigns)
):
blocked.add("openssl-write-outside")
if t in _OPENSSL_WRITE_FLAGS and k + 1 < len(tokens):
_op = tokens[k + 1]
if _git_operand_escapes(_op, _local_assigns) or (
_ossl_cwd_escapes and _operand_relative_local(_op)
):
blocked.add("openssl-write-outside")
# sqlite3 <DBFILE> creates / opens a database in an unguarded child (no realpath guard), and
# its dot-commands (.output / .backup / .dump / .read ...) read + write arbitrary files. Flag
@ -1961,6 +2029,10 @@ def _find_blocked_commands(command: str) -> set[str]:
for i in _cmd_word_idx:
if _token_basename(tokens[i]) != "sqlite3":
continue
# env -C DIR / subprocess cwd= (reconstructed as env -C DIR) that escapes the workdir makes
# even a RELATIVE DBFILE / dot-file / -init operand (sqlite3 db.sqlite ..., cwd=/tmp) land
# outside; combine the escaping cwd with a relative operand below.
_sqlite_cwd_escapes = _cwd_wrapper_escapes(tokens, i)
_seen_db = False
_sk = i + 1
while _sk < len(tokens):
@ -1970,12 +2042,12 @@ def _find_blocked_commands(command: str) -> set[str]:
# sqlite3 options that consume a SEPARATED operand; skip the value so it is not
# mistaken for the DBFILE (only -init reads a file, checked via its own value here).
if t in _SQLITE_OPERAND_OPTS:
if (
t == "-init"
and _sk + 1 < len(tokens)
and _git_operand_escapes(tokens[_sk + 1], _local_assigns)
):
blocked.add("sqlite3-write-outside")
if t == "-init" and _sk + 1 < len(tokens):
_iv = tokens[_sk + 1]
if _git_operand_escapes(_iv, _local_assigns) or (
_sqlite_cwd_escapes and _operand_relative_local(_iv)
):
blocked.add("sqlite3-write-outside")
_sk += 2
continue
# Any dot-command file target that escapes the workdir (.output /tmp/leak, .backup
@ -1994,8 +2066,9 @@ def _find_blocked_commands(command: str) -> set[str]:
# .output |CMD / .once |CMD open CMD as a PIPE (a shell command), not a file.
if _dot_f.startswith("|"):
blocked.add("sqlite3-shell")
elif _dot_f not in ("stdout", "stderr", "off") and _git_operand_escapes(
_dot_f, _local_assigns
elif _dot_f not in ("stdout", "stderr", "off") and (
_git_operand_escapes(_dot_f, _local_assigns)
or (_sqlite_cwd_escapes and _operand_relative_local(_dot_f))
):
blocked.add("sqlite3-write-outside")
if t.startswith("-"):
@ -2013,7 +2086,10 @@ def _find_blocked_commands(command: str) -> set[str]:
or _dblow.startswith("file::memory:")
or "mode=memory" in _dblow
)
if not _is_mem and _git_operand_escapes(_dbn, _local_assigns):
if not _is_mem and (
_git_operand_escapes(_dbn, _local_assigns)
or (_sqlite_cwd_escapes and _operand_relative_local(_dbn))
):
blocked.add("sqlite3-write-outside")
_sk += 1
@ -6684,6 +6760,46 @@ def _check_signal_escape_patterns(
return None
return None
def _env_mapping_pairs(node):
"""Flatten a subprocess env= mapping (a literal dict, a dict(...) call, or a nested
``**{...}`` splat) into ([(key_str, value_node), ...], opaque). ``opaque`` is True when
any entry's KEY cannot be resolved to a constant string -- a computed key, or a
non-literal ``**mapping`` splat -- since such an entry could carry BASH_ENV / ENV /
GIT_CONFIG_COUNT. Only literal-keyed entries appear in the pair list."""
pairs = []
opaque = False
if isinstance(node, ast.Dict):
for _k, _v in zip(node.keys, node.values):
if _k is None: # **mapping splat
if isinstance(_v, ast.Dict):
_ip, _io = _env_mapping_pairs(_v)
pairs.extend(_ip)
opaque = opaque or _io
else:
opaque = True
else:
_ks = _extract_string_from_node(_k)
if _ks is None:
opaque = True
else:
pairs.append((_ks, _v))
elif (
isinstance(node, ast.Call)
and isinstance(node.func, ast.Name)
and node.func.id == "dict"
):
for _kw in node.keywords:
if _kw.arg is None: # dict(**mapping)
if isinstance(_kw.value, ast.Dict):
_ip, _io = _env_mapping_pairs(_kw.value)
pairs.extend(_ip)
opaque = opaque or _io
else:
opaque = True
else:
pairs.append((_kw.arg, _kw.value))
return pairs, opaque
def _extract_strings_from_list(node):
"""Extract string elements from an AST List or Tuple node."""
if isinstance(node, (ast.List, ast.Tuple)):
@ -6809,6 +6925,9 @@ def _check_signal_escape_patterns(
self.imports_signal = False
self.signal_aliases = {"signal"}
self.os_aliases = {"os"}
# Names bound to os.environ / os.environb (bare, from-imported as X, or e = os.environ),
# so an aliased env mutation (e['BASH_ENV'] = ...) is still recognized.
self.environ_aliases = {"environ", "environb"}
self.subprocess_aliases = {"subprocess"}
self.importlib_aliases = {"importlib"}
self.sys_aliases = {"sys"}
@ -6946,6 +7065,9 @@ def _check_signal_escape_patterns(
fq = f"{_eff_mod}.{alias.name}"
if fq in _SHELL_EXEC_FUNCS:
self.shell_exec_aliases[alias.asname or alias.name] = fq
# from os import environ as e / from os import environb as eb.
if _eff_mod == "os" and alias.name in ("environ", "environb"):
self.environ_aliases.add(alias.asname or alias.name)
elif node.module == "importlib":
for alias in node.names:
if alias.name in ("import_module", "reload", "__import__"):
@ -7495,14 +7617,29 @@ def _check_signal_escape_patterns(
return False
def _is_environ_receiver(self, _v):
# os.environ / os.environb (or a bare `environ` / `environb` from `from os import ...`).
# environb is the SAME inherited process environment via byte keys/values.
return (
# os.environ / os.environb (or a bare `environ` / `environb`, a `from os import environ
# as e` alias, or a single-assignment `e = os.environ`). environb is the SAME inherited
# process environment via byte keys/values.
if (
isinstance(_v, ast.Attribute)
and _v.attr in ("environ", "environb")
and isinstance(_v.value, ast.Name)
and _v.value.id in self.os_aliases
) or (isinstance(_v, ast.Name) and _v.id in ("environ", "environb"))
):
return True
if isinstance(_v, ast.Name):
if _v.id in self.environ_aliases:
return True
if _analyzer_on:
_rhs = _scope_idx.resolve(_v.id, _v, "rhsnode")
if (
isinstance(_rhs, ast.Attribute)
and _rhs.attr in ("environ", "environb")
and isinstance(_rhs.value, ast.Name)
and _rhs.value.id in self.os_aliases
):
return True
return False
def _environ_subscript_key(self, target):
# The literal key of an os.environ[...] / os.environb[...] (or a bare environ[...] /
@ -7539,6 +7676,19 @@ def _check_signal_escape_patterns(
return None
def visit_Assign(self, node):
# e = os.environ (or os.environb) binds a NEW name to the same inherited-env mapping, so
# a later e['BASH_ENV'] = ... escape reads as a plain-name subscript. Record the alias
# (source order puts this assignment before the mutation) so _is_environ_receiver treats
# `e` as the environ mapping. A bare `environ` RHS (from-import alias) is covered too.
if len(node.targets) == 1 and isinstance(node.targets[0], ast.Name):
_rv = node.value
if (
isinstance(_rv, ast.Attribute)
and _rv.attr in ("environ", "environb")
and isinstance(_rv.value, ast.Name)
and _rv.value.id in self.os_aliases
) or (isinstance(_rv, ast.Name) and _rv.id in self.environ_aliases):
self.environ_aliases.add(node.targets[0].id)
# os.environ['PATH'] = '.' (or BASH_ENV / ENV / GIT_CONFIG* / GIT_DIR) mutates the
# INHERITED environment a later unguarded subprocess child reads, the same escape as
# passing env={...} to the child: a bare-argv workdir exec via PATH='.', a sourced
@ -7844,10 +7994,18 @@ def _check_signal_escape_patterns(
if not _is_shell_child:
_is_shell_child = _cw0 in _SHELL_BINARIES
_is_git_child = _cw0 == "git"
if isinstance(_env_node, ast.Dict):
_opaque_key = False
for _ek, _ev in zip(_env_node.keys, _env_node.values):
_ekey = _extract_string_from_node(_ek) if _ek is not None else None
_is_env_mapping = isinstance(_env_node, ast.Dict) or (
isinstance(_env_node, ast.Call)
and isinstance(_env_node.func, ast.Name)
and _env_node.func.id == "dict"
)
if _is_env_mapping:
# A literal dict, a dict(...) call, and any nested **{...} splat are
# flattened together so the BASH_ENV / PATH / GIT_* checks (and the git
# hook-suppression check) apply uniformly; a computed / non-literal-**
# key marks the mapping opaque (fail closed for a shell child).
_epairs, _opaque_key = _env_mapping_pairs(_env_node)
for _ekey, _ev in _epairs:
_evstr = _extract_string_from_node(_ev)
if _ekey in ("BASH_ENV", "ENV") and _evstr != "":
blocked_in_args = blocked_in_args | {"shell-startup-env:" + _ekey}
@ -7873,43 +8031,18 @@ def _check_signal_escape_patterns(
):
# env={'GIT_CONFIG_COUNT': '0'} drops the sandbox hook suppression.
blocked_in_args = blocked_in_args | {"git-config-env-override"}
elif _ek is not None and _ekey is None:
_opaque_key = True # a computed key could be BASH_ENV / ENV
if _opaque_key and _is_shell_child:
blocked_in_args = blocked_in_args | {"shell-startup-env:opaque"}
# A git child whose literal env drops the sandbox's GIT_CONFIG_COUNT hook
# suppression (env={} / any dict without it and without a ** splat that
# could carry it) re-enables a planted .git/hooks/* in the unguarded child.
# A git child whose replaced env drops the sandbox's GIT_CONFIG_COUNT hook
# suppression (env={} / dict(PATH=...) / any mapping without it and without
# an opaque ** that could carry it) re-enables a planted .git/hooks/* in the
# unguarded child. Applies to the literal-dict AND dict(...) forms.
if (
_is_git_child
and not _opaque_key
and not any(
_extract_string_from_node(_k) == "GIT_CONFIG_COUNT"
for _k in _env_node.keys
if _k is not None
)
and not any(_k == "GIT_CONFIG_COUNT" for _k, _ in _epairs)
):
blocked_in_args = blocked_in_args | {"git-config-env-override"}
elif (
isinstance(_env_node, ast.Call)
and isinstance(_env_node.func, ast.Name)
and _env_node.func.id == "dict"
):
for _kw2 in _env_node.keywords:
if _kw2.arg in ("BASH_ENV", "ENV") and (
_extract_string_from_node(_kw2.value) != ""
):
blocked_in_args = blocked_in_args | {
"shell-startup-env:" + _kw2.arg
}
elif (
_kw2.arg == "PATH"
and isinstance(_extract_string_from_node(_kw2.value), str)
and _path_value_is_unsafe(_extract_string_from_node(_kw2.value))
):
blocked_in_args = blocked_in_args | {"unsafe-path-assign"}
elif _kw2.arg is None and _is_shell_child:
blocked_in_args = blocked_in_args | {"shell-startup-env:opaque"}
elif _is_shell_child:
# A non-literal env mapping (env=e, a comprehension) for a shell child
# cannot be proven free of BASH_ENV / ENV, so fail closed.

View file

@ -5082,3 +5082,88 @@ class TestRound51Bypasses:
)
def test_round51_benign_allowed(self, code):
_ok(code)
class TestRound52Bypasses:
"""Fifty-second-round Codex findings: env=dict(...) / env={**mapping} child-env mappings,
aliased os.environ mutations, git helper-command env vars, and a relative native output
operand (openssl -out / sqlite3 DBFILE) under an escaping env -C / subprocess cwd=."""
@pytest.mark.parametrize(
"code",
[
# env=dict(PATH=...) on a git child still drops the injected hook suppression, the same
# as env={...}; the dict() call form must flatten to the same (key, value) analysis.
"import subprocess\nsubprocess.run(['git','commit'], env=dict(PATH='/usr/bin'))",
"import subprocess\nsubprocess.run(['git','status'], env=dict(HOME='/x'))",
],
)
def test_env_dict_call_git_override_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# env={**mapping} unpacks BASH_ENV into the child env; the ** splat must be flattened.
"import subprocess\nsubprocess.run(['bash','-c','echo ok'], env={**{'BASH_ENV':'env.sh'}})",
"import subprocess\nsubprocess.run(['git','commit'], env={**{'GIT_CONFIG_COUNT':'0'}, 'PATH':'/usr/bin'})",
],
)
def test_env_splat_mapping_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# e = os.environ / from os import environ as e alias the inherited env, so a later
# e['BASH_ENV'] = ... subscript escape reads as a plain-name mutation.
"import os, subprocess\ne = os.environ\ne['BASH_ENV'] = 'env.sh'\nsubprocess.run(['bash','-c','echo hi'])",
"from os import environ as e\nimport subprocess\ne['BASH_ENV'] = 'env.sh'\nsubprocess.run(['bash','-c','echo hi'])",
"import os, subprocess\ne = os.environ\ne['PATH'] = '.:/usr/bin'\nsubprocess.run(['evil'])",
],
)
def test_aliased_environ_mutation_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# GIT_EXTERNAL_DIFF / GIT_SSH_COMMAND / GIT_ASKPASS name a helper program git executes;
# a workdir-local / ~ target runs unreviewed code in the unguarded git child.
"import os\nos.system('GIT_EXTERNAL_DIFF=./evil git diff')",
"import os\nos.system('GIT_SSH_COMMAND=./evil git fetch')",
"import os\nos.system('GIT_ASKPASS=./evil git push')",
],
)
def test_git_exec_env_var_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# A RELATIVE -out / DBFILE operand resolves under an escaping env -C / subprocess cwd=,
# so the native openssl / sqlite3 child writes OUTSIDE the session workdir.
"import os\nos.system('env -C /tmp openssl rand -out key 4')",
"import subprocess\nsubprocess.run(['openssl','rand','-out','key','4'], cwd='/tmp')",
"import subprocess\nsubprocess.run(['sqlite3','db.sqlite','create table t(x);'], cwd='/tmp')",
],
)
def test_relative_native_output_under_escaping_cwd_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# A non-git child with env=dict(PATH=...), a benign ** splat var, an aliased-env benign
# var, a benign GIT_PAGER, and a relative native output with NO escaping cwd (or a
# workdir-subdir env -C) all stay allowed.
"import subprocess\nsubprocess.run(['ls'], env=dict(PATH='/usr/bin'))",
"import subprocess\nsubprocess.run(['ls'], env={**{'MYVAR':'x'}})",
"import os, subprocess\ne = os.environ\ne['MYVAR'] = 'x'\nsubprocess.run(['ls'])",
"import os\nos.system('GIT_PAGER=cat git log')",
"import subprocess\nsubprocess.run(['openssl','rand','-out','key','4'])",
"import os\nos.system('env -C sub openssl rand -out key 4')",
],
)
def test_round52_benign_allowed(self, code):
_ok(code)