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}"