From eed25b3845ed1b6a0cb8cc3698ecc07f5553b88e Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:22:16 -0700 Subject: [PATCH 01/21] Harden auto permission network gates --- studio/backend/core/inference/tools.py | 45 +++++++++++++++++--- studio/backend/tests/test_permission_mode.py | 16 +++++++ studio/backend/tests/test_sandbox_tools.py | 13 ++++++ 3 files changed, 67 insertions(+), 7 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index dd268a6bb7..29feaf40b0 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -552,6 +552,10 @@ _AUTO_SENSITIVE_MCP_NOUN_RE = re.compile( re.IGNORECASE, ) +# Low-level clients bypass the sandbox host scanner, so sandboxed Python blocks +# them and auto mode asks before they can run. +_SANDBOX_BLOCKED_NETWORK_MODULES = frozenset({"httpcore", "boto3", "botocore"}) + # Python: modules whose import alone signals side effects auto mode should ask # about (process spawning, network, bulk file ops, low-level memory). _AUTO_UNSAFE_PY_MODULES = frozenset( @@ -605,6 +609,7 @@ _AUTO_UNSAFE_PY_MODULES = frozenset( "venv", } ) +_AUTO_UNSAFE_PY_MODULES |= _SANDBOX_BLOCKED_NETWORK_MODULES # Attribute calls that mutate the filesystem / spawn processes (os.remove, # Path.write_text, sock.connect, ...) regardless of how the module was bound. _AUTO_UNSAFE_PY_ATTRS = frozenset( @@ -2403,19 +2408,22 @@ _RENDER_HTML_NETWORK_RE = re.compile( r"@import|" r"url\(\s*[\"']?\s*(?:https?:|/)|" r"]*\bsrc\s*=|" - r"\b(?:src|href|srcset)\s*=\s*[\"']?\s*(?:https?:|/)|" + r"\b(?:src|href|srcset|action|formaction|poster|data|ping)\s*=" + r"\s*[\"']?\s*(?:https?:|/)|" + r"\.\s*setAttribute\s*\(\s*[\"'`](?:src|href|srcset|action|formaction|" + r"poster|data|ping)[\"'`]|" # Self-navigation sinks: location.assign/replace(...), window.open(...), and # assigning a URL to (window.)location(.href). location.reload()/history.back # do not navigate to a new URL, so they stay static. r"\blocation\s*\.\s*(?:assign|replace)\s*\(|" r"\bwindow\s*\.\s*open\s*\(|" r"\b(?:window\s*\.\s*)?location(?:\s*\.\s*href)?\s*=\s*[\"'`]?\s*(?:https?:|/)|" - # Bracket-access obfuscation: window['fetch'](...), self["open"](...). - r"\[\s*[\"'](?:fetch|open|XMLHttpRequest|WebSocket|EventSource|importScripts|" - r"sendBeacon|serviceWorker)[\"']\s*\]|" - # Computed bracket key spliced at runtime on a global host object - # (window['fet'+'ch'](...)): a quoted fragment adjacent to a + inside the - # index. Anchored to a host object so a plain obj['a'+'b'] key stays safe. + # Bracket access and computed keys on global host objects. + r"\[\s*[\"'`](?:fetch|open|XMLHttpRequest|WebSocket|EventSource|importScripts|" + r"sendBeacon|serviceWorker)[\"'`]\s*\]|" + r"\b(?:window|self|globalThis|top|parent|frames)\s*\[[^\]]*" + r"(?:fetch|open|XMLHttpRequest|WebSocket|EventSource|importScripts|" + r"sendBeacon|serviceWorker)[^\]]*\]|" r"\b(?:window|self|globalThis|top|parent|frames)\s*\[[^\]]*" r"(?:[\"']\s*\+|\+\s*[\"'])[^\]]*\]|" # Declarative meta-refresh navigation to a URL (order-tolerant); a bare @@ -5116,6 +5124,29 @@ def _check_signal_escape_patterns(code: str): return None class NetworkAndIoVisitor(ast.NodeVisitor): + def _block_low_level_network_module(self, module_name: str, node) -> None: + root = module_name.split(".", 1)[0] + if root not in _SANDBOX_BLOCKED_NETWORK_MODULES: + return + network_calls.append( + { + "type": "low_level_network_module_blocked", + "line": getattr(node, "lineno", -1), + "description": ( + f"Blocked: low-level network module {root!r} is unavailable " + "in sandboxed code" + ), + } + ) + + def visit_Import(self, node): + for alias in node.names: + self._block_low_level_network_module(alias.name, node) + + def visit_ImportFrom(self, node): + if node.module: + self._block_low_level_network_module(node.module, node) + def visit_Call(self, node): parts: list[str] = [] cur = node.func diff --git a/studio/backend/tests/test_permission_mode.py b/studio/backend/tests/test_permission_mode.py index 3b7197fc49..ad6c99d187 100644 --- a/studio/backend/tests/test_permission_mode.py +++ b/studio/backend/tests/test_permission_mode.py @@ -926,6 +926,9 @@ def test_terminal_classifier(command, unsafe): "from huggingface_hub import snapshot_download\nsnapshot_download('r')", True, ), # bare-imported repo snapshot download + ("import httpcore\nhttpcore.request('GET', 'https://example.com')", True), + ("import boto3\nboto3.client('s3').list_buckets()", True), + ("from botocore.session import get_session\nget_session()", True), ("import statistics\nstatistics.mean([1, 2])", False), # benign stdlib import stays safe # A concrete write callable handed to a user-defined helper that can # invoke it bypasses the direct open()/writer site, so it asks. @@ -984,6 +987,10 @@ def test_render_html_gated_only_when_networked(): assert rh("") is True assert rh("") is True # root-relative resolves to origin assert rh("") is True # protocol-relative + assert rh("
") is True + assert rh("") is True + assert rh("") is True + assert rh("link") is True # Self-navigation sinks exfiltrate by navigating the frame away. assert rh("") is True assert rh("") is True @@ -995,6 +1002,15 @@ def test_render_html_gated_only_when_networked(): # Obfuscated egress: a block comment splitting fetch(, or bracket access. assert rh("") is True assert rh("") is True + assert rh("") is True + assert rh("") is True + assert ( + rh( + "" + ) + is True + ) # A computed bracket key spliced from string fragments on a global host object. assert rh("") is True assert rh("") is True diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index 2970b1a6bb..2341bef108 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -121,6 +121,19 @@ class TestUntrustedHostBlock: _ok('import requests; url = "https://example.com/"; requests.get(url)') +class TestLowLevelNetworkModules: + @pytest.mark.parametrize( + "code", + [ + 'import httpcore; httpcore.request("GET", "https://example.com")', + 'import boto3; boto3.client("s3").list_buckets()', + "from botocore.session import get_session; get_session()", + ], + ) + def test_low_level_client_blocked(self, code): + _blocked(code, expect_phrase = "Blocked: low-level network module") + + class TestHostNormalization: def test_trailing_dot_treated_same(self): _ok('import requests; requests.get("https://wikipedia.org./")') From ec26768cbbfe3bcbfbd1a6042f3d8de4648d88df Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Thu, 16 Jul 2026 06:59:51 -0700 Subject: [PATCH 02/21] Cover dynamic network imports and canvas edge cases --- studio/backend/core/inference/tools.py | 198 +++++++++++++++++-- studio/backend/tests/test_permission_mode.py | 39 ++++ studio/backend/tests/test_sandbox_tools.py | 23 +++ 3 files changed, 249 insertions(+), 11 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 29feaf40b0..f2268e7962 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -2410,22 +2410,12 @@ _RENDER_HTML_NETWORK_RE = re.compile( r"]*\bsrc\s*=|" r"\b(?:src|href|srcset|action|formaction|poster|data|ping)\s*=" r"\s*[\"']?\s*(?:https?:|/)|" - r"\.\s*setAttribute\s*\(\s*[\"'`](?:src|href|srcset|action|formaction|" - r"poster|data|ping)[\"'`]|" # Self-navigation sinks: location.assign/replace(...), window.open(...), and # assigning a URL to (window.)location(.href). location.reload()/history.back # do not navigate to a new URL, so they stay static. r"\blocation\s*\.\s*(?:assign|replace)\s*\(|" r"\bwindow\s*\.\s*open\s*\(|" r"\b(?:window\s*\.\s*)?location(?:\s*\.\s*href)?\s*=\s*[\"'`]?\s*(?:https?:|/)|" - # Bracket access and computed keys on global host objects. - r"\[\s*[\"'`](?:fetch|open|XMLHttpRequest|WebSocket|EventSource|importScripts|" - r"sendBeacon|serviceWorker)[\"'`]\s*\]|" - r"\b(?:window|self|globalThis|top|parent|frames)\s*\[[^\]]*" - r"(?:fetch|open|XMLHttpRequest|WebSocket|EventSource|importScripts|" - r"sendBeacon|serviceWorker)[^\]]*\]|" - r"\b(?:window|self|globalThis|top|parent|frames)\s*\[[^\]]*" - r"(?:[\"']\s*\+|\+\s*[\"'])[^\]]*\]|" # Declarative meta-refresh navigation to a URL (order-tolerant); a bare # content="30" self-reload has no url= and stays static. r"]*http-equiv\s*=\s*[\"']?\s*refresh)(?=[^>]*\burl\s*=)|" @@ -2436,13 +2426,115 @@ _RENDER_HTML_NETWORK_RE = re.compile( # matching. Line // comments are left alone -- stripping them would eat the // in # an https:// URL and hide a real load. _JS_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) +_RENDER_HTML_GLOBAL_BRACKET_RE = re.compile( + r"\b(?:window|self|globalThis|top|parent|frames)\s*\[([^\]]*)\]", + re.IGNORECASE | re.DOTALL, +) +_RENDER_HTML_SET_ATTRIBUTE_RE = re.compile( + r"""\.\s*setAttribute\s*\(\s* + (?P["'`]) + (?Psrc|href|srcset|action|formaction|poster|data|ping) + (?P=quote)\s*,\s*(?P[^)]*)\)""", + re.IGNORECASE | re.DOTALL | re.VERBOSE, +) +_RENDER_HTML_NETWORK_MEMBERS = frozenset( + { + "fetch", + "open", + "xmlhttprequest", + "websocket", + "eventsource", + "importscripts", + "sendbeacon", + "serviceworker", + } +) + + +def _leading_js_string(expression: str) -> tuple[str, int] | None: + """Return a leading JS string literal and its end offset.""" + i = 0 + while i < len(expression) and expression[i].isspace(): + i += 1 + if i >= len(expression) or expression[i] not in "\"'`": + return None + quote = expression[i] + i += 1 + value: list[str] = [] + while i < len(expression): + char = expression[i] + if char == "\\": + i += 1 + if i >= len(expression): + return None + if expression[i] not in ("\\", '"', "'", "`"): + return None + value.append(expression[i]) + i += 1 + continue + if char == quote: + text = "".join(value) + if quote == "`" and "${" in text: + return None + return text, i + 1 + value.append(char) + i += 1 + return None + + +def _static_js_string(expression: str) -> str | None: + """Fold a sequence of JS string literals joined with +.""" + parts: list[str] = [] + offset = 0 + while True: + parsed = _leading_js_string(expression[offset:]) + if parsed is None: + return None + value, end = parsed + parts.append(value) + offset += end + while offset < len(expression) and expression[offset].isspace(): + offset += 1 + if offset == len(expression): + return "".join(parts) + if expression[offset] != "+": + return None + offset += 1 + + +def _render_html_computed_network_access(code: str) -> bool: + for match in _RENDER_HTML_GLOBAL_BRACKET_RE.finditer(code): + expression = match.group(1) + member = _static_js_string(expression) + if member is not None: + if member.lower() in _RENDER_HTML_NETWORK_MEMBERS: + return True + continue + leading = _leading_js_string(expression) + if leading is not None: + member, end = leading + if member.lower() in _RENDER_HTML_NETWORK_MEMBERS and expression[ + end: + ].lstrip().startswith("."): + return True + if not re.fullmatch(r"\s*\d+\s*", expression): + return True + + for match in _RENDER_HTML_SET_ATTRIBUTE_RE.finditer(code): + value = _static_js_string(match.group("value")) + if value is None: + return True + if value.lstrip().lower().startswith(("http:", "https:", "/")): + return True + return False def _render_html_reaches_network(arguments: dict) -> bool: code = arguments.get("code") if not isinstance(code, str): return False - return bool(_RENDER_HTML_NETWORK_RE.search(_JS_BLOCK_COMMENT_RE.sub("", code))) + code = _JS_BLOCK_COMMENT_RE.sub("", code) + return bool(_RENDER_HTML_NETWORK_RE.search(code) or _render_html_computed_network_access(code)) # Tools that are read-only regardless of their arguments, so auto mode never has @@ -5124,6 +5216,12 @@ def _check_signal_escape_patterns(code: str): return None class NetworkAndIoVisitor(ast.NodeVisitor): + def __init__(self): + self.importlib_aliases = {"importlib"} + self.builtins_aliases = {"builtins", "__builtins__"} + self.import_loader_aliases = {"__import__"} + self.literal_strings: dict[str, str] = {} + def _block_low_level_network_module(self, module_name: str, node) -> None: root = module_name.split(".", 1)[0] if root not in _SANDBOX_BLOCKED_NETWORK_MODULES: @@ -5139,15 +5237,93 @@ def _check_signal_escape_patterns(code: str): } ) + def _static_string(self, node) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, ast.Name): + return self.literal_strings.get(node.id) + if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): + left = self._static_string(node.left) + right = self._static_string(node.right) + if left is not None and right is not None: + return left + right + return None + + def _is_import_loader(self, node) -> bool: + if isinstance(node, ast.Name): + return node.id in self.import_loader_aliases + if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name): + return False + if node.attr == "import_module": + return node.value.id in self.importlib_aliases + if node.attr == "__import__": + return node.value.id in self.builtins_aliases + return False + + @staticmethod + def _target_names(node) -> list[str]: + if isinstance(node, ast.Name): + return [node.id] + if isinstance(node, (ast.Tuple, ast.List)): + names: list[str] = [] + for item in node.elts: + names.extend(NetworkAndIoVisitor._target_names(item)) + return names + return [] + + def _bind_assignment(self, targets, value) -> None: + names: list[str] = [] + for target in targets: + names.extend(self._target_names(target)) + static_string = self._static_string(value) + for name in names: + if static_string is None: + self.literal_strings.pop(name, None) + else: + self.literal_strings[name] = static_string + + if self._is_import_loader(value): + self.import_loader_aliases.update(names) + else: + self.import_loader_aliases.difference_update(names) + if isinstance(value, ast.Name) and value.id in self.importlib_aliases: + self.importlib_aliases.update(names) + if isinstance(value, ast.Name) and value.id in self.builtins_aliases: + self.builtins_aliases.update(names) + def visit_Import(self, node): for alias in node.names: self._block_low_level_network_module(alias.name, node) + if alias.name == "importlib": + self.importlib_aliases.add(alias.asname or "importlib") + elif alias.name == "builtins": + self.builtins_aliases.add(alias.asname or "builtins") def visit_ImportFrom(self, node): if node.module: self._block_low_level_network_module(node.module, node) + for alias in node.names: + bound = alias.asname or alias.name + if node.module == "importlib" and alias.name == "import_module": + self.import_loader_aliases.add(bound) + elif node.module == "builtins" and alias.name == "__import__": + self.import_loader_aliases.add(bound) + + def visit_Assign(self, node): + self._bind_assignment(node.targets, node.value) + self.generic_visit(node) + + def visit_AnnAssign(self, node): + if node.value is not None: + self._bind_assignment([node.target], node.value) + self.generic_visit(node) def visit_Call(self, node): + if node.args and self._is_import_loader(node.func): + module_name = self._static_string(node.args[0]) + if module_name is not None: + self._block_low_level_network_module(module_name, node) + parts: list[str] = [] cur = node.func while isinstance(cur, ast.Attribute): diff --git a/studio/backend/tests/test_permission_mode.py b/studio/backend/tests/test_permission_mode.py index ad6c99d187..b097a3d248 100644 --- a/studio/backend/tests/test_permission_mode.py +++ b/studio/backend/tests/test_permission_mode.py @@ -1004,6 +1004,9 @@ def test_render_html_gated_only_when_networked(): assert rh("") is True assert rh("") is True assert rh("") is True + assert rh("") is True + assert rh("") is True + assert rh("") is False assert ( rh( "") is True # A computed key on a plain object (not a global host) stays a static canvas. assert rh("") is False + assert rh("") is False + assert rh("") is False + assert rh("") is False + # Local and fragment setAttribute values do not leave the canvas. + assert ( + rh( + "" + ) + is False + ) + assert ( + rh( + "" + ) + is False + ) + assert ( + rh( + "" + ) + is False + ) + assert ( + rh( + "" + ) + is True + ) + assert ( + rh("") + is True + ) assert rh("") is False # comment only # A meta-refresh with a url navigates the frame to an external origin. assert rh('') is True diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index 2341bef108..09f0a3b8ce 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -128,11 +128,34 @@ class TestLowLevelNetworkModules: 'import httpcore; httpcore.request("GET", "https://example.com")', 'import boto3; boto3.client("s3").list_buckets()', "from botocore.session import get_session; get_session()", + "m = __import__('boto3'); print(m.__name__)", + ("import importlib as il; m = il.import_module('http' + 'core'); print(m.__name__)"), + ( + "from importlib import import_module as load; " + "name = 'botocore.session'; print(load(name).__name__)" + ), + ( + "from builtins import __import__ as load; " + "loader = load; print(loader('boto3').__name__)" + ), ], ) def test_low_level_client_blocked(self, code): _blocked(code, expect_phrase = "Blocked: low-level network module") + @pytest.mark.parametrize( + "code", + [ + "m = __import__('statistics'); print(m.mean([1, 2]))", + ( + "from importlib import import_module as load; " + "print(load('statistics').mean([1, 2]))" + ), + ], + ) + def test_other_dynamic_imports_stay_available(self, code): + _ok(code) + class TestHostNormalization: def test_trailing_dot_treated_same(self): From 13e9426e44e50ecd07d9e4fbca6d8836827fe347 Mon Sep 17 00:00:00 2001 From: Michael Han <107991372+shimmyshimmer@users.noreply.github.com> Date: Thu, 16 Jul 2026 14:32:38 -0700 Subject: [PATCH 03/21] Harden sandbox import and canvas analysis --- .../inference/sandbox_site/sitecustomize.py | 53 +++++ studio/backend/core/inference/tools.py | 215 +++++++++++++++--- studio/backend/tests/test_permission_mode.py | 56 +++++ studio/backend/tests/test_sandbox_tools.py | 77 +++++++ 4 files changed, 375 insertions(+), 26 deletions(-) diff --git a/studio/backend/core/inference/sandbox_site/sitecustomize.py b/studio/backend/core/inference/sandbox_site/sitecustomize.py index d655e8e35a..e2b2cf031e 100644 --- a/studio/backend/core/inference/sandbox_site/sitecustomize.py +++ b/studio/backend/core/inference/sandbox_site/sitecustomize.py @@ -46,6 +46,54 @@ _remapped_writes: dict = {} # on-disk sidecar carries the map across runs. It records only sources the # fallback healed, so an unrelated same-basename file is never adopted. _REMAP_SIDECAR = ".unsloth_sandbox_remap.json" +_BLOCKED_NETWORK_MODULES = frozenset({"boto3", "botocore"}) +_import_guard_installed = False + + +def _blocked_network_module(fullname): + if not isinstance(fullname, str): + return None + root = fullname.split(".", 1)[0] + return root if root in _BLOCKED_NETWORK_MODULES else None + + +def _network_import_audit(event, args): + if event != "import" or not args: + return + root = _blocked_network_module(args[0]) + if root is not None: + raise ModuleNotFoundError( + f"Blocked: low-level network module {root!r} is unavailable in sandboxed code" + ) + + +class _BlockedNetworkModuleFinder: + _unsloth_blocked_network_guard = True + + def find_spec( + self, + fullname, + path = None, + target = None, + ): + root = _blocked_network_module(fullname) + if root is not None: + raise ModuleNotFoundError( + f"Blocked: low-level network module {root!r} is unavailable in sandboxed code" + ) + return None + + +def _install_import_guard(): + global _import_guard_installed + if os.environ.get("UNSLOTH_STUDIO_SANDBOXED") != "1": + return + if not _import_guard_installed: + sys.addaudithook(_network_import_audit) + _import_guard_installed = True + if any(getattr(finder, "_unsloth_blocked_network_guard", False) for finder in sys.meta_path): + return + sys.meta_path.insert(0, _BlockedNetworkModuleFinder()) def _note(subject, original, mapped): @@ -307,6 +355,11 @@ def _install(): pathlib.Path.mkdir = _path_mkdir +try: + _install_import_guard() +except Exception: # noqa: BLE001 - a broken guard must not break startup + pass + try: _install() except Exception: # noqa: BLE001 - a broken shim must never break user code diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index f2268e7962..0eef681582 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -10,6 +10,7 @@ import fnmatch import http.client import os import signal +from html.parser import HTMLParser os.environ["UNSLOTH_IS_PRESENT"] = "1" @@ -2408,8 +2409,6 @@ _RENDER_HTML_NETWORK_RE = re.compile( r"@import|" r"url\(\s*[\"']?\s*(?:https?:|/)|" r"]*\bsrc\s*=|" - r"\b(?:src|href|srcset|action|formaction|poster|data|ping)\s*=" - r"\s*[\"']?\s*(?:https?:|/)|" # Self-navigation sinks: location.assign/replace(...), window.open(...), and # assigning a URL to (window.)location(.href). location.reload()/history.back # do not navigate to a new URL, so they stay static. @@ -2427,15 +2426,16 @@ _RENDER_HTML_NETWORK_RE = re.compile( # an https:// URL and hide a real load. _JS_BLOCK_COMMENT_RE = re.compile(r"/\*.*?\*/", re.DOTALL) _RENDER_HTML_GLOBAL_BRACKET_RE = re.compile( - r"\b(?:window|self|globalThis|top|parent|frames)\s*\[([^\]]*)\]", + r"\b(?:window|self|globalThis|top|parent|frames|this)\s*(?:\?\.\s*)?\[([^\]]*)\]", re.IGNORECASE | re.DOTALL, ) -_RENDER_HTML_SET_ATTRIBUTE_RE = re.compile( - r"""\.\s*setAttribute\s*\(\s* - (?P["'`]) - (?Psrc|href|srcset|action|formaction|poster|data|ping) - (?P=quote)\s*,\s*(?P[^)]*)\)""", - re.IGNORECASE | re.DOTALL | re.VERBOSE, +_RENDER_HTML_SET_ATTRIBUTE_START_RE = re.compile( + r"\.\s*setAttribute\s*(?:\?\.\s*)?\(", + re.IGNORECASE, +) +_RENDER_HTML_PROPERTY_ASSIGNMENT_START_RE = re.compile( + r"\.\s*(?Psrc|href|srcset|action|formaction|poster|data|ping)\s*=(?!=)", + re.IGNORECASE, ) _RENDER_HTML_NETWORK_MEMBERS = frozenset( { @@ -2449,6 +2449,11 @@ _RENDER_HTML_NETWORK_MEMBERS = frozenset( "serviceworker", } ) +_RENDER_HTML_NETWORK_ATTRIBUTES = frozenset( + {"src", "href", "srcset", "action", "formaction", "poster", "data", "ping"} +) +_RENDER_HTML_URL_LIST_ATTRIBUTES = frozenset({"srcset", "ping"}) +_RENDER_HTML_URL_LIST_NETWORK_RE = re.compile(r"(?:^|[\s,])(?:https?:|/)", re.IGNORECASE) def _leading_js_string(expression: str) -> tuple[str, int] | None: @@ -2482,8 +2487,8 @@ def _leading_js_string(expression: str) -> tuple[str, int] | None: return None -def _static_js_string(expression: str) -> str | None: - """Fold a sequence of JS string literals joined with +.""" +def _static_js_string_prefix(expression: str) -> tuple[str, int] | None: + """Fold a leading sequence of JS string literals joined with +.""" parts: list[str] = [] offset = 0 while True: @@ -2496,12 +2501,91 @@ def _static_js_string(expression: str) -> str | None: while offset < len(expression) and expression[offset].isspace(): offset += 1 if offset == len(expression): - return "".join(parts) + return "".join(parts), offset if expression[offset] != "+": - return None + return "".join(parts), offset offset += 1 +def _static_js_string(expression: str) -> str | None: + """Fold a complete sequence of JS string literals joined with +.""" + parsed = _static_js_string_prefix(expression) + if parsed is None: + return None + value, end = parsed + if expression[end:].strip(): + return None + return value + + +def _render_html_attribute_reaches_network(name: str, value: str | None) -> bool: + if value is None: + return False + value = value.lstrip() + if name in _RENDER_HTML_URL_LIST_ATTRIBUTES: + return bool(_RENDER_HTML_URL_LIST_NETWORK_RE.search(value)) + return value.lower().startswith(("http:", "https:", "/")) + + +class _RenderHtmlAttributeParser(HTMLParser): + def __init__(self): + super().__init__(convert_charrefs = True) + self.reaches_network = False + + def handle_starttag(self, tag, attrs): + for name, value in attrs: + name = name.lower() + if name in _RENDER_HTML_NETWORK_ATTRIBUTES and _render_html_attribute_reaches_network( + name, value + ): + self.reaches_network = True + return + + +def _render_html_attributes_reach_network(code: str) -> bool: + parser = _RenderHtmlAttributeParser() + try: + parser.feed(code) + parser.close() + except Exception: + return True + return parser.reaches_network + + +def _js_call_arguments(code: str, offset: int) -> list[str] | None: + arguments: list[str] = [] + start = offset + stack: list[str] = [] + quote: str | None = None + escaped = False + pairs = {")": "(", "]": "[", "}": "{"} + for i in range(offset, len(code)): + char = code[i] + if quote is not None: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = None + continue + if char in "\"'`": + quote = char + elif char in "([{": + stack.append(char) + elif char in ")]}": + if char == ")" and not stack: + arguments.append(code[start:i]) + return arguments + if not stack or stack[-1] != pairs[char]: + return None + stack.pop() + elif char == "," and not stack: + arguments.append(code[start:i]) + start = i + 1 + return None + + def _render_html_computed_network_access(code: str) -> bool: for match in _RENDER_HTML_GLOBAL_BRACKET_RE.finditer(code): expression = match.group(1) @@ -2520,11 +2604,30 @@ def _render_html_computed_network_access(code: str) -> bool: if not re.fullmatch(r"\s*\d+\s*", expression): return True - for match in _RENDER_HTML_SET_ATTRIBUTE_RE.finditer(code): - value = _static_js_string(match.group("value")) - if value is None: + for match in _RENDER_HTML_SET_ATTRIBUTE_START_RE.finditer(code): + arguments = _js_call_arguments(code, match.end()) + if arguments is None: return True - if value.lstrip().lower().startswith(("http:", "https:", "/")): + if len(arguments) < 2: + continue + name = _static_js_string(arguments[0]) + value = _static_js_string(arguments[1]) + if name is None: + if value is None or _RENDER_HTML_URL_LIST_NETWORK_RE.search(value.lstrip()): + return True + continue + name = name.lower() + if name not in _RENDER_HTML_NETWORK_ATTRIBUTES: + continue + if value is None or _render_html_attribute_reaches_network(name, value): + return True + + for match in _RENDER_HTML_PROPERTY_ASSIGNMENT_START_RE.finditer(code): + parsed = _static_js_string_prefix(code[match.end() :]) + if parsed is None: + continue + value, _ = parsed + if _render_html_attribute_reaches_network(match.group("attr").lower(), value): return True return False @@ -2534,7 +2637,11 @@ def _render_html_reaches_network(arguments: dict) -> bool: if not isinstance(code, str): return False code = _JS_BLOCK_COMMENT_RE.sub("", code) - return bool(_RENDER_HTML_NETWORK_RE.search(code) or _render_html_computed_network_access(code)) + return bool( + _RENDER_HTML_NETWORK_RE.search(code) + or _render_html_attributes_reach_network(code) + or _render_html_computed_network_access(code) + ) # Tools that are read-only regardless of their arguments, so auto mode never has @@ -2629,6 +2736,7 @@ def _build_safe_env(workdir: str) -> dict[str, str]: "LANG": os.environ.get("LANG", "C.UTF-8"), "TERM": "dumb", "PYTHONIOENCODING": "utf-8", + "UNSLOTH_STUDIO_SANDBOXED": "1", # sitecustomize shim: remaps ChatGPT code-interpreter paths (/mnt/data # etc.) onto the sandbox CWD; see sandbox_site/sitecustomize.py. "PYTHONPATH": _SANDBOX_SITE_DIR, @@ -2813,6 +2921,7 @@ def _build_bypass_env(workdir: str) -> dict[str, str]: # the bypassed tool writes under the per-session sandbox dir on every OS. env["TEMP"] = workdir env["TMP"] = workdir + env.pop("UNSLOTH_STUDIO_SANDBOXED", None) # sitecustomize path shim (see _build_safe_env). Bypass inherits the # operator's PYTHONPATH, so prepend rather than replace. inherited_pythonpath = env.get("PYTHONPATH", "") @@ -5249,15 +5358,63 @@ def _check_signal_escape_patterns(code: str): return left + right return None + def _import_namespace(self, node) -> str | None: + if isinstance(node, ast.Name): + if node.id in self.importlib_aliases: + return "importlib" + if node.id in self.builtins_aliases: + return "builtins" + return None + if isinstance(node, ast.Attribute) and node.attr == "__dict__": + return self._import_namespace(node.value) + if isinstance(node, ast.Call): + if ( + isinstance(node.func, ast.Name) + and node.func.id == "vars" + and len(node.args) == 1 + ): + return self._import_namespace(node.args[0]) + if self._is_getattr(node): + name = self._static_string(node.args[1]) + if name == "__dict__": + return self._import_namespace(node.args[0]) + return None + + def _is_getattr(self, node) -> bool: + if not isinstance(node, ast.Call) or len(node.args) < 2: + return False + if isinstance(node.func, ast.Name): + return node.func.id == "getattr" + return ( + isinstance(node.func, ast.Attribute) + and node.func.attr == "getattr" + and isinstance(node.func.value, ast.Name) + and node.func.value.id in self.builtins_aliases + ) + def _is_import_loader(self, node) -> bool: if isinstance(node, ast.Name): return node.id in self.import_loader_aliases - if not isinstance(node, ast.Attribute) or not isinstance(node.value, ast.Name): - return False - if node.attr == "import_module": - return node.value.id in self.importlib_aliases - if node.attr == "__import__": - return node.value.id in self.builtins_aliases + if isinstance(node, ast.Attribute): + namespace = self._import_namespace(node.value) + return (namespace, node.attr) in { + ("importlib", "import_module"), + ("builtins", "__import__"), + } + if self._is_getattr(node): + namespace = self._import_namespace(node.args[0]) + name = self._static_string(node.args[1]) + return (namespace, name) in { + ("importlib", "import_module"), + ("builtins", "__import__"), + } + if isinstance(node, ast.Subscript): + namespace = self._import_namespace(node.value) + name = self._static_string(node.slice) + return (namespace, name) in { + ("importlib", "import_module"), + ("builtins", "__import__"), + } return False @staticmethod @@ -5319,8 +5476,14 @@ def _check_signal_escape_patterns(code: str): self.generic_visit(node) def visit_Call(self, node): - if node.args and self._is_import_loader(node.func): - module_name = self._static_string(node.args[0]) + if self._is_import_loader(node.func): + module_node = node.args[0] if node.args else None + if module_node is None: + for keyword in node.keywords: + if keyword.arg == "name": + module_node = keyword.value + break + module_name = self._static_string(module_node) if module_name is not None: self._block_low_level_network_module(module_name, node) diff --git a/studio/backend/tests/test_permission_mode.py b/studio/backend/tests/test_permission_mode.py index b097a3d248..2fe77f0367 100644 --- a/studio/backend/tests/test_permission_mode.py +++ b/studio/backend/tests/test_permission_mode.py @@ -991,6 +991,9 @@ def test_render_html_gated_only_when_networked(): assert rh("") is True assert rh("") is True assert rh("link") is True + assert rh("") is True + assert rh("link") is True + assert rh("") is False # Self-navigation sinks exfiltrate by navigating the frame away. assert rh("") is True assert rh("") is True @@ -1003,9 +1006,13 @@ def test_render_html_gated_only_when_networked(): assert rh("") is True assert rh("") is True assert rh("") is True + assert rh("") is True assert rh("") is True assert rh("") is True assert rh("") is True + assert rh("") is True + assert rh("") is True + assert rh("") is True assert rh("") is False assert ( rh( @@ -1014,6 +1021,37 @@ def test_render_html_gated_only_when_networked(): ) is True ) + assert ( + rh( + "" + ) + is True + ) + assert ( + rh( + "" + ) + is True + ) + assert ( + rh( + "" + ) + is True + ) + assert ( + rh( + "" + ) + is True + ) + assert rh("") is True + assert rh("") is True + assert rh("") is True # A computed bracket key spliced from string fragments on a global host object. assert rh("") is True assert rh("") is True @@ -1030,6 +1068,24 @@ def test_render_html_gated_only_when_networked(): ) is False ) + assert ( + rh( + "" + ) + is False + ) + assert ( + rh( + "" + ) + is False + ) + assert rh("") is False + assert rh("") is False + assert rh("") is False + assert rh("") is False assert ( rh( "") is True assert rh("") is True assert rh("") is True + assert rh('') is True + assert rh('') is False + assert rh('') is True + assert rh('') is False # Worker / SharedWorker constructors run an off-thread script the scan cannot # see (a module worker from a CORS CDN, or a blob/same-origin worker that # fetches/importScripts) under worker-src http: https: blob:, so they ask. @@ -1014,6 +1018,10 @@ def test_render_html_gated_only_when_networked(): assert rh("") is True assert rh("") is True assert rh("") is False + assert rh("") is True + assert rh("") is True + assert rh("") is True + assert rh("") is True assert ( rh( "" + ) + is True + ) + assert ( + rh( + "" + ) + is False + ) assert rh("") is True assert rh("") is True + assert rh("") is True + assert rh("") is True + assert rh("") is True + assert rh("") is True + assert rh("") is True assert rh("") is True + assert rh("") is True + assert rh("") is False + assert rh("") is True + assert rh("") is True + assert rh("") is True + assert rh("") is True + assert rh("") is False + assert rh("") is False # A computed bracket key spliced from string fragments on a global host object. assert rh("") is True assert rh("") is True diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index 7fcf732801..c1dbe5a753 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -403,6 +403,65 @@ class TestSandboxEnvIsolation: assert bypass.returncode == 0, bypass.stderr assert bypass.stdout.strip() == "7" + @pytest.mark.parametrize( + "code", + [ + "name = ''.join(['http', 'core']); print(__import__(name).__name__)", + ( + "import importlib; name = ''.join(['http', 'core']); " + "print(importlib.import_module(name).__name__)" + ), + ("import httpx; name = ''.join(['http', 'core']); print(__import__(name).__name__)"), + ( + "import httpx, importlib; suffix = ''.join(['_', 'api']); " + "print(importlib.import_module('.' + suffix, package='httpcore')" + ".__name__.split('.')[0])" + ), + ], + ) + def test_runtime_import_guard_blocks_direct_dynamic_httpcore(self, tmp_path, code): + from core.inference.tools import _build_bypass_env, _build_safe_env + + sandboxed = subprocess.run( + [sys.executable, "-c", code], + cwd = tmp_path, + env = _build_safe_env(str(tmp_path)), + capture_output = True, + text = True, + check = False, + ) + assert sandboxed.returncode != 0 + assert "Blocked: low-level network module 'httpcore'" in sandboxed.stderr + + bypass = subprocess.run( + [sys.executable, "-c", code], + cwd = tmp_path, + env = _build_bypass_env(str(tmp_path)), + capture_output = True, + text = True, + check = False, + ) + assert bypass.returncode == 0, bypass.stderr + assert bypass.stdout.strip() == "httpcore" + + def test_runtime_import_guard_blocks_local_module_httpcore_import(self, tmp_path): + from core.inference.tools import _build_safe_env + + (tmp_path / "loader.py").write_text( + "name = ''.join(['http', 'core'])\nprint(__import__(name).__name__)\n", + encoding = "utf-8", + ) + result = subprocess.run( + [sys.executable, "-c", "import loader"], + cwd = tmp_path, + env = _build_safe_env(str(tmp_path)), + capture_output = True, + text = True, + check = False, + ) + assert result.returncode != 0 + assert "Blocked: low-level network module 'httpcore'" in result.stderr + @pytest.mark.parametrize("module", ["httpx", "requests", "huggingface_hub"]) def test_runtime_import_guard_keeps_supported_clients_available(self, tmp_path, module): from core.inference.tools import _build_safe_env From e3e1d38678a7d4381594e7bc14eee0e8d2359d59 Mon Sep 17 00:00:00 2001 From: shimmyshimmer <107991372+shimmyshimmer@users.noreply.github.com> Date: Sun, 19 Jul 2026 00:34:25 -0700 Subject: [PATCH 05/21] Harden sandbox review paths --- .../inference/sandbox_site/sitecustomize.py | 156 +++++++- studio/backend/core/inference/tools.py | 339 ++++++++++++++++-- .../backend/tests/test_bypass_permissions.py | 61 ++++ studio/backend/tests/test_permission_mode.py | 45 ++- studio/backend/tests/test_sandbox_tools.py | 110 +++++- .../test_studio_text_descender_clipping.py | 10 +- 6 files changed, 649 insertions(+), 72 deletions(-) diff --git a/studio/backend/core/inference/sandbox_site/sitecustomize.py b/studio/backend/core/inference/sandbox_site/sitecustomize.py index a0557e00ca..399f9d6103 100644 --- a/studio/backend/core/inference/sandbox_site/sitecustomize.py +++ b/studio/backend/core/inference/sandbox_site/sitecustomize.py @@ -34,6 +34,7 @@ import io import json import os import sys +import types # Code-interpreter convention prefixes. Remapping is gated on the prefix being # ABSENT (see _remap) so a genuine host mount / user dir is never shadowed. @@ -56,20 +57,60 @@ _original_import = builtins.__import__ _original_import_module = importlib.import_module -def _path_is_in_sandbox(filename): +def _initial_trusted_library_roots(): + """Capture interpreter-managed package roots before sandbox code can edit sys.path.""" + roots = [] + for entry in sys.path: + if not isinstance(entry, str) or not entry: + continue + try: + path = os.path.realpath(entry) + except OSError: + continue + if os.path.basename(path).lower() not in {"site-packages", "dist-packages"}: + continue + if path not in roots: + roots.append(path) + return tuple(roots) + + +_TRUSTED_LIBRARY_ROOTS = _initial_trusted_library_roots() + + +def _path_is_in_roots(filename, roots): if not isinstance(filename, str) or filename.startswith("<"): return False try: - cwd = os.path.realpath(os.getcwd()) path = os.path.realpath(filename) - return os.path.commonpath((cwd, path)) == cwd + return any(os.path.commonpath((root, path)) == root for root in roots) except (OSError, ValueError): return False -def _sandbox_code_requested_import(): +def _trusted_http_client_frame(frame): + module = frame.f_globals.get("__name__", "") + root = module.split(".", 1)[0] + return root in {"httpx", "httpcore"} and _path_is_in_roots( + frame.f_globals.get("__file__"), _TRUSTED_LIBRARY_ROOTS + ) + + +def _trusted_httpx_in_call_stack(skip = 1): try: - frame = sys._getframe(1) + frame = sys._getframe(skip) + except ValueError: + return False + while frame is not None: + module = frame.f_globals.get("__name__", "") + if module == "httpx" or module.startswith("httpx."): + return _trusted_http_client_frame(frame) + frame = frame.f_back + return False + + +def _sandbox_code_requested_import(skip = 1): + try: + frame = sys._getframe(skip) except ValueError: return True while frame is not None: @@ -82,9 +123,7 @@ def _sandbox_code_requested_import(): ): frame = frame.f_back continue - if module == "__main__" or _path_is_in_sandbox(frame.f_globals.get("__file__")): - return True - return False + return not _trusted_http_client_frame(frame) return True @@ -105,6 +144,91 @@ def _raise_blocked_network_module(root): ) +_HTTP_CORE_METADATA = frozenset( + { + "__cached__", + "__doc__", + "__file__", + "__loader__", + "__name__", + "__package__", + "__path__", + "__spec__", + } +) + + +class _GuardedHttpcoreModule(types.ModuleType): + """Keep httpx working while denying cached low-level APIs to sandbox code.""" + + def __getattribute__(self, name): + if name not in _HTTP_CORE_METADATA and _sandbox_code_requested_import(2): + _raise_blocked_network_module("httpcore") + return types.ModuleType.__getattribute__(self, name) + + def __setattr__(self, name, value): + if _sandbox_code_requested_import(2): + _raise_blocked_network_module("httpcore") + return types.ModuleType.__setattr__(self, name, value) + + def __delattr__(self, name): + if _sandbox_code_requested_import(2): + _raise_blocked_network_module("httpcore") + return types.ModuleType.__delattr__(self, name) + + +def _guard_httpcore_backend_method(cls, method_name): + original = cls.__dict__.get(method_name) + if not callable(original) or getattr(original, "_unsloth_httpcore_backend_guard", False): + return + + code = getattr(original, "__code__", None) + if code is not None and code.co_flags & 0x80: # CO_COROUTINE + + async def guarded(*args, **kwargs): + if not _trusted_httpx_in_call_stack(2): + _raise_blocked_network_module("httpcore") + return await original(*args, **kwargs) + + else: + + def guarded(*args, **kwargs): + if not _trusted_httpx_in_call_stack(2): + _raise_blocked_network_module("httpcore") + return original(*args, **kwargs) + + guarded._unsloth_httpcore_backend_guard = True + guarded.__name__ = getattr(original, "__name__", method_name) + guarded.__qualname__ = getattr(original, "__qualname__", guarded.__name__) + guarded.__doc__ = getattr(original, "__doc__", None) + setattr(cls, method_name, guarded) + + +def _guard_httpcore_network_backends(module): + """Guard httpcore's connection boundary even if module attribute lookup is bypassed.""" + seen = set() + for value in vars(module).values(): + if not isinstance(value, type) or id(value) in seen: + continue + seen.add(id(value)) + _guard_httpcore_backend_method(value, "connect_tcp") + _guard_httpcore_backend_method(value, "connect_unix_socket") + + +def _guard_loaded_httpcore_modules(): + """Harden httpcore modules loaded transitively by an approved high-level client.""" + for name, module in tuple(sys.modules.items()): + if name != "httpcore" and not name.startswith("httpcore."): + continue + if not isinstance(module, types.ModuleType) or isinstance(module, _GuardedHttpcoreModule): + continue + spec = getattr(module, "__spec__", None) + if getattr(spec, "_initializing", False): + continue + _guard_httpcore_network_backends(module) + module.__class__ = _GuardedHttpcoreModule + + def _absolute_import_name( name, package = None, @@ -134,17 +258,25 @@ def _guarded_import( level = 0, ): package = globals.get("__package__") if isinstance(globals, dict) else None - root = _blocked_network_module(_absolute_import_name(name, package, level)) + absolute_name = _absolute_import_name(name, package, level) + root = _blocked_network_module(absolute_name) if root is not None: _raise_blocked_network_module(root) - return _original_import(name, globals, locals, fromlist, level) + module = _original_import(name, globals, locals, fromlist, level) + if isinstance(absolute_name, str) and absolute_name.split(".", 1)[0] == "httpcore": + _guard_loaded_httpcore_modules() + return module def _guarded_import_module(name, package = None): - root = _blocked_network_module(_absolute_import_name(name, package)) + absolute_name = _absolute_import_name(name, package) + root = _blocked_network_module(absolute_name) if root is not None: _raise_blocked_network_module(root) - return _original_import_module(name, package) + module = _original_import_module(name, package) + if isinstance(absolute_name, str) and absolute_name.split(".", 1)[0] == "httpcore": + _guard_loaded_httpcore_modules() + return module def _network_import_audit(event, args): diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index acd72952f5..cfe46d4190 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -5,6 +5,8 @@ (DuckDuckGo), Python code execution, and terminal commands.""" import ast +import base64 +import binascii import codecs import fnmatch import http.client @@ -207,6 +209,154 @@ def _env_assignment_is_unsafe(name: str) -> bool: _FIND_EXEC_FLAGS = frozenset({"-exec", "-execdir", "-ok", "-okdir"}) +_SANDBOX_PYTHON_ENV_VARS = frozenset( + {"PATH", "PYTHONHOME", "PYTHONPATH", "UNSLOTH_STUDIO_SANDBOXED"} +) +_PYTHON_LAUNCH_WRAPPERS = _COMMAND_PREFIXES | frozenset({"conda", "hatch", "pipx", "poetry", "uv"}) + + +def _python_executable_token(token: str) -> bool: + base = os.path.basename(token.replace("\\", "/")).lower() + if base.endswith(".exe"): + base = base[:-4] + return bool(re.fullmatch(r"(?:python(?:w)?(?:\d+(?:\.\d+)*)?|py)", base)) + + +def _shell_command_segments(command: str) -> list[list[str]]: + try: + lexer = shlex.shlex(command, posix = sys.platform != "win32", punctuation_chars = ";&|()`") + lexer.whitespace_split = True + tokens = list(lexer) + except ValueError: + tokens = command.split() + segments: list[list[str]] = [[]] + for token in tokens: + if token in _SHELL_SEPARATORS: + if segments[-1]: + segments.append([]) + continue + segments[-1].append(token.strip("\"'")) + return [segment for segment in segments if segment] + + +def _segment_python_index(segment: list[str]) -> int | None: + command_index = 0 + while command_index < len(segment) and _ASSIGNMENT_RE.match(segment[command_index]): + command_index += 1 + if command_index >= len(segment): + return None + command = os.path.basename(segment[command_index].replace("\\", "/")).lower() + if _python_executable_token(segment[command_index]): + return command_index + if command not in _PYTHON_LAUNCH_WRAPPERS: + if command not in {"find", "fd"}: + return None + for index in range(command_index + 1, len(segment)): + if _python_executable_token(segment[index]) and any( + token in _FIND_EXEC_FLAGS for token in segment[command_index:index] + ): + return index + return None + for index in range(command_index + 1, len(segment)): + if _python_executable_token(segment[index]): + return index + return None + + +def _python_flags_skip_sitecustomize(arguments: list[str]) -> bool: + skip_next = False + for argument in arguments: + if skip_next: + skip_next = False + continue + if argument in {"-c", "-m"} or not argument.startswith("-"): + break + if argument == "--": + break + if argument in {"--ignore-environment", "--isolated", "--no-site"}: + return True + if re.fullmatch(r"-[^-]*[SEI][^-]*", argument): + return True + if argument in {"-W", "-X", "--check-hash-based-pycs"}: + skip_next = True + return False + + +def _segment_mutates_sandbox_python_env(segment: list[str], python_index: int) -> bool: + before_python = segment[:python_index] + for index, token in enumerate(before_python): + assignment = _ASSIGNMENT_RE.match(token) + if assignment and token.split("=", 1)[0].upper() in _SANDBOX_PYTHON_ENV_VARS: + return True + lowered = token.lower() + if lowered in {"-i", "--ignore-environment"} and before_python: + if os.path.basename(before_python[0]).lower() == "env": + return True + if lowered.startswith("--unset="): + if token.split("=", 1)[1].upper() in _SANDBOX_PYTHON_ENV_VARS: + return True + if lowered in {"-u", "--unset"} and index + 1 < len(before_python): + if before_python[index + 1].upper() in _SANDBOX_PYTHON_ENV_VARS: + return True + return False + + +def _segment_persistently_mutates_sandbox_python_env(segment: list[str]) -> bool: + if not segment: + return False + first = os.path.basename(segment[0].replace("\\", "/")).lower() + if all(_ASSIGNMENT_RE.match(token) for token in segment): + return any(token.split("=", 1)[0].upper() in _SANDBOX_PYTHON_ENV_VARS for token in segment) + if first in {"unset", "unsetenv"}: + return any(token.upper() in _SANDBOX_PYTHON_ENV_VARS for token in segment[1:]) + if first in {"export", "set", "setenv"}: + return any( + token.split("=", 1)[0].upper() in _SANDBOX_PYTHON_ENV_VARS for token in segment[1:] + ) + return False + + +def _sandbox_python_startup_bypasses_guard(command: str, depth: int = 0) -> bool: + """Detect terminal-launched Python that suppresses the sandbox sitecustomize guard.""" + if depth > 4: + return True + environment_tainted = False + shell_names = {"bash", "cmd", "cmd.exe", "dash", "fish", "ksh", "sh", "zsh"} + for segment in _shell_command_segments(command): + first = os.path.basename(segment[0].replace("\\", "/")).lower() + wrapper_context = first in _PYTHON_LAUNCH_WRAPPERS + for shell_index, shell_token in enumerate(segment): + shell = os.path.basename(shell_token.replace("\\", "/")).lower() + if shell not in shell_names or (shell_index and not wrapper_context): + continue + for index in range(shell_index + 1, len(segment) - 1): + token = segment[index] + if token.lower() == "/c" or (token.startswith("-") and token.lower().endswith("c")): + nested = segment[index + 1] + if _segment_mutates_sandbox_python_env(segment, shell_index): + nested = f"PYTHONPATH=; {nested}" + if _sandbox_python_startup_bypasses_guard(nested, depth + 1): + return True + break + break + if first == "env": + for index, token in enumerate(segment): + if token in {"-S", "--split-string"} and index + 1 < len(segment): + if _sandbox_python_startup_bypasses_guard(segment[index + 1], depth + 1): + return True + elif token.startswith("--split-string="): + if _sandbox_python_startup_bypasses_guard(token.split("=", 1)[1], depth + 1): + return True + python_index = _segment_python_index(segment) + if python_index is not None: + if environment_tainted or _segment_mutates_sandbox_python_env(segment, python_index): + return True + if _python_flags_skip_sitecustomize(segment[python_index + 1 :]): + return True + environment_tainted = ( + environment_tainted or _segment_persistently_mutates_sandbox_python_env(segment) + ) + return False def _find_blocked_commands(command: str) -> set[str]: @@ -2436,11 +2586,12 @@ _RENDER_HTML_SET_ATTRIBUTE_START_RE = re.compile( re.IGNORECASE, ) _RENDER_HTML_PROPERTY_ASSIGNMENT_START_RE = re.compile( - r"\.\s*(?Psrc|href|srcset|action|formaction|poster|data|ping)\s*=(?!=)", + r"\.\s*(?Psrc|href|srcset|action|formaction|poster|data|ping|srcdoc)\s*" + r"(?P\+=|&&=|\|\|=|\?\?=|=(?!=))", re.IGNORECASE, ) _RENDER_HTML_MARKUP_ASSIGNMENT_START_RE = re.compile( - r"\.\s*(?:innerHTML|outerHTML)\s*=(?!=)", + r"\.\s*(?:innerHTML|outerHTML)\s*(?:\+=|&&=|\|\|=|\?\?=|=(?!=))", re.IGNORECASE, ) _RENDER_HTML_MARKUP_CALL_START_RE = re.compile( @@ -2449,6 +2600,26 @@ _RENDER_HTML_MARKUP_CALL_START_RE = re.compile( r"\s*(?:\?\.\s*)?\(", re.IGNORECASE, ) +_RENDER_HTML_COMPUTED_ASSIGNMENT_START_RE = re.compile( + r"\[\s*(?P[^\]]+)\s*\]\s*(?:\+=|&&=|\|\|=|\?\?=|=(?!=))", + re.IGNORECASE | re.DOTALL, +) +_RENDER_HTML_COMPUTED_CALL_START_RE = re.compile( + r"(?:(?P\bdocument)\s*)?\[\s*(?P[^\]]+)\s*\]\s*" + r"(?:\?\.\s*)?\(", + re.IGNORECASE | re.DOTALL, +) +_RENDER_HTML_REFLECT_SET_START_RE = re.compile( + r"\bReflect\s*\.\s*set\s*(?:\?\.\s*)?\(", re.IGNORECASE +) +_RENDER_HTML_OBJECT_ASSIGN_START_RE = re.compile( + r"\bObject\s*\.\s*assign\s*(?:\?\.\s*)?\(", re.IGNORECASE +) +_RENDER_HTML_OBJECT_PROPERTY_START_RE = re.compile( + r"(?P['\"]?)(?Psrc|href|srcset|action|formaction|poster|data|ping|" + r"srcdoc|innerHTML|outerHTML)(?P=quote)\s*:\s*", + re.IGNORECASE, +) _RENDER_HTML_NETWORK_MEMBERS = frozenset( { "fetch", @@ -2466,6 +2637,9 @@ _RENDER_HTML_NETWORK_ATTRIBUTES = frozenset( ) _RENDER_HTML_URL_LIST_ATTRIBUTES = frozenset({"srcset", "ping"}) _RENDER_HTML_URL_LIST_NETWORK_RE = re.compile(r"(?:^|[\s,])(?:https?:|/)", re.IGNORECASE) +_RENDER_HTML_ACTIVE_DATA_MIME_TYPES = frozenset( + {"text/html", "application/xhtml+xml", "image/svg+xml"} +) def _leading_js_string(expression: str) -> tuple[str, int] | None: @@ -2592,10 +2766,37 @@ def _static_js_assignment_string(expression: str) -> str | None: return None -def _render_html_attribute_reaches_network(name: str, value: str | None) -> bool: +def _render_html_data_document_reaches_network(value: str, depth: int) -> bool: + """Inspect executable document payloads embedded in data: URLs.""" + if not value.lower().startswith("data:"): + return False + header, separator, payload = value[5:].partition(",") + if not separator: + return True + parts = [part.strip() for part in header.split(";")] + media_type = (parts[0] or "text/plain").lower() + if media_type not in _RENDER_HTML_ACTIVE_DATA_MIME_TYPES: + return False + try: + payload_bytes = urllib.parse.unquote_to_bytes(payload) + if any(part.lower() == "base64" for part in parts[1:]): + payload_bytes = base64.b64decode(b"".join(payload_bytes.split()), validate = True) + markup = payload_bytes.decode("utf-8", errors = "replace") + except (ValueError, binascii.Error): + return True + return _render_html_code_reaches_network(markup, depth + 1) + + +def _render_html_attribute_reaches_network( + name: str, + value: str | None, + depth: int = 0, +) -> bool: if value is None: return False value = value.lstrip() + if value.lower().startswith("data:"): + return _render_html_data_document_reaches_network(value, depth) if name in _RENDER_HTML_URL_LIST_ATTRIBUTES: return bool(_RENDER_HTML_URL_LIST_NETWORK_RE.search(value)) return value.lower().startswith(("http:", "https:", "/")) @@ -2617,7 +2818,7 @@ class _RenderHtmlAttributeParser(HTMLParser): if name == "xlink:href": name = "href" if name in _RENDER_HTML_NETWORK_ATTRIBUTES and _render_html_attribute_reaches_network( - name, value + name, value, self.depth ): self.reaches_network = True return @@ -2667,6 +2868,51 @@ def _js_call_arguments(code: str, offset: int) -> list[str] | None: return None +def _render_html_assigned_member_reaches_network( + member: str, value: str | None, depth: int +) -> bool: + member = member.lower() + if member in {"innerhtml", "outerhtml", "srcdoc"}: + return value is None or _render_html_code_reaches_network(value, depth + 1) + if member in _RENDER_HTML_NETWORK_ATTRIBUTES: + return value is None or _render_html_attribute_reaches_network(member, value, depth) + return False + + +def _render_html_set_attribute_arguments(arguments: list[str], method: str, depth: int) -> bool: + if method == "setattributens": + name_index, value_index = 1, 2 + else: + name_index, value_index = 0, 1 + if len(arguments) <= value_index: + return False + name = _static_js_string(arguments[name_index]) + value = _static_js_string(arguments[value_index]) + if name is None: + return bool( + value is None + or _RENDER_HTML_URL_LIST_NETWORK_RE.search(value.lstrip()) + or _render_html_code_reaches_network(value, depth + 1) + ) + name = name.lower().rsplit(":", 1)[-1] + return _render_html_assigned_member_reaches_network(name, value, depth) + + +def _render_html_markup_call_reaches_network(arguments: list[str], method: str, depth: int) -> bool: + if method == "insertadjacenthtml": + if len(arguments) < 2: + return False + markup = _static_js_string(arguments[1]) + else: + if not arguments: + return False + parts = [_static_js_string(argument) for argument in arguments] + markup = "".join(part for part in parts if part is not None) + if any(part is None for part in parts): + markup = None + return markup is None or _render_html_code_reaches_network(markup, depth + 1) + + def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: for match in _RENDER_HTML_GLOBAL_BRACKET_RE.finditer(code): expression = match.group(1) @@ -2689,29 +2935,15 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: arguments = _js_call_arguments(code, match.end()) if arguments is None: return True - if match.group("method").lower() == "setattributens": - name_index, value_index = 1, 2 - else: - name_index, value_index = 0, 1 - if len(arguments) <= value_index: - continue - name = _static_js_string(arguments[name_index]) - value = _static_js_string(arguments[value_index]) - if name is None: - if value is None or _RENDER_HTML_URL_LIST_NETWORK_RE.search(value.lstrip()): - return True - continue - name = name.lower().rsplit(":", 1)[-1] - if name not in _RENDER_HTML_NETWORK_ATTRIBUTES: - continue - if value is None or _render_html_attribute_reaches_network(name, value): + if _render_html_set_attribute_arguments(arguments, match.group("method").lower(), depth): return True for match in _RENDER_HTML_PROPERTY_ASSIGNMENT_START_RE.finditer(code): value = _static_js_assignment_string(code[match.end() :]) if value is None: return True - if _render_html_attribute_reaches_network(match.group("attr").lower(), value): + name = match.group("attr").lower() + if _render_html_assigned_member_reaches_network(name, value, depth): return True for match in _RENDER_HTML_MARKUP_ASSIGNMENT_START_RE.finditer(code): @@ -2726,19 +2958,58 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: if arguments is None: return True method = (match.group("insert") or match.group("write")).lower() - if method == "insertadjacenthtml": - if len(arguments) < 2: - continue - markup = _static_js_string(arguments[1]) - else: - if not arguments: - continue - parts = [_static_js_string(argument) for argument in arguments] - markup = "".join(part for part in parts if part is not None) - if any(part is None for part in parts): - markup = None - if markup is None or _render_html_code_reaches_network(markup, depth + 1): + if _render_html_markup_call_reaches_network(arguments, method, depth): return True + + for match in _RENDER_HTML_COMPUTED_ASSIGNMENT_START_RE.finditer(code): + member = _static_js_string(match.group("member")) + if member is None: + continue + value = _static_js_assignment_string(code[match.end() :]) + if _render_html_assigned_member_reaches_network(member, value, depth): + return True + + for match in _RENDER_HTML_COMPUTED_CALL_START_RE.finditer(code): + method = _static_js_string(match.group("member")) + if method is None: + continue + method = method.lower() + arguments = _js_call_arguments(code, match.end()) + if arguments is None: + return True + if method in {"setattribute", "setattributens"}: + if _render_html_set_attribute_arguments(arguments, method, depth): + return True + elif method == "insertadjacenthtml" or ( + method in {"write", "writeln"} and match.group("document") + ): + if _render_html_markup_call_reaches_network(arguments, method, depth): + return True + + for match in _RENDER_HTML_REFLECT_SET_START_RE.finditer(code): + arguments = _js_call_arguments(code, match.end()) + if arguments is None: + return True + if len(arguments) < 3: + continue + member = _static_js_string(arguments[1]) + if member is None: + continue + value = _static_js_string(arguments[2]) + if _render_html_assigned_member_reaches_network(member, value, depth): + return True + + for match in _RENDER_HTML_OBJECT_ASSIGN_START_RE.finditer(code): + arguments = _js_call_arguments(code, match.end()) + if arguments is None: + return True + for source in arguments[1:]: + for property_match in _RENDER_HTML_OBJECT_PROPERTY_START_RE.finditer(source): + value = _static_js_assignment_string(source[property_match.end() :]) + if _render_html_assigned_member_reaches_network( + property_match.group("member"), value, depth + ): + return True return False @@ -6217,6 +6488,8 @@ def _bash_exec( # Block dangerous commands (skipped when the sandbox is disabled) if not disable_sandbox: + if _sandbox_python_startup_bypasses_guard(command): + return "Blocked: sandboxed Python cannot disable the Studio runtime guard." blocked = _find_blocked_commands(command) if blocked: return f"Blocked command(s) for safety: {', '.join(sorted(blocked))}" diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py index 2635f4e7c8..a3c87b21ce 100644 --- a/studio/backend/tests/test_bypass_permissions.py +++ b/studio/backend/tests/test_bypass_permissions.py @@ -159,6 +159,67 @@ def test_bash_blocklist_enforced_when_sandboxed(captured_popen): assert "cmd" not in captured_popen # never reached Popen +@pytest.mark.parametrize( + "command", + [ + 'python -S -c "import boto3"', + 'python -E -c "import boto3"', + 'python -I -c "import boto3"', + 'python --no-site -c "import boto3"', + 'python --ignore-environment -c "import boto3"', + 'python --isolated -c "import boto3"', + 'env -u PYTHONPATH python -c "import boto3"', + 'env --unset=UNSLOTH_STUDIO_SANDBOXED python3 -c "import boto3"', + 'env -i python -c "import boto3"', + 'PYTHONPATH= python -c "import boto3"', + 'unset PYTHONPATH; python -c "import boto3"', + 'export UNSLOTH_STUDIO_SANDBOXED=0; python -c "import boto3"', + 'uv run python -S -c "import boto3"', + 'bash -lc "python -I -c import\\ boto3"', + 'env -S "python -S -c import\\ boto3"', + 'env --split-string="python -I -c import\\ boto3"', + 'env -u PYTHONPATH sh -c "python -c import\\ boto3"', + 'command sh -c "python -I -c import\\ boto3"', + 'find . -exec python -S -c "import boto3" ;', + ], +) +def test_bash_blocks_python_startup_guard_bypasses(captured_popen, command): + out = _bash_exec(command, None, 5, "t", disable_sandbox = False) + assert "cannot disable the Studio runtime guard" in out + assert "cmd" not in captured_popen + + +@pytest.mark.parametrize( + "command", + [ + 'python -c "print(1)"', + "python script.py -S", + "echo python -S", + "python -c \"print('-S')\"", + ], +) +def test_bash_allows_python_without_startup_guard_bypass(captured_popen, command): + out = _bash_exec(command, None, 5, "t", disable_sandbox = False) + assert out == "FAKEOUT" + assert "cmd" in captured_popen + + +@pytest.mark.parametrize( + ("command", "blocked"), + [ + ('py -3.12 -I -c "import boto3"', True), + ('C:\\Python312\\python.exe -S -c "import boto3"', True), + ('set PYTHONPATH= & python -c "import boto3"', True), + ('cmd /c "python -E -c import boto3"', True), + ('python.exe -c "print(1)"', False), + ("echo python -S", False), + ], +) +def test_python_startup_guard_windows_command_parsing(monkeypatch, command, blocked): + monkeypatch.setattr(tools.sys, "platform", "win32") + assert tools._sandbox_python_startup_bypasses_guard(command) is blocked + + def test_bash_blocklist_skipped_when_bypassed(captured_popen): out = _bash_exec("rm -rf /", None, 5, "t", disable_sandbox = True) assert out == "FAKEOUT" # blocklist skipped -> reached (faked) execution diff --git a/studio/backend/tests/test_permission_mode.py b/studio/backend/tests/test_permission_mode.py index 5053efa4df..962c37921d 100644 --- a/studio/backend/tests/test_permission_mode.py +++ b/studio/backend/tests/test_permission_mode.py @@ -1080,8 +1080,34 @@ def test_render_html_gated_only_when_networked(): assert rh("") is True assert rh("") is True assert rh("") is True + assert rh("") is True + assert rh("") is False + assert rh("") is True assert rh("") is False assert rh("") is True + assert rh("") is True + assert rh("") is False + assert rh("") is True + assert rh("") is True + assert rh("") is False + assert rh("") is True + assert rh("") is True + assert rh("") is True + assert rh("") is True + assert ( + rh("") + is True + ) + assert rh("") is True + assert rh("") is True + assert rh("") is True + assert rh("") is False + assert rh("") is False + assert rh("") is False + assert ( + rh("") + is False + ) assert rh("") is True assert rh("") is True assert rh("") is True @@ -1103,6 +1129,20 @@ def test_render_html_gated_only_when_networked(): ) is False ) + assert rh('') is True + assert ( + rh('') + is True + ) + assert ( + rh('') is True + ) + assert rh('') is False + assert rh('') is False + assert rh('') is False + assert rh('') is True + assert rh('') is True + assert rh("") is True assert ( rh( "") is False assert rh("") is False assert ( - rh( - "" - ) + rh("") is False ) assert ( diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index c1dbe5a753..ccd7cef01e 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -144,14 +144,8 @@ class TestLowLevelNetworkModules: "load = getattr(importlib, 'import_' + 'module'); " "print(load('boto3').__name__)" ), - ( - "import importlib; " - "print(getattr(importlib, 'import_module')(name='boto3').__name__)" - ), - ( - "import importlib; " - "print(importlib.import_module(name='botocore.session').__name__)" - ), + ("import importlib; print(getattr(importlib, 'import_module')(name='boto3').__name__)"), + ("import importlib; print(importlib.import_module(name='botocore.session').__name__)"), ("import importlib; print(vars(importlib)['import_module']('httpcore').__name__)"), ("import importlib; print(importlib.__dict__['import_module']('boto3').__name__)"), ("import builtins; print(getattr(builtins, '__import__')('botocore').__name__)"), @@ -164,10 +158,7 @@ class TestLowLevelNetworkModules: "code", [ "m = __import__('statistics'); print(m.mean([1, 2]))", - ( - "from importlib import import_module as load; " - "print(load('statistics').mean([1, 2]))" - ), + ("from importlib import import_module as load; print(load('statistics').mean([1, 2]))"), ( "import importlib; " "print(getattr(importlib, 'import_module')(name='statistics').mean([1, 2]))" @@ -280,7 +271,7 @@ class TestUploadDenylist: ) def test_plain_post_json_not_blocked(self): - _ok("import requests\n" 'requests.post("https://api.weather.gov/lookup", json={"k": "v"})') + _ok('import requests\nrequests.post("https://api.weather.gov/lookup", json={"k": "v"})') class TestSandboxEnvIsolation: @@ -462,6 +453,76 @@ class TestSandboxEnvIsolation: assert result.returncode != 0 assert "Blocked: low-level network module 'httpcore'" in result.stderr + @pytest.mark.parametrize("module_name", ["loader", "httpx"]) + def test_runtime_import_guard_blocks_external_module_httpcore_import( + self, tmp_path, module_name + ): + from core.inference.tools import _build_safe_env + + workdir = tmp_path / "sandbox" + external = tmp_path / "external" + workdir.mkdir() + external.mkdir() + (external / f"{module_name}.py").write_text( + "name = ''.join(['http', 'core'])\nprint(__import__(name).__name__)\n", + encoding = "utf-8", + ) + code = f"import sys; sys.path.insert(0, {str(external)!r}); import {module_name}" + result = subprocess.run( + [sys.executable, "-c", code], + cwd = workdir, + env = _build_safe_env(str(workdir)), + capture_output = True, + text = True, + check = False, + ) + assert result.returncode != 0 + assert "Blocked: low-level network module 'httpcore'" in result.stderr + + @pytest.mark.parametrize( + "code", + [ + ( + "import httpx, sys; client = httpx.Client(); client.close(); " + "print(sys.modules['httpcore'].request)" + ), + ( + "import httpx, sys; client = httpx.Client(); client.close(); " + "print(sys.modules['httpcore._sync.connection_pool'].ConnectionPool)" + ), + ( + "import httpx, sys, types; client = httpx.Client(); client.close(); " + "module = sys.modules['httpcore']; " + "request = types.ModuleType.__getattribute__(module, 'request'); " + "request('GET', 'http://127.0.0.1:9/probe')" + ), + ( + "import asyncio, httpx, sys, types\n" + "async def main():\n" + " async with httpx.AsyncClient():\n" + " pass\n" + " module = sys.modules['httpcore']\n" + " pool_type = types.ModuleType.__getattribute__(module, 'AsyncConnectionPool')\n" + " async with pool_type() as pool:\n" + " await pool.request('GET', 'http://127.0.0.1:9/probe')\n" + "asyncio.run(main())" + ), + ], + ) + def test_runtime_import_guard_blocks_cached_httpcore_access(self, tmp_path, code): + from core.inference.tools import _build_safe_env + + result = subprocess.run( + [sys.executable, "-c", code], + cwd = tmp_path, + env = _build_safe_env(str(tmp_path)), + capture_output = True, + text = True, + check = False, + ) + assert result.returncode != 0 + assert "Blocked: low-level network module 'httpcore'" in result.stderr + @pytest.mark.parametrize("module", ["httpx", "requests", "huggingface_hub"]) def test_runtime_import_guard_keeps_supported_clients_available(self, tmp_path, module): from core.inference.tools import _build_safe_env @@ -477,6 +538,21 @@ class TestSandboxEnvIsolation: assert result.returncode == 0, result.stderr assert result.stdout.strip() == module + def test_runtime_import_guard_keeps_httpx_transport_available(self, tmp_path): + from core.inference.tools import _build_safe_env + + code = "import httpx; client = httpx.Client(); print(type(client).__name__); client.close()" + result = subprocess.run( + [sys.executable, "-c", code], + cwd = tmp_path, + env = _build_safe_env(str(tmp_path)), + capture_output = True, + text = True, + check = False, + ) + assert result.returncode == 0, result.stderr + assert result.stdout.strip() == "Client" + def test_home_points_at_sandbox_workdir(self, tmp_path): from core.inference.tools import _build_safe_env @@ -693,15 +769,11 @@ class TestHfUploadImportGate: def test_hf_bare_name_upload_folder_safe_allowed(self): _ok( - "from huggingface_hub import upload_folder;" - " upload_folder(folder_path='x', repo_id='r')" + "from huggingface_hub import upload_folder; upload_folder(folder_path='x', repo_id='r')" ) def test_hf_bare_name_create_commit_safe_allowed(self): - _ok( - "from huggingface_hub import create_commit;" - " create_commit(operations=[], repo_id='r')" - ) + _ok("from huggingface_hub import create_commit; create_commit(operations=[], repo_id='r')") def test_bare_name_upload_file_without_hf_import_allowed(self): # No HF import -- local helper named upload_file passes. diff --git a/tests/studio/test_studio_text_descender_clipping.py b/tests/studio/test_studio_text_descender_clipping.py index 7cad6cacfe..7b9b375374 100644 --- a/tests/studio/test_studio_text_descender_clipping.py +++ b/tests/studio/test_studio_text_descender_clipping.py @@ -34,14 +34,16 @@ def test_model_selector_trigger_label_uses_leading_tight(): def test_sidebar_account_block_uses_leading_tight(): src = _read(APP_SIDEBAR) - # Match the account-block parent div regardless of its gap utility; this - # guard is about the leading-* class, not the spacing. + # Match class membership without assuming utility order. pattern = re.compile( - r'', + r'', ) matches = pattern.findall(src) assert matches, "could not find sidebar account-block parent div" - leading_classes = [m for m in matches if m.startswith("leading-")] + leading_classes = [ + token for classes in matches for token in classes.split() if token.startswith("leading-") + ] assert leading_classes, f"no leading-* class on sidebar account-block parent: {matches}" for cls in leading_classes: assert cls == "leading-tight", f"sidebar account-block must use leading-tight, got: {cls}" From 67d01e9cfb306c04d6000dd1353cb8b3108205a1 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 07:40:23 +0000 Subject: [PATCH 06/21] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/tools.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 0f1ed464cc..6ef5e12b7c 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -2605,8 +2605,7 @@ _RENDER_HTML_COMPUTED_ASSIGNMENT_START_RE = re.compile( re.IGNORECASE | re.DOTALL, ) _RENDER_HTML_COMPUTED_CALL_START_RE = re.compile( - r"(?:(?P\bdocument)\s*)?\[\s*(?P[^\]]+)\s*\]\s*" - r"(?:\?\.\s*)?\(", + r"(?:(?P\bdocument)\s*)?\[\s*(?P[^\]]+)\s*\]\s*(?:\?\.\s*)?\(", re.IGNORECASE | re.DOTALL, ) _RENDER_HTML_REFLECT_SET_START_RE = re.compile( From a37f225cd652d5bfd2c6a41b369e719f99b26877 Mon Sep 17 00:00:00 2001 From: Michael Han Date: Sun, 19 Jul 2026 04:14:16 -0700 Subject: [PATCH 07/21] Studio: close remaining sandbox review gaps - Freeze the runtime import guard's block decision in the non-removable audit hook so sandbox code cannot neutralise it by rebinding sitecustomize globals, restoring builtins.__import__, or detaching the meta-path finder. - Keep child interpreters guarded when sandbox code lowers UNSLOTH_STUDIO_SANDBOXED before spawning them (present-but-not-"1" flag + sandbox site dir on PYTHONPATH); bypass (flag removed) is unaffected. - Recurse into shells launched via find/fd -exec when scanning for Python startup-guard bypasses. - Treat optional-chained computed document.write and document.open().write() receivers as HTML sinks. - Honour a declared charset (or fail closed on an unknown one) when decoding active data: documents. - Add regression tests for each. --- .../inference/sandbox_site/sitecustomize.py | 73 ++++++++++++++++--- studio/backend/core/inference/tools.py | 30 ++++++-- .../backend/tests/test_bypass_permissions.py | 4 + studio/backend/tests/test_permission_mode.py | 29 ++++++++ studio/backend/tests/test_sandbox_tools.py | 55 ++++++++++++++ 5 files changed, 176 insertions(+), 15 deletions(-) diff --git a/studio/backend/core/inference/sandbox_site/sitecustomize.py b/studio/backend/core/inference/sandbox_site/sitecustomize.py index 399f9d6103..be81948c08 100644 --- a/studio/backend/core/inference/sandbox_site/sitecustomize.py +++ b/studio/backend/core/inference/sandbox_site/sitecustomize.py @@ -279,14 +279,6 @@ def _guarded_import_module(name, package = None): return module -def _network_import_audit(event, args): - if event != "import" or not args: - return - root = _blocked_network_module(args[0]) - if root is not None: - _raise_blocked_network_module(root) - - class _BlockedNetworkModuleFinder: _unsloth_blocked_network_guard = True @@ -302,12 +294,73 @@ class _BlockedNetworkModuleFinder: return None +def _loaded_from_sandbox_site(): + """True when this shim is imported from the sandbox site dir on PYTHONPATH. + + The parent adds this directory to a sandbox child's PYTHONPATH, so its + presence confirms the child is still running under the sandbox launcher even + if ``UNSLOTH_STUDIO_SANDBOXED`` has been altered in ``os.environ``. + """ + try: + module_dir = os.path.realpath(os.path.dirname(__file__)) + except (OSError, NameError, TypeError): + return False + for entry in os.environ.get("PYTHONPATH", "").split(os.pathsep): + if not entry: + continue + try: + if os.path.realpath(entry) == module_dir: + return True + except OSError: + continue + return False + + +def _sandbox_guard_should_activate(): + """Decide whether to install the runtime network guard. + + Normal sandbox children set ``UNSLOTH_STUDIO_SANDBOXED=1``. Bypass (full + access) removes the variable entirely, so an absent flag means "do not + guard". A flag that is PRESENT but not ``"1"`` (e.g. sandbox code running + ``os.environ['UNSLOTH_STUDIO_SANDBOXED']='0'`` before spawning a child to + escape the guard) is tampering: keep the guard on as long as this shim was + still loaded from the sandbox site dir the launcher put on PYTHONPATH. + """ + flag = os.environ.get("UNSLOTH_STUDIO_SANDBOXED") + if flag == "1": + return True + if flag is None: + return False + return _loaded_from_sandbox_site() + + def _install_import_guard(): global _import_guard_installed - if os.environ.get("UNSLOTH_STUDIO_SANDBOXED") != "1": + if not _sandbox_guard_should_activate(): return if not _import_guard_installed: - sys.addaudithook(_network_import_audit) + # Capture the block sets and trust probe as closure locals. Audit hooks + # cannot be removed once registered, so this hook is the backstop for the + # import wrapper and meta-path finder (both of which sandbox code can + # restore/detach). Reading globals here would let sandbox code neutralise + # it by rebinding this module's attributes, so the decision is frozen. + blocked_roots = frozenset(_BLOCKED_NETWORK_MODULES) + direct_roots = frozenset(_DIRECT_BLOCKED_NETWORK_MODULES) + sandbox_requested = _sandbox_code_requested_import + + def _immutable_network_import_audit(event, args): + if event != "import" or not args: + return + name = args[0] + if not isinstance(name, str): + return + root = name.split(".", 1)[0] + if root in blocked_roots or (root in direct_roots and sandbox_requested()): + raise ModuleNotFoundError( + f"Blocked: low-level network module {root!r} is unavailable in sandboxed code" + ) + + sys.addaudithook(_immutable_network_import_audit) builtins.__import__ = _guarded_import importlib.import_module = _guarded_import_module _import_guard_installed = True diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 6ef5e12b7c..151c1ac072 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -325,9 +325,18 @@ def _sandbox_python_startup_bypasses_guard(command: str, depth: int = 0) -> bool for segment in _shell_command_segments(command): first = os.path.basename(segment[0].replace("\\", "/")).lower() wrapper_context = first in _PYTHON_LAUNCH_WRAPPERS + find_exec = first in {"find", "fd"} for shell_index, shell_token in enumerate(segment): shell = os.path.basename(shell_token.replace("\\", "/")).lower() - if shell not in shell_names or (shell_index and not wrapper_context): + # A shell after the first token is only a real launch when it follows + # a launch wrapper (env/xargs/...) or a find/fd -exec flag; otherwise + # it is an argument (e.g. a path) and is ignored. + find_exec_context = find_exec and any( + token in _FIND_EXEC_FLAGS for token in segment[:shell_index] + ) + if shell not in shell_names or ( + shell_index and not wrapper_context and not find_exec_context + ): continue for index in range(shell_index + 1, len(segment) - 1): token = segment[index] @@ -2596,7 +2605,11 @@ _RENDER_HTML_MARKUP_ASSIGNMENT_START_RE = re.compile( ) _RENDER_HTML_MARKUP_CALL_START_RE = re.compile( r"(?:\.\s*(?PinsertAdjacentHTML)|" - r"\bdocument\s*(?:\?\.\s*|\.\s*)(?Pwrite|writeln))" + # document.write / writeln, optionally reached through a document-valued + # receiver such as document.open(): document.open().write('') + # returns the same document and inserts the remote-loading markup. + r"\bdocument\s*(?:(?:\?\.\s*|\.\s*)open\s*\([^()]*\)\s*)?" + r"(?:\?\.\s*|\.\s*)(?Pwrite|writeln))" r"\s*(?:\?\.\s*)?\(", re.IGNORECASE, ) @@ -2605,7 +2618,7 @@ _RENDER_HTML_COMPUTED_ASSIGNMENT_START_RE = re.compile( re.IGNORECASE | re.DOTALL, ) _RENDER_HTML_COMPUTED_CALL_START_RE = re.compile( - r"(?:(?P\bdocument)\s*)?\[\s*(?P[^\]]+)\s*\]\s*(?:\?\.\s*)?\(", + r"(?:(?P\bdocument)\s*(?:\?\.\s*)?)?\[\s*(?P[^\]]+)\s*\]\s*(?:\?\.\s*)?\(", re.IGNORECASE | re.DOTALL, ) _RENDER_HTML_REFLECT_SET_START_RE = re.compile( @@ -2776,12 +2789,19 @@ def _render_html_data_document_reaches_network(value: str, depth: int) -> bool: media_type = (parts[0] or "text/plain").lower() if media_type not in _RENDER_HTML_ACTIVE_DATA_MIME_TYPES: return False + # Honour a declared charset so a UTF-16/Latin-1 document decodes the same way + # the browser would; an unknown/undecodable charset fails closed rather than + # letting a mangled UTF-8 read hide a remote load. + charset = "utf-8" + for part in parts[1:]: + if part.lower().startswith("charset="): + charset = part.split("=", 1)[1].strip() or "utf-8" try: payload_bytes = urllib.parse.unquote_to_bytes(payload) if any(part.lower() == "base64" for part in parts[1:]): payload_bytes = base64.b64decode(b"".join(payload_bytes.split()), validate = True) - markup = payload_bytes.decode("utf-8", errors = "replace") - except (ValueError, binascii.Error): + markup = payload_bytes.decode(charset, errors = "replace") + except (ValueError, binascii.Error, LookupError): return True return _render_html_code_reaches_network(markup, depth + 1) diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py index a3c87b21ce..bbf38dae58 100644 --- a/studio/backend/tests/test_bypass_permissions.py +++ b/studio/backend/tests/test_bypass_permissions.py @@ -181,6 +181,10 @@ def test_bash_blocklist_enforced_when_sandboxed(captured_popen): 'env -u PYTHONPATH sh -c "python -c import\\ boto3"', 'command sh -c "python -I -c import\\ boto3"', 'find . -exec python -S -c "import boto3" ;', + # A find/fd -exec that hides the interpreter behind a nested shell must + # still be recursed into, not left as an opaque exec target. + 'find . -exec sh -c "python -S -c import\\ boto3" ;', + 'find . -type f -execdir bash -c "python -I -c import\\ boto3" ;', ], ) def test_bash_blocks_python_startup_guard_bypasses(captured_popen, command): diff --git a/studio/backend/tests/test_permission_mode.py b/studio/backend/tests/test_permission_mode.py index 962c37921d..0684d17744 100644 --- a/studio/backend/tests/test_permission_mode.py +++ b/studio/backend/tests/test_permission_mode.py @@ -1113,6 +1113,16 @@ def test_render_html_gated_only_when_networked(): assert rh("") is True assert rh("") is False assert rh("") is False + # Optional-chained computed document.write still recurses into the markup. + assert rh("") is True + assert rh("") is False + # document.open() returns the document, so a write through it is an HTML sink. + assert rh("") is True + assert ( + rh("") + is True + ) + assert rh("") is False # A computed bracket key spliced from string fragments on a global host object. assert rh("") is True assert rh("") is True @@ -1142,6 +1152,25 @@ def test_render_html_gated_only_when_networked(): assert rh('') is False assert rh('') is True assert rh('') is True + # A declared charset is honoured so a UTF-16 document is decoded like the + # browser would; an unknown charset fails closed instead of hiding the load. + assert ( + rh( + '' + ) + is True + ) + assert ( + rh( + '' + ) + is False + ) + assert ( + rh('') is True + ) # unknown charset fails closed assert rh("") is True assert ( rh( diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index ccd7cef01e..eec85d1d0e 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -394,6 +394,61 @@ class TestSandboxEnvIsolation: assert bypass.returncode == 0, bypass.stderr assert bypass.stdout.strip() == "7" + def test_runtime_import_guard_survives_global_tampering(self, monkeypatch, tmp_path): + # Sandbox code can restore builtins.__import__, detach the meta-path + # finder and rebind this module's globals, but the audit hook (which + # cannot be removed) freezes its decision in a closure and still blocks. + from core.inference.tools import _build_safe_env + + monkeypatch.setenv("UNSLOTH_STUDIO_SANDBOXED", "1") + code = ( + "import sys, builtins, sitecustomize\n" + "sitecustomize._blocked_network_module = lambda _: None\n" + "sitecustomize._BLOCKED_NETWORK_MODULES = frozenset()\n" + "builtins.__import__ = sitecustomize._original_import\n" + "sys.meta_path[:] = [f for f in sys.meta_path " + "if not getattr(f, '_unsloth_blocked_network_guard', False)]\n" + "name = ''.join(['bo', 'to3'])\n" + "print(__import__(name).__name__)\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + cwd = tmp_path, + env = _build_safe_env(str(tmp_path)), + capture_output = True, + text = True, + check = False, + ) + assert result.returncode != 0 + assert "Blocked: low-level network module 'boto3'" in result.stderr + + def test_runtime_import_guard_survives_env_flag_reset_for_children(self, tmp_path): + # Clearing UNSLOTH_STUDIO_SANDBOXED before spawning a child must not + # unguard the child: the child re-imports this shim from the sandbox site + # dir still on PYTHONPATH, which is itself the sandbox signal. + from core.inference.tools import _build_safe_env + + code = ( + "import os, subprocess, sys\n" + "os.environ['UNSLOTH_STUDIO_SANDBOXED'] = '0'\n" + "r = subprocess.run([sys.executable, '-c', 'import boto3'], " + "capture_output=True, text=True)\n" + "sys.stdout.write('RC=%d\\n' % r.returncode)\n" + "sys.stdout.write('BLOCKED=%d\\n' % " + "(\"low-level network module 'boto3'\" in r.stderr))\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + cwd = tmp_path, + env = _build_safe_env(str(tmp_path)), + capture_output = True, + text = True, + check = False, + ) + assert result.returncode == 0, result.stderr + assert "RC=1" in result.stdout + assert "BLOCKED=1" in result.stdout + @pytest.mark.parametrize( "code", [ From 5bf9833d740d5a65bf8b102cb1079ae5bfe65e06 Mon Sep 17 00:00:00 2001 From: Michael Han Date: Sun, 19 Jul 2026 05:00:57 -0700 Subject: [PATCH 08/21] Harden sandbox guard review bypasses --- .../inference/bypass_site/sitecustomize.py | 8 + .../inference/sandbox_site/sitecustomize.py | 296 +++++++++++++--- studio/backend/core/inference/tools.py | 332 +++++++++++++++++- .../backend/tests/test_bypass_permissions.py | 4 + studio/backend/tests/test_permission_mode.py | 18 + studio/backend/tests/test_sandbox_tools.py | 112 +++++- 6 files changed, 704 insertions(+), 66 deletions(-) create mode 100644 studio/backend/core/inference/bypass_site/sitecustomize.py diff --git a/studio/backend/core/inference/bypass_site/sitecustomize.py b/studio/backend/core/inference/bypass_site/sitecustomize.py new file mode 100644 index 0000000000..36ff0d197a --- /dev/null +++ b/studio/backend/core/inference/bypass_site/sitecustomize.py @@ -0,0 +1,8 @@ +"""Load path remapping without sandbox network guards.""" + +import runpy +from pathlib import Path + + +_SHIM = Path(__file__).resolve().parents[1] / "sandbox_site" / "sitecustomize.py" +runpy.run_path(str(_SHIM)) diff --git a/studio/backend/core/inference/sandbox_site/sitecustomize.py b/studio/backend/core/inference/sandbox_site/sitecustomize.py index be81948c08..7dcc362e09 100644 --- a/studio/backend/core/inference/sandbox_site/sitecustomize.py +++ b/studio/backend/core/inference/sandbox_site/sitecustomize.py @@ -1,7 +1,7 @@ # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 -"""Sandbox-side compatibility shim for ChatGPT code-interpreter paths. +"""Sandbox-side compatibility shim for code-interpreter path conventions. Models habitually write to /mnt/data (or /mnt/outputs, /home/sandbox, /workspace), none of which exist in the Studio sandbox. This module sits on the @@ -28,7 +28,9 @@ Identical with and without output streaming because the child env is. """ import builtins +import contextvars import importlib +import importlib.machinery import importlib.util import io import json @@ -87,11 +89,31 @@ def _path_is_in_roots(filename, roots): return False +def _frame_uses_trusted_package(frame, package): + module_name = frame.f_globals.get("__name__", "") + if not isinstance(module_name, str): + return False + if module_name != package and not module_name.startswith(f"{package}."): + return False + module = sys.modules.get(module_name) + if module is None: + return False + try: + module_dict = types.ModuleType.__getattribute__(module, "__dict__") + except TypeError: + module_dict = getattr(module, "__dict__", None) + if module_dict is not frame.f_globals: + return False + spec = getattr(module, "__spec__", None) + origin = getattr(spec, "origin", None) or getattr(module, "__file__", None) + if not _path_is_in_roots(origin, _TRUSTED_LIBRARY_ROOTS): + return False + return _path_is_in_roots(frame.f_code.co_filename, _TRUSTED_LIBRARY_ROOTS) + + def _trusted_http_client_frame(frame): - module = frame.f_globals.get("__name__", "") - root = module.split(".", 1)[0] - return root in {"httpx", "httpcore"} and _path_is_in_roots( - frame.f_globals.get("__file__"), _TRUSTED_LIBRARY_ROOTS + return _frame_uses_trusted_package(frame, "httpx") or _frame_uses_trusted_package( + frame, "httpcore" ) @@ -101,25 +123,32 @@ def _trusted_httpx_in_call_stack(skip = 1): except ValueError: return False while frame is not None: - module = frame.f_globals.get("__name__", "") - if module == "httpx" or module.startswith("httpx."): - return _trusted_http_client_frame(frame) + if _frame_uses_trusted_package(frame, "httpx"): + return True frame = frame.f_back return False +def _frame_is_importlib(frame): + module_name = frame.f_globals.get("__name__", "") + if not isinstance(module_name, str) or ( + module_name != "importlib" and not module_name.startswith("importlib.") + ): + return False + module = sys.modules.get(module_name) + return module is not None and getattr(module, "__dict__", None) is frame.f_globals + + def _sandbox_code_requested_import(skip = 1): try: frame = sys._getframe(skip) except ValueError: return True while frame is not None: - module = frame.f_globals.get("__name__", "") if ( - module == __name__ - or module == "importlib" - or module.startswith("importlib.") - or module.startswith("_frozen_importlib") + frame.f_code.co_filename == __file__ + or _frame_is_importlib(frame) + or str(frame.f_code.co_filename).startswith(" bool return False +def _shell_command_substitutions(command: str) -> list[str]: + """Extract executable command substitutions, including quoted forms.""" + substitutions: list[str] = [] + index = 0 + quote: str | None = None + while index < len(command): + char = command[index] + if quote == "'": + if char == "'": + quote = None + index += 1 + continue + if char == "'" and quote is None: + quote = "'" + index += 1 + continue + if char == '"': + quote = None if quote == '"' else '"' + index += 1 + continue + if char == "\\": + index += 2 + continue + if char == "\x60": + end = index + 1 + while end < len(command): + if command[end] == "\\": + end += 2 + continue + if command[end] == "\x60": + substitutions.append(command[index + 1 : end]) + index = end + 1 + break + end += 1 + else: + return substitutions + continue + if command.startswith("$((", index): + index += 3 + continue + if not command.startswith("$(", index): + index += 1 + continue + start = index + 2 + end = start + depth = 1 + nested_quote: str | None = None + while end < len(command): + nested = command[end] + if nested_quote == "'": + if nested == "'": + nested_quote = None + end += 1 + continue + if nested == "'" and nested_quote is None: + nested_quote = "'" + end += 1 + continue + if nested == '"': + nested_quote = None if nested_quote == '"' else '"' + end += 1 + continue + if nested == "\\": + end += 2 + continue + if command.startswith("$(", end) and not command.startswith("$((", end): + depth += 1 + end += 2 + continue + if nested == "(": + depth += 1 + elif nested == ")": + depth -= 1 + if depth == 0: + substitutions.append(command[start:end]) + index = end + 1 + break + end += 1 + else: + return substitutions + return substitutions + + +_SHELL_EXPANSION_TOKEN_RE = re.compile( + r"\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[^}]*\}|[0-9#?*$!@_-])|%[A-Za-z_][A-Za-z0-9_]*%" +) +_PYTHON_CHILD_LAUNCHERS = frozenset({"run", "call", "check_call", "check_output", "Popen"}) + + +def _segment_with_shell_expansions_split(segment: list[str]) -> list[str] | None: + expanded: list[str] = [] + changed = False + for token in segment: + if not _SHELL_EXPANSION_TOKEN_RE.search(token): + expanded.append(token) + continue + changed = True + replacement = _SHELL_EXPANSION_TOKEN_RE.sub(" ", token).split() + expanded.extend(replacement) + return expanded if changed else None + + +def _python_inline_payload(arguments: list[str]) -> str | None: + skip_next = False + for index, argument in enumerate(arguments): + if skip_next: + skip_next = False + continue + if argument == "-c": + return arguments[index + 1] if index + 1 < len(arguments) else "" + if argument.startswith("-c") and argument != "-c": + return argument[2:] + if argument == "-m" or not argument.startswith("-"): + return None + if argument == "--": + return None + if argument in {"-W", "-X", "--check-hash-based-pycs"}: + skip_next = True + return None + + +def _static_python_command_argument(node: ast.AST) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + if isinstance(node, (ast.List, ast.Tuple)): + parts: list[str] = [] + for element in node.elts: + if not isinstance(element, ast.Constant) or not isinstance(element.value, str): + return None + parts.append(element.value) + return shlex.join(parts) + return None + + +def _python_payload_launches_startup_bypass(code: str, depth: int) -> bool: + if depth > 4: + return True + try: + tree = ast.parse(code) + except SyntaxError: + return False + subprocess_aliases = {"subprocess"} + os_aliases = {"os"} + launcher_aliases: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name == "subprocess": + subprocess_aliases.add(alias.asname or "subprocess") + elif alias.name == "os": + os_aliases.add(alias.asname or "os") + elif isinstance(node, ast.ImportFrom): + if node.module == "subprocess": + for alias in node.names: + if alias.name in _PYTHON_CHILD_LAUNCHERS: + launcher_aliases.add(alias.asname or alias.name) + elif isinstance(node, ast.Call): + command_node = None + if isinstance(node.func, ast.Name) and node.func.id in launcher_aliases: + command_node = node.args[0] if node.args else None + elif isinstance(node.func, ast.Attribute): + if ( + isinstance(node.func.value, ast.Name) + and node.func.value.id in subprocess_aliases + and node.func.attr in _PYTHON_CHILD_LAUNCHERS + ): + command_node = node.args[0] if node.args else None + elif ( + isinstance(node.func.value, ast.Name) + and node.func.value.id in os_aliases + and node.func.attr in {"system", "popen"} + ): + command_node = node.args[0] if node.args else None + if command_node is None: + continue + nested = _static_python_command_argument(command_node) + if nested is not None and _sandbox_python_startup_bypasses_guard( + nested, depth + 1 + ): + return True + return False + + +def _segment_python_launch_bypasses_guard( + segment: list[str], + python_index: int, + environment_tainted: bool, + depth: int, +) -> bool: + if environment_tainted or _segment_mutates_sandbox_python_env(segment, python_index): + return True + arguments = segment[python_index + 1 :] + if _python_flags_skip_sitecustomize(arguments): + return True + payload = _python_inline_payload(arguments) + return payload is not None and _python_payload_launches_startup_bypass(payload, depth + 1) + + def _sandbox_python_startup_bypasses_guard(command: str, depth: int = 0) -> bool: """Detect terminal-launched Python that suppresses the sandbox sitecustomize guard.""" if depth > 4: return True + for nested in _shell_command_substitutions(command): + if _sandbox_python_startup_bypasses_guard(nested, depth + 1): + return True environment_tainted = False shell_names = {"bash", "cmd", "cmd.exe", "dash", "fish", "ksh", "sh", "zsh"} for segment in _shell_command_segments(command): @@ -356,11 +557,18 @@ def _sandbox_python_startup_bypasses_guard(command: str, depth: int = 0) -> bool elif token.startswith("--split-string="): if _sandbox_python_startup_bypasses_guard(token.split("=", 1)[1], depth + 1): return True + expanded_segment = _segment_with_shell_expansions_split(segment) + if expanded_segment: + expanded_python_index = _segment_python_index(expanded_segment) + if expanded_python_index is not None and _segment_python_launch_bypasses_guard( + expanded_segment, expanded_python_index, environment_tainted, depth + ): + return True python_index = _segment_python_index(segment) if python_index is not None: - if environment_tainted or _segment_mutates_sandbox_python_env(segment, python_index): - return True - if _python_flags_skip_sitecustomize(segment[python_index + 1 :]): + if _segment_python_launch_bypasses_guard( + segment, python_index, environment_tainted, depth + ): return True environment_tainted = ( environment_tainted or _segment_persistently_mutates_sandbox_python_env(segment) @@ -488,6 +696,7 @@ def _find_blocked_commands(command: str) -> set[str]: # Directory holding the sandbox ``sitecustomize.py`` shim (code-interpreter # path remap); placed on the sandboxed child's PYTHONPATH in _build_safe_env. _SANDBOX_SITE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "sandbox_site") +_BYPASS_SITE_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "bypass_site") # ── "Approve for me" (permission_mode="auto") safety detection ────────────── # Auto mode pauses only calls classified here as potentially unsafe. The sandbox # and hard blocks (blocklist, rlimits) still apply at run time; this gate only @@ -831,6 +1040,7 @@ _AUTO_UNSAFE_PY_ATTRS = frozenset( # extractall/extract write arbitrary files (zip-slip): extract takes a # single member but an attacker-controlled member path still escapes. "exec_module", + "load_module", "extractall", "extract", "FileIO", @@ -2605,11 +2815,12 @@ _RENDER_HTML_MARKUP_ASSIGNMENT_START_RE = re.compile( ) _RENDER_HTML_MARKUP_CALL_START_RE = re.compile( r"(?:\.\s*(?PinsertAdjacentHTML)|" + r"\.\s*(?PcreateContextualFragment)|" # document.write / writeln, optionally reached through a document-valued # receiver such as document.open(): document.open().write('') # returns the same document and inserts the remote-loading markup. r"\bdocument\s*(?:(?:\?\.\s*|\.\s*)open\s*\([^()]*\)\s*)?" - r"(?:\?\.\s*|\.\s*)(?Pwrite|writeln))" + r"\s*(?:\?\.\s*|\.\s*)(?Pwrite|writeln))" r"\s*(?:\?\.\s*)?\(", re.IGNORECASE, ) @@ -2618,7 +2829,8 @@ _RENDER_HTML_COMPUTED_ASSIGNMENT_START_RE = re.compile( re.IGNORECASE | re.DOTALL, ) _RENDER_HTML_COMPUTED_CALL_START_RE = re.compile( - r"(?:(?P\bdocument)\s*(?:\?\.\s*)?)?\[\s*(?P[^\]]+)\s*\]\s*(?:\?\.\s*)?\(", + r"(?:(?P\bdocument)\s*(?:\?\.\s*)?)?" + r"\[\s*(?P[^\]]+)\s*\]\s*(?:\?\.\s*)?\(", re.IGNORECASE | re.DOTALL, ) _RENDER_HTML_REFLECT_SET_START_RE = re.compile( @@ -2632,6 +2844,16 @@ _RENDER_HTML_OBJECT_PROPERTY_START_RE = re.compile( r"srcdoc|innerHTML|outerHTML)(?P=quote)\s*:\s*", re.IGNORECASE, ) +_RENDER_HTML_DESTRUCTURING_ASSIGNMENT_START_RE = re.compile( + r"(?:\[[^\]]*(?:\.\s*|\[\s*['\"`])" + r"(?:src|href|srcset|action|formaction|poster|data|ping|srcdoc|innerHTML|outerHTML)" + r"[^\]]*\]|" + r"\{[^{}]*(?:src|href|srcset|action|formaction|poster|data|ping|srcdoc|innerHTML|" + r"outerHTML)\s*:\s*[^{}]*(?:\.\s*|\[\s*['\"`])" + r"(?:src|href|srcset|action|formaction|poster|data|ping|srcdoc|innerHTML|outerHTML)" + r"[^{}]*\})\s*=", + re.IGNORECASE | re.DOTALL, +) _RENDER_HTML_NETWORK_MEMBERS = frozenset( { "fetch", @@ -2649,6 +2871,7 @@ _RENDER_HTML_NETWORK_ATTRIBUTES = frozenset( ) _RENDER_HTML_URL_LIST_ATTRIBUTES = frozenset({"srcset", "ping"}) _RENDER_HTML_URL_LIST_NETWORK_RE = re.compile(r"(?:^|[\s,])(?:https?:|/)", re.IGNORECASE) +_RENDER_HTML_JS_NETWORK_URL_RE = re.compile(r"(?:^|[\s,:'\"`\[(])(?:https?:|/)", re.IGNORECASE) _RENDER_HTML_ACTIVE_DATA_MIME_TYPES = frozenset( {"text/html", "application/xhtml+xml", "image/svg+xml"} ) @@ -2789,19 +3012,22 @@ def _render_html_data_document_reaches_network(value: str, depth: int) -> bool: media_type = (parts[0] or "text/plain").lower() if media_type not in _RENDER_HTML_ACTIVE_DATA_MIME_TYPES: return False - # Honour a declared charset so a UTF-16/Latin-1 document decodes the same way - # the browser would; an unknown/undecodable charset fails closed rather than - # letting a mangled UTF-8 read hide a remote load. + # Decode declared charset so data documents match browser behavior. charset = "utf-8" - for part in parts[1:]: - if part.lower().startswith("charset="): - charset = part.split("=", 1)[1].strip() or "utf-8" + for parameter in parts[1:]: + name, equals, parameter_value = parameter.partition("=") + if equals and name.strip().lower() == "charset": + charset = urllib.parse.unquote(parameter_value.strip().strip("\"'")) + if not charset: + return True + break try: + encoding = codecs.lookup(charset).name payload_bytes = urllib.parse.unquote_to_bytes(payload) if any(part.lower() == "base64" for part in parts[1:]): payload_bytes = base64.b64decode(b"".join(payload_bytes.split()), validate = True) - markup = payload_bytes.decode(charset, errors = "replace") - except (ValueError, binascii.Error, LookupError): + markup = payload_bytes.decode(encoding) + except (LookupError, UnicodeError, ValueError, binascii.Error): return True return _render_html_code_reaches_network(markup, depth + 1) @@ -2887,6 +3113,33 @@ def _js_call_arguments(code: str, offset: int) -> list[str] | None: return None +def _js_assignment_expression(code: str) -> str | None: + stack: list[str] = [] + quote: str | None = None + escaped = False + pairs = {")": "(", "]": "[", "}": "{"} + for index, char in enumerate(code): + if quote is not None: + if escaped: + escaped = False + elif char == "\\": + escaped = True + elif char == quote: + quote = None + continue + if char in "\"'`": + quote = char + elif char in "([{": + stack.append(char) + elif char in ")]}": + if not stack or stack[-1] != pairs[char]: + return code[:index] + stack.pop() + elif char in ";\n\r<" and not stack: + return code[:index] + return code if quote is None and not stack else None + + def _render_html_assigned_member_reaches_network( member: str, value: str | None, depth: int ) -> bool: @@ -2976,10 +3229,19 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: arguments = _js_call_arguments(code, match.end()) if arguments is None: return True - method = (match.group("insert") or match.group("write")).lower() + method = (match.group("insert") or match.group("contextual") or match.group("write")).lower() if _render_html_markup_call_reaches_network(arguments, method, depth): return True + for match in _RENDER_HTML_DESTRUCTURING_ASSIGNMENT_START_RE.finditer(code): + value = _js_assignment_expression(code[match.end() :]) + if value is None: + return True + if _RENDER_HTML_JS_NETWORK_URL_RE.search(value) or _render_html_code_reaches_network( + value, depth + 1 + ): + return True + for match in _RENDER_HTML_COMPUTED_ASSIGNMENT_START_RE.finditer(code): member = _static_js_string(match.group("member")) if member is None: @@ -3141,7 +3403,7 @@ def _build_safe_env(workdir: str) -> dict[str, str]: "TERM": "dumb", "PYTHONIOENCODING": "utf-8", "UNSLOTH_STUDIO_SANDBOXED": "1", - # sitecustomize shim: remaps ChatGPT code-interpreter paths (/mnt/data + # sitecustomize shim: remaps code-interpreter paths (/mnt/data # etc.) onto the sandbox CWD; see sandbox_site/sitecustomize.py. "PYTHONPATH": _SANDBOX_SITE_DIR, } @@ -3330,7 +3592,7 @@ def _build_bypass_env(workdir: str) -> dict[str, str]: # operator's PYTHONPATH, so prepend rather than replace. inherited_pythonpath = env.get("PYTHONPATH", "") env["PYTHONPATH"] = os.pathsep.join( - part for part in (_SANDBOX_SITE_DIR, inherited_pythonpath) if part + part for part in (_BYPASS_SITE_DIR, inherited_pythonpath) if part ) # Windows SDKs read creds under the profile dirs, not $HOME; repoint set # ones to the workdir (HOMEDRIVE/HOMEPATH are dropped above). @@ -3557,7 +3819,7 @@ WEB_SEARCH_TOOL = { } # Appended to the python/terminal descriptions: models habitually write to -# /mnt/data (a ChatGPT code-interpreter path), which does not exist here. +# /mnt/data (a code-interpreter path), which does not exist here. _SANDBOX_PATHS_NOTE = ( " Read and write files using relative paths in the current working " "directory, which persists for this conversation; absolute paths like " @@ -5826,6 +6088,8 @@ def _check_signal_escape_patterns(code: str): self.importlib_aliases = {"importlib"} self.builtins_aliases = {"builtins", "__builtins__"} self.import_loader_aliases = {"__import__"} + self.source_loader_aliases = {"SourceFileLoader"} + self.source_loader_names: dict[str, str] = {} self.literal_strings: dict[str, str] = {} def _block_low_level_network_module(self, module_name: str, node) -> None: @@ -5914,6 +6178,22 @@ def _check_signal_escape_patterns(code: str): } return False + def _is_source_file_loader(self, node) -> bool: + if isinstance(node, ast.Name): + return node.id in self.source_loader_aliases + return isinstance(node, ast.Attribute) and node.attr == "SourceFileLoader" + + def _source_loader_module_name(self, node) -> str | None: + if isinstance(node, ast.Name): + return self.source_loader_names.get(node.id) + if ( + isinstance(node, ast.Call) + and self._is_source_file_loader(node.func) + and node.args + ): + return self._static_string(node.args[0]) + return None + @staticmethod def _target_names(node) -> list[str]: if isinstance(node, ast.Name): @@ -5944,6 +6224,12 @@ def _check_signal_escape_patterns(code: str): self.importlib_aliases.update(names) if isinstance(value, ast.Name) and value.id in self.builtins_aliases: self.builtins_aliases.update(names) + loader_name = self._source_loader_module_name(value) + for name in names: + if loader_name is None: + self.source_loader_names.pop(name, None) + else: + self.source_loader_names[name] = loader_name def visit_Import(self, node): for alias in node.names: @@ -5962,6 +6248,8 @@ def _check_signal_escape_patterns(code: str): self.import_loader_aliases.add(bound) elif node.module == "builtins" and alias.name == "__import__": self.import_loader_aliases.add(bound) + elif node.module == "importlib.machinery" and alias.name == "SourceFileLoader": + self.source_loader_aliases.add(bound) def visit_Assign(self, node): self._bind_assignment(node.targets, node.value) @@ -5983,6 +6271,14 @@ def _check_signal_escape_patterns(code: str): module_name = self._static_string(module_node) if module_name is not None: self._block_low_level_network_module(module_name, node) + if isinstance(node.func, ast.Attribute) and node.func.attr == "load_module": + module_name = None + if node.args: + module_name = self._static_string(node.args[0]) + if module_name is None: + module_name = self._source_loader_module_name(node.func.value) + if module_name is not None: + self._block_low_level_network_module(module_name, node) parts: list[str] = [] cur = node.func @@ -6255,7 +6551,7 @@ def _truncate(text: str, limit: int = _MAX_OUTPUT_CHARS) -> str: return text -# ChatGPT code-interpreter path conventions models write out of habit; none +# Code-interpreter path conventions models write out of habit; none # exist in the Studio sandbox, so a failure on one earns the retry hint. _MISSING_PATH_PREFIXES = ( "/mnt/data", diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py index bbf38dae58..621cde9292 100644 --- a/studio/backend/tests/test_bypass_permissions.py +++ b/studio/backend/tests/test_bypass_permissions.py @@ -185,6 +185,9 @@ def test_bash_blocklist_enforced_when_sandboxed(captured_popen): # still be recursed into, not left as an opaque exec target. 'find . -exec sh -c "python -S -c import\\ boto3" ;', 'find . -type f -execdir bash -c "python -I -c import\\ boto3" ;', + 'python$IFS-S -c "import boto3"', + "python -c \"import subprocess; subprocess.run(['python','-S','-c','import boto3'])\"", + "python -c \"import os; os.system(\\\"python -S -c 'import boto3'\\\")\"", ], ) def test_bash_blocks_python_startup_guard_bypasses(captured_popen, command): @@ -200,6 +203,7 @@ def test_bash_blocks_python_startup_guard_bypasses(captured_popen, command): "python script.py -S", "echo python -S", "python -c \"print('-S')\"", + "python -c \"import subprocess; subprocess.run(['python','-c','print(1)'])\"", ], ) def test_bash_allows_python_without_startup_guard_bypass(captured_popen, command): diff --git a/studio/backend/tests/test_permission_mode.py b/studio/backend/tests/test_permission_mode.py index 0684d17744..433b03788c 100644 --- a/studio/backend/tests/test_permission_mode.py +++ b/studio/backend/tests/test_permission_mode.py @@ -1123,6 +1123,24 @@ def test_render_html_gated_only_when_networked(): is True ) assert rh("") is False + assert ( + rh( + "" + ) + is True + ) + assert ( + rh( + "" + ) + is False + ) + assert rh("") is True + assert rh("") is False + assert rh("") is True + assert rh("") is False # A computed bracket key spliced from string fragments on a global host object. assert rh("") is True assert rh("") is True diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index eec85d1d0e..66aa5f71e1 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -149,6 +149,12 @@ class TestLowLevelNetworkModules: ("import importlib; print(vars(importlib)['import_module']('httpcore').__name__)"), ("import importlib; print(importlib.__dict__['import_module']('boto3').__name__)"), ("import builtins; print(getattr(builtins, '__import__')('botocore').__name__)"), + ( + "import importlib.machinery, importlib.util\n" + "spec = importlib.util.find_spec('httpcore')\n" + "loader = importlib.machinery.SourceFileLoader('httpcore', spec.origin)\n" + "loader.load_module()" + ), ], ) def test_low_level_client_blocked(self, code): @@ -490,6 +496,101 @@ class TestSandboxEnvIsolation: assert bypass.returncode == 0, bypass.stderr assert bypass.stdout.strip() == "httpcore" + def test_runtime_import_guard_rejects_spoofed_httpx_globals(self, tmp_path): + from core.inference.tools import _build_safe_env + + code = ( + "import httpx\n" + "__name__ = 'httpx._client'\n" + "__file__ = httpx.__file__\n" + "name = ''.join(['http', 'core'])\n" + "module = __import__(name)\n" + "print(module.__name__)\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + cwd = tmp_path, + env = _build_safe_env(str(tmp_path)), + capture_output = True, + text = True, + check = False, + ) + assert result.returncode != 0 + assert "Blocked: low-level network module 'httpcore'" in result.stderr + + def test_runtime_import_guard_blocks_legacy_loader_httpcore(self, tmp_path): + from core.inference.tools import _build_safe_env + + code = ( + "import importlib.machinery, importlib.util\n" + "name = ''.join(['http', 'core'])\n" + "spec = importlib.util.find_spec(name)\n" + "loader = importlib.machinery.SourceFileLoader(name, spec.origin)\n" + "loader.load_module()\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + cwd = tmp_path, + env = _build_safe_env(str(tmp_path)), + capture_output = True, + text = True, + check = False, + ) + assert result.returncode != 0 + assert "Blocked: low-level network module 'httpcore'" in result.stderr + + def test_runtime_import_guard_blocks_httpcore_context_flag_tampering(self, tmp_path): + from core.inference.tools import _build_safe_env + + code = ( + "import sys, types\n" + "import httpx, sitecustomize\n" + "client = httpx.Client()\n" + "client.close()\n" + "module = sys.modules['httpcore._backends.sync']\n" + "module_dict = types.ModuleType.__getattribute__(module, '__dict__')\n" + "backend_type = module_dict['SyncBackend']\n" + "guarded = type.__getattribute__(backend_type, '__dict__')['connect_tcp']\n" + "dispatch = key = None\n" + "for cell in guarded.__closure__ or ():\n" + " value = cell.cell_contents\n" + " if callable(value) and getattr(value, '__name__', '') == 'dispatch':\n" + " dispatch = value\n" + " elif type(value) is object:\n" + " key = value\n" + "originals = None\n" + "for cell in dispatch.__closure__ or ():\n" + " value = cell.cell_contents\n" + " if type(value) is dict:\n" + " originals = value\n" + "original = originals[key]\n" + "tokens = []\n" + "for value in vars(sitecustomize).values():\n" + " for cell in getattr(value, '__closure__', ()) or ():\n" + " content = cell.cell_contents\n" + " if type(content).__name__ == 'ContextVar':\n" + " tokens.append((content, content.set(True)))\n" + "assert tokens\n" + "try:\n" + " original(\n" + " backend_type(), '127.0.0.1', 9,\n" + " timeout=0.01, local_address=None, socket_options=None,\n" + " )\n" + "finally:\n" + " for content, token in reversed(tokens):\n" + " content.reset(token)\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + cwd = tmp_path, + env = _build_safe_env(str(tmp_path)), + capture_output = True, + text = True, + check = False, + ) + assert result.returncode != 0 + assert "Blocked: low-level network module 'httpcore'" in result.stderr + def test_runtime_import_guard_blocks_local_module_httpcore_import(self, tmp_path): from core.inference.tools import _build_safe_env @@ -624,20 +725,19 @@ class TestSandboxEnvIsolation: assert env["TERM"] == "dumb" def test_bypass_env_installs_sitecustomize_path_shim(self, tmp_path): - # Bypass mode must install the same /mnt/data path-remap shim as the safe - # env (finding 17), else /mnt/data writes work only in normal mode. - from core.inference.tools import _SANDBOX_SITE_DIR, _build_bypass_env + # Bypass mode keeps path remapping without installing network guards. + from core.inference.tools import _BYPASS_SITE_DIR, _build_bypass_env env = _build_bypass_env(str(tmp_path)) - assert _SANDBOX_SITE_DIR in env["PYTHONPATH"].split(os.pathsep) + assert _BYPASS_SITE_DIR in env["PYTHONPATH"].split(os.pathsep) def test_bypass_env_prepends_shim_and_keeps_inherited_pythonpath(self, monkeypatch, tmp_path): - from core.inference.tools import _SANDBOX_SITE_DIR, _build_bypass_env + from core.inference.tools import _BYPASS_SITE_DIR, _build_bypass_env monkeypatch.setenv("PYTHONPATH", "/operator/libs") env = _build_bypass_env(str(tmp_path)) parts = env["PYTHONPATH"].split(os.pathsep) # Shim first so its open()/makedirs remap wins, operator entries kept. - assert parts[0] == _SANDBOX_SITE_DIR + assert parts[0] == _BYPASS_SITE_DIR assert "/operator/libs" in parts From 27311984acb079acc2fe9e48666a42ce308b533b Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 19 Jul 2026 12:07:48 +0000 Subject: [PATCH 09/21] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- .../inference/sandbox_site/sitecustomize.py | 25 ++++++++----------- studio/backend/core/inference/tools.py | 19 +++++--------- .../backend/tests/test_bypass_permissions.py | 2 +- studio/backend/tests/test_permission_mode.py | 5 +--- 4 files changed, 18 insertions(+), 33 deletions(-) diff --git a/studio/backend/core/inference/sandbox_site/sitecustomize.py b/studio/backend/core/inference/sandbox_site/sitecustomize.py index b0cae85935..3b6e2aaee7 100644 --- a/studio/backend/core/inference/sandbox_site/sitecustomize.py +++ b/studio/backend/core/inference/sandbox_site/sitecustomize.py @@ -380,6 +380,7 @@ def _guarded_import_module(name, package = None): _guard_loaded_httpcore_modules() return module + def _guard_legacy_source_loader(): cls = importlib.machinery.SourceFileLoader original = getattr(cls, "load_module", None) @@ -450,12 +451,8 @@ def _make_network_guard_audit(): origin_relative = relpath(origin_path, root).replace("\\", "/") code_relative = relpath(code_path, root).replace("\\", "/") return ( - ( - origin_relative == package - or origin_relative.startswith(f"{package}/") - ) - and (code_relative == package or code_relative.startswith(f"{package}/")) - ) + origin_relative == package or origin_relative.startswith(f"{package}/") + ) and (code_relative == package or code_relative.startswith(f"{package}/")) except (OSError, ValueError): return False return False @@ -483,9 +480,7 @@ def _make_network_guard_audit(): ): frame = frame.f_back continue - return not ( - frame_uses_package(frame, "httpx") or frame_uses_package(frame, "httpcore") - ) + return not (frame_uses_package(frame, "httpx") or frame_uses_package(frame, "httpcore")) return True def audit(event, args): @@ -494,18 +489,18 @@ def _make_network_guard_audit(): if not isinstance(fullname, str): return root = fullname.split(".", 1)[0] - if root in blocked or ( - root in direct_blocked and sandbox_requested_import(2) - ): + if root in blocked or (root in direct_blocked and sandbox_requested_import(2)): blocked_error(root) return if event not in {"socket.connect", "socket.connect_ex", "socket.getaddrinfo"}: return if httpcore_network_active(): return - if package_in_stack("httpcore", 2) or package_in_stack( - "anyio", 2 - ) or package_in_stack("trio", 2): + if ( + package_in_stack("httpcore", 2) + or package_in_stack("anyio", 2) + or package_in_stack("trio", 2) + ): blocked_error("httpcore") return audit diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index e05d448491..26c6600612 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -492,18 +492,13 @@ def _python_payload_launches_startup_bypass(code: str, depth: int) -> bool: if command_node is None: continue nested = _static_python_command_argument(command_node) - if nested is not None and _sandbox_python_startup_bypasses_guard( - nested, depth + 1 - ): + if nested is not None and _sandbox_python_startup_bypasses_guard(nested, depth + 1): return True return False def _segment_python_launch_bypasses_guard( - segment: list[str], - python_index: int, - environment_tainted: bool, - depth: int, + segment: list[str], python_index: int, environment_tainted: bool, depth: int ) -> bool: if environment_tainted or _segment_mutates_sandbox_python_env(segment, python_index): return True @@ -3229,7 +3224,9 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: arguments = _js_call_arguments(code, match.end()) if arguments is None: return True - method = (match.group("insert") or match.group("contextual") or match.group("write")).lower() + method = ( + match.group("insert") or match.group("contextual") or match.group("write") + ).lower() if _render_html_markup_call_reaches_network(arguments, method, depth): return True @@ -6186,11 +6183,7 @@ def _check_signal_escape_patterns(code: str): def _source_loader_module_name(self, node) -> str | None: if isinstance(node, ast.Name): return self.source_loader_names.get(node.id) - if ( - isinstance(node, ast.Call) - and self._is_source_file_loader(node.func) - and node.args - ): + if isinstance(node, ast.Call) and self._is_source_file_loader(node.func) and node.args: return self._static_string(node.args[0]) return None diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py index 621cde9292..79f5957bec 100644 --- a/studio/backend/tests/test_bypass_permissions.py +++ b/studio/backend/tests/test_bypass_permissions.py @@ -187,7 +187,7 @@ def test_bash_blocklist_enforced_when_sandboxed(captured_popen): 'find . -type f -execdir bash -c "python -I -c import\\ boto3" ;', 'python$IFS-S -c "import boto3"', "python -c \"import subprocess; subprocess.run(['python','-S','-c','import boto3'])\"", - "python -c \"import os; os.system(\\\"python -S -c 'import boto3'\\\")\"", + 'python -c "import os; os.system(\\"python -S -c \'import boto3\'\\")"', ], ) def test_bash_blocks_python_startup_guard_bypasses(captured_popen, command): diff --git a/studio/backend/tests/test_permission_mode.py b/studio/backend/tests/test_permission_mode.py index e884cbb4ab..cde473561e 100644 --- a/studio/backend/tests/test_permission_mode.py +++ b/studio/backend/tests/test_permission_mode.py @@ -1131,10 +1131,7 @@ def test_render_html_gated_only_when_networked(): is True ) assert ( - rh( - "" - ) + rh("") is False ) assert rh("") is True From bc74de6a06d8342224efa25528f7a7be6dad4426 Mon Sep 17 00:00:00 2001 From: Michael Han Date: Mon, 20 Jul 2026 05:28:58 -0700 Subject: [PATCH 10/21] Close sandbox review edge cases --- .../inference/sandbox_site/sitecustomize.py | 113 ++++++- studio/backend/core/inference/tools.py | 301 +++++++++++++++--- .../backend/tests/test_bypass_permissions.py | 9 + studio/backend/tests/test_permission_mode.py | 25 ++ studio/backend/tests/test_sandbox_tools.py | 26 ++ 5 files changed, 416 insertions(+), 58 deletions(-) diff --git a/studio/backend/core/inference/sandbox_site/sitecustomize.py b/studio/backend/core/inference/sandbox_site/sitecustomize.py index 3b6e2aaee7..5bf8a136a6 100644 --- a/studio/backend/core/inference/sandbox_site/sitecustomize.py +++ b/studio/backend/core/inference/sandbox_site/sitecustomize.py @@ -89,6 +89,23 @@ def _path_is_in_roots(filename, roots): return False +def _blocked_network_module_origin(filename): + if not isinstance(filename, str) or filename.startswith("<"): + return None + try: + path = os.path.realpath(filename) + for root in _TRUSTED_LIBRARY_ROOTS: + if os.path.commonpath((root, path)) != root: + continue + relative = os.path.relpath(path, root).replace("\\", "/") + package = relative.split("/", 1)[0].removesuffix(".py") + if package in _BLOCKED_NETWORK_MODULES or package in _DIRECT_BLOCKED_NETWORK_MODULES: + return package + except (OSError, ValueError): + return None + return None + + def _frame_uses_trusted_package(frame, package): module_name = frame.f_globals.get("__name__", "") if not isinstance(module_name, str): @@ -167,6 +184,15 @@ def _blocked_network_module(fullname): return None +def _blocked_network_loader_origin(filename): + root = _blocked_network_module_origin(filename) + if root in _BLOCKED_NETWORK_MODULES: + return root + if root in _DIRECT_BLOCKED_NETWORK_MODULES and _sandbox_code_requested_import(): + return root + return None + + def _raise_blocked_network_module(root): raise ModuleNotFoundError( f"Blocked: low-level network module {root!r} is unavailable in sandboxed code" @@ -383,22 +409,52 @@ def _guarded_import_module(name, package = None): def _guard_legacy_source_loader(): cls = importlib.machinery.SourceFileLoader - original = getattr(cls, "load_module", None) - if not callable(original) or getattr(original, "_unsloth_network_guard", False): - return + original_load_module = getattr(cls, "load_module", None) + original_exec_module = getattr(cls, "exec_module", None) - def guarded(self, *args, **kwargs): - fullname = args[0] if args else kwargs.get("fullname", getattr(self, "name", None)) + def blocked_loader_root(self, fullname = None): root = _blocked_network_module(fullname) - if root is not None: - _raise_blocked_network_module(root) - return original(self, *args, **kwargs) + if root is None: + root = _blocked_network_loader_origin(getattr(self, "path", None)) + return root - guarded._unsloth_network_guard = True - guarded.__name__ = getattr(original, "__name__", "load_module") - guarded.__qualname__ = getattr(original, "__qualname__", guarded.__name__) - guarded.__doc__ = getattr(original, "__doc__", None) - cls.load_module = guarded + if callable(original_load_module) and not getattr( + original_load_module, "_unsloth_network_guard", False + ): + + def guarded_load_module(self, *args, **kwargs): + fullname = args[0] if args else kwargs.get("fullname", getattr(self, "name", None)) + root = blocked_loader_root(self, fullname) + if root is not None: + _raise_blocked_network_module(root) + return original_load_module(self, *args, **kwargs) + + guarded_load_module._unsloth_network_guard = True + guarded_load_module.__name__ = getattr(original_load_module, "__name__", "load_module") + guarded_load_module.__qualname__ = getattr( + original_load_module, "__qualname__", guarded_load_module.__name__ + ) + guarded_load_module.__doc__ = getattr(original_load_module, "__doc__", None) + cls.load_module = guarded_load_module + + if callable(original_exec_module) and not getattr( + original_exec_module, "_unsloth_network_guard", False + ): + + def guarded_exec_module(self, module): + fullname = getattr(module, "__name__", getattr(self, "name", None)) + root = blocked_loader_root(self, fullname) + if root is not None: + _raise_blocked_network_module(root) + return original_exec_module(self, module) + + guarded_exec_module._unsloth_network_guard = True + guarded_exec_module.__name__ = getattr(original_exec_module, "__name__", "exec_module") + guarded_exec_module.__qualname__ = getattr( + original_exec_module, "__qualname__", guarded_exec_module.__name__ + ) + guarded_exec_module.__doc__ = getattr(original_exec_module, "__doc__", None) + cls.exec_module = guarded_exec_module def _make_network_guard_audit(): @@ -420,6 +476,34 @@ def _make_network_guard_audit(): f"Blocked: low-level network module {root!r} is unavailable in sandboxed code" ) + def blocked_origin(filename): + if not isinstance(filename, str) or filename.startswith("<"): + return None + try: + path = realpath(filename) + for root in trusted_roots: + if commonpath((root, path)) != root: + continue + relative = relpath(path, root).replace("\\", "/") + package = relative.split("/", 1)[0].removesuffix(".py") + if package in blocked or package in direct_blocked: + return package + except (OSError, ValueError): + return None + return None + + def blocked_origin_in_stack(skip): + try: + frame = getframe(skip) + except ValueError: + return None + while frame is not None: + root = blocked_origin(frame.f_code.co_filename) + if root is not None: + return root + frame = frame.f_back + return None + def frame_uses_package(frame, package): module_name = frame.f_globals.get("__name__", "") if not isinstance(module_name, str): @@ -496,6 +580,9 @@ def _make_network_guard_audit(): return if httpcore_network_active(): return + root = blocked_origin_in_stack(2) + if root is not None: + blocked_error(root) if ( package_in_stack("httpcore", 2) or package_in_stack("anyio", 2) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 26c6600612..a074c422f7 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -239,6 +239,43 @@ def _shell_command_segments(command: str) -> list[list[str]]: return [segment for segment in segments if segment] +def _shell_command_with_unquoted_newlines_as_separators(command: str) -> str: + if "\n" not in command and "\r" not in command: + return command + out: list[str] = [] + quote: str | None = None + escaped = False + index = 0 + while index < len(command): + char = command[index] + if escaped: + out.append(char) + escaped = False + index += 1 + continue + if char == "\\": + out.append(char) + escaped = True + index += 1 + continue + if char == "'" and quote is None: + quote = "'" + elif char == "'" and quote == "'": + quote = None + elif char == '"' and quote is None: + quote = '"' + elif char == '"' and quote == '"': + quote = None + if char in "\r\n" and quote is None: + out.append(" ; ") + if char == "\r" and index + 1 < len(command) and command[index + 1] == "\n": + index += 1 + else: + out.append(char) + index += 1 + return "".join(out) + + def _segment_python_index(segment: list[str]) -> int | None: command_index = 0 while command_index < len(segment) and _ASSIGNMENT_RE.match(segment[command_index]): @@ -290,7 +327,10 @@ def _segment_mutates_sandbox_python_env(segment: list[str], python_index: int) - return True lowered = token.lower() if lowered in {"-i", "--ignore-environment"} and before_python: - if os.path.basename(before_python[0]).lower() == "env": + if any( + os.path.basename(candidate.replace("\\", "/")).lower() == "env" + for candidate in before_python[:index] + ): return True if lowered.startswith("--unset="): if token.split("=", 1)[1].upper() in _SANDBOX_PYTHON_ENV_VARS: @@ -399,6 +439,44 @@ def _shell_command_substitutions(command: str) -> list[str]: return substitutions +_HEREDOC_START_RE = re.compile(r"<<-?\s*(?P['\"]?)(?P[A-Za-z_][A-Za-z0-9_]*)") + + +def _shell_here_doc_payloads(command: str) -> tuple[list[str], bool]: + if "<<" not in command: + return [], False + lines = command.splitlines() + payloads: list[str] = [] + malformed = False + index = 0 + while index < len(lines): + line = lines[index] + matches = list(_HEREDOC_START_RE.finditer(line)) + if not matches: + index += 1 + continue + for match in matches: + quote = match.group("quote") + delimiter = match.group("name") + end = match.end() + if quote and (end >= len(line) or line[end] != quote): + continue + body_start = index + 1 + body_end = body_start + while body_end < len(lines): + candidate = lines[body_end] + if candidate == delimiter or candidate.lstrip("\t") == delimiter: + break + body_end += 1 + if body_end >= len(lines): + malformed = True + continue + payloads.append("\n".join(lines[body_start:body_end])) + index = max(index, body_end) + index += 1 + return payloads, malformed + + _SHELL_EXPANSION_TOKEN_RE = re.compile( r"\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[^}]*\}|[0-9#?*$!@_-])|%[A-Za-z_][A-Za-z0-9_]*%" ) @@ -450,7 +528,56 @@ def _static_python_command_argument(node: ast.AST) -> str | None: return None -def _python_payload_launches_startup_bypass(code: str, depth: int) -> bool: +def _python_env_key(node: ast.AST) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value.upper() + return None + + +def _python_payload_mutates_sandbox_env(node: ast.AST, os_aliases: set[str]) -> bool: + def is_os_environ(candidate: ast.AST) -> bool: + return ( + isinstance(candidate, ast.Attribute) + and candidate.attr == "environ" + and isinstance(candidate.value, ast.Name) + and candidate.value.id in os_aliases + ) + + if isinstance(node, (ast.Assign, ast.AnnAssign, ast.AugAssign)): + targets = node.targets if isinstance(node, ast.Assign) else [node.target] + for target in targets: + if isinstance(target, ast.Subscript) and is_os_environ(target.value): + key = _python_env_key(target.slice) + if key is None or key in _SANDBOX_PYTHON_ENV_VARS: + return True + if isinstance(node, ast.Delete): + for target in node.targets: + if isinstance(target, ast.Subscript) and is_os_environ(target.value): + key = _python_env_key(target.slice) + if key is None or key in _SANDBOX_PYTHON_ENV_VARS: + return True + if not isinstance(node, ast.Call): + return False + if isinstance(node.func, ast.Attribute): + if is_os_environ(node.func.value): + if node.func.attr in {"clear", "popitem"}: + return True + if node.func.attr in {"pop", "setdefault", "update", "__delitem__", "__setitem__"}: + key = _python_env_key(node.args[0]) if node.args else None + return key is None or key in _SANDBOX_PYTHON_ENV_VARS + if ( + isinstance(node.func.value, ast.Name) + and node.func.value.id in os_aliases + and node.func.attr in {"putenv", "unsetenv"} + ): + key = _python_env_key(node.args[0]) if node.args else None + return key is None or key in _SANDBOX_PYTHON_ENV_VARS + return False + + +def _python_payload_launches_startup_bypass( + code: str, depth: int, environment_tainted: bool = False +) -> bool: if depth > 4: return True try: @@ -472,28 +599,34 @@ def _python_payload_launches_startup_bypass(code: str, depth: int) -> bool: for alias in node.names: if alias.name in _PYTHON_CHILD_LAUNCHERS: launcher_aliases.add(alias.asname or alias.name) - elif isinstance(node, ast.Call): - command_node = None - if isinstance(node.func, ast.Name) and node.func.id in launcher_aliases: + if _python_payload_mutates_sandbox_env(node, os_aliases): + environment_tainted = True + for node in ast.walk(tree): + if not isinstance(node, ast.Call): + continue + command_node = None + if isinstance(node.func, ast.Name) and node.func.id in launcher_aliases: + command_node = node.args[0] if node.args else None + elif isinstance(node.func, ast.Attribute): + if ( + isinstance(node.func.value, ast.Name) + and node.func.value.id in subprocess_aliases + and node.func.attr in _PYTHON_CHILD_LAUNCHERS + ): command_node = node.args[0] if node.args else None - elif isinstance(node.func, ast.Attribute): - if ( - isinstance(node.func.value, ast.Name) - and node.func.value.id in subprocess_aliases - and node.func.attr in _PYTHON_CHILD_LAUNCHERS - ): - command_node = node.args[0] if node.args else None - elif ( - isinstance(node.func.value, ast.Name) - and node.func.value.id in os_aliases - and node.func.attr in {"system", "popen"} - ): - command_node = node.args[0] if node.args else None - if command_node is None: - continue - nested = _static_python_command_argument(command_node) - if nested is not None and _sandbox_python_startup_bypasses_guard(nested, depth + 1): - return True + elif ( + isinstance(node.func.value, ast.Name) + and node.func.value.id in os_aliases + and node.func.attr in {"system", "popen"} + ): + command_node = node.args[0] if node.args else None + if command_node is None: + continue + nested = _static_python_command_argument(command_node) + if nested is not None and _sandbox_python_startup_bypasses_guard( + nested, depth + 1, environment_tainted + ): + return True return False @@ -509,14 +642,22 @@ def _segment_python_launch_bypasses_guard( return payload is not None and _python_payload_launches_startup_bypass(payload, depth + 1) -def _sandbox_python_startup_bypasses_guard(command: str, depth: int = 0) -> bool: +def _sandbox_python_startup_bypasses_guard( + command: str, depth: int = 0, environment_tainted: bool = False +) -> bool: """Detect terminal-launched Python that suppresses the sandbox sitecustomize guard.""" if depth > 4: return True - for nested in _shell_command_substitutions(command): - if _sandbox_python_startup_bypasses_guard(nested, depth + 1): + payloads, malformed_here_doc = _shell_here_doc_payloads(command) + if malformed_here_doc: + return True + for payload in payloads: + if _sandbox_python_startup_bypasses_guard(payload, depth + 1, environment_tainted): + return True + command = _shell_command_with_unquoted_newlines_as_separators(command) + for nested in _shell_command_substitutions(command): + if _sandbox_python_startup_bypasses_guard(nested, depth + 1, environment_tainted): return True - environment_tainted = False shell_names = {"bash", "cmd", "cmd.exe", "dash", "fish", "ksh", "sh", "zsh"} for segment in _shell_command_segments(command): first = os.path.basename(segment[0].replace("\\", "/")).lower() @@ -540,17 +681,23 @@ def _sandbox_python_startup_bypasses_guard(command: str, depth: int = 0) -> bool nested = segment[index + 1] if _segment_mutates_sandbox_python_env(segment, shell_index): nested = f"PYTHONPATH=; {nested}" - if _sandbox_python_startup_bypasses_guard(nested, depth + 1): + if _sandbox_python_startup_bypasses_guard( + nested, depth + 1, environment_tainted + ): return True break break if first == "env": for index, token in enumerate(segment): if token in {"-S", "--split-string"} and index + 1 < len(segment): - if _sandbox_python_startup_bypasses_guard(segment[index + 1], depth + 1): + if _sandbox_python_startup_bypasses_guard( + segment[index + 1], depth + 1, environment_tainted + ): return True elif token.startswith("--split-string="): - if _sandbox_python_startup_bypasses_guard(token.split("=", 1)[1], depth + 1): + if _sandbox_python_startup_bypasses_guard( + token.split("=", 1)[1], depth + 1, environment_tainted + ): return True expanded_segment = _segment_with_shell_expansions_split(segment) if expanded_segment: @@ -2834,6 +2981,20 @@ _RENDER_HTML_REFLECT_SET_START_RE = re.compile( _RENDER_HTML_OBJECT_ASSIGN_START_RE = re.compile( r"\bObject\s*\.\s*assign\s*(?:\?\.\s*)?\(", re.IGNORECASE ) +_RENDER_HTML_JS_STATIC_NAME_START_RE = re.compile( + r"\b(?:const|let|var)\s+(?P[A-Za-z_$][A-Za-z0-9_$]*)\s*=\s*", + re.IGNORECASE, +) +_RENDER_HTML_WITH_BLOCK_RE = re.compile( + r"\bwith\s*\((?:[^()]|\([^()]*\))*\)\s*\{(?P[^{}]*)\}", + re.IGNORECASE | re.DOTALL, +) +_RENDER_HTML_BARE_PROPERTY_ASSIGNMENT_START_RE = re.compile( + r"(?src|href|srcset|action|formaction|poster|data|ping|srcdoc|innerHTML|outerHTML)" + r"\s*(?:\+=|&&=|\|\|=|\?\?=|=(?!=))", + re.IGNORECASE, +) _RENDER_HTML_OBJECT_PROPERTY_START_RE = re.compile( r"(?P['\"]?)(?Psrc|href|srcset|action|formaction|poster|data|ping|" r"srcdoc|innerHTML|outerHTML)(?P=quote)\s*:\s*", @@ -2996,6 +3157,25 @@ def _static_js_assignment_string(expression: str) -> str | None: return None +def _render_html_static_js_name_aliases(code: str) -> dict[str, str]: + aliases: dict[str, str] = {} + for match in _RENDER_HTML_JS_STATIC_NAME_START_RE.finditer(code): + value = _static_js_assignment_string(code[match.end() :]) + if value is not None: + aliases[match.group("name")] = value + return aliases + + +def _render_html_resolve_member(expression: str, aliases: dict[str, str]) -> str | None: + member = _static_js_string(expression) + if member is not None: + return member + name = expression.strip() + if re.fullmatch(r"[A-Za-z_$][A-Za-z0-9_$]*", name): + return aliases.get(name) + return None + + def _render_html_data_document_reaches_network(value: str, depth: int) -> bool: """Inspect executable document payloads embedded in data: URLs.""" if not value.lower().startswith("data:"): @@ -3146,21 +3326,30 @@ def _render_html_assigned_member_reaches_network( return False -def _render_html_set_attribute_arguments(arguments: list[str], method: str, depth: int) -> bool: +def _render_html_value_reaches_network_without_member(value: str | None, depth: int) -> bool: + if value is None: + return True + value = value.lstrip() + return bool( + _RENDER_HTML_URL_LIST_NETWORK_RE.search(value) + or _render_html_data_document_reaches_network(value, depth) + or _render_html_code_reaches_network(value, depth + 1) + ) + + +def _render_html_set_attribute_arguments( + arguments: list[str], method: str, depth: int, aliases: dict[str, str] +) -> bool: if method == "setattributens": name_index, value_index = 1, 2 else: name_index, value_index = 0, 1 if len(arguments) <= value_index: return False - name = _static_js_string(arguments[name_index]) + name = _render_html_resolve_member(arguments[name_index], aliases) value = _static_js_string(arguments[value_index]) if name is None: - return bool( - value is None - or _RENDER_HTML_URL_LIST_NETWORK_RE.search(value.lstrip()) - or _render_html_code_reaches_network(value, depth + 1) - ) + return _render_html_value_reaches_network_without_member(value, depth) name = name.lower().rsplit(":", 1)[-1] return _render_html_assigned_member_reaches_network(name, value, depth) @@ -3181,9 +3370,10 @@ def _render_html_markup_call_reaches_network(arguments: list[str], method: str, def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: + aliases = _render_html_static_js_name_aliases(code) for match in _RENDER_HTML_GLOBAL_BRACKET_RE.finditer(code): expression = match.group(1) - member = _static_js_string(expression) + member = _render_html_resolve_member(expression, aliases) if member is not None: if member.lower() in _RENDER_HTML_NETWORK_MEMBERS: return True @@ -3202,7 +3392,9 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: arguments = _js_call_arguments(code, match.end()) if arguments is None: return True - if _render_html_set_attribute_arguments(arguments, match.group("method").lower(), depth): + if _render_html_set_attribute_arguments( + arguments, match.group("method").lower(), depth, aliases + ): return True for match in _RENDER_HTML_PROPERTY_ASSIGNMENT_START_RE.finditer(code): @@ -3240,15 +3432,20 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: return True for match in _RENDER_HTML_COMPUTED_ASSIGNMENT_START_RE.finditer(code): - member = _static_js_string(match.group("member")) - if member is None: - continue value = _static_js_assignment_string(code[match.end() :]) + member_expression = match.group("member") + member = _render_html_resolve_member(member_expression, aliases) + if member is None: + if "." in member_expression: + continue + if _render_html_value_reaches_network_without_member(value, depth): + return True + continue if _render_html_assigned_member_reaches_network(member, value, depth): return True for match in _RENDER_HTML_COMPUTED_CALL_START_RE.finditer(code): - method = _static_js_string(match.group("member")) + method = _render_html_resolve_member(match.group("member"), aliases) if method is None: continue method = method.lower() @@ -3256,7 +3453,7 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: if arguments is None: return True if method in {"setattribute", "setattributens"}: - if _render_html_set_attribute_arguments(arguments, method, depth): + if _render_html_set_attribute_arguments(arguments, method, depth, aliases): return True elif method == "insertadjacenthtml" or ( method in {"write", "writeln"} and match.group("document") @@ -3270,8 +3467,11 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: return True if len(arguments) < 3: continue - member = _static_js_string(arguments[1]) + member = _render_html_resolve_member(arguments[1], aliases) if member is None: + value = _static_js_string(arguments[2]) + if _render_html_value_reaches_network_without_member(value, depth): + return True continue value = _static_js_string(arguments[2]) if _render_html_assigned_member_reaches_network(member, value, depth): @@ -3288,6 +3488,17 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: property_match.group("member"), value, depth ): return True + for match in _RENDER_HTML_WITH_BLOCK_RE.finditer(code): + body = match.group("body") + for assignment in _RENDER_HTML_BARE_PROPERTY_ASSIGNMENT_START_RE.finditer(body): + prefix = body[max(0, assignment.start() - 12) : assignment.start()] + if re.search(r"\b(?:const|let|var)\s+$", prefix, re.IGNORECASE): + continue + value = _static_js_assignment_string(body[assignment.end() :]) + if _render_html_assigned_member_reaches_network( + assignment.group("attr"), value, depth + ): + return True return False diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py index 79f5957bec..20ae6adbb1 100644 --- a/studio/backend/tests/test_bypass_permissions.py +++ b/studio/backend/tests/test_bypass_permissions.py @@ -188,6 +188,15 @@ def test_bash_blocklist_enforced_when_sandboxed(captured_popen): 'python$IFS-S -c "import boto3"', "python -c \"import subprocess; subprocess.run(['python','-S','-c','import boto3'])\"", 'python -c "import os; os.system(\\"python -S -c \'import boto3\'\\")"', + 'echo ok\npython -S -c "import boto3"', + 'timeout 1 env -i python -c "import boto3"', + 'find . -exec env -i python -c "import boto3" ;', + 'bash <<\'EOF\'\npython -S -c "import boto3"\nEOF', + ( + "python -c \"import os,subprocess; os.environ.pop('PYTHONPATH',None); " + "os.environ['UNSLOTH_STUDIO_SANDBOXED']='0'; " + "subprocess.run(['python','-c','import boto3'])\"" + ), ], ) def test_bash_blocks_python_startup_guard_bypasses(captured_popen, command): diff --git a/studio/backend/tests/test_permission_mode.py b/studio/backend/tests/test_permission_mode.py index cde473561e..5c0762a91a 100644 --- a/studio/backend/tests/test_permission_mode.py +++ b/studio/backend/tests/test_permission_mode.py @@ -1138,6 +1138,31 @@ def test_render_html_gated_only_when_networked(): assert rh("") is False assert rh("") is True assert rh("") is False + assert rh("") is True + assert rh("") is False + assert rh("") is False + assert rh("") is True + assert ( + rh( + "" + ) + is True + ) + assert ( + rh("") + is False + ) + assert ( + rh( + "" + ) + is True + ) + assert rh("") is True + assert rh("") is False + assert rh("") is False # A computed bracket key spliced from string fragments on a global host object. assert rh("") is True assert rh("") is True diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py index 66aa5f71e1..6d55cb1dce 100644 --- a/studio/backend/tests/test_sandbox_tools.py +++ b/studio/backend/tests/test_sandbox_tools.py @@ -539,6 +539,32 @@ class TestSandboxEnvIsolation: assert result.returncode != 0 assert "Blocked: low-level network module 'httpcore'" in result.stderr + def test_runtime_import_guard_blocks_aliased_httpcore_origin(self, tmp_path): + from core.inference.tools import _build_safe_env + + code = ( + "import importlib.machinery, importlib.util, sys\n" + "spec = importlib.machinery.PathFinder.find_spec('httpcore')\n" + "alias = importlib.util.spec_from_file_location(\n" + " 'hc', spec.origin,\n" + " submodule_search_locations=list(spec.submodule_search_locations or []),\n" + ")\n" + "module = importlib.util.module_from_spec(alias)\n" + "sys.modules['hc'] = module\n" + "alias.loader.exec_module(module)\n" + "module.request('GET', 'http://127.0.0.1:9')\n" + ) + result = subprocess.run( + [sys.executable, "-c", code], + cwd = tmp_path, + env = _build_safe_env(str(tmp_path)), + capture_output = True, + text = True, + check = False, + ) + assert result.returncode != 0 + assert "Blocked: low-level network module 'httpcore'" in result.stderr + def test_runtime_import_guard_blocks_httpcore_context_flag_tampering(self, tmp_path): from core.inference.tools import _build_safe_env From b58410aa122b56b5d532d2e75c0617eed5fe8a2d Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 12:30:02 +0000 Subject: [PATCH 11/21] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/tools.py | 12 +++++++----- studio/backend/tests/test_bypass_permissions.py | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index a074c422f7..54ea5207c5 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -576,7 +576,9 @@ def _python_payload_mutates_sandbox_env(node: ast.AST, os_aliases: set[str]) -> def _python_payload_launches_startup_bypass( - code: str, depth: int, environment_tainted: bool = False + code: str, + depth: int, + environment_tainted: bool = False, ) -> bool: if depth > 4: return True @@ -643,7 +645,9 @@ def _segment_python_launch_bypasses_guard( def _sandbox_python_startup_bypasses_guard( - command: str, depth: int = 0, environment_tainted: bool = False + command: str, + depth: int = 0, + environment_tainted: bool = False, ) -> bool: """Detect terminal-launched Python that suppresses the sandbox sitecustomize guard.""" if depth > 4: @@ -3495,9 +3499,7 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: if re.search(r"\b(?:const|let|var)\s+$", prefix, re.IGNORECASE): continue value = _static_js_assignment_string(body[assignment.end() :]) - if _render_html_assigned_member_reaches_network( - assignment.group("attr"), value, depth - ): + if _render_html_assigned_member_reaches_network(assignment.group("attr"), value, depth): return True return False diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py index 20ae6adbb1..0461e45b00 100644 --- a/studio/backend/tests/test_bypass_permissions.py +++ b/studio/backend/tests/test_bypass_permissions.py @@ -191,7 +191,7 @@ def test_bash_blocklist_enforced_when_sandboxed(captured_popen): 'echo ok\npython -S -c "import boto3"', 'timeout 1 env -i python -c "import boto3"', 'find . -exec env -i python -c "import boto3" ;', - 'bash <<\'EOF\'\npython -S -c "import boto3"\nEOF', + "bash <<'EOF'\npython -S -c \"import boto3\"\nEOF", ( "python -c \"import os,subprocess; os.environ.pop('PYTHONPATH',None); " "os.environ['UNSLOTH_STUDIO_SANDBOXED']='0'; " From aac8feb09fea0564a54767c2bde47c0860168c59 Mon Sep 17 00:00:00 2001 From: Michael Han Date: Mon, 20 Jul 2026 06:18:57 -0700 Subject: [PATCH 12/21] Close sandbox parity gaps --- studio/backend/core/inference/tools.py | 227 ++++++++++++++++-- .../backend/tests/test_bypass_permissions.py | 5 + studio/backend/tests/test_permission_mode.py | 55 +++++ 3 files changed, 271 insertions(+), 16 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 54ea5207c5..17031b388d 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -276,6 +276,46 @@ def _shell_command_with_unquoted_newlines_as_separators(command: str) -> str: return "".join(out) +def _shell_command_without_line_continuations(command: str) -> str: + if "\\\n" not in command and "\\\r" not in command: + return command + out: list[str] = [] + quote: str | None = None + escaped = False + index = 0 + while index < len(command): + char = command[index] + if escaped: + if char in "\r\n" and quote != "'": + escaped = False + if char == "\r" and index + 1 < len(command) and command[index + 1] == "\n": + index += 1 + index += 1 + continue + out.append("\\") + out.append(char) + escaped = False + index += 1 + continue + if char == "\\": + escaped = True + index += 1 + continue + if char == "'" and quote is None: + quote = "'" + elif char == "'" and quote == "'": + quote = None + elif char == '"' and quote is None: + quote = '"' + elif char == '"' and quote == '"': + quote = None + out.append(char) + index += 1 + if escaped: + out.append("\\") + return "".join(out) + + def _segment_python_index(segment: list[str]) -> int | None: command_index = 0 while command_index < len(segment) and _ASSIGNMENT_RE.match(segment[command_index]): @@ -335,6 +375,9 @@ def _segment_mutates_sandbox_python_env(segment: list[str], python_index: int) - if lowered.startswith("--unset="): if token.split("=", 1)[1].upper() in _SANDBOX_PYTHON_ENV_VARS: return True + if lowered.startswith("-u") and lowered != "-u" and not lowered.startswith("--"): + if token[2:].upper() in _SANDBOX_PYTHON_ENV_VARS: + return True if lowered in {"-u", "--unset"} and index + 1 < len(before_python): if before_python[index + 1].upper() in _SANDBOX_PYTHON_ENV_VARS: return True @@ -442,11 +485,11 @@ def _shell_command_substitutions(command: str) -> list[str]: _HEREDOC_START_RE = re.compile(r"<<-?\s*(?P['\"]?)(?P[A-Za-z_][A-Za-z0-9_]*)") -def _shell_here_doc_payloads(command: str) -> tuple[list[str], bool]: +def _shell_here_doc_entries(command: str) -> tuple[list[tuple[str, str]], bool]: if "<<" not in command: return [], False lines = command.splitlines() - payloads: list[str] = [] + entries: list[tuple[str, str]] = [] malformed = False index = 0 while index < len(lines): @@ -471,10 +514,10 @@ def _shell_here_doc_payloads(command: str) -> tuple[list[str], bool]: if body_end >= len(lines): malformed = True continue - payloads.append("\n".join(lines[body_start:body_end])) + entries.append((line[: match.start()], "\n".join(lines[body_start:body_end]))) index = max(index, body_end) index += 1 - return payloads, malformed + return entries, malformed _SHELL_EXPANSION_TOKEN_RE = re.compile( @@ -515,6 +558,27 @@ def _python_inline_payload(arguments: list[str]) -> str | None: return None +def _python_reads_program_from_stdin(arguments: list[str]) -> bool: + skip_next = False + for argument in arguments: + if skip_next: + skip_next = False + continue + if argument == "-c" or argument.startswith("-c"): + return False + if argument == "-m": + return False + if argument == "--": + return False + if argument == "-": + return True + if not argument.startswith("-"): + return False + if argument in {"-W", "-X", "--check-hash-based-pycs"}: + skip_next = True + return True + + def _static_python_command_argument(node: ast.AST) -> str | None: if isinstance(node, ast.Constant) and isinstance(node.value, str): return node.value @@ -575,6 +639,36 @@ def _python_payload_mutates_sandbox_env(node: ast.AST, os_aliases: set[str]) -> return False +def _python_payload_child_env_taints_guard(node: ast.Call, os_aliases: set[str]) -> bool: + env_node = None + for keyword in node.keywords or []: + if keyword.arg == "env": + env_node = keyword.value + break + if env_node is None: + return False + if isinstance(env_node, ast.Constant) and env_node.value is None: + return False + if ( + isinstance(env_node, ast.Attribute) + and env_node.attr == "environ" + and isinstance(env_node.value, ast.Name) + and env_node.value.id in os_aliases + ): + return False + if ( + isinstance(env_node, ast.Call) + and isinstance(env_node.func, ast.Attribute) + and env_node.func.attr == "copy" + and isinstance(env_node.func.value, ast.Attribute) + and env_node.func.value.attr == "environ" + and isinstance(env_node.func.value.value, ast.Name) + and env_node.func.value.value.id in os_aliases + ): + return False + return True + + def _python_payload_launches_startup_bypass( code: str, depth: int, @@ -626,7 +720,9 @@ def _python_payload_launches_startup_bypass( continue nested = _static_python_command_argument(command_node) if nested is not None and _sandbox_python_startup_bypasses_guard( - nested, depth + 1, environment_tainted + nested, + depth + 1, + environment_tainted or _python_payload_child_env_taints_guard(node, os_aliases), ): return True return False @@ -652,13 +748,35 @@ def _sandbox_python_startup_bypasses_guard( """Detect terminal-launched Python that suppresses the sandbox sitecustomize guard.""" if depth > 4: return True - payloads, malformed_here_doc = _shell_here_doc_payloads(command) + here_doc_entries, malformed_here_doc = _shell_here_doc_entries(command) if malformed_here_doc: return True - for payload in payloads: + for opener, payload in here_doc_entries: + opener_command = _shell_command_with_unquoted_newlines_as_separators( + _shell_command_without_line_continuations(opener) + ) + for segment in _shell_command_segments(opener_command): + python_index = _segment_python_index(segment) + if python_index is None: + continue + if _segment_python_launch_bypasses_guard( + segment, python_index, environment_tainted, depth + ): + return True + arguments = segment[python_index + 1 :] + if _python_inline_payload(arguments) is None and _python_reads_program_from_stdin( + arguments + ): + if _python_payload_launches_startup_bypass( + payload, depth + 1, environment_tainted + ): + return True + # Preserve the previous fail-safe for nested shell bodies. if _sandbox_python_startup_bypasses_guard(payload, depth + 1, environment_tainted): return True - command = _shell_command_with_unquoted_newlines_as_separators(command) + command = _shell_command_with_unquoted_newlines_as_separators( + _shell_command_without_line_continuations(command) + ) for nested in _shell_command_substitutions(command): if _sandbox_python_startup_bypasses_guard(nested, depth + 1, environment_tainted): return True @@ -2970,6 +3088,14 @@ _RENDER_HTML_MARKUP_CALL_START_RE = re.compile( r"\s*(?:\?\.\s*)?\(", re.IGNORECASE, ) +_RENDER_HTML_MARKUP_INDIRECT_CALL_START_RE = re.compile( + r"(?:\.\s*(?PinsertAdjacentHTML)|" + r"\.\s*(?PcreateContextualFragment)|" + r"\bdocument\s*(?:(?:\?\.\s*|\.\s*)open\s*\([^()]*\)\s*)?" + r"\s*(?:\?\.\s*|\.\s*)(?Pwrite|writeln))" + r"\s*(?:\?\.\s*|\.\s*)(?Pcall|apply)\s*(?:\?\.\s*)?\(", + re.IGNORECASE, +) _RENDER_HTML_COMPUTED_ASSIGNMENT_START_RE = re.compile( r"\[\s*(?P[^\]]+)\s*\]\s*(?:\+=|&&=|\|\|=|\?\?=|=(?!=))", re.IGNORECASE | re.DOTALL, @@ -2993,12 +3119,20 @@ _RENDER_HTML_WITH_BLOCK_RE = re.compile( r"\bwith\s*\((?:[^()]|\([^()]*\))*\)\s*\{(?P[^{}]*)\}", re.IGNORECASE | re.DOTALL, ) +_RENDER_HTML_WITH_STATEMENT_RE = re.compile( + r"\bwith\s*\((?:[^()]|\([^()]*\))*\)\s*(?!\{)(?P[^;\n\r<{}]*)", + re.IGNORECASE | re.DOTALL, +) _RENDER_HTML_BARE_PROPERTY_ASSIGNMENT_START_RE = re.compile( r"(?src|href|srcset|action|formaction|poster|data|ping|srcdoc|innerHTML|outerHTML)" r"\s*(?:\+=|&&=|\|\|=|\?\?=|=(?!=))", re.IGNORECASE, ) +_RENDER_HTML_OBJECT_COMPUTED_PROPERTY_START_RE = re.compile( + r"\[\s*(?P[^\]]+)\s*\]\s*:\s*", + re.IGNORECASE | re.DOTALL, +) _RENDER_HTML_OBJECT_PROPERTY_START_RE = re.compile( r"(?P['\"]?)(?Psrc|href|srcset|action|formaction|poster|data|ping|" r"srcdoc|innerHTML|outerHTML)(?P=quote)\s*:\s*", @@ -3373,6 +3507,44 @@ def _render_html_markup_call_reaches_network(arguments: list[str], method: str, return markup is None or _render_html_code_reaches_network(markup, depth + 1) +def _js_array_literal_items(expression: str) -> list[str] | None: + expression = expression.strip() + if len(expression) < 2 or not expression.startswith("[") or not expression.endswith("]"): + return None + inner = expression[1:-1] + if not inner.strip(): + return [] + return _js_call_arguments(f"({inner})", 1) + + +def _render_html_indirect_markup_call_reaches_network( + arguments: list[str], method: str, wrapper: str, depth: int +) -> bool: + if wrapper == "call": + if not arguments: + return False + return _render_html_markup_call_reaches_network(arguments[1:], method, depth) + if wrapper == "apply": + if len(arguments) < 2: + return False + applied = _js_array_literal_items(arguments[1]) + if applied is None: + return True + return _render_html_markup_call_reaches_network(applied, method, depth) + return False + + +def _render_html_with_body_reaches_network(body: str, depth: int) -> bool: + for assignment in _RENDER_HTML_BARE_PROPERTY_ASSIGNMENT_START_RE.finditer(body): + prefix = body[max(0, assignment.start() - 12) : assignment.start()] + if re.search(r"\b(?:const|let|var)\s+$", prefix, re.IGNORECASE): + continue + value = _static_js_assignment_string(body[assignment.end() :]) + if _render_html_assigned_member_reaches_network(assignment.group("attr"), value, depth): + return True + return False + + def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: aliases = _render_html_static_js_name_aliases(code) for match in _RENDER_HTML_GLOBAL_BRACKET_RE.finditer(code): @@ -3426,6 +3598,18 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: if _render_html_markup_call_reaches_network(arguments, method, depth): return True + for match in _RENDER_HTML_MARKUP_INDIRECT_CALL_START_RE.finditer(code): + arguments = _js_call_arguments(code, match.end()) + if arguments is None: + return True + method = ( + match.group("insert") or match.group("contextual") or match.group("write") + ).lower() + if _render_html_indirect_markup_call_reaches_network( + arguments, method, match.group("wrapper").lower(), depth + ): + return True + for match in _RENDER_HTML_DESTRUCTURING_ASSIGNMENT_START_RE.finditer(code): value = _js_assignment_expression(code[match.end() :]) if value is None: @@ -3492,15 +3676,21 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool: property_match.group("member"), value, depth ): return True + for property_match in _RENDER_HTML_OBJECT_COMPUTED_PROPERTY_START_RE.finditer(source): + member = _render_html_resolve_member(property_match.group("member"), aliases) + value = _static_js_assignment_string(source[property_match.end() :]) + if member is None: + if _render_html_value_reaches_network_without_member(value, depth): + return True + continue + if _render_html_assigned_member_reaches_network(member, value, depth): + return True for match in _RENDER_HTML_WITH_BLOCK_RE.finditer(code): - body = match.group("body") - for assignment in _RENDER_HTML_BARE_PROPERTY_ASSIGNMENT_START_RE.finditer(body): - prefix = body[max(0, assignment.start() - 12) : assignment.start()] - if re.search(r"\b(?:const|let|var)\s+$", prefix, re.IGNORECASE): - continue - value = _static_js_assignment_string(body[assignment.end() :]) - if _render_html_assigned_member_reaches_network(assignment.group("attr"), value, depth): - return True + if _render_html_with_body_reaches_network(match.group("body"), depth): + return True + for match in _RENDER_HTML_WITH_STATEMENT_RE.finditer(code): + if _render_html_with_body_reaches_network(match.group("body"), depth): + return True return False @@ -6639,6 +6829,11 @@ def _check_code_safety(code: str) -> str | None: Returns an error message string if the code is unsafe, or None if OK. """ + if _python_payload_launches_startup_bypass(code, 0): + return ( + "Error: unsafe code detected (Python child process cannot disable " + "the Studio runtime guard). Please remove unsafe patterns from your code." + ) safe, info = _check_signal_escape_patterns(code) if not safe: # Let SyntaxError from ast.parse through so the subprocess produces a diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py index 0461e45b00..412118faca 100644 --- a/studio/backend/tests/test_bypass_permissions.py +++ b/studio/backend/tests/test_bypass_permissions.py @@ -192,6 +192,9 @@ def test_bash_blocklist_enforced_when_sandboxed(captured_popen): 'timeout 1 env -i python -c "import boto3"', 'find . -exec env -i python -c "import boto3" ;', "bash <<'EOF'\npython -S -c \"import boto3\"\nEOF", + "python <<'PY'\nimport subprocess\nsubprocess.run(['python','-S','-c','import boto3'])\nPY", + 'python \\\n-S -c "import boto3"', + 'env -uPYTHONPATH python -c "import boto3"', ( "python -c \"import os,subprocess; os.environ.pop('PYTHONPATH',None); " "os.environ['UNSLOTH_STUDIO_SANDBOXED']='0'; " @@ -213,6 +216,8 @@ def test_bash_blocks_python_startup_guard_bypasses(captured_popen, command): "echo python -S", "python -c \"print('-S')\"", "python -c \"import subprocess; subprocess.run(['python','-c','print(1)'])\"", + "python <<'PY'\nprint(1)\nPY", + 'python \\\n-c "print(1)"', ], ) def test_bash_allows_python_without_startup_guard_bypass(captured_popen, command): diff --git a/studio/backend/tests/test_permission_mode.py b/studio/backend/tests/test_permission_mode.py index 5c0762a91a..bd3a690cac 100644 --- a/studio/backend/tests/test_permission_mode.py +++ b/studio/backend/tests/test_permission_mode.py @@ -951,6 +951,34 @@ def test_python_classifier(code, unsafe): assert is_potentially_unsafe_tool_call("python", {"code": code}) is unsafe +def test_python_runtime_safety_blocks_child_startup_guard_bypass(): + from core.inference.tools import _check_code_safety + + assert _check_code_safety("import subprocess\nsubprocess.run(['python','-c','print(1)'])") is None + assert ( + _check_code_safety( + "import os, subprocess\n" + "subprocess.run(['python','-c','print(1)'], env=os.environ.copy())" + ) + is None + ) + assert "runtime guard" in ( + _check_code_safety("import subprocess\nsubprocess.run(['python','-S','-c','print(1)'])") + or "" + ) + assert "runtime guard" in ( + _check_code_safety("import subprocess\nsubprocess.run(['python','-c','print(1)'], env={})") + or "" + ) + assert "runtime guard" in ( + _check_code_safety( + "import os, subprocess\nos.environ.pop('PYTHONPATH', None)\n" + "subprocess.run(['python','-c','print(1)'])" + ) + or "" + ) + + def test_builtin_readonly_tools_are_safe(): assert is_potentially_unsafe_tool_call("web_search", {"query": "hi"}) is False assert is_potentially_unsafe_tool_call("search_knowledge_base", {}) is False @@ -1101,6 +1129,13 @@ def test_render_html_gated_only_when_networked(): assert rh("") is True assert rh("") is True assert rh("") is True + assert rh("") is True + assert rh("") is False + assert rh("") is True + assert ( + rh("") + is False + ) assert rh("") is False assert rh("") is False assert rh("") is False @@ -1111,6 +1146,23 @@ def test_render_html_gated_only_when_networked(): assert rh("") is True assert rh("") is True assert rh("") is True + assert rh("") is True + assert rh("") is False + assert ( + rh("") + is True + ) + assert ( + rh("") + is False + ) + assert ( + rh( + "" + ) + is True + ) assert rh("") is False assert rh("") is False # Optional-chained computed document.write still recurses into the markup. @@ -1163,6 +1215,9 @@ def test_render_html_gated_only_when_networked(): assert rh("") is True assert rh("") is False assert rh("") is False + assert rh("") is True + assert rh("") is False + assert rh("") is False # A computed bracket key spliced from string fragments on a global host object. assert rh("") is True assert rh("") is True From d9ba4e7d0577ea1d0c561ba12a1d51587c4f75b0 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:19:35 +0000 Subject: [PATCH 13/21] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- studio/backend/core/inference/tools.py | 4 +--- studio/backend/tests/test_permission_mode.py | 12 +++++++++--- 2 files changed, 10 insertions(+), 6 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 17031b388d..74dd6ae717 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -767,9 +767,7 @@ def _sandbox_python_startup_bypasses_guard( if _python_inline_payload(arguments) is None and _python_reads_program_from_stdin( arguments ): - if _python_payload_launches_startup_bypass( - payload, depth + 1, environment_tainted - ): + if _python_payload_launches_startup_bypass(payload, depth + 1, environment_tainted): return True # Preserve the previous fail-safe for nested shell bodies. if _sandbox_python_startup_bypasses_guard(payload, depth + 1, environment_tainted): diff --git a/studio/backend/tests/test_permission_mode.py b/studio/backend/tests/test_permission_mode.py index bd3a690cac..547d1e8e73 100644 --- a/studio/backend/tests/test_permission_mode.py +++ b/studio/backend/tests/test_permission_mode.py @@ -954,7 +954,9 @@ def test_python_classifier(code, unsafe): def test_python_runtime_safety_blocks_child_startup_guard_bypass(): from core.inference.tools import _check_code_safety - assert _check_code_safety("import subprocess\nsubprocess.run(['python','-c','print(1)'])") is None + assert ( + _check_code_safety("import subprocess\nsubprocess.run(['python','-c','print(1)'])") is None + ) assert ( _check_code_safety( "import os, subprocess\n" @@ -1133,7 +1135,9 @@ def test_render_html_gated_only_when_networked(): assert rh("") is False assert rh("") is True assert ( - rh("") + rh( + "" + ) is False ) assert rh("") is False @@ -1149,7 +1153,9 @@ def test_render_html_gated_only_when_networked(): assert rh("") is True assert rh("") is False assert ( - rh("") + rh( + "" + ) is True ) assert ( From 39a9167c1cf2483a7b6177d0735afaeca4c2abb8 Mon Sep 17 00:00:00 2001 From: Michael Han Date: Mon, 20 Jul 2026 21:19:31 -0700 Subject: [PATCH 14/21] Close sandbox scanner gaps --- studio/backend/core/inference/tools.py | 323 +++++++++++++++++- .../backend/tests/test_bypass_permissions.py | 6 + studio/backend/tests/test_permission_mode.py | 37 ++ 3 files changed, 349 insertions(+), 17 deletions(-) diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py index 74dd6ae717..90ac839f01 100644 --- a/studio/backend/core/inference/tools.py +++ b/studio/backend/core/inference/tools.py @@ -213,6 +213,9 @@ _SANDBOX_PYTHON_ENV_VARS = frozenset( {"PATH", "PYTHONHOME", "PYTHONPATH", "UNSLOTH_STUDIO_SANDBOXED"} ) _PYTHON_LAUNCH_WRAPPERS = _COMMAND_PREFIXES | frozenset({"conda", "hatch", "pipx", "poetry", "uv"}) +_SHELL_SCRIPT_INTERPRETERS = frozenset( + {"bash", "cmd", "cmd.exe", "dash", "fish", "ksh", "sh", "zsh"} +) def _python_executable_token(token: str) -> bool: @@ -230,12 +233,18 @@ def _shell_command_segments(command: str) -> list[list[str]]: except ValueError: tokens = command.split() segments: list[list[str]] = [[]] + expect_command = True for token in tokens: - if token in _SHELL_SEPARATORS: + stripped = token.strip("\"'") + if token in _SHELL_SEPARATORS or ( + expect_command and stripped.lower() in _SHELL_KEYWORDS_AS_SEP + ): if segments[-1]: segments.append([]) + expect_command = True continue - segments[-1].append(token.strip("\"'")) + segments[-1].append(stripped) + expect_command = False return [segment for segment in segments if segment] @@ -340,6 +349,43 @@ def _segment_python_index(segment: list[str]) -> int | None: return None +def _segment_shell_index(segment: list[str]) -> int | None: + if not segment: + return None + first = os.path.basename(segment[0].replace("\\", "/")).lower() + wrapper_context = first in _PYTHON_LAUNCH_WRAPPERS + find_exec = first in {"find", "fd"} + for index, token in enumerate(segment): + shell = os.path.basename(token.replace("\\", "/")).lower() + find_exec_context = find_exec and any( + token in _FIND_EXEC_FLAGS for token in segment[:index] + ) + if shell in _SHELL_SCRIPT_INTERPRETERS and ( + index == 0 or wrapper_context or find_exec_context + ): + return index + return None + + +def _segment_shell_reads_stdin(segment: list[str], shell_index: int) -> bool: + saw_stdin_flag = False + for token in segment[shell_index + 1 :]: + lowered = token.lower() + if lowered == "/c" or (lowered.startswith("-") and lowered.endswith("c")): + return False + if lowered in {"-s", "--stdin"} or ( + lowered.startswith("-") and not lowered.startswith("--") and "s" in lowered[1:] + ): + saw_stdin_flag = True + continue + if lowered.startswith("-"): + continue + if saw_stdin_flag: + continue + return False + return True + + def _python_flags_skip_sitecustomize(arguments: list[str]) -> bool: skip_next = False for argument in arguments: @@ -520,10 +566,66 @@ def _shell_here_doc_entries(command: str) -> tuple[list[tuple[str, str]], bool]: return entries, malformed +def _shell_here_string_entries(command: str) -> tuple[list[tuple[str, str]], bool]: + if "<<<" not in command: + return [], False + try: + lexer = shlex.shlex(command, posix = sys.platform != "win32", punctuation_chars = ";&|()`{}<") + lexer.whitespace_split = True + tokens = list(lexer) + except ValueError: + return [], True + segments: list[list[str]] = [[]] + expect_command = True + for token in tokens: + if token in _SHELL_SEPARATORS or ( + expect_command and token.lower() in _SHELL_KEYWORDS_AS_SEP + ): + if segments[-1]: + segments.append([]) + expect_command = True + continue + segments[-1].append(token) + expect_command = False + + entries: list[tuple[str, str]] = [] + malformed = False + for segment in segments: + index = 0 + while index < len(segment): + if segment[index] != "<<<": + index += 1 + continue + if index + 1 >= len(segment): + malformed = True + break + opener = segment[:index] + segment[index + 2 :] + if not opener: + malformed = True + else: + entries.append((shlex.join(opener), segment[index + 1])) + index += 2 + return entries, malformed + + _SHELL_EXPANSION_TOKEN_RE = re.compile( r"\$(?:[A-Za-z_][A-Za-z0-9_]*|\{[^}]*\}|[0-9#?*$!@_-])|%[A-Za-z_][A-Za-z0-9_]*%" ) _PYTHON_CHILD_LAUNCHERS = frozenset({"run", "call", "check_call", "check_output", "Popen"}) +_PYTHON_OS_EXEC_LAUNCHERS = frozenset( + { + "execl", + "execle", + "execlp", + "execlpe", + "execv", + "execve", + "execvp", + "execvpe", + "posix_spawn", + "posix_spawnp", + } +) def _segment_with_shell_expansions_split(segment: list[str]) -> list[str] | None: @@ -592,6 +694,31 @@ def _static_python_command_argument(node: ast.AST) -> str | None: return None +def _static_python_string(node: ast.AST | None) -> str | None: + if isinstance(node, ast.Constant) and isinstance(node.value, str): + return node.value + return None + + +def _static_python_string_list(node: ast.AST | None) -> list[str] | None: + if not isinstance(node, (ast.List, ast.Tuple)): + return None + parts: list[str] = [] + for element in node.elts: + value = _static_python_string(element) + if value is None: + return None + parts.append(value) + return parts + + +def _python_call_keyword(node: ast.Call, name: str) -> ast.AST | None: + for keyword in node.keywords or []: + if keyword.arg == name: + return keyword.value + return None + + def _python_env_key(node: ast.AST) -> str | None: if isinstance(node, ast.Constant) and isinstance(node.value, str): return node.value.upper() @@ -640,13 +767,13 @@ def _python_payload_mutates_sandbox_env(node: ast.AST, os_aliases: set[str]) -> def _python_payload_child_env_taints_guard(node: ast.Call, os_aliases: set[str]) -> bool: - env_node = None - for keyword in node.keywords or []: - if keyword.arg == "env": - env_node = keyword.value - break + env_node = _python_call_keyword(node, "env") if env_node is None: return False + return _python_env_node_taints_guard(env_node, os_aliases) + + +def _python_env_node_taints_guard(env_node: ast.AST, os_aliases: set[str]) -> bool: if isinstance(env_node, ast.Constant) and env_node.value is None: return False if ( @@ -669,6 +796,53 @@ def _python_payload_child_env_taints_guard(node: ast.Call, os_aliases: set[str]) return True +def _python_subprocess_command_argument(node: ast.Call, command_node: ast.AST) -> str | None: + executable = _static_python_string(_python_call_keyword(node, "executable")) + parts = _static_python_string_list(command_node) + if executable and _python_executable_token(executable): + if parts is not None: + return shlex.join([executable, *parts[1:]]) + nested = _static_python_command_argument(command_node) + if nested is not None: + return f"{shlex.quote(executable)} {nested}" + return None + return _static_python_command_argument(command_node) + + +def _python_os_exec_command_argument(node: ast.Call, launcher: str) -> str | None: + args = list(node.args) + if not args: + return None + executable = _static_python_string(args[0]) + if executable is None: + return None + if launcher in {"execl", "execlp"}: + parts = [_static_python_string(arg) for arg in args[2:]] + elif launcher in {"execle", "execlpe"}: + parts = [_static_python_string(arg) for arg in args[2:-1]] + elif launcher in {"execv", "execvp"}: + argv = _static_python_string_list(args[1] if len(args) > 1 else None) + parts = None if argv is None else argv[1:] + elif launcher in {"execve", "execvpe", "posix_spawn", "posix_spawnp"}: + argv = _static_python_string_list(args[1] if len(args) > 1 else None) + parts = None if argv is None else argv[1:] + else: + return None + if parts is None or any(part is None for part in parts): + return None + return shlex.join([executable, *parts]) + + +def _python_os_exec_env_taints_guard(node: ast.Call, launcher: str, os_aliases: set[str]) -> bool: + if launcher in {"execle", "execlpe"}: + env_node = node.args[-1] if len(node.args) >= 2 else None + elif launcher in {"execve", "execvpe", "posix_spawn", "posix_spawnp"}: + env_node = node.args[2] if len(node.args) > 2 else None + else: + env_node = None + return env_node is not None and _python_env_node_taints_guard(env_node, os_aliases) + + def _python_payload_launches_startup_bypass( code: str, depth: int, @@ -683,6 +857,7 @@ def _python_payload_launches_startup_bypass( subprocess_aliases = {"subprocess"} os_aliases = {"os"} launcher_aliases: set[str] = set() + os_exec_aliases: dict[str, str] = {} for node in ast.walk(tree): if isinstance(node, ast.Import): for alias in node.names: @@ -693,36 +868,63 @@ def _python_payload_launches_startup_bypass( elif isinstance(node, ast.ImportFrom): if node.module == "subprocess": for alias in node.names: - if alias.name in _PYTHON_CHILD_LAUNCHERS: + if alias.name == "*": + launcher_aliases.update(_PYTHON_CHILD_LAUNCHERS) + elif alias.name in _PYTHON_CHILD_LAUNCHERS: launcher_aliases.add(alias.asname or alias.name) + elif node.module == "os": + for alias in node.names: + if alias.name == "*": + os_exec_aliases.update( + (launcher, launcher) for launcher in _PYTHON_OS_EXEC_LAUNCHERS + ) + elif alias.name in _PYTHON_OS_EXEC_LAUNCHERS: + os_exec_aliases[alias.asname or alias.name] = alias.name if _python_payload_mutates_sandbox_env(node, os_aliases): environment_tainted = True for node in ast.walk(tree): if not isinstance(node, ast.Call): continue - command_node = None + nested = None + env_tainted = _python_payload_child_env_taints_guard(node, os_aliases) if isinstance(node.func, ast.Name) and node.func.id in launcher_aliases: - command_node = node.args[0] if node.args else None + command_node = node.args[0] if node.args else _python_call_keyword(node, "args") + if command_node is not None: + nested = _python_subprocess_command_argument(node, command_node) + elif isinstance(node.func, ast.Name) and node.func.id in os_exec_aliases: + launcher = os_exec_aliases[node.func.id] + nested = _python_os_exec_command_argument(node, launcher) + env_tainted = _python_os_exec_env_taints_guard(node, launcher, os_aliases) elif isinstance(node.func, ast.Attribute): if ( isinstance(node.func.value, ast.Name) and node.func.value.id in subprocess_aliases and node.func.attr in _PYTHON_CHILD_LAUNCHERS ): - command_node = node.args[0] if node.args else None + command_node = node.args[0] if node.args else _python_call_keyword(node, "args") + if command_node is not None: + nested = _python_subprocess_command_argument(node, command_node) elif ( isinstance(node.func.value, ast.Name) and node.func.value.id in os_aliases and node.func.attr in {"system", "popen"} ): command_node = node.args[0] if node.args else None - if command_node is None: + if command_node is not None: + nested = _static_python_command_argument(command_node) + elif ( + isinstance(node.func.value, ast.Name) + and node.func.value.id in os_aliases + and node.func.attr in _PYTHON_OS_EXEC_LAUNCHERS + ): + nested = _python_os_exec_command_argument(node, node.func.attr) + env_tainted = _python_os_exec_env_taints_guard(node, node.func.attr, os_aliases) + if nested is None: continue - nested = _static_python_command_argument(command_node) - if nested is not None and _sandbox_python_startup_bypasses_guard( + if _sandbox_python_startup_bypasses_guard( nested, depth + 1, - environment_tainted or _python_payload_child_env_taints_guard(node, os_aliases), + environment_tainted or env_tainted, ): return True return False @@ -772,13 +974,43 @@ def _sandbox_python_startup_bypasses_guard( # Preserve the previous fail-safe for nested shell bodies. if _sandbox_python_startup_bypasses_guard(payload, depth + 1, environment_tainted): return True + here_string_entries, malformed_here_string = _shell_here_string_entries(command) + if malformed_here_string: + return True + for opener, payload in here_string_entries: + dynamic_payload = _SHELL_EXPANSION_TOKEN_RE.search(payload) is not None + opener_command = _shell_command_with_unquoted_newlines_as_separators( + _shell_command_without_line_continuations(opener) + ) + for segment in _shell_command_segments(opener_command): + python_index = _segment_python_index(segment) + if python_index is not None: + if _segment_python_launch_bypasses_guard( + segment, python_index, environment_tainted, depth + ): + return True + arguments = segment[python_index + 1 :] + if _python_inline_payload(arguments) is None and _python_reads_program_from_stdin( + arguments + ): + if dynamic_payload: + return True + if _python_payload_launches_startup_bypass( + payload, depth + 1, environment_tainted + ): + return True + shell_index = _segment_shell_index(segment) + if shell_index is not None and _segment_shell_reads_stdin(segment, shell_index): + if dynamic_payload: + return True + if _sandbox_python_startup_bypasses_guard(payload, depth + 1, environment_tainted): + return True command = _shell_command_with_unquoted_newlines_as_separators( _shell_command_without_line_continuations(command) ) for nested in _shell_command_substitutions(command): if _sandbox_python_startup_bypasses_guard(nested, depth + 1, environment_tainted): return True - shell_names = {"bash", "cmd", "cmd.exe", "dash", "fish", "ksh", "sh", "zsh"} for segment in _shell_command_segments(command): first = os.path.basename(segment[0].replace("\\", "/")).lower() wrapper_context = first in _PYTHON_LAUNCH_WRAPPERS @@ -791,7 +1023,7 @@ def _sandbox_python_startup_bypasses_guard( find_exec_context = find_exec and any( token in _FIND_EXEC_FLAGS for token in segment[:shell_index] ) - if shell not in shell_names or ( + if shell not in _SHELL_SCRIPT_INTERPRETERS or ( shell_index and not wrapper_context and not find_exec_context ): continue @@ -3066,6 +3298,11 @@ _RENDER_HTML_SET_ATTRIBUTE_START_RE = re.compile( r"\.\s*(?PsetAttribute(?:NS)?)\s*(?:\?\.\s*)?\(", re.IGNORECASE, ) +_RENDER_HTML_SET_ATTRIBUTE_INDIRECT_CALL_START_RE = re.compile( + r"\.\s*(?PsetAttribute(?:NS)?)\s*(?:\?\.\s*|\.\s*)" + r"(?Pcall|apply)\s*(?:\?\.\s*)?\(", + re.IGNORECASE, +) _RENDER_HTML_PROPERTY_ASSIGNMENT_START_RE = re.compile( r"\.\s*(?Psrc|href|srcset|action|formaction|poster|data|ping|srcdoc)\s*" r"(?P\+=|&&=|\|\|=|\?\?=|=(?!=))", @@ -3094,6 +3331,14 @@ _RENDER_HTML_MARKUP_INDIRECT_CALL_START_RE = re.compile( r"\s*(?:\?\.\s*|\.\s*)(?Pcall|apply)\s*(?:\?\.\s*)?\(", re.IGNORECASE, ) +_RENDER_HTML_MARKUP_TAGGED_TEMPLATE_START_RE = re.compile( + r"(?:\.\s*(?PinsertAdjacentHTML)|" + r"\.\s*(?PcreateContextualFragment)|" + r"\bdocument\s*(?:(?:\?\.\s*|\.\s*)open\s*\([^()]*\)\s*)?" + r"\s*(?:\?\.\s*|\.\s*)(?Pwrite|writeln))" + r"\s*(?P