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"") 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"") 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"") 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`)",
+ re.IGNORECASE,
+)
_RENDER_HTML_COMPUTED_ASSIGNMENT_START_RE = re.compile(
r"\[\s*(?P[^\]]+)\s*\]\s*(?:\+=|&&=|\|\|=|\?\?=|=(?!=))",
re.IGNORECASE | re.DOTALL,
@@ -3532,6 +3777,31 @@ def _render_html_indirect_markup_call_reaches_network(
return False
+def _render_html_indirect_set_attribute_reaches_network(
+ arguments: list[str], method: str, wrapper: str, depth: int, aliases: dict[str, str]
+) -> bool:
+ if wrapper == "call":
+ if not arguments:
+ return False
+ return _render_html_set_attribute_arguments(arguments[1:], method, depth, aliases)
+ 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_set_attribute_arguments(applied, method, depth, aliases)
+ return False
+
+
+def _render_html_tagged_template_markup_reaches_network(code: str, offset: int, depth: int) -> bool:
+ parsed = _leading_js_string(code[offset:])
+ if parsed is None:
+ return True
+ markup, _ = parsed
+ return _render_html_code_reaches_network(markup, depth + 1)
+
+
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()]
@@ -3571,6 +3841,19 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool:
):
return True
+ for match in _RENDER_HTML_SET_ATTRIBUTE_INDIRECT_CALL_START_RE.finditer(code):
+ arguments = _js_call_arguments(code, match.end())
+ if arguments is None:
+ return True
+ if _render_html_indirect_set_attribute_reaches_network(
+ arguments,
+ match.group("method").lower(),
+ match.group("wrapper").lower(),
+ depth,
+ aliases,
+ ):
+ 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:
@@ -3608,6 +3891,12 @@ def _render_html_computed_network_access(code: str, depth: int = 0) -> bool:
):
return True
+ for match in _RENDER_HTML_MARKUP_TAGGED_TEMPLATE_START_RE.finditer(code):
+ if _render_html_tagged_template_markup_reaches_network(
+ code, match.start("template"), 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:
diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py
index 412118faca..1f58c46ddf 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",
+ "bash <<< 'python -S -c \"import boto3\"'",
+ 'if true; then python -S -c "import boto3"; fi',
+ 'for x in 1; do python -S -c "import boto3"; done',
"python <<'PY'\nimport subprocess\nsubprocess.run(['python','-S','-c','import boto3'])\nPY",
'python \\\n-S -c "import boto3"',
'env -uPYTHONPATH python -c "import boto3"',
@@ -217,6 +220,9 @@ def test_bash_blocks_python_startup_guard_bypasses(captured_popen, command):
"python -c \"print('-S')\"",
"python -c \"import subprocess; subprocess.run(['python','-c','print(1)'])\"",
"python <<'PY'\nprint(1)\nPY",
+ 'bash <<< "echo safe"',
+ "cat <<< 'python -S -c \"import boto3\"'",
+ "echo then python -S",
'python \\\n-c "print(1)"',
],
)
diff --git a/studio/backend/tests/test_permission_mode.py b/studio/backend/tests/test_permission_mode.py
index 547d1e8e73..c9a94f819e 100644
--- a/studio/backend/tests/test_permission_mode.py
+++ b/studio/backend/tests/test_permission_mode.py
@@ -964,10 +964,40 @@ def test_python_runtime_safety_blocks_child_startup_guard_bypass():
)
is None
)
+ assert (
+ _check_code_safety(
+ "import subprocess\nsubprocess.run(['ignored','-c','print(1)'], executable='python')"
+ )
+ is None
+ )
+ assert (
+ _check_code_safety("import os\nos.execl('/usr/bin/python','python','-c','print(1)')")
+ 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(args=['python','-S','-c','print(1)'])"
+ )
+ or ""
+ )
+ assert "runtime guard" in (
+ _check_code_safety(
+ "import subprocess\n"
+ "subprocess.run(['ignored','-S','-c','print(1)'], executable='python')"
+ )
+ or ""
+ )
+ assert "runtime guard" in (
+ _check_code_safety("from subprocess import *\nrun(['python','-S','-c','print(1)'])") or ""
+ )
+ assert "runtime guard" in (
+ _check_code_safety("import os\nos.execl('/usr/bin/python','python','-S','-c','print(1)')")
+ or ""
+ )
assert "runtime guard" in (
_check_code_safety("import subprocess\nsubprocess.run(['python','-c','print(1)'], env={})")
or ""
@@ -1142,6 +1172,11 @@ def test_render_html_gated_only_when_networked():
)
assert rh("") is False
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 False
assert (
rh("")
@@ -1150,6 +1185,8 @@ 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 (
From 9a7c2d852ee43d4d0bdd84e87b0604eb813d2ef9 Mon Sep 17 00:00:00 2001
From: Unsloth
Date: Mon, 20 Jul 2026 21:29:09 -0700
Subject: [PATCH 15/21] Harden httpcore audit path
---
.../inference/sandbox_site/sitecustomize.py | 60 +++----------------
studio/backend/tests/test_sandbox_tools.py | 44 ++++++++------
2 files changed, 36 insertions(+), 68 deletions(-)
diff --git a/studio/backend/core/inference/sandbox_site/sitecustomize.py b/studio/backend/core/inference/sandbox_site/sitecustomize.py
index 5bf8a136a6..d098b75184 100644
--- a/studio/backend/core/inference/sandbox_site/sitecustomize.py
+++ b/studio/backend/core/inference/sandbox_site/sitecustomize.py
@@ -28,7 +28,6 @@ Identical with and without output streaming because the child env is.
"""
import builtins
-import contextvars
import importlib
import importlib.machinery
import importlib.util
@@ -232,40 +231,6 @@ class _GuardedHttpcoreModule(types.ModuleType):
return types.ModuleType.__delattr__(self, name)
-def _make_httpcore_network_gate():
- active = contextvars.ContextVar("unsloth_httpcore_network_active", default = None)
- active_markers = set()
- trusted_request = _trusted_httpx_in_call_stack
- blocked = _raise_blocked_network_module
-
- def enter():
- if not trusted_request(2):
- blocked("httpcore")
- marker = object()
- active_markers.add(id(marker))
- return active.set(marker), marker
-
- def exit(state):
- token, marker = state
- try:
- active.reset(token)
- finally:
- active_markers.discard(id(marker))
-
- def is_active():
- marker = active.get()
- return marker is not None and id(marker) in active_markers
-
- return enter, exit, is_active
-
-
-(
- _enter_httpcore_network,
- _exit_httpcore_network,
- _httpcore_network_is_active,
-) = _make_httpcore_network_gate()
-
-
def _make_httpcore_backend_dispatchers():
originals = {}
@@ -296,8 +261,6 @@ def _guard_httpcore_backend_method(cls, method_name):
return
key = _register_httpcore_backend(original)
- network_enter = _enter_httpcore_network
- network_exit = _exit_httpcore_network
trusted_request = _trusted_httpx_in_call_stack
blocked = _raise_blocked_network_module
code = getattr(original, "__code__", None)
@@ -307,11 +270,7 @@ def _guard_httpcore_backend_method(cls, method_name):
async def guarded(*args, **kwargs):
if not trusted_request(2):
blocked("httpcore")
- state = network_enter()
- try:
- return await dispatch(key, *args, **kwargs)
- finally:
- network_exit(state)
+ return await dispatch(key, *args, **kwargs)
else:
dispatch = _dispatch_httpcore_backend
@@ -319,11 +278,7 @@ def _guard_httpcore_backend_method(cls, method_name):
def guarded(*args, **kwargs):
if not trusted_request(2):
blocked("httpcore")
- state = network_enter()
- try:
- return dispatch(key, *args, **kwargs)
- finally:
- network_exit(state)
+ return dispatch(key, *args, **kwargs)
guarded._unsloth_httpcore_backend_guard = True
guarded.__name__ = getattr(original, "__name__", method_name)
@@ -469,7 +424,6 @@ def _make_network_guard_audit():
realpath = os.path.realpath
relpath = os.path.relpath
shim_path = realpath(__file__)
- httpcore_network_active = _httpcore_network_is_active
def blocked_error(root):
raise ModuleNotFoundError(
@@ -578,16 +532,20 @@ def _make_network_guard_audit():
return
if event not in {"socket.connect", "socket.connect_ex", "socket.getaddrinfo"}:
return
- if httpcore_network_active():
- return
root = blocked_origin_in_stack(2)
- if root is not None:
+ if root in blocked:
+ blocked_error(root)
+ if root in direct_blocked:
+ if package_in_stack("httpx", 2):
+ return
blocked_error(root)
if (
package_in_stack("httpcore", 2)
or package_in_stack("anyio", 2)
or package_in_stack("trio", 2)
):
+ if package_in_stack("httpx", 2):
+ return
blocked_error("httpcore")
return audit
diff --git a/studio/backend/tests/test_sandbox_tools.py b/studio/backend/tests/test_sandbox_tools.py
index 6d55cb1dce..77b8f94af8 100644
--- a/studio/backend/tests/test_sandbox_tools.py
+++ b/studio/backend/tests/test_sandbox_tools.py
@@ -565,12 +565,12 @@ class TestSandboxEnvIsolation:
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):
+ def test_runtime_import_guard_blocks_httpcore_backend_reflection(self, tmp_path):
from core.inference.tools import _build_safe_env
code = (
"import sys, types\n"
- "import httpx, sitecustomize\n"
+ "import httpx\n"
"client = httpx.Client()\n"
"client.close()\n"
"module = sys.modules['httpcore._backends.sync']\n"
@@ -590,21 +590,10 @@ class TestSandboxEnvIsolation:
" 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"
+ "original(\n"
+ " backend_type(), '127.0.0.1', 9,\n"
+ " timeout=0.01, local_address=None, socket_options=None,\n"
+ ")\n"
)
result = subprocess.run(
[sys.executable, "-c", code],
@@ -617,6 +606,27 @@ class TestSandboxEnvIsolation:
assert result.returncode != 0
assert "Blocked: low-level network module 'httpcore'" in result.stderr
+ def test_runtime_import_guard_allows_httpx_backend_connect(self, tmp_path):
+ from core.inference.tools import _build_safe_env
+
+ code = (
+ "import httpx\n"
+ "try:\n"
+ " httpx.get('http://127.0.0.1:9/probe', timeout=0.01)\n"
+ "except Exception as exc:\n"
+ " print(type(exc).__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, result.stderr
+ assert "Blocked: low-level network module" not in result.stderr
+
def test_runtime_import_guard_blocks_local_module_httpcore_import(self, tmp_path):
from core.inference.tools import _build_safe_env
From bf7914c29524cfeb42fdcf4264c66b899c202d2c Mon Sep 17 00:00:00 2001
From: Michael Han
Date: Tue, 21 Jul 2026 17:46:27 -0700
Subject: [PATCH 16/21] Studio: close remaining terminal Python startup-guard
bypasses
Close the outstanding sandbox terminal-guard gaps flagged in review so an
approved sandboxed command can no longer spawn a child interpreter that skips
sitecustomize (and thus the runtime network-module block):
- shell=True with a sequence: scan args[0] as the /bin/sh -c script instead of
shlex-joining the whole vector into one quoted word.
- Rebound launchers: propagate assignment aliases (r = subprocess.run; r([...]))
to a fixpoint, including chained rebinds and os.exec*/pty/asyncio launchers.
- Defaulted command-word expansions: resolve ${PYTHON:-python} to its operand
before scanning so a wholly-expanded python launch is still modelled.
- Here-doc delimiters with '.'/'-' (<<'PY-EOF') are parsed as here-docs.
- Fold concatenated/constant f-string argv literals ('py'+'thon' -> python).
- os.environ |= {...} (PEP 584) taints the guard env like pop/update/clear.
- GNU env's lone '-' implies -i (clear environment).
- Recurse into static exec()/eval() string payloads.
- Cover pty.spawn and asyncio.create_subprocess_exec/create_subprocess_shell.
Adds regression cases (blocked + safe/no-false-positive complements) to
test_bypass_permissions.py.
---
studio/backend/core/inference/tools.py | 226 +++++++++++++++++-
.../backend/tests/test_bypass_permissions.py | 42 ++++
2 files changed, 261 insertions(+), 7 deletions(-)
diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py
index 90ac839f01..61af42cfc0 100644
--- a/studio/backend/core/inference/tools.py
+++ b/studio/backend/core/inference/tools.py
@@ -412,7 +412,9 @@ def _segment_mutates_sandbox_python_env(segment: list[str], python_index: int) -
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:
+ # GNU env treats a lone ``-`` as ``-i`` (clear the environment), so
+ # ``env - python`` starts the child without PYTHONPATH / the sandbox flag.
+ if lowered in {"-i", "--ignore-environment", "-"} and before_python:
if any(
os.path.basename(candidate.replace("\\", "/")).lower() == "env"
for candidate in before_python[:index]
@@ -528,7 +530,7 @@ 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_]*)")
+_HEREDOC_START_RE = re.compile(r"<<-?\s*(?P['\"]?)(?P[A-Za-z_][A-Za-z0-9_.-]*)")
def _shell_here_doc_entries(command: str) -> tuple[list[tuple[str, str]], bool]:
@@ -626,6 +628,14 @@ _PYTHON_OS_EXEC_LAUNCHERS = frozenset(
"posix_spawnp",
}
)
+# argv-list launchers whose first positional is the full ``[program, *args]``
+# vector (``pty.spawn(argv)``). Modelled like a subprocess sequence.
+_PYTHON_ARGV_LIST_LAUNCHERS = frozenset({"spawn"})
+# ``asyncio.create_subprocess_exec(program, *args)`` passes the program and its
+# arguments as separate positionals; ``create_subprocess_shell(cmd)`` passes a
+# single shell-script string.
+_PYTHON_ASYNCIO_EXEC_LAUNCHERS = frozenset({"create_subprocess_exec"})
+_PYTHON_ASYNCIO_SHELL_LAUNCHERS = frozenset({"create_subprocess_shell"})
def _segment_with_shell_expansions_split(segment: list[str]) -> list[str] | None:
@@ -636,7 +646,12 @@ def _segment_with_shell_expansions_split(segment: list[str]) -> list[str] | None
expanded.append(token)
continue
changed = True
- replacement = _SHELL_EXPANSION_TOKEN_RE.sub(" ", token).split()
+ # Resolve default/alternate parameter expansions to their operand first
+ # (``${PYTHON:-python} -S`` runs ``python -S`` when PYTHON is unset), so
+ # a command word supplied entirely by a defaulted expansion is still
+ # modelled instead of dropped.
+ resolved = _expand_param_defaults(token)
+ replacement = _SHELL_EXPANSION_TOKEN_RE.sub(" ", resolved).split()
expanded.extend(replacement)
return expanded if changed else None
@@ -682,14 +697,16 @@ def _python_reads_program_from_stdin(arguments: list[str]) -> bool:
def _static_python_command_argument(node: ast.AST) -> str | None:
- if isinstance(node, ast.Constant) and isinstance(node.value, str):
- return node.value
+ value = _static_python_string(node)
+ if value is not None:
+ return 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):
+ piece = _static_python_string(element)
+ if piece is None:
return None
- parts.append(element.value)
+ parts.append(piece)
return shlex.join(parts)
return None
@@ -697,6 +714,23 @@ def _static_python_command_argument(node: ast.AST) -> str | None:
def _static_python_string(node: ast.AST | None) -> str | None:
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
+ # Fold statically concatenated string literals (``'py' + 'thon'``) and
+ # constant-only f-strings so an argv element assembled from pieces is still
+ # recognised as ``python`` rather than treated as dynamic.
+ if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add):
+ left = _static_python_string(node.left)
+ right = _static_python_string(node.right)
+ if left is not None and right is not None:
+ return left + right
+ return None
+ if isinstance(node, ast.JoinedStr):
+ parts: list[str] = []
+ for value in node.values:
+ piece = _static_python_string(value)
+ if piece is None:
+ return None
+ parts.append(piece)
+ return "".join(parts)
return None
@@ -719,12 +753,30 @@ def _python_call_keyword(node: ast.Call, name: str) -> ast.AST | None:
return None
+def _python_call_is_shell_true(node: ast.Call) -> bool:
+ keyword = _python_call_keyword(node, "shell")
+ return isinstance(keyword, ast.Constant) and keyword.value is True
+
+
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_env_mapping_taints_guard(value: ast.AST) -> bool:
+ """True if merging ``value`` into ``os.environ`` could clear/redirect a
+ guard variable. A literal dict is safe only when every key is a known,
+ non-guard name; any unknown mapping fails closed."""
+ if isinstance(value, ast.Dict):
+ for key in value.keys:
+ name = _python_env_key(key) if key is not None else None
+ if name is None or name in _SANDBOX_PYTHON_ENV_VARS:
+ return True
+ return False
+ return True
+
+
def _python_payload_mutates_sandbox_env(node: ast.AST, os_aliases: set[str]) -> bool:
def is_os_environ(candidate: ast.AST) -> bool:
return (
@@ -741,6 +793,15 @@ def _python_payload_mutates_sandbox_env(node: ast.AST, os_aliases: set[str]) ->
key = _python_env_key(target.slice)
if key is None or key in _SANDBOX_PYTHON_ENV_VARS:
return True
+ # ``os.environ |= {...}`` (PEP 584) merges a mapping in place; its target is
+ # an Attribute, not a Subscript, so it is checked separately here.
+ if (
+ isinstance(node, ast.AugAssign)
+ and isinstance(node.op, ast.BitOr)
+ and is_os_environ(node.target)
+ ):
+ if _python_env_mapping_taints_guard(node.value):
+ return True
if isinstance(node, ast.Delete):
for target in node.targets:
if isinstance(target, ast.Subscript) and is_os_environ(target.value):
@@ -797,6 +858,18 @@ def _python_env_node_taints_guard(env_node: ast.AST, os_aliases: set[str]) -> bo
def _python_subprocess_command_argument(node: ast.Call, command_node: ast.AST) -> str | None:
+ if _python_call_is_shell_true(node):
+ # ``shell=True`` runs the command through ``/bin/sh -c``. A string is the
+ # script verbatim; for a sequence POSIX passes element 0 as the script
+ # (later elements become ``$0``, ``$1`` ...). Either way the value is
+ # shell input, so return it raw for the shell-aware recursion rather than
+ # ``shlex.join``-ing the whole vector into one quoted word.
+ script = _static_python_string(command_node)
+ if script is not None:
+ return script
+ if isinstance(command_node, (ast.List, ast.Tuple)) and command_node.elts:
+ return _static_python_string(command_node.elts[0])
+ return None
executable = _static_python_string(_python_call_keyword(node, "executable"))
parts = _static_python_string_list(command_node)
if executable and _python_executable_token(executable):
@@ -856,8 +929,13 @@ def _python_payload_launches_startup_bypass(
return False
subprocess_aliases = {"subprocess"}
os_aliases = {"os"}
+ pty_aliases = {"pty"}
+ asyncio_aliases = {"asyncio"}
launcher_aliases: set[str] = set()
os_exec_aliases: dict[str, str] = {}
+ argv_list_launcher_aliases: set[str] = set()
+ asyncio_exec_aliases: set[str] = set()
+ asyncio_shell_aliases: set[str] = set()
for node in ast.walk(tree):
if isinstance(node, ast.Import):
for alias in node.names:
@@ -865,6 +943,10 @@ def _python_payload_launches_startup_bypass(
subprocess_aliases.add(alias.asname or "subprocess")
elif alias.name == "os":
os_aliases.add(alias.asname or "os")
+ elif alias.name == "pty":
+ pty_aliases.add(alias.asname or "pty")
+ elif alias.name == "asyncio":
+ asyncio_aliases.add(alias.asname or "asyncio")
elif isinstance(node, ast.ImportFrom):
if node.module == "subprocess":
for alias in node.names:
@@ -880,17 +962,125 @@ def _python_payload_launches_startup_bypass(
)
elif alias.name in _PYTHON_OS_EXEC_LAUNCHERS:
os_exec_aliases[alias.asname or alias.name] = alias.name
+ elif node.module == "pty":
+ for alias in node.names:
+ if alias.name == "*":
+ argv_list_launcher_aliases.update(_PYTHON_ARGV_LIST_LAUNCHERS)
+ elif alias.name in _PYTHON_ARGV_LIST_LAUNCHERS:
+ argv_list_launcher_aliases.add(alias.asname or alias.name)
+ elif node.module == "asyncio":
+ for alias in node.names:
+ if alias.name == "*" or alias.name in _PYTHON_ASYNCIO_EXEC_LAUNCHERS:
+ if alias.name == "*":
+ asyncio_exec_aliases.update(_PYTHON_ASYNCIO_EXEC_LAUNCHERS)
+ asyncio_shell_aliases.update(_PYTHON_ASYNCIO_SHELL_LAUNCHERS)
+ else:
+ asyncio_exec_aliases.add(alias.asname or alias.name)
+ elif alias.name in _PYTHON_ASYNCIO_SHELL_LAUNCHERS:
+ asyncio_shell_aliases.add(alias.asname or alias.name)
if _python_payload_mutates_sandbox_env(node, os_aliases):
environment_tainted = True
+ # Propagate simple launcher aliases assigned by name
+ # (``r = subprocess.run``; ``r([...])``) to a fixpoint so chained rebinds
+ # (``a = subprocess.run; b = a``) resolve too.
+ assigns = [
+ n
+ for n in ast.walk(tree)
+ if isinstance(n, ast.Assign)
+ and len(n.targets) == 1
+ and isinstance(n.targets[0], ast.Name)
+ ]
+ changed = True
+ while changed:
+ changed = False
+ for assign in assigns:
+ name = assign.targets[0].id
+ value = assign.value
+ if isinstance(value, ast.Attribute) and isinstance(value.value, ast.Name):
+ base, attr = value.value.id, value.attr
+ if (
+ base in subprocess_aliases
+ and attr in _PYTHON_CHILD_LAUNCHERS
+ and name not in launcher_aliases
+ ):
+ launcher_aliases.add(name)
+ changed = True
+ elif (
+ base in os_aliases
+ and attr in _PYTHON_OS_EXEC_LAUNCHERS
+ and os_exec_aliases.get(name) != attr
+ ):
+ os_exec_aliases[name] = attr
+ changed = True
+ elif (
+ base in pty_aliases
+ and attr in _PYTHON_ARGV_LIST_LAUNCHERS
+ and name not in argv_list_launcher_aliases
+ ):
+ argv_list_launcher_aliases.add(name)
+ changed = True
+ elif (
+ base in asyncio_aliases
+ and attr in _PYTHON_ASYNCIO_EXEC_LAUNCHERS
+ and name not in asyncio_exec_aliases
+ ):
+ asyncio_exec_aliases.add(name)
+ changed = True
+ elif (
+ base in asyncio_aliases
+ and attr in _PYTHON_ASYNCIO_SHELL_LAUNCHERS
+ and name not in asyncio_shell_aliases
+ ):
+ asyncio_shell_aliases.add(name)
+ changed = True
+ elif isinstance(value, ast.Name):
+ if value.id in launcher_aliases and name not in launcher_aliases:
+ launcher_aliases.add(name)
+ changed = True
+ elif value.id in os_exec_aliases and os_exec_aliases.get(name) != os_exec_aliases[value.id]:
+ os_exec_aliases[name] = os_exec_aliases[value.id]
+ changed = True
+ elif value.id in argv_list_launcher_aliases and name not in argv_list_launcher_aliases:
+ argv_list_launcher_aliases.add(name)
+ changed = True
+ elif value.id in asyncio_exec_aliases and name not in asyncio_exec_aliases:
+ asyncio_exec_aliases.add(name)
+ changed = True
+ elif value.id in asyncio_shell_aliases and name not in asyncio_shell_aliases:
+ asyncio_shell_aliases.add(name)
+ changed = True
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
nested = None
env_tainted = _python_payload_child_env_taints_guard(node, os_aliases)
+ # Recurse into a statically-known ``exec``/``eval`` string payload so a
+ # child launch hidden inside ``exec("... subprocess.run([...]) ...")`` is
+ # scanned rather than treated as an opaque call.
+ if (
+ isinstance(node.func, ast.Name)
+ and node.func.id in {"exec", "eval"}
+ and node.args
+ ):
+ inner = _static_python_string(node.args[0])
+ if inner is not None and _python_payload_launches_startup_bypass(
+ inner, depth + 1, environment_tainted
+ ):
+ return True
if isinstance(node.func, ast.Name) and node.func.id in launcher_aliases:
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 argv_list_launcher_aliases:
+ command_node = node.args[0] if node.args else None
+ if command_node is not None:
+ nested = _static_python_command_argument(command_node)
+ elif isinstance(node.func, ast.Name) and node.func.id in asyncio_exec_aliases:
+ parts = [_static_python_string(arg) for arg in node.args]
+ if parts and all(part is not None for part in parts):
+ nested = shlex.join(parts)
+ elif isinstance(node.func, ast.Name) and node.func.id in asyncio_shell_aliases:
+ nested = _static_python_string(node.args[0]) if node.args else None
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)
@@ -919,6 +1109,28 @@ def _python_payload_launches_startup_bypass(
):
nested = _python_os_exec_command_argument(node, node.func.attr)
env_tainted = _python_os_exec_env_taints_guard(node, node.func.attr, os_aliases)
+ elif (
+ isinstance(node.func.value, ast.Name)
+ and node.func.value.id in pty_aliases
+ and node.func.attr in _PYTHON_ARGV_LIST_LAUNCHERS
+ ):
+ command_node = node.args[0] if node.args else 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 asyncio_aliases
+ and node.func.attr in _PYTHON_ASYNCIO_EXEC_LAUNCHERS
+ ):
+ parts = [_static_python_string(arg) for arg in node.args]
+ if parts and all(part is not None for part in parts):
+ nested = shlex.join(parts)
+ elif (
+ isinstance(node.func.value, ast.Name)
+ and node.func.value.id in asyncio_aliases
+ and node.func.attr in _PYTHON_ASYNCIO_SHELL_LAUNCHERS
+ ):
+ nested = _static_python_string(node.args[0]) if node.args else None
if nested is None:
continue
if _sandbox_python_startup_bypasses_guard(
diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py
index 1f58c46ddf..bf5ba94f67 100644
--- a/studio/backend/tests/test_bypass_permissions.py
+++ b/studio/backend/tests/test_bypass_permissions.py
@@ -203,6 +203,39 @@ def test_bash_blocklist_enforced_when_sandboxed(captured_popen):
"os.environ['UNSLOTH_STUDIO_SANDBOXED']='0'; "
"subprocess.run(['python','-c','import boto3'])\""
),
+ # shell=True passes a sequence's first element to /bin/sh -c, so the
+ # embedded ``python -S`` is shell input, not a shlex-joined argv word.
+ 'python -c "import subprocess; subprocess.run([\'python -S -c \\"import boto3\\"\'], shell=True)"',
+ # A launcher rebound by assignment (``r = subprocess.run``) still spawns
+ # an unguarded child.
+ "python -c \"import subprocess; r = subprocess.run; r(['python','-S','-c','import boto3'])\"",
+ # A command word supplied entirely by a defaulted parameter expansion
+ # (``${PYTHON:-python}`` with PYTHON unset) still runs ``python -S``.
+ '${PYTHON:-python} -S -c "import boto3"',
+ # A quoted here-doc delimiter containing a hyphen is still a here-doc; its
+ # Python body must be parsed as stdin code.
+ "python <<'PY-EOF'\nimport subprocess\nsubprocess.run(['python','-S','-c','import boto3'])\nPY-EOF",
+ # argv elements assembled from concatenated literals fold to ``python``.
+ "python -c \"import subprocess; subprocess.run(['py'+'thon','-S','-c','import boto3'])\"",
+ # ``os.environ |= {...}`` (PEP 584) can clear PYTHONPATH for the child.
+ (
+ "python -c \"import os,subprocess; os.environ |= {'PYTHONPATH': ''}; "
+ "subprocess.run(['python','-c','import boto3'])\""
+ ),
+ # GNU env's lone ``-`` implies ``-i`` (clear environment).
+ 'env - /usr/bin/python -c "import boto3"',
+ # A child launch hidden inside a static ``exec`` string payload.
+ (
+ "python -c \"exec('import subprocess; "
+ "subprocess.run([\\\"python\\\",\\\"-S\\\",\\\"-c\\\",\\\"import boto3\\\"])')\""
+ ),
+ # Non-subprocess child launchers (pty.spawn / asyncio) skip sitecustomize
+ # in the child too.
+ "python -c \"import pty; pty.spawn(['python','-S','-c','import boto3'])\"",
+ (
+ "python -c \"import asyncio; "
+ "asyncio.create_subprocess_exec('python','-S','-c','import boto3')\""
+ ),
],
)
def test_bash_blocks_python_startup_guard_bypasses(captured_popen, command):
@@ -224,6 +257,15 @@ def test_bash_blocks_python_startup_guard_bypasses(captured_popen, command):
"cat <<< 'python -S -c \"import boto3\"'",
"echo then python -S",
'python \\\n-c "print(1)"',
+ # A guard-neutral env merge (non-guard key) must still auto-run.
+ "python -c \"import os; os.environ |= {'MYVAR': '1'}\"",
+ # A dynamic argv element (sys.executable) is not a foldable literal, so a
+ # legitimate self-relaunch is not misclassified as a bypass.
+ "python -c \"import sys, subprocess; subprocess.run([sys.executable, '-c', 'print(1)'])\"",
+ # shell=True with an entirely benign script.
+ "python -c \"import subprocess; subprocess.run(['echo hi'], shell=True)\"",
+ # A rebound launcher that spawns a guarded (no -S) child.
+ "python -c \"import subprocess; r = subprocess.run; r(['python','-c','print(1)'])\"",
],
)
def test_bash_allows_python_without_startup_guard_bypass(captured_popen, command):
From 7ef07b2495aafac213971db0e99d344d0d6f7647 Mon Sep 17 00:00:00 2001
From: "pre-commit-ci[bot]"
<66853113+pre-commit-ci[bot]@users.noreply.github.com>
Date: Wed, 22 Jul 2026 00:47:11 +0000
Subject: [PATCH 17/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 | 20 +++++++++----------
.../backend/tests/test_bypass_permissions.py | 4 ++--
2 files changed, 12 insertions(+), 12 deletions(-)
diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py
index 61af42cfc0..9d4ce96e07 100644
--- a/studio/backend/core/inference/tools.py
+++ b/studio/backend/core/inference/tools.py
@@ -986,9 +986,7 @@ def _python_payload_launches_startup_bypass(
assigns = [
n
for n in ast.walk(tree)
- if isinstance(n, ast.Assign)
- and len(n.targets) == 1
- and isinstance(n.targets[0], ast.Name)
+ if isinstance(n, ast.Assign) and len(n.targets) == 1 and isinstance(n.targets[0], ast.Name)
]
changed = True
while changed:
@@ -1037,10 +1035,16 @@ def _python_payload_launches_startup_bypass(
if value.id in launcher_aliases and name not in launcher_aliases:
launcher_aliases.add(name)
changed = True
- elif value.id in os_exec_aliases and os_exec_aliases.get(name) != os_exec_aliases[value.id]:
+ elif (
+ value.id in os_exec_aliases
+ and os_exec_aliases.get(name) != os_exec_aliases[value.id]
+ ):
os_exec_aliases[name] = os_exec_aliases[value.id]
changed = True
- elif value.id in argv_list_launcher_aliases and name not in argv_list_launcher_aliases:
+ elif (
+ value.id in argv_list_launcher_aliases
+ and name not in argv_list_launcher_aliases
+ ):
argv_list_launcher_aliases.add(name)
changed = True
elif value.id in asyncio_exec_aliases and name not in asyncio_exec_aliases:
@@ -1057,11 +1061,7 @@ def _python_payload_launches_startup_bypass(
# Recurse into a statically-known ``exec``/``eval`` string payload so a
# child launch hidden inside ``exec("... subprocess.run([...]) ...")`` is
# scanned rather than treated as an opaque call.
- if (
- isinstance(node.func, ast.Name)
- and node.func.id in {"exec", "eval"}
- and node.args
- ):
+ if isinstance(node.func, ast.Name) and node.func.id in {"exec", "eval"} and node.args:
inner = _static_python_string(node.args[0])
if inner is not None and _python_payload_launches_startup_bypass(
inner, depth + 1, environment_tainted
diff --git a/studio/backend/tests/test_bypass_permissions.py b/studio/backend/tests/test_bypass_permissions.py
index bf5ba94f67..b7835b86c8 100644
--- a/studio/backend/tests/test_bypass_permissions.py
+++ b/studio/backend/tests/test_bypass_permissions.py
@@ -227,13 +227,13 @@ def test_bash_blocklist_enforced_when_sandboxed(captured_popen):
# A child launch hidden inside a static ``exec`` string payload.
(
"python -c \"exec('import subprocess; "
- "subprocess.run([\\\"python\\\",\\\"-S\\\",\\\"-c\\\",\\\"import boto3\\\"])')\""
+ 'subprocess.run([\\"python\\",\\"-S\\",\\"-c\\",\\"import boto3\\"])\')"'
),
# Non-subprocess child launchers (pty.spawn / asyncio) skip sitecustomize
# in the child too.
"python -c \"import pty; pty.spawn(['python','-S','-c','import boto3'])\"",
(
- "python -c \"import asyncio; "
+ 'python -c "import asyncio; '
"asyncio.create_subprocess_exec('python','-S','-c','import boto3')\""
),
],
From fe7e965b468edbd1e4a43a0d8e95bd713a77ef67 Mon Sep 17 00:00:00 2001
From: Michael Han
Date: Tue, 21 Jul 2026 22:50:32 -0700
Subject: [PATCH 18/21] Studio: close second-round sandbox guard review gaps
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
Terminal startup-guard (hard block in _bash_exec):
- env -S/--split-string is honoured wherever env sits in the launcher chain
(timeout 1 env -S ..., find . -exec env -S ...), not only as the first token.
- Grouped GNU env short options: an 'i' in a bundle (env -iuPYTHONPATH) clears
the whole environment; a grouped -u unset is recognised too.
- Expand bash aliases that fold skip flags into the python command word
(alias python='python -S'; python -c ...).
- declare -x / typeset -x exporting an emptied guard var (PYTHONPATH=) taints
the child env like export/set; a non-exported declare stays inherited-safe.
render_html network gate (auto-approve path):
- Dotted computed keys carrying a static network URL (img[o.k]='https://...')
fail closed; non-network dotted writes stay on the auto path.
- ES module loads of a remote/root URL — dynamic import('https://...') and
static import ... from 'https://...' — are gated; relative specifiers are not.
- Entity-obfuscated CSS URLs (style="background:url(https://...)") are
decoded before URL matching.
Adds blocked + safe/no-false-positive regression cases for each.
---
studio/backend/core/inference/tools.py | 126 +++++++++++++++++-
.../backend/tests/test_bypass_permissions.py | 19 +++
studio/backend/tests/test_permission_mode.py | 14 ++
3 files changed, 155 insertions(+), 4 deletions(-)
diff --git a/studio/backend/core/inference/tools.py b/studio/backend/core/inference/tools.py
index 9d4ce96e07..5fb668206b 100644
--- a/studio/backend/core/inference/tools.py
+++ b/studio/backend/core/inference/tools.py
@@ -9,6 +9,7 @@ import base64
import binascii
import codecs
import fnmatch
+import html
import http.client
import os
import signal
@@ -429,6 +430,27 @@ def _segment_mutates_sandbox_python_env(segment: list[str], python_index: int) -
if lowered in {"-u", "--unset"} and index + 1 < len(before_python):
if before_python[index + 1].upper() in _SANDBOX_PYTHON_ENV_VARS:
return True
+ # GNU env also accepts grouped short options: ``-i`` clears the whole
+ # environment and ``-uNAME`` unsets a variable, so a bundle like
+ # ``-iuPYTHONPATH`` both resets the environment and would drop PYTHONPATH.
+ # ``-u`` consumes the remainder of the cluster as the NAME.
+ if (
+ token.startswith("-")
+ and not token.startswith("--")
+ and len(token) > 1
+ and any(
+ os.path.basename(candidate.replace("\\", "/")).lower() == "env"
+ for candidate in before_python[:index]
+ )
+ ):
+ cluster = token[1:]
+ flags = cluster.split("u", 1)[0] if "u" in cluster else cluster
+ if "i" in flags:
+ return True
+ if "u" in cluster:
+ name = cluster.split("u", 1)[1]
+ if name and name.upper() in _SANDBOX_PYTHON_ENV_VARS:
+ return True
return False
@@ -444,9 +466,60 @@ def _segment_persistently_mutates_sandbox_python_env(segment: list[str]) -> bool
return any(
token.split("=", 1)[0].upper() in _SANDBOX_PYTHON_ENV_VARS for token in segment[1:]
)
+ # ``declare -x``/``typeset -x`` export to the child environment, so an empty
+ # ``declare -x PYTHONPATH=`` drops the guard var. Only the exporting form
+ # (``-x`` present) reaches the child; a bare ``declare NAME=`` stays a shell
+ # local and is inherited-safe.
+ if first in {"declare", "typeset"}:
+ exported = any(
+ token.startswith("-") and not token.startswith("--") and "x" in token[1:]
+ for token in segment[1:]
+ )
+ if exported:
+ return any(
+ token.split("=", 1)[0].upper() in _SANDBOX_PYTHON_ENV_VARS
+ for token in segment[1:]
+ if not token.startswith("-")
+ )
return False
+def _shell_alias_definitions(command: str) -> dict[str, str]:
+ """Collect ``alias NAME=VALUE`` definitions so a later use of NAME can be
+ expanded (``alias python='python -S'`` makes a subsequent ``python -c`` run
+ ``python -S -c``, skipping sitecustomize). Fail-open: only adds detections."""
+ aliases: dict[str, str] = {}
+ for segment in _shell_command_segments(command):
+ if not segment or os.path.basename(segment[0].replace("\\", "/")).lower() != "alias":
+ continue
+ for token in segment[1:]:
+ if token.startswith("-") or "=" not in token:
+ continue
+ name, value = token.split("=", 1)
+ if name:
+ aliases[name] = value
+ return aliases
+
+
+def _segment_with_alias_expansion(
+ segment: list[str], aliases: dict[str, str]
+) -> list[str] | None:
+ if not aliases:
+ return None
+ cmd_index = 0
+ while cmd_index < len(segment) and _ASSIGNMENT_RE.match(segment[cmd_index]):
+ cmd_index += 1
+ if cmd_index >= len(segment) or segment[cmd_index] not in aliases:
+ return None
+ try:
+ value_tokens = shlex.split(aliases[segment[cmd_index]])
+ except ValueError:
+ return None
+ if not value_tokens:
+ return None
+ return segment[:cmd_index] + value_tokens + segment[cmd_index + 1 :]
+
+
def _shell_command_substitutions(command: str) -> list[str]:
"""Extract executable command substitutions, including quoted forms."""
substitutions: list[str] = []
@@ -1223,6 +1296,7 @@ def _sandbox_python_startup_bypasses_guard(
for nested in _shell_command_substitutions(command):
if _sandbox_python_startup_bypasses_guard(nested, depth + 1, environment_tainted):
return True
+ aliases = _shell_alias_definitions(command)
for segment in _shell_command_segments(command):
first = os.path.basename(segment[0].replace("\\", "/")).lower()
wrapper_context = first in _PYTHON_LAUNCH_WRAPPERS
@@ -1251,8 +1325,22 @@ def _sandbox_python_startup_bypasses_guard(
return True
break
break
- if first == "env":
- for index, token in enumerate(segment):
+ # ``env -S "python -S ..."`` splits its argument into a command; this
+ # applies wherever env sits in the launcher chain (env first, or behind a
+ # wrapper such as ``timeout 1 env -S ...`` / ``find . -exec env -S ...``),
+ # not only when env is the first token.
+ for env_index, env_token in enumerate(segment):
+ if os.path.basename(env_token.replace("\\", "/")).lower() != "env":
+ continue
+ env_launch = (
+ env_index == 0
+ or wrapper_context
+ or (find_exec and any(t in _FIND_EXEC_FLAGS for t in segment[:env_index]))
+ )
+ if not env_launch:
+ continue
+ for index in range(env_index + 1, len(segment)):
+ token = segment[index]
if token in {"-S", "--split-string"} and index + 1 < len(segment):
if _sandbox_python_startup_bypasses_guard(
segment[index + 1], depth + 1, environment_tainted
@@ -1270,6 +1358,13 @@ def _sandbox_python_startup_bypasses_guard(
expanded_segment, expanded_python_index, environment_tainted, depth
):
return True
+ aliased_segment = _segment_with_alias_expansion(segment, aliases)
+ if aliased_segment is not None:
+ aliased_python_index = _segment_python_index(aliased_segment)
+ if aliased_python_index is not None and _segment_python_launch_bypasses_guard(
+ aliased_segment, aliased_python_index, environment_tainted, depth
+ ):
+ return True
python_index = _segment_python_index(segment)
if python_index is not None:
if _segment_python_launch_bypasses_guard(
@@ -3483,6 +3578,11 @@ _RENDER_HTML_NETWORK_RE = re.compile(
r"\bnew\s+(?:Shared)?Worker\s*\(|"
r"@import|"
r"url\(\s*[\"']?\s*(?:https?:|/)|"
+ # ES module loads of a remote/root URL: dynamic import('https://...') and
+ # static `import ... from 'https://...'` / side-effect `import 'https://...'`
+ # (a relative './mod.js' specifier has no https:/root prefix and stays static).
+ r"\bimport\s*\(\s*[\"'`]?\s*(?:https?:|/)|"
+ r"\bimport\b[^;\n]*?[\"'`]\s*(?:https?:|/)|"
r"") is False
assert rh("") is False
assert rh("") is True
+ # A dotted computed key carrying a static network URL fails closed; a
+ # dotted key assigning a non-network value stays a static canvas.
+ assert rh("") is True
+ assert rh("") is False
+ # ES module loads of a remote/root URL need approval; a relative specifier
+ # (dynamic or static) stays a static canvas.
+ assert rh("") is True
+ assert rh("") is True
+ assert rh("") is True
+ assert rh("") is False
+ assert rh("") is False
+ # An entity-obfuscated CSS URL is a network load after the browser decodes it.
+ assert rh('') is True
+ assert rh('
& local
') is False
assert (
rh(
"") is True
+ assert rh("") is True
+ assert rh("") is False
+ assert rh("") is False
+ # A reassigned computed-key alias is position-dependent, so it fails closed on
+ # a network value but a same-valued redefinition stays resolvable/static.
+ assert rh("") is True
+ assert rh("") is False
assert (
rh(
"