studio/sandbox: close 4 bypass classes from round-7 audit

Round-7 sonnet-panel review found four more concrete bypass
classes. All four are now closed (670 tests passing, 41 new R7
regression tests):

1. Deep path traversal through home prefix. ``~/foo/../../etc/shadow``,
   ``~/a/b/c/../../../../etc/shadow``, and chains where a regular
   segment precedes the ``..`` run slipped because the previous
   escape check only fired when the tail STARTED with ``..``.
   ``_tail_escapes_home`` now walks the tail with a depth counter
   and returns true as soon as depth goes negative, matching the
   runtime resolve when HOME is a single-segment path like
   ``/root``.

2. Brace false-positive for benign user-data listings.
   ``cat ~/data/{maps,routes}`` and ``cat /home/u/{maps,docs}/file``
   were blocked because the single unscoped brace regex matched
   ``maps`` regardless of root. The defence is now split per root:
   ``_HOME_BRACE_RE`` (home credentials), ``_ETC_BRACE_RE``
   (/etc), ``_PROC_BRACE_RE`` (per-process state names like
   ``maps`` / ``mem`` / ``environ`` only fire here), and
   ``_VAR_SPOOL_BRACE_RE`` (cron). The proc / cron generic names
   no longer fire on home or local paths.

3. BinOp.Add depth cap. ``open('/' + 'e' + 't' + 'c' + ...)``
   chains over ~63 operands hit the 64-level recursion cap in
   ``_extract_string_from_node`` and resolved to ``None``, so
   the sensitive literal escaped detection. Both
   ``_extract_string_from_node`` and ``_extract_string_literal``
   now flatten left-leaning ``+`` chains iteratively in one
   pass, so arbitrarily long concatenations resolve.

4. NetworkAndIoVisitor module rebinding. ``import shutil as sh``
   was tracked, but the plain ``import shutil; sh = shutil``
   (a Name = Name assignment) was not, so
   ``sh.copytree('~/.ssh', dst)`` slipped past the
   ``NetworkAndIoVisitor`` shutil-copy gate. A new
   ``visit_Assign`` propagates pathlib, shutil, builtins, and
   ``pathlib.Path`` class aliases across rebinding, mirroring
   ``SignalEscapeVisitor.visit_Assign`` so the two visitors are
   independently correct regardless of execution order.

Cumulative bypass closures across rounds 1 through 7: 28
distinct classes, 670 regression tests, three-OS green.
This commit is contained in:
danielhanchen 2026-05-24 15:56:57 +00:00
commit 7a8c07f216
2 changed files with 343 additions and 43 deletions

View file

