diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index b023e1aa2f..4af8b1552a 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -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//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//``, ``/root/``, ``/Users//``, +# ``%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//``: 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//``). + 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 diff --git a/studio/backend/tests/test_sandbox_hardening.py b/studio/backend/tests/test_sandbox_hardening.py index f8f660eedb..c38e2c3613 100644 --- a/studio/backend/tests/test_sandbox_hardening.py +++ b/studio/backend/tests/test_sandbox_hardening.py @@ -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//`` 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// 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}"