Harden sandbox: order/scope-aware exec caller aliases, explicit exec namespaces, non-literal + non-assignment env mutations

Close five bypasses Codex found on the round-53 branch (all P1).

- exec/eval caller-alias order + scope: the caller-alias check removed a payload
  name from the free set if it was stored ANYWHERE, so f('touch /tmp/pwn');
  f = None still called the caller's f = os.system before the rebind. Replace
  the flat loaded-minus-bound with an order-aware, scope-aware analysis: a
  module-top-level Load before the name's first top-level binding (source order)
  resolves outward, as does a free / global Load inside a nested function or
  class scope (it can run after a later rebind); a name bound at module top
  level is payload-local (its own binding shadows the caller, and a
  payload-local sink is caught by the inner scan). symtable computes the
  nested-scope free / global references.

- explicit exec/eval namespace: exec("f('touch /tmp/p')", {'f': os.system})
  resolves the payload's free names from the supplied namespace, not the caller
  scope, so it was treated as a safe literal. Inspect a literal-dict namespace
  precisely (a free name mapped to a shell / exec / deserialize / import sink
  blocks) and fail closed on an opaque namespace when a non-builtin free name is
  called.

- subprocess env PATH via non-literal / bytes value: the env={'PATH': ...} check
  only read an inline str constant, missing P='.:/usr/bin'; env={'PATH': P}, a
  concatenation, and a POSIX bytes value. Const-fold / decode the value (via the
  now-folding _extract_env_scalar) and fall back to the dynamic-PATH analysis for
  a non-literal value, mirroring the os.environ['PATH'] handling.

- non-assignment env mutations: only Assign targets (plus update / setdefault)
  were covered, so os.environ['PATH'] += ':.' (AugAssign), del
  os.environ['GIT_CONFIG_COUNT'] (Delete), os.environ.pop('GIT_CONFIG_COUNT') /
  .clear(), and os.unsetenv('GIT_CONFIG_COUNT') slipped through. Add
  visit_AugAssign (modeled as old-value + appended), visit_Delete, and pop /
  clear / unsetenv handling; removing a GIT_CONFIG* var (or clearing the env)
  drops the sandbox git hook suppression.

- opaque env mapping for git children: the missing-GIT_CONFIG_COUNT check only
  fired for a fully inspectable mapping, and the non-literal fallback was scoped
  to shell children, so env={**d} / env=f() for a git child (which can evaluate
  to {} and drop the injected core.hooksPath suppression) was accepted. Fail
  closed for a git child on an opaque or non-literal env mapping unless a literal
  GIT_CONFIG_COUNT is present.

Regression coverage: TestRound54Bypasses in tests/test_sandbox_tools.py
(caller-alias-before-rebind, explicit-namespace alias, non-literal / bytes env
PATH, augmented / del / pop / clear / unsetenv env mutations, opaque git env,
plus a round54 benign-allowed set: store-only payload, benign literal namespace,
absolute PATH via const var, benign augmented / pop env var, non-git opaque env,
git with no env).
This commit is contained in:
danielhanchen 2026-07-11 00:03:45 +00:00
commit 8bb6ed501a
2 changed files with 399 additions and 40 deletions

View file