@ -416,6 +416,29 @@ _BRACE_EXPANSION_RE = re.compile(r"\{([^{}]*,[^{}]*)\}")
_TILDE_USER_PREFIX_RE = re.compile(r"^~[^/]+/")
def _tail_escapes_home(tail: str) -> bool:
"""Return True if *tail* (the path after a home prefix) contains
a ``..`` chain that takes the cursor above its starting directory.
A simple ``startswith('..')`` check misses ``foo/../../etc/shadow``
where a regular segment precedes the chain. Walks segments with a
depth counter -- a negative depth at any point means the path has
escaped its starting directory and the runtime resolve will land
outside HOME (worst case ``/etc/shadow`` on a single-segment HOME
like ``/root``)."""
depth = 0
for seg in tail.split("/"):
if not seg or seg == ".":
continue
if seg == "..":
depth -= 1
if depth < 0:
return True
else:
depth += 1
return False
def _normalize_path_separators(text: str) -> str:
"""Collapse ``//`` to ``/``, remove ``/./`` segments, and resolve
``/..`` parent-directory traversal so that filesystem-equivalent
@ -446,13 +469,13 @@ def _normalize_path_separators(text: str) -> str:
for prefix in ("~/", "$HOME/", "${HOME}/", "%USERPROFILE%/"):
if collapsed.startswith(prefix):
tail = collapsed[len(prefix) :]
if tail.startswith("..") or tail.startswith("./.."):
if _tail_escapes_home(tail):
return posixpath.normpath("/" + tail)
return prefix + posixpath.normpath("/" + tail).lstrip("/")
tilde_user = _TILDE_USER_PREFIX_RE.match(collapsed)
if tilde_user:
tail = collapsed[tilde_user.end() :]
if tail.startswith("..") or tail.startswith("./.."):
if _tail_escapes_home(tail):
return posixpath.normpath("/" + tail)
return tilde_user.group(0) + posixpath.normpath("/" + tail).lstrip("/")
collapsed = posixpath.normpath(collapsed)
@ -471,10 +494,17 @@ def _expand_token_normalisations(token: str) -> set[str]:
return out
# Sensitive-name fragments that the brace-aware regex below catches
# even when the full string is never expanded (e.g. when the brace
# group has so many alternatives that the expansion cap stops short).
_SENSITIVE_BRACE_NAMES = (
# Brace-defence sensitive names are SPLIT by root context so the gate
# does not over-block. ``cat ~/data/{maps,routes}`` is a legitimate
# user-data brace listing whose ``maps`` alternative is the name of
# a folder, NOT ``/proc/<pid>/maps``. Pairing each root with its own
# applicable sensitive-name set keeps the gate precise.
# Names that target a home / credential root. Apply to ``~/``,
# ``$HOME/``, ``/home/<u>/``, ``/root/``, ``/Users/<u>/``,
# ``%USERPROFILE%/`` -- the credential families that live under the
# user's home directory.
_HOME_BRACE_SENSITIVE_NAMES = (
r"\.ssh/id_rsa",
r"\.ssh/id_ed25519",
r"\.ssh/id_ecdsa",
@ -487,40 +517,66 @@ _SENSITIVE_BRACE_NAMES = (
r"\.npmrc",
r"\.docker/config\.json",
r"\.kube/config",
)
# Names that target ``/etc/``: only the four well-defined credential /
# privilege files. ``hosts`` / ``hostname`` / ``resolv.conf`` /
# ``os-release`` are still allowed.
_ETC_BRACE_SENSITIVE_NAMES = (
r"shadow",
r"sudoers",
r"passwd",
r"gshadow",
)
# Names that target ``/proc/<pid>/``: the per-process state files that
# leak the runtime environment. Generic words like ``maps`` and
# ``mem`` only fire under this root, never under a home or local path.
_PROC_BRACE_SENSITIVE_NAMES = (
r"environ",
r"cmdline",
r"maps",
r"mem",
r"auxv",
)
_SENSITIVE_IN_BRACE_RE = re.compile(
_PATH_TOKEN_START
+ r"(?:"
+ r"~(?:[^/\s'\";&|)<>]*)?/+"
def _build_brace_re(prefix_alt: str, names: tuple[str, ...]) -> "re.Pattern[str]":
"""Compile a brace-aware sensitive-name regex for a single root
alternation. Anchors:
* ``_PATH_TOKEN_START`` -- shell-token boundary so project-local
lookalikes (``./workspace/home/u/...``) do not match.
* Path body between root and final brace can contain its own
brace groups (the empty-alt + dummies bypass uses this).
* ``(?<=[,{/])`` lookbehind plus ``(?=,|\\}|/)`` lookahead so
the sensitive name is one complete brace alternative
(``\\b`` does not fire between ``.`` and ``{`` -- both
non-word -- so it cannot anchor here)."""
return re.compile(
_PATH_TOKEN_START
+ r"(?:" + prefix_alt + r")"
+ r"[^\s'\";&|`$]*?"
+ r"\{[^{}]*?(?<=[,{/])(?:" + "|".join(names) + r")(?=,|\}|/)[^{}]*\}",
re.IGNORECASE,
)
_HOME_BRACE_PREFIX_ALT = (
r"~(?:[^/\s'\";&|)<>]*)?/+"
+ r"|\$\{?HOME\}?/+"
+ r"|/home/[^/\s'\"]+/+"
+ r"|/root/+"
+ r"|/Users/[^/\s'\"]+/+"
+ r"|/etc/+"
+ r"|/proc/(?:self|thread-self|\d+)/+"
+ r"|/var/spool/cron/+"
+ r")"
# Path body between the sensitive root and the final brace can
# contain its own brace groups (the bypass uses a leading brace
# with many dummy alternatives plus one empty alt that elides the
# intermediate path segment). ``[^\s'\";&|`$]*`` allows any path
# content but no shell-token terminator. The inner alternative
# is anchored with a ``(?<=[,{/])`` lookbehind plus a ``(?=,|\}|/)``
# lookahead so the sensitive name is matched as a complete brace
# alternative (``\b`` does not fire between ``.`` and ``{`` -- both
# non-word -- so it cannot be used here).
+ r"[^\s'\";&|`$]*?"
+ r"\{[^{}]*?(?<=[,{/])(?:"
+ "|".join(_SENSITIVE_BRACE_NAMES)
+ r")(?=,|\}|/)[^{}]*\}",
re.IGNORECASE,
+ r"|%USERPROFILE%/+"
+ r"|%HOMEDRIVE%%HOMEPATH%/+"
)
_HOME_BRACE_RE = _build_brace_re(
_HOME_BRACE_PREFIX_ALT, _HOME_BRACE_SENSITIVE_NAMES
)
_ETC_BRACE_RE = _build_brace_re(r"/etc/+", _ETC_BRACE_SENSITIVE_NAMES)
_PROC_BRACE_RE = _build_brace_re(
r"/proc/(?:self|thread-self|\d+)/+", _PROC_BRACE_SENSITIVE_NAMES
)
_VAR_SPOOL_BRACE_RE = _build_brace_re(
r"/var/spool/cron/+", (r"[\w.-]+",)
)
@ -670,11 +726,19 @@ def _find_sensitive_paths(command: str) -> set[str]:
# Brace-bomb defence. ``cat ~/{,x0,...,x341}/{.ssh/id_rsa,...}``
# exceeds ``_expand_brace_projections``'s cap so the leaf
# projection ``~/.ssh/id_rsa`` never reaches the literal regex.
# This pattern catches the sensitive-name fragments inside a
# brace group attached to a sensitive root and fires
# regardless of whether the expansion completed.
for m in _SENSITIVE_IN_BRACE_RE.finditer(text):
found.add(m.group(0))
# These patterns catch sensitive-name fragments inside a brace
# group attached to a sensitive root and fire regardless of
# whether the expansion completed. Split by root so legitimate
# brace listings like ``cat ~/data/{maps,routes}`` are not
# flagged (``maps`` only matches under ``/proc/<pid>/``).
for regex in (
_HOME_BRACE_RE,
_ETC_BRACE_RE,
_PROC_BRACE_RE,
_VAR_SPOOL_BRACE_RE,
):
for m in regex.finditer(text):
found.add(m.group(0))
# Recurse into nested shells. Mirrors the structure in
# _find_blocked_commands so ``bash -c "cat ~/.ssh/id_rsa"`` and
@ -1635,11 +1699,24 @@ def _check_signal_escape_patterns(code: str):
# Walrus (``open((p := '/etc/shadow'))``): resolve the RHS.
return _extract_string_literal(node.value, _depth + 1)
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
left = _extract_string_literal(node.left, _depth + 1)
right = _extract_string_literal(node.right, _depth + 1)
if left is not None and right is not None:
return left + right
return None
# Flatten left-leaning ``+`` chains iteratively so a long
# concat ``v0+v1+...+v64+'/etc/shadow'`` does not blow the
# depth cap (each level adds 1, so the recursive form
# fails closed at 64 operands).
operands: list[ast.AST] = []
cur = node
while isinstance(cur, ast.BinOp) and isinstance(cur.op, ast.Add):
operands.append(cur.right)
cur = cur.left
operands.append(cur)
operands.reverse()
parts: list[str] = []
for op in operands:
s = _extract_string_literal(op, _depth + 1)
if s is None:
return None
parts.append(s)
return "".join(parts)
if isinstance(node, ast.JoinedStr):
parts: list[str] = []
for v in node.values:
@ -1770,11 +1847,23 @@ def _check_signal_escape_patterns(code: str):
return v
return None
if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
left = _extract_string_from_node(node.left, _depth + 1)
right = _extract_string_from_node(node.right, _depth + 1)
if left is not None and right is not None:
return left + right
return None
# Flatten left-leaning ``+`` chains iteratively to avoid
# the recursion depth cap rejecting long concat bypasses
# like ``open(v0+v1+...+v64+'/etc/shadow')``.
operands: list[ast.AST] = []
cur = node
while isinstance(cur, ast.BinOp) and isinstance(cur.op, ast.Add):
operands.append(cur.right)
cur = cur.left
operands.append(cur)
operands.reverse()
parts: list[str] = []
for op in operands:
s = _extract_string_from_node(op, _depth + 1)
if s is None:
return None
parts.append(s)
return "".join(parts)
if isinstance(node, ast.JoinedStr):
parts: list[str] = []
for v in node.values:
@ -3130,6 +3219,44 @@ def _check_signal_escape_patterns(code: str):
self.file_reader_aliases.add(alias.asname or alias.name)
self.generic_visit(node)
def visit_Assign(self, node):
# Module rebinding: ``import pathlib; pl = pathlib``,
# ``import shutil; sh = shutil`` (and the equivalent for
# ``io`` / ``codecs``). Mirrors ``SignalEscapeVisitor.visit_Assign``
# so the file-read / shutil-copy / pathlib gates see the
# bound alias the same way they see the import-time alias.
if isinstance(node.value, ast.Name):
src = node.value.id
for tgt in node.targets:
if not isinstance(tgt, ast.Name):
continue
if src in self.pathlib_aliases:
self.pathlib_aliases.add(tgt.id)
if src in shutil_module_aliases:
shutil_module_aliases.add(tgt.id)
if src in self.builtins_aliases:
self.builtins_aliases.add(tgt.id)
if src in self.path_aliases:
self.path_aliases.add(tgt.id)
# Method rebinding inside the file-read surface:
# ``r = pl.Path`` so a later ``r('/etc/shadow').read_text()``
# flows through the pathlib resolver. The receiver alias
# for ``shutil.copy`` etc. is handled by the shutil-fq
# canonicalisation in the gate itself.
if isinstance(node.value, ast.Attribute) and isinstance(
node.value.value, ast.Name
):
recv = node.value.value.id
attr = node.value.attr
if (
recv in self.pathlib_aliases
and attr in _PATHLIB_PATH_CLASSES
):
for tgt in node.targets:
if isinstance(tgt, ast.Name):
self.path_aliases.add(tgt.id)
self.generic_visit(node)
def visit_Call(self, node):
func = node.func
# eval/exec payload recursion — see SignalEscapeVisitor for

View file

@ -2232,3 +2232,176 @@ class TestR6_BraceBombEmptyAlt:
)
def test_brace_legit_allowed(self, cmd):
assert not _find_sensitive_paths(cmd), f"legit brace blocked: {cmd!r}"
# ---------------------------------------------------------------------------
# Round 7 -- four more bypass classes from a follow-on sonnet panel.
# ---------------------------------------------------------------------------
class TestR7_DeepPathTraversal:
"""``~/foo/../../etc/shadow`` and longer chains slipped because the
previous escape check only fired when the tail started with ``..``.
The depth-counter walk in ``_tail_escapes_home`` now catches a
``..`` chain that takes the cursor above HOME no matter where in
the tail it appears."""
@pytest.mark.parametrize(
"cmd",
[
"cat ~/foo/../../etc/shadow",
"cat ~/a/b/../../../etc/shadow",
"cat ~/a/b/c/../../../../etc/shadow",
"cat ~/a/b/c/d/../../../../../etc/shadow",
"cat ~/x/../../etc/sudoers",
"cat ~/foo/bar/../../../etc/shadow",
],
)
def test_deep_traversal_bash_blocked(self, cmd):
assert _find_sensitive_paths(cmd), f"deep ~/foo/../../etc traversal leaked: {cmd!r}"
@pytest.mark.parametrize(
"code",
[
"open('~/foo/../../etc/shadow').read()",
"open('~/a/b/../../../etc/shadow').read()",
"open('~/a/b/c/d/../../../../../etc/shadow').read()",
],
)
def test_deep_traversal_python_blocked(self, code):
assert _is_blocked(code), f"deep python traversal leaked: {code!r}"
@pytest.mark.parametrize(
"cmd",
[
"cat ~/a/b/../c/file.txt",
"cat ~/a/./b/file.txt",
"cat ~/notes/2026/../2025/draft.md",
],
)
def test_in_home_traversal_allowed(self, cmd):
assert not _find_sensitive_paths(cmd), f"legit in-home ../ traversal blocked: {cmd!r}"
class TestR7_BraceFalsePositive:
"""The single unscoped brace regex over-matched ``~/data/{maps,routes}``
because ``maps`` lives in the generic sensitive-name list. The
round-7 split now applies ``maps`` / ``mem`` / ``environ`` only
under ``/proc/<pid>/`` and home-credential names only under a home
root, so legitimate user-data brace listings stay allowed."""
@pytest.mark.parametrize(
"cmd",
[
"cat ~/data/{maps,routes}",
"cat ~/data/{maps,docs}/file.txt",
"ls ~/projects/{frontend,backend}",
"cp ~/{src,dst}/file.txt /tmp/",
"cat /home/u/{maps,routes}/data.csv",
],
)
def test_user_data_brace_allowed(self, cmd):
assert not _find_sensitive_paths(cmd), f"user-data brace falsely blocked: {cmd!r}"
@pytest.mark.parametrize(
"cmd",
[
"cat /proc/self/{maps,environ}",
"cat /proc/1/{cmdline,environ}",
"cat /proc/self/{maps,status}",
"cat /proc/12345/{environ,auxv}",
],
)
def test_proc_brace_blocked(self, cmd):
assert _find_sensitive_paths(cmd), f"/proc/<pid>/ brace listing leaked: {cmd!r}"
@pytest.mark.parametrize(
"cmd",
[
"cat ~/{.ssh/id_rsa,notes}",
"cat ~/{.aws/credentials,other}",
"cat /home/u/{.ssh/id_rsa,safe}",
],
)
def test_home_credential_brace_blocked(self, cmd):
assert _find_sensitive_paths(cmd), f"home-credential brace listing leaked: {cmd!r}"
class TestR7_BinOpAddDepthCap:
"""The recursive ``_extract_string_from_node`` BinOp.Add walk hit
its 64-level depth cap for chains over ~63 operands. The iterative
flatten now collects the entire left-leaning ``+`` chain in a
single pass so arbitrarily long concatenations resolve."""
@pytest.mark.parametrize("n_parts", [10, 64, 65, 100, 200])
def test_long_concat_blocked(self, n_parts):
# Build ``open('/' + 'e' + 't' + 'c' + '/' + 's' + ...)`` so the
# full chain resolves to ``/etc/shadow``.
target = "/etc/shadow"
# Pad with empty string parts at the start so the full chain has
# >= n_parts operands but still reaches the sensitive literal.
pad = max(0, n_parts - len(target))
parts = ["''"] * pad + [repr(c) for c in target]
expr = " + ".join(parts)
code = f"open({expr}).read()"
assert _is_blocked(code), f"long {n_parts}-operand concat leaked: open({expr!r})"
def test_long_concat_legit_allowed(self):
# A long concatenation that resolves to a benign path must
# still be allowed -- no over-blocking from the iterative walk.
parts = ["'a'"] * 80
code = f"name = {' + '.join(parts)}\nopen(name)"
# ``aaaa...`` is not a sensitive path; should not be blocked
# purely because of the BinOp depth.
assert not _is_blocked(code), "long benign concat falsely blocked"
class TestR7_NetworkAndIoVisitorModuleRebinding:
"""``import shutil as sh`` was tracked by the alias prepass, but
``import shutil; sh = shutil`` (a plain Name = Name assignment) was
not, so ``sh.copytree('~/.ssh', dst)`` slipped past the
``NetworkAndIoVisitor`` shutil gate. The new ``visit_Assign``
propagates pathlib, shutil, builtins, and Path-class aliases."""
@pytest.mark.parametrize(
"code",
[
"import shutil\nsh = shutil\nsh.copytree('/home/u/.ssh', '/tmp/out')",
"import shutil\nsh = shutil\nsh.copytree('~/.ssh', '/tmp/out')",
"import shutil\nx = shutil\ny = x\ny.copytree('~/.aws', '/tmp/out')",
],
)
def test_shutil_rebound_blocked(self, code):
assert _is_blocked(code), f"shutil rebinding leaked: {code!r}"
@pytest.mark.parametrize(
"code",
[
"import pathlib\npl = pathlib\npl.Path('/etc/shadow').read_text()",
"import pathlib\nP = pathlib\nQ = P\nQ.Path('/etc/shadow').read_text()",
"import pathlib\npl = pathlib\nr = pl.Path\nr('/etc/shadow').read_text()",
],
)
def test_pathlib_rebound_blocked(self, code):
assert _is_blocked(code), f"pathlib rebinding leaked: {code!r}"
@pytest.mark.parametrize(
"code",
[
"import builtins\nb = builtins\nb.exec(\"open('/etc/shadow').read()\")",
"import builtins\nb = builtins\nb.eval(\"open('/etc/shadow').read()\")",
],
)
def test_builtins_rebound_blocked(self, code):
assert _is_blocked(code), f"builtins rebinding leaked: {code!r}"
@pytest.mark.parametrize(
"code",
[
"import shutil\nsh = shutil\nsh.copytree('./src', './dst')",
"import pathlib\npl = pathlib\npl.Path('./data.json').read_text()",
"import os\no = os\no.path.join('a', 'b')",
],
)
def test_rebound_legit_allowed(self, code):
assert not _is_blocked(code), f"legit rebinding blocked: {code!r}"