Harden sandbox: env -C command-subs / nested-shell / argv cwd, env --unset git hooks, workdir meta_path, git apply --unsafe-paths, patch; scope literal-path reads to readers

Close eight findings Codex raised on the round-40 branch (7 P1 + 1 P2 FP):

- env -C command-substitution operand: env -C $(printf /etc) cat passwd (and the
  backtick form) tokenizes the operand into separator tokens, so the per-command
  chdir-dynamic state was reset before the trailing reader. Keep the env -C dynamic
  flag across command-substitution punctuation ( ( ) ` ), and mark it when the operand
  itself starts with a substitution token.
- dynamic env -C into a nested shell: env -C ${X:-/etc} bash -c 'cat passwd' marked the
  cwd dynamic but the nested-shell recursion passed only the original cwd_dynamic,
  dropping it. Propagate the current env -C cwd and its dynamic flag into the payload
  scan.
- argv env -C before a bash -c payload: subprocess.run(['env','-C','/etc','bash','-c',
  'cat passwd']) scanned the payload before applying the argv env -C, treating passwd as
  workdir-local. Fold the argv env -C (via _argv_env_chdir) into the payload's cwd, or
  fail closed on a dynamic DIR.
- env --unset (separated) / bare - drop git hook suppression: the git backscan handled
  -i / --ignore-environment / -u NAME / --unset=NAME but not --unset NAME (separated)
  or a bare - (GNU env: implies -i). Add both so the injected GIT_CONFIG_COUNT hook
  suppression cannot be stripped before a git child.
- workdir module import-vetter mutation: a workdir module of just
  `import sys; sys.meta_path.pop(0)` passed the vetter (pop is not an exec attr), then a
  second workdir module imported unscanned with the vetter removed. Refuse a workdir
  module that touches the import machinery (sys.meta_path / path_hooks /
  path_importer_cache).
- git apply --unsafe-paths: a patch applied with --unsafe-paths can write targets
  outside the working tree (a +++ ../../tmp/x hunk) in the unguarded git child. Deny the
  unsafe mode; a plain git apply p.patch (in-tree targets) stays allowed.
- patch child writer: patch is an unguarded native writer (patch -o /tmp/x, or a ../../
  target in the diff), so add it to the child-writer denylist alongside touch / cp / tar.
- P2 FP -- literal sensitive-path scan over-blocked non-readers: the unconditional token
  scan flagged any command that merely mentioned a sensitive path (echo /etc/passwd,
  printf %s /etc/passwd). Make the scan command-word aware and exempt an explicit
  non-reader allowlist (echo / printf / : / true / false / test / [); every other command
  word -- readers AND unknown commands -- still fails closed.

Regression coverage: TestRound41Bypasses in tests/test_sandbox_tools.py (the seven
static items plus an unknown-command-still-blocks control and a benign-allowed set incl.
echo/printf/test with a sensitive path) and a workdir-module meta_path mutation test in
tests/test_sandbox_runtime_backstop.py.
This commit is contained in:
danielhanchen 2026-07-10 15:28:30 +00:00
commit 6f54040042
3 changed files with 247 additions and 26 deletions

View file

@ -192,6 +192,10 @@ _CHILD_WRITE_COMMANDS = frozenset(
"mknod",
"shred",
"unlink",
# patch applies a diff in an unguarded child; patch -o /tmp/x writes the result outside
# the workdir, and a patch targeting ../../tmp/x escapes even without -o. Same native
# writer class as touch / cp / tar.
"patch",
# rmdir removes (empty) directories; a bash child gets no realpath guard, so
# rmdir /tmp/some-empty-dir deletes a host directory outside the workdir.
"rmdir",
@ -240,6 +244,22 @@ _BLOCKED_COMMANDS_WIN = frozenset(
"pwsh",
}
)
# Commands that take a path as DATA but never read its CONTENTS: echo / printf print their args,
# the no-ops do nothing, and test / [ only stat. A literal sensitive path handed to one of these
# (echo /etc/passwd) is not an exfiltration, so the literal-sensitive-path scan skips it. Any
# OTHER (unknown) command word still fails closed -- only this explicit allowlist is exempt.
_SHELL_NON_READER_COMMANDS = frozenset(
{
"echo",
"printf",
":",
"true",
"false",
"test",
"[",
"[[",
}
)
_BLOCKED_COMMANDS = (
_BLOCKED_COMMANDS_COMMON | _BLOCKED_COMMANDS_WIN
if sys.platform == "win32"
@ -1492,13 +1512,17 @@ def _find_blocked_commands(command: str) -> set[str]:
_git_cwd_escapes = True
elif _bt.startswith("--chdir=") and _arg_escapes_workdir(_bt.split("=", 1)[1]):
_git_cwd_escapes = True
# env -i / --ignore-environment starts git with an EMPTY environment, and env -u
# GIT_CONFIG_COUNT / --unset=GIT_CONFIG_* strips just the suppression var; either
# env -i / --ignore-environment / a bare `-` start git with an EMPTY environment, and
# env -u NAME / --unset NAME / --unset=NAME strip just the suppression var; either
# removes the injected core.hooksPath suppression so a planted .git/hooks/* runs in
# the unguarded git child. Only attribute these flags to an actual env wrapper.
if _bt in ("-i", "--ignore-environment"):
# the unguarded git child. Handle the separated and glued long forms and the bare `-`.
if _bt in ("-i", "--ignore-environment", "-"):
_env_suppress_dropped = True
elif _bt == "-u" and _bk + 1 < len(tokens) and tokens[_bk + 1].startswith("GIT_CONFIG"):
elif (
_bt in ("-u", "--unset")
and _bk + 1 < len(tokens)
and tokens[_bk + 1].startswith("GIT_CONFIG")
):
_env_suppress_dropped = True
elif _bt.startswith("--unset=") and _bt.split("=", 1)[1].startswith("GIT_CONFIG"):
_env_suppress_dropped = True
@ -1577,6 +1601,12 @@ def _find_blocked_commands(command: str) -> set[str]:
elif not _gt.startswith("-") and _git_operand_escapes(_gt, _local_assigns):
blocked.add("git-write-outside")
_gk += 1
# git apply --unsafe-paths lets a patch write to targets OUTSIDE the working tree (a
# +++ ../../tmp/x hunk), which the native git child applies with no realpath guard. The
# patch body is not statically visible, so deny the unsafe mode outright; a plain
# git apply p.patch (in-tree targets) stays allowed.
if "apply" in _seg and "--unsafe-paths" in _seg:
blocked.add("git-write-outside")
# git config [options] KEY [VALUE]: setting an execution-capable config key (git config
# core.pager 'sh -c ...') runs its value on later git operations, like the -c form; and
# git config --file <path> / -f <path> writes the config to an arbitrary file, escaping
@ -5256,23 +5286,6 @@ def _scan_command_string_for_reads(
return _r
return None
# Literal-path token scan (absolute-sensitive + traversal), splitting shell punctuation
# glued to an adjacent word (cat /etc/passwd|wc) so the path piece is still checked.
try:
toks = shlex.split(cmd, posix = True)
except ValueError:
toks = cmd.split()
for _t in toks:
for _piece in re.split(r"[;|&<>()`{}]+", _t):
if _piece and not _piece.startswith("-"):
_r = _flag(_piece)
if _r is not None:
return _r
if _ASSIGNMENT_RE.match(_t):
_r = _check_assignment_rhs(_t)
if _r is not None:
return _r
# Re-tokenize keeping redirects / separators for the input-redirect + expansion scan.
try:
_lx = shlex.shlex(cmd, posix = True, punctuation_chars = ";&|()`<>")
@ -5281,6 +5294,47 @@ def _scan_command_string_for_reads(
except ValueError:
ptoks = cmd.split()
# Literal-path token scan (absolute-sensitive + traversal), COMMAND-WORD aware: a sensitive
# path that a non-reader merely prints / passes as data (echo /etc/passwd, printf %s
# /etc/passwd) is not a read, so its args are exempt. Every other command word -- readers AND
# unknown commands -- still fails closed. A prefix assignment binding a sensitive path
# (P=/etc/passwd cat ${P}) is flagged at the command position; punctuation glued to a word
# (cat /etc/passwd|wc) is split so the path piece is still checked.
_lit_at_cmd = True
_lit_wrapper = None
_lit_is_reader_ctx = True # unknown command word -> still fail closed
for _lt in ptoks:
if _lt in _READ_SCAN_SEPARATORS:
_lit_at_cmd = True
_lit_wrapper = None
_lit_is_reader_ctx = True
continue
if _lit_at_cmd:
if _ASSIGNMENT_RE.match(_lt):
_r = _check_assignment_rhs(_lt)
if _r is not None:
return _r
continue # assignment prefix; command word still ahead
if _lt.startswith("-"):
continue # wrapper flag before the command word
_ltbase = os.path.basename(_lt).lower()
if _ltbase in _COMMAND_PREFIXES:
_lit_wrapper = _ltbase
continue
if _lit_wrapper is not None and _is_wrapper_numeric_arg(_lt):
continue
# command word resolved: a known non-reader exempts its args from the literal scan.
_lit_is_reader_ctx = _ltbase not in _SHELL_NON_READER_COMMANDS
_lit_at_cmd = False
continue
if not _lit_is_reader_ctx:
continue # echo / printf / ... argument: the path is data, not a read
for _piece in re.split(r"[;|&<>()`{}]+", _lt):
if _piece and not _piece.startswith("-"):
_r = _flag(_piece)
if _r is not None:
return _r
# find ... -exec CMD ... ; runs CMD directly on each match; CMD may be a nested shell
# (sh -c 'cat /etc/passwd') or a reader, so scan each -exec segment through this scanner
# (mirrors the blocked-command find -exec handling). The main command-word loop below only
@ -5400,13 +5454,24 @@ def _scan_command_string_for_reads(
_local_assigns = {}
for _pi, _pt in enumerate(ptoks):
if _pt in _READ_SCAN_SEPARATORS:
# env -C `...` / env -C $(...): the substitution operand STARTS with a punctuation
# token ( ` or ( ) that the tokenizer emits as a separator, so the pending env -C
# never captured it. Mark the cwd dynamic here so the trailing reader fails closed.
if _pending_chdir and _pt in ("(", "`"):
_chdir_dynamic = True
_at_cmd = True
_cur_reader = False
_wrapper = None
_skip_operand = False
_pending_argfile = False
_chdir = cwd
_chdir_dynamic = False
# A command-substitution punctuation token ( ( ) ` ) does NOT end the current
# command, so it must not drop a pending env -C dynamic-cwd flag: env -C $(printf
# /etc) cat passwd tokenizes the operand into $ ( printf /etc ), and the ( / )
# would otherwise reset _chdir_dynamic before the trailing reader is scanned. Only a
# real command separator ( ; | & newline / keyword ) ends the env -C scope.
if _pt not in ("(", ")", "`"):
_chdir_dynamic = False
_pending_chdir = False
continue
if _pt.startswith("<"):
@ -5483,11 +5548,15 @@ def _scan_command_string_for_reads(
_fl.startswith("-") and not _fl.startswith("--") and _fl.endswith("c")
):
if _k + 1 < len(ptoks):
# Propagate the CURRENT env -C cwd AND its dynamic flag: env -C
# ${X:-/etc} bash -c 'cat passwd' chdirs to an unprovable dir, so the
# nested payload's relative reads must fail closed too (not just the
# original ambient cwd_dynamic).
_r = _scan_command_string_for_reads(
ptoks[_k + 1],
strict_traversal = strict_traversal,
cwd = _chdir,
cwd_dynamic = cwd_dynamic,
cwd_dynamic = cwd_dynamic or _chdir_dynamic,
_depth = _depth + 1,
)
if _r is not None:
@ -9132,6 +9201,18 @@ def _check_signal_escape_patterns(
_ci = _blocked_in_argv(_elts)[1]
_sh = _elts[_ci] if _ci is not None and _ci < len(_elts) else None
if _sh is not None and os.path.basename(_sh).lower() in _SHELL_BINARIES:
# An env -C DIR earlier in the SAME argv chdirs the child before the nested
# shell runs (['env', '-C', '/etc', 'bash', '-c', 'cat passwd']), so the -c
# payload's relative reads resolve against DIR, not the workdir. Fold the
# argv env -C into the payload's cwd (or fail closed on a dynamic DIR).
_pl_cwd, _pl_dyn = _cwd_lit, _cwd_dyn
_envc = _argv_env_chdir(_elts)
if _envc is not None:
_rdir, _rdyn = _resolve_read_chdir(_envc, {})
if _rdyn:
_pl_dyn = True
else:
_pl_cwd = _join_chdir(_cwd_lit, _rdir)
for _k in range(_ci + 1, len(_elts)):
_ev = _elts[_k]
if _ev is not None and (
@ -9142,8 +9223,16 @@ def _check_signal_escape_patterns(
and _ev.endswith("c")
)
):
if _k + 1 < len(_elts) and _scan_one_command(_elts[_k + 1]):
return True
if _k + 1 < len(_elts) and _elts[_k + 1] is not None:
_rr = _scan_command_string_for_reads(
_elts[_k + 1],
strict_traversal = True,
cwd = _pl_cwd,
cwd_dynamic = _pl_dyn,
)
if _rr is not None:
_fs_block(node, _rr)
return True
break
# env -S 'payload' / --split-string in an argv (subprocess.run(['env', '-S',
# 'cat /etc/passwd'])) runs the split payload as the child; the -c block above
@ -10129,6 +10218,12 @@ try:
# (p.system = 'linux') is NOT a sink, so require a sink-module receiver root.
if _nd.attr in _GUARD_EXEC_ATTRS and _guard_attr_root(_nd.value) in _GUARD_EXEC_RECEIVERS:
return True
# A workdir module that touches the import machinery (sys.meta_path /
# sys.path_hooks / sys.path_importer_cache) can remove THIS vetter, then a
# sibling `import evil` loads unscanned. The top-level analyzer blocks such
# mutation in submitted code; refuse it inside a vetted workdir module too.
if _nd.attr in ("meta_path", "path_hooks", "path_importer_cache"):
return True
return False
class _GuardWorkdirImportVetter:
def find_spec(self, _name, _path=None, _target=None):

View file

@ -764,6 +764,31 @@ def test_sandboxed_benign_attr_named_sink_workdir_module_allowed():
os.remove(os.path.join(workdir, "helper_attr.py"))
@_POSIX_ONLY
def test_sandboxed_workdir_module_meta_path_mutation_denied():
# A workdir module that mutates the import machinery (sys.meta_path.pop(0)) would remove THIS
# vetter, after which a second workdir module could import unscanned and run an unguarded
# sink. The top-level analyzer blocks meta_path mutation in submitted code; the vetter must
# refuse it inside a workdir module too.
session = "backstop-workdir-metapop"
workdir = get_sandbox_workdir(session)
with open(os.path.join(workdir, "mp_popper.py"), "w") as f:
f.write("import sys\nsys.meta_path.pop(0)\nprint('POPPED_OK')\n")
try:
out = _python_exec(
"import mp_popper; print('REACHED_' + 'BODY')",
None,
30,
session,
disable_sandbox = False,
)
assert "POPPED_OK" not in out
assert "REACHED_BODY" not in out
assert "sandbox:" in out or "ImportError" in out
finally:
os.remove(os.path.join(workdir, "mp_popper.py"))
@_POSIX_ONLY
def test_sandboxed_realpath_monkeypatch_write_escape_denied(tmp_path):
# Sandboxed code reassigns os.path.realpath to a lambda that echoes an in-workdir

View file

@ -4315,3 +4315,104 @@ class TestRound40Bypasses:
)
def test_round40_benign_allowed(self, code):
_ok(code)
class TestRound41Bypasses:
"""Forty-first-round Codex findings: env -C with a command-substitution operand, a dynamic
env -C chdir dropped when recursing into a nested shell, an argv env -C not applied before the
bash -c payload scan, env --unset (separated) / bare - dropping the git hook suppression, a
git apply --unsafe-paths escape, and the patch child writer. Plus a P2 FP: a non-reader that
merely mentions a sensitive path (echo /etc/passwd) must not be flagged as a read. The
workdir-module import-vetter mutation item is covered in test_sandbox_runtime_backstop.py."""
@pytest.mark.parametrize(
"code",
[
# env -C $(...) / `...` chdirs the child to a substitution result; the tokenizer splits
# the operand into separator tokens, which must not drop the dynamic-cwd state.
"import os\nos.system('env -C $(printf /etc) cat passwd')",
"import os\nos.system('env -C `printf /etc` cat passwd')",
],
)
def test_env_c_command_substitution_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# env -C ${X:-/etc} bash -c 'cat passwd': the dynamic chdir must propagate into the
# nested shell scan so the payload's relative read fails closed.
"import os\nos.system(\"env -C ${X:-/etc} bash -c 'cat passwd'\")",
# argv form: env -C /etc must be applied before the bash -c payload is scanned.
"import subprocess\nsubprocess.run(['env', '-C', '/etc', 'bash', '-c', 'cat passwd'])",
],
)
def test_env_c_nested_shell_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# env --unset GIT_CONFIG_COUNT (separated) and a bare - (implies -i) strip the injected
# hook suppression before a git child, like -u / --unset= / -i.
"import os\nos.system('env --unset GIT_CONFIG_COUNT git commit -m x')",
"import os\nos.system('env - git commit -m x')",
],
)
def test_env_unset_git_hook_suppression_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# git apply --unsafe-paths applies a patch whose targets can escape the working tree.
"import os\nos.system('git apply --unsafe-paths p.patch')",
"import subprocess\nsubprocess.run(['git', 'apply', '--unsafe-paths', 'p.patch'])",
],
)
def test_git_apply_unsafe_paths_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# patch is an unguarded native writer (patch -o /tmp/x, or a ../../ target in the diff).
"import os\nos.system('patch -o /tmp/x < p.patch')",
"import subprocess\nsubprocess.run(['patch', '-o', '/tmp/x'])",
"import os\nos.system('patch < p.patch')",
],
)
def test_patch_child_writer_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# An UNKNOWN command word mentioning a sensitive path still fails closed (the exemption
# is an explicit non-reader allowlist, not "anything but a known reader").
"import os\nos.system('cat /etc/passwd')",
"import os\nos.system('mytool /etc/shadow')",
"import os\nos.system('grep root /etc/passwd')",
],
)
def test_unknown_command_sensitive_path_still_blocked(self, code):
assert _check_code_safety(code) is not None, code
@pytest.mark.parametrize(
"code",
[
# P2 FP: a non-reader that only PRINTS / passes a sensitive path as data is not a read.
"import os\nos.system('echo /etc/passwd')",
"import os\nos.system('printf %s /etc/passwd')",
"import os\nos.system('test -f /etc/passwd')",
"import os\nos.system('echo hello')",
# Benign env -C to a workdir-relative dir, a plain git apply, a non-shell env, and a
# benign nested shell read must all still pass.
"import os\nos.system('env -C sub cat file.txt')",
"import os\nos.system('git apply p.patch')",
"import os\nos.system('env PATH=/bin:$PATH ls')",
"import subprocess\nsubprocess.run(['bash', '-c', 'cat notes.txt'])",
],
)
def test_round41_benign_allowed(self, code):
_ok(code)