@ -6457,6 +6457,123 @@ def _resolve_static_shell_sink(node, os_aliases, subprocess_aliases, from_aliase
return None
_PY_NESTED_SCOPE_NODES = (
ast.FunctionDef,
ast.AsyncFunctionDef,
ast.Lambda,
ast.ClassDef,
ast.ListComp,
ast.SetComp,
ast.DictComp,
ast.GeneratorExp,
)
def _module_stmt_names(node, loads, stores):
"""Append Name loads / stores GOVERNED by the current (module) scope from ``node`` WITHOUT
descending into nested function / class / lambda / comprehension scopes (which have their own
scope). A def / class / import binds its name in the current scope; its body is skipped."""
for _child in ast.iter_child_nodes(node):
if isinstance(_child, ast.Name):
if isinstance(_child.ctx, ast.Load):
loads.append(_child.id)
elif isinstance(_child.ctx, (ast.Store, ast.Del)):
stores.append(_child.id)
elif isinstance(_child, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
stores.append(_child.name) # binds its name; the body is a nested scope (skipped)
elif isinstance(
_child, (ast.ListComp, ast.SetComp, ast.DictComp, ast.GeneratorExp, ast.Lambda)
):
pass # nested scope: contributes no binding to the current scope
elif isinstance(_child, (ast.Import, ast.ImportFrom)):
for _al in _child.names:
stores.append((_al.asname or _al.name).split(".")[0])
else:
_module_stmt_names(_child, loads, stores)
def _module_toplevel_free_loads(module_node):
"""(early_loads, all_bound) for a payload Module top level: ``early_loads`` are names Loaded at
module top level BEFORE that name's first top-level binding, in SOURCE order (these resolve to
the enclosing global scope at runtime -- ``f('x'); f = None`` still calls the caller's ``f``),
and ``all_bound`` is every name bound at module top level."""
early: set = set()
bound: set = set()
for _stmt in module_node.body:
_loads: list = []
_stores: list = []
_module_stmt_names(_stmt, _loads, _stores)
for _nm in _loads:
if _nm not in bound:
early.add(_nm)
bound.update(_stores)
return early, bound
def _payload_outward_load_names(src, mode):
"""Names in an exec/eval payload whose Load can resolve to the ENCLOSING (caller / provided-
namespace / global) scope rather than a payload-local binding. exec/eval run at module scope,
so this is ORDER-sensitive: a top-level Load before the name's first top-level binding resolves
outward, as does a free (non-local) Load inside any nested function/class scope -- that can run
after a later top-level rebind. A name bound at module top level is treated as payload-local for
nested references (its own binding shadows the caller), and a payload-local sink is caught by
the inner recursive scan instead. Returns a set of names."""
try:
inner = ast.parse(src, mode = "eval" if mode == "eval" else "exec")
except Exception:
return set()
# eval: a single expression with no bindings -- every loaded name resolves outward.
if isinstance(inner, ast.Expression):
return {
_n.id
for _n in ast.walk(inner)
if isinstance(_n, ast.Name) and isinstance(_n.ctx, ast.Load)
}
names, module_bound = _module_toplevel_free_loads(inner)
try:
import symtable as _symtable
_stack = list(_symtable.symtable(src, "<payload>", "exec").get_children())
while _stack:
_s = _stack.pop()
for _sym in _s.get_symbols():
# A nested-scope reference that is free / global resolves to the module (caller)
# scope UNLESS the payload binds it at module top level (then the payload controls
# it, and any payload-local sink is caught by the inner scan).
if (
_sym.is_referenced()
and (_sym.is_free() or _sym.is_global())
and _sym.get_name() not in module_bound
):
names.add(_sym.get_name())
_stack.extend(_s.get_children())
except Exception: # pragma: no cover - defensive: fail closed by flagging every load
for _n in ast.walk(inner):
if isinstance(_n, ast.Name) and isinstance(_n.ctx, ast.Load):
names.add(_n.id)
return names
def _payload_calls_nonbuiltin_free_name(src, mode, free):
"""True when the payload calls (``f(...)``) a FREE name that is not a Python builtin -- the
sink-execution vector when an OPAQUE exec/eval namespace could map that name to a hidden sink."""
try:
inner = ast.parse(src, mode = "eval" if mode == "eval" else "exec")
except Exception:
return True # fail closed
import builtins as _bpy
_bi_names = set(dir(_bpy))
for _n in ast.walk(inner):
if (
isinstance(_n, ast.Call)
and isinstance(_n.func, ast.Name)
and _n.func.id in free
and _n.func.id not in _bi_names
):
return True
return False
def _check_signal_escape_patterns(
code: str,
_depth: int = 0,
@ -6535,31 +6652,63 @@ def _check_signal_escape_patterns(
"""A FREE name in an exec/eval payload that resolves, in the CALLER's scope at ``node``, to
a shell / exec-builtin / deserialize / import alias -- exec/eval run in the caller
namespace, so ``f`` in ``exec('f(...)')`` is the caller's ``f = os.system``. Returns the
offending name or None. Builtins / undefined names never resolve, so ``exec('print(1)')``
and ``exec('x = 1')`` stay allowed."""
try:
inner = ast.parse(src, mode = "eval" if mode == "eval" else "exec")
except Exception:
return None
bound: set[str] = set()
loaded: set[str] = set()
for n in ast.walk(inner):
if isinstance(n, ast.Name):
if isinstance(n.ctx, (ast.Store, ast.Del)):
bound.add(n.id)
elif isinstance(n.ctx, ast.Load):
loaded.add(n.id)
elif isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
bound.add(n.name)
elif isinstance(n, (ast.Import, ast.ImportFrom)):
for _al in n.names:
bound.add((_al.asname or _al.name).split(".")[0])
for nm in loaded - bound:
offending name or None. The outward-name analysis is ORDER-aware (``f('x'); f = None`` still
calls the caller's ``f`` before the rebind) and scope-aware (a free load inside a nested
function resolves outward too). Builtins / undefined names never resolve, so
``exec('print(1)')`` and ``exec('x = 1')`` stay allowed."""
for nm in _payload_outward_load_names(src, mode):
for _kind in ("shell", "execb", "deser", "impf"):
if _scope_idx.resolve(nm, node, _kind):
return nm
return None
def _namespace_value_is_sink(_v):
"""True when an exec/eval namespace VALUE node ({'f': os.system}) resolves to a shell /
exec-builtin / deserialize / import sink."""
if isinstance(_v, ast.Name):
if _v.id in _DYNAMIC_EXEC_BUILTINS or _v.id == "__import__":
return True
for _kind in ("shell", "execb", "deser", "impf"):
if _scope_idx.resolve(_v.id, _v, _kind):
return True
return False
_fq = _fq_attr_name(_v)
if _fq in _SHELL_SINK_FUNCS or _fq in _CODE_DESERIALIZE_SINKS:
return True
_last = _fq.rsplit(".", 1)[-1] if _fq else ""
return _last in _DYNAMIC_EXEC_BUILTINS or _last == "__import__"
def _exec_namespace_alias_hit(node, src, mode):
"""exec/eval with an EXPLICIT globals/locals namespace resolves the payload's free names
from that mapping, not the caller scope. Inspect a literal-dict namespace precisely (a free
name mapped to a sink blocks) and fail closed on an OPAQUE namespace when a non-builtin free
name is CALLED (it could map to a hidden sink). Returns the offending key / marker or None."""
_ns_nodes = []
for _i in (1, 2): # exec(obj, globals, locals) / eval(expr, globals, locals)
if len(node.args) > _i:
_ns_nodes.append(node.args[_i])
for _kw in node.keywords:
if _kw.arg in ("globals", "locals"):
_ns_nodes.append(_kw.value)
if not _ns_nodes:
return None
_free = _payload_outward_load_names(src, mode)
if not _free:
return None
for _ns in _ns_nodes:
if isinstance(_ns, ast.Dict):
for _k, _v in zip(_ns.keys, _ns.values):
_ks = _extract_string_from_node(_k) if _k is not None else None
if _ks is not None and _ks in _free and _namespace_value_is_sink(_v):
return _ks
if _k is None and not isinstance(_v, ast.Dict):
# a **opaque splat could carry a sink alias for a called free name
if _payload_calls_nonbuiltin_free_name(src, mode, _free):
return "<opaque-namespace>"
elif _payload_calls_nonbuiltin_free_name(src, mode, _free):
return "<opaque-namespace>"
return None
def _analyze_exec_call(node, func_id):
"""Stage 2 driver: recover + recurse a foldable payload, else dynamic policy."""
try:
@ -6603,6 +6752,22 @@ def _check_signal_escape_patterns(
),
}
)
return
# exec("f(...)", {'f': os.system}) resolves the payload's free names from the
# EXPLICIT namespace, not the caller scope; inspect a literal-dict namespace for
# a sink alias and fail closed on an opaque one.
_ns_hit = _exec_namespace_alias_hit(node, src, mode)
if _ns_hit is not None:
dynamic_exec.append(
{
"type": "dynamic_exec",
"line": getattr(node, "lineno", -1),
"description": (
f"{func_id}() payload free name resolves to a shell / exec / "
f"deserialize sink in the supplied namespace ({_ns_hit})"
),
}
)
return
if parsed_kind == "BOUND_HIT":
dynamic_exec.append(
@ -6794,8 +6959,10 @@ def _check_signal_escape_patterns(
return None
def _extract_env_scalar(node):
"""A str constant, or a bytes constant decoded to str (os.environb byte keys / values are
the same inherited environment as os.environ), else None."""
"""A str constant, a const-folded string (a const-var / concatenation via the module const
env, ``P='.:/usr/bin'; ... P``), or a bytes constant / folded bytes decoded to str
(os.environb byte keys / values and ``env={'PATH': b'.:'}`` are the same inherited
environment), else None."""
if isinstance(node, ast.Constant):
if isinstance(node.value, str):
return node.value
@ -6804,6 +6971,14 @@ def _check_signal_escape_patterns(
return bytes(node.value).decode("utf-8", "surrogateescape")
except Exception:
return None
_f = _const_fold(node, _const_env)
if isinstance(_f, str):
return _f
if isinstance(_f, (bytes, bytearray)):
try:
return bytes(_f).decode("utf-8", "surrogateescape")
except Exception:
return None
return None
def _env_mapping_pairs(node):
@ -7790,6 +7965,14 @@ def _check_signal_escape_patterns(
return None
return None
def _env_removal_escape(self, key):
# A short reason when REMOVING inherited env var ``key`` (del / pop / unsetenv) is a
# child-escape prelude, else None: dropping a GIT_CONFIG* var re-enables the
# sandbox-suppressed git hooks (core.hooksPath) in a later unguarded git child.
if isinstance(key, str) and key.startswith("GIT_CONFIG"):
return "removes the sandbox git hook suppression"
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
@ -7858,6 +8041,43 @@ def _check_signal_escape_patterns(
)
self.generic_visit(node)
def visit_AugAssign(self, node):
# os.environ['PATH'] += ':.' (or BASH_ENV / GIT_CONFIG*) mutates the inherited env in
# place; model the result as (old value + appended) and run the same policy as a plain
# assignment, so a relative / cwd PATH entry appended to $PATH is caught while a dynamic
# ABSOLUTE extension (+= ':/usr/local/bin') stays allowed.
_envkey = self._environ_subscript_key(node.target)
if _envkey is not None:
_synth = ast.BinOp(left = node.target, op = node.op, right = node.value)
_reason = self._env_mutation_escape(_envkey, _synth)
if _reason is not None:
shell_escapes.append(
{
"type": "shell_escape",
"line": getattr(node, "lineno", -1),
"description": f"os.environ[{_envkey!r}] augmented mutation: {_reason}",
}
)
self.generic_visit(node)
def visit_Delete(self, node):
# del os.environ['GIT_CONFIG_COUNT'] removes an inherited env var without an assignment;
# dropping a GIT_CONFIG* var re-enables the sandbox-suppressed git hooks in a later
# unguarded git child.
for _t in node.targets:
_envkey = self._environ_subscript_key(_t)
if _envkey is not None:
_reason = self._env_removal_escape(_envkey)
if _reason is not None:
shell_escapes.append(
{
"type": "shell_escape",
"line": getattr(node, "lineno", -1),
"description": f"del os.environ[{_envkey!r}]: {_reason}",
}
)
self.generic_visit(node)
def visit_Call(self, node):
# operator.methodcaller('system', 'rm -rf /')(os) applies a deferred method to a
# module receiver; rewrite it to the direct os.system('rm -rf /') call and analyze
@ -7906,6 +8126,51 @@ def _check_signal_escape_patterns(
),
}
)
# os.environ.pop('GIT_CONFIG_COUNT') / .clear() / .popitem() REMOVE an inherited env var
# without an assignment or del; dropping a GIT_CONFIG* var (or clearing the whole env)
# re-enables the sandbox-suppressed git hooks in a later unguarded git child.
if isinstance(_mf, ast.Attribute) and self._is_environ_receiver(_mf.value):
if _mf.attr in ("clear", "popitem"):
shell_escapes.append(
{
"type": "shell_escape",
"line": getattr(node, "lineno", -1),
"description": (
f"os.environ.{_mf.attr}() drops inherited env "
"(incl. the git hook suppression)"
),
}
)
elif _mf.attr == "pop" and node.args:
_rk = _extract_env_scalar(node.args[0])
_rreason = self._env_removal_escape(_rk)
if _rreason is not None:
shell_escapes.append(
{
"type": "shell_escape",
"line": getattr(node, "lineno", -1),
"description": f"os.environ.pop({_rk!r}): {_rreason}",
}
)
# os.unsetenv('GIT_CONFIG_COUNT') is the C-level twin of os.putenv that removes an
# inherited var, dropping the git hook suppression the same way as del os.environ[...].
if (
isinstance(_mf, ast.Attribute)
and _mf.attr == "unsetenv"
and isinstance(_mf.value, ast.Name)
and _mf.value.id in self.os_aliases
and node.args
):
_xk = _extract_env_scalar(node.args[0])
_xreason = self._env_removal_escape(_xk)
if _xreason is not None:
shell_escapes.append(
{
"type": "shell_escape",
"line": getattr(node, "lineno", -1),
"description": f"os.unsetenv({_xk!r}): {_xreason}",
}
)
# os.putenv(key, value) sets an inherited env var through the C-level setter (NOT via
# os.environ), so the subscript / update checks miss it; a later child still inherits it
# (os.putenv('BASH_ENV', 'evil.sh') then subprocess.run(['bash','-c',...])). Run the
@ -8150,16 +8415,22 @@ def _check_signal_escape_patterns(
# 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)
# Const-fold / decode the value so a const-var, a concatenation, or a
# POSIX bytes value (env={'PATH': P}, {'PATH': '.:' + x}, {'PATH':
# b'.:'}) is analyzed, not just an inline str constant.
_evstr = _extract_env_scalar(_ev)
if _ekey in ("BASH_ENV", "ENV") and _evstr != "":
blocked_in_args = blocked_in_args | {"shell-startup-env:" + _ekey}
elif (
_ekey == "PATH"
and isinstance(_evstr, str)
and _path_value_is_unsafe(_evstr)
):
elif _ekey == "PATH":
# env={'PATH': '.'} lets a bare argv[0] resolve to a workdir exec.
blocked_in_args = blocked_in_args | {"unsafe-path-assign"}
# A folded literal is checked directly; a non-literal value that
# provably contributes a relative / cwd entry ('.:' + $PATH) fails
# closed, while a dynamic ABSOLUTE extension stays allowed.
if isinstance(_evstr, str):
if _path_value_is_unsafe(_evstr):
blocked_in_args = blocked_in_args | {"unsafe-path-assign"}
elif _dynamic_path_value_unsafe(_ev, _const_env):
blocked_in_args = blocked_in_args | {"unsafe-path-assign"}
elif (
_ekey in ("GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE")
and _is_git_child
@ -8178,19 +8449,22 @@ def _check_signal_escape_patterns(
if _opaque_key and _is_shell_child:
blocked_in_args = blocked_in_args | {"shell-startup-env:opaque"}
# 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(_k == "GIT_CONFIG_COUNT" for _k, _ in _epairs)
# suppression (env={} / dict(PATH=...) / any mapping without a literal
# GIT_CONFIG_COUNT) re-enables a planted .git/hooks/* in the unguarded
# child. An OPAQUE mapping (env={**d}) cannot PROVE the suppression is
# present, so fail closed too.
if _is_git_child and (
_opaque_key or not any(_k == "GIT_CONFIG_COUNT" for _k, _ in _epairs)
):
blocked_in_args = blocked_in_args | {"git-config-env-override"}
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.
blocked_in_args = blocked_in_args | {"shell-startup-env:non-literal"}
elif _is_shell_child or _is_git_child:
# A non-literal env mapping (env=e, env=f(), a comprehension) cannot be
# proven free of BASH_ENV / ENV (shell child) nor proven to carry the
# GIT_CONFIG_COUNT hook suppression (git child), so fail closed.
if _is_shell_child:
blocked_in_args = blocked_in_args | {"shell-startup-env:non-literal"}
if _is_git_child:
blocked_in_args = blocked_in_args | {"git-config-env-override"}
# os.execl(path, a0, a1, ...) / os.execv(path, [a0, ...]) / os.spawnl(mode,
# path, a0, ...) / os.posix_spawn(path, argv, env) spread the child's argv across

View file

@ -5233,3 +5233,88 @@ class TestRound53Bypasses:
)
def test_round53_benign_allowed(self, code):
_ok(code)
class TestRound54Bypasses:
"""Fifty-fourth-round Codex findings: exec/eval caller-alias order + explicit-namespace
resolution, and non-literal / non-assignment environment mutations."""
@pytest.mark.parametrize(
"code",
[
# A caller alias CALLED before a later rebind store in the payload still hits the
# caller's sink (module scope, order-sensitive).
"import os\nf = os.system\nexec(\"f('touch /tmp/pwn'); f = None\")",
"import os\ng = os.system\nexec(\"g('rm -rf /tmp/x')\\ng = 1\")",
],
)
def test_exec_caller_alias_before_rebind_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# exec/eval with an explicit namespace mapping a free name to a sink.
"import os\nexec(\"f('touch /tmp/p')\", {'f': os.system})",
"import os\neval(\"f('id')\", {'f': os.system})",
],
)
def test_exec_explicit_namespace_alias_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# subprocess env PATH via a const var, a concatenation, or a POSIX bytes value.
"import subprocess\nP='.:/usr/bin'\nsubprocess.run(['evil'], env={'PATH': P})",
"import subprocess\nsubprocess.run(['evil'], env={'PATH': b'.:/usr/bin'})",
"import subprocess\nsubprocess.run(['evil'], env={'PATH': '.:' + '/usr/bin'})",
],
)
def test_subprocess_env_path_nonliteral_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# Non-assignment environment mutations: augmented PATH, del / pop / clear / unsetenv of
# the git hook-suppression var.
"import os, subprocess\nos.environ['PATH'] += ':.'\nsubprocess.run(['evil'])",
"import os, subprocess\ndel os.environ['GIT_CONFIG_COUNT']\nsubprocess.run(['git','status'])",
"import os, subprocess\nos.environ.pop('GIT_CONFIG_COUNT')\nsubprocess.run(['git','status'])",
"import os, subprocess\nos.environ.clear()\nsubprocess.run(['git','status'])",
"import os, subprocess\nos.unsetenv('GIT_CONFIG_COUNT')\nsubprocess.run(['git','status'])",
],
)
def test_nonassignment_env_mutation_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# An opaque / non-literal env mapping for a git child cannot prove the GIT_CONFIG_COUNT
# hook suppression is present, so fail closed.
"import subprocess\nd = {}\nsubprocess.run(['git','commit'], env={**d})",
"import subprocess\ndef f():\n return {}\nsubprocess.run(['git','status'], env=f())",
],
)
def test_git_opaque_env_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# A payload that only binds f, an exec with a benign literal namespace, an absolute
# PATH via const var, a benign augmented / pop env var, and a non-git opaque env all
# stay allowed.
'exec("f = 1\\nprint(f)")',
'exec("x = 1 + 2\\nprint(x)", {})',
"import subprocess\nP='/usr/bin:/bin'\nsubprocess.run(['ls'], env={'PATH': P})",
"import os, subprocess\nos.environ['MYVAR'] += ':x'\nsubprocess.run(['ls'])",
"import os, subprocess\nos.environ.pop('MYVAR', None)\nsubprocess.run(['ls'])",
"import subprocess\nd = {}\nsubprocess.run(['ls'], env={**d})",
"import subprocess\nsubprocess.run(['git','status'])",
],
)
def test_round54_benign_allowed(self, code):
_ok(code)