diff --git a/studio/backend/core/inference/mcp_client.py b/studio/backend/core/inference/mcp_client.py index 5a36d90c5d..7bd4a7d6e9 100644 --- a/studio/backend/core/inference/mcp_client.py +++ b/studio/backend/core/inference/mcp_client.py @@ -117,10 +117,25 @@ def join_stdio_command(parts: list[str]) -> str: def stdio_mcp_enabled() -> bool: """stdio MCP servers spawn local processes as the backend user (bypassing the - sandbox), so allowed only when the host is the user's own machine. The Tauri - app sets UNSLOTH_STUDIO_ALLOW_STDIO_MCP=1; localhost/self-hosted users can opt - in with the same var. Off for Colab and any network (0.0.0.0) bind.""" - return os.environ.get("UNSLOTH_STUDIO_ALLOW_STDIO_MCP") == "1" + sandbox), so allowed only when the host is the user's own machine. On startup + a loopback bind defaults UNSLOTH_STUDIO_ALLOW_STDIO_MCP=1 (see + utils.host_policy.apply_stdio_mcp_loopback_default, called from run.py); the + Tauri app does the same. Off for Colab and any network (0.0.0.0) bind unless + an operator sets the var out-of-band; set it to 0 to force-disable. + + When stdio is on only because of that loopback auto-default, an explicit + `unsloth studio run --disable-tools` turns it back off (a local stdio command + is server-side code execution). An explicit operator opt-in via the env var + still wins -- including the documented `=1` network opt-in, where the process + tool policy is False merely by the external-host default, not by choice.""" + if os.environ.get("UNSLOTH_STUDIO_ALLOW_STDIO_MCP") != "1": + return False + from state.tool_policy import get_tool_policy + from utils.host_policy import loopback_default_active + + if loopback_default_active() and get_tool_policy() is False: + return False + return True # Probe timeouts for discovering a server's tool list. OAuth needs minutes for diff --git a/studio/backend/run.py b/studio/backend/run.py index 84991a71bb..18e96f11c4 100644 --- a/studio/backend/run.py +++ b/studio/backend/run.py @@ -869,6 +869,13 @@ def run_server( from main import app, setup_frontend, _IS_COLAB from utils.paths import ensure_studio_directories + # Allow local stdio MCP servers on a loopback bind (the user's own machine), + # but never on Colab, which is a hosted VM reachable through its proxy. The + # gate reads the env var at request time, so this need not precede the import. + from utils.host_policy import apply_stdio_mcp_loopback_default + + apply_stdio_mcp_loopback_default(host, is_colab = _IS_COLAB) + # Create all standard directories on startup. ensure_studio_directories() diff --git a/studio/backend/startup_banner.py b/studio/backend/startup_banner.py index 7bfca61b73..52ac8cb012 100644 --- a/studio/backend/startup_banner.py +++ b/studio/backend/startup_banner.py @@ -94,6 +94,8 @@ def print_studio_access_banner( external_url = f"http://{display_host}:{port}" listen_all = bind_host in ("0.0.0.0", "::") + # The exact aliases the canned loopback_url below is valid for; any other bind + # (e.g. a specific LAN IP) must show its real address, not http://127.0.0.1. loopback_bind = bind_host in ("127.0.0.1", "localhost", "::1") # Use the loopback URL only when reachable on loopback; otherwise show diff --git a/studio/backend/tests/test_mcp_stdio_pr5863.py b/studio/backend/tests/test_mcp_stdio_pr5863.py index 9a3e8d6882..15fe553fb2 100644 --- a/studio/backend/tests/test_mcp_stdio_pr5863.py +++ b/studio/backend/tests/test_mcp_stdio_pr5863.py @@ -7,6 +7,7 @@ and reaches it when enabled. The transport is stubbed so no subprocess spawns; a recorder asserts whether it was reached. """ +import os import sys import pytest @@ -14,6 +15,7 @@ from fastapi import HTTPException from core.inference import mcp_client from storage import mcp_servers_db +from utils import host_policy def _reset_db(tmp_path, monkeypatch): @@ -33,6 +35,25 @@ def _disable(monkeypatch): monkeypatch.delenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", raising = False) +@pytest.fixture(autouse = True) +def _isolate_stdio_env(): + # apply_stdio_mcp_loopback_default() mutates os.environ and a module flag that + # monkeypatch can't roll back, and stdio_mcp_enabled() reads the process tool + # policy; snapshot/restore all three so nothing leaks between tests or files. + from state import tool_policy + + saved = os.environ.get("UNSLOTH_STUDIO_ALLOW_STDIO_MCP") + saved_policy = tool_policy.get_tool_policy() + host_policy._reset_loopback_default_state() + yield + host_policy._reset_loopback_default_state() + tool_policy.set_tool_policy(saved_policy) + if saved is None: + os.environ.pop("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", None) + else: + os.environ["UNSLOTH_STUDIO_ALLOW_STDIO_MCP"] = saved + + # ── transport stub + recorder ─────────────────────────────────────── @@ -181,6 +202,145 @@ def test_stdio_enabled_only_for_exact_one(monkeypatch): assert mcp_client.stdio_mcp_enabled() is True +# ── 3b. loopback bind defaults the gate on ────────────────────────── + + +@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "LOCALHOST", "::1"]) +def test_is_external_host_false_for_loopback(host): + assert host_policy.is_external_host(host) is False + + +# 127.0.0.2 is loopback in principle, but the rest of the stack hard-codes +# 127.0.0.1, so only the exact aliases count as local here. +@pytest.mark.parametrize("host", ["0.0.0.0", "::", "127.0.0.2", "192.168.1.10", "example.com"]) +def test_is_external_host_true_for_network(host): + assert host_policy.is_external_host(host) is True + + +@pytest.mark.parametrize("host", ["127.0.0.1", "localhost", "LOCALHOST", "::1"]) +def test_loopback_bind_enables_stdio(monkeypatch, host): + _disable(monkeypatch) + host_policy.apply_stdio_mcp_loopback_default(host) + assert mcp_client.stdio_mcp_enabled() is True + + +@pytest.mark.parametrize("host", ["0.0.0.0", "::", "127.0.0.2", "192.168.1.10", "example.com"]) +def test_network_bind_leaves_stdio_off(monkeypatch, host): + _disable(monkeypatch) + host_policy.apply_stdio_mcp_loopback_default(host) + assert mcp_client.stdio_mcp_enabled() is False + + +def test_colab_loopback_does_not_auto_enable(monkeypatch): + # Colab loopback is a hosted VM reachable via the proxy, so it stays off. + _disable(monkeypatch) + host_policy.apply_stdio_mcp_loopback_default("127.0.0.1", is_colab = True) + assert mcp_client.stdio_mcp_enabled() is False + + +def test_explicit_enable_survives_colab(monkeypatch): + # An explicit operator opt-in still wins over the Colab exclusion (apply_ + # early-returns on an explicit value, before the is_colab check). + monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1") + host_policy.apply_stdio_mcp_loopback_default("127.0.0.1", is_colab = True) + assert mcp_client.stdio_mcp_enabled() is True + + +def test_explicit_disable_survives_loopback(monkeypatch): + # An explicit =0 must not be overridden by the loopback auto-default. + monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "0") + host_policy.apply_stdio_mcp_loopback_default("127.0.0.1") + assert mcp_client.stdio_mcp_enabled() is False + + +def test_explicit_enable_survives_network_bind(monkeypatch): + # A deliberate network opt-in (-H 0.0.0.0 + var=1) must not be clobbered. + monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1") + host_policy.apply_stdio_mcp_loopback_default("0.0.0.0") + assert mcp_client.stdio_mcp_enabled() is True + + +def test_loopback_default_not_inherited_by_later_public_bind(monkeypatch): + # Reusing run_server in one process: a loopback launch auto-enables, a later + # 0.0.0.0 launch must take it back down (not inherit it as an opt-in). + _disable(monkeypatch) + host_policy.apply_stdio_mcp_loopback_default("127.0.0.1") + assert mcp_client.stdio_mcp_enabled() is True + host_policy.apply_stdio_mcp_loopback_default("0.0.0.0") + assert mcp_client.stdio_mcp_enabled() is False + + +@pytest.mark.parametrize("second_host", ["127.0.0.1", "0.0.0.0"]) +def test_force_disable_after_auto_default_in_same_process(monkeypatch, second_host): + # Reuse: a loopback launch auto-enables, then the operator sets =0 before a + # later launch. The force-disable must win whether the later bind is loopback + # (must not rewrite to 1) or public (the relinquish path must not pop the =0). + _disable(monkeypatch) + host_policy.apply_stdio_mcp_loopback_default("127.0.0.1") + assert mcp_client.stdio_mcp_enabled() is True + monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "0") + host_policy.apply_stdio_mcp_loopback_default(second_host) + assert mcp_client.stdio_mcp_enabled() is False + + +def test_cleared_env_after_auto_default_falls_back_to_host_default(monkeypatch): + # Unsetting the var (unlike =0) is "no preference", so a loopback re-apply + # re-enables -- the asymmetry the staleness guard documents. + _disable(monkeypatch) + host_policy.apply_stdio_mcp_loopback_default("127.0.0.1") + monkeypatch.delenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", raising = False) + host_policy.apply_stdio_mcp_loopback_default("127.0.0.1") + assert mcp_client.stdio_mcp_enabled() is True + + +def test_disable_tools_overrides_loopback_default(monkeypatch): + # When stdio is on only via the loopback auto-default, --disable-tools (the + # only way tool policy is False on a loopback bind) turns it back off. + from state import tool_policy + + _disable(monkeypatch) + host_policy.apply_stdio_mcp_loopback_default("127.0.0.1") + assert mcp_client.stdio_mcp_enabled() is True + tool_policy.set_tool_policy(False) + assert mcp_client.stdio_mcp_enabled() is False + + +def test_explicit_env_opt_in_survives_external_default_policy(monkeypatch): + # `UNSLOTH_STUDIO_ALLOW_STDIO_MCP=1 unsloth studio run -H 0.0.0.0` with no + # --enable-tools: tool policy is False by the external-host default, not by + # --disable-tools, so the explicit env opt-in must still win. + from state import tool_policy + + monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1") + host_policy.apply_stdio_mcp_loopback_default("0.0.0.0") # no-op: value is explicit + tool_policy.set_tool_policy(False) + assert mcp_client.stdio_mcp_enabled() is True + + +def test_explicit_env_opt_in_beats_disable_tools_on_loopback(monkeypatch): + # An operator who hand-sets =1 before launch outranks --disable-tools even on + # loopback: apply_ leaves the auto-default inactive, so the veto doesn't apply. + from state import tool_policy + + monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1") + host_policy.apply_stdio_mcp_loopback_default("127.0.0.1") # no-op: value is explicit + tool_policy.set_tool_policy(False) + assert mcp_client.stdio_mcp_enabled() is True + + +@pytest.mark.parametrize("policy", [None, True]) +def test_non_false_tool_policy_defers_to_env(monkeypatch, policy): + # Only an explicit --disable-tools (False) gates stdio; None/True fall through + # to the env var so the gate keeps its normal meaning. + from state import tool_policy + + tool_policy.set_tool_policy(policy) + _disable(monkeypatch) + assert mcp_client.stdio_mcp_enabled() is False + monkeypatch.setenv("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", "1") + assert mcp_client.stdio_mcp_enabled() is True + + # ── 4. probe_timeout ──────────────────────────────────────────────── diff --git a/studio/backend/tests/test_startup_banner_loopback.py b/studio/backend/tests/test_startup_banner_loopback.py new file mode 100644 index 0000000000..e82b741a7b --- /dev/null +++ b/studio/backend/tests/test_startup_banner_loopback.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Regression for PR #6295: the banner's canned http://127.0.0.1 URL is valid +only for the exact loopback aliases, so any other bind (e.g. a specific LAN IP) +must show its real address.""" + +import pytest + +from startup_banner import print_studio_access_banner + + +def test_non_alias_loopback_shows_real_address(capsys): + # A server bound to 127.0.0.2 does not listen on 127.0.0.1. + print_studio_access_banner(port = 8891, bind_host = "127.0.0.2", display_host = "127.0.0.2") + out = capsys.readouterr().out + assert "http://127.0.0.2:8891" in out + assert "http://127.0.0.1" not in out + + +@pytest.mark.parametrize("host", ["127.0.0.1", "localhost"]) +def test_alias_loopback_shows_canned_url(capsys, host): + print_studio_access_banner(port = 8891, bind_host = host, display_host = host) + assert "http://127.0.0.1:8891" in capsys.readouterr().out diff --git a/studio/backend/utils/host_policy.py b/studio/backend/utils/host_policy.py new file mode 100644 index 0000000000..bd9ebd68ba --- /dev/null +++ b/studio/backend/utils/host_policy.py @@ -0,0 +1,78 @@ +# SPDX-License-Identifier: AGPL-3.0-only +# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0 + +"""Bind-host trust policy for the Studio backend. + +Stdlib only -- safe to import without the rest of the backend. + +`is_external_host` mirrors the CLI's `unsloth_cli/_tool_policy.py`: a loopback +bind is the user's own machine, any other address is network-reachable. The +logic is duplicated rather than shared because the backend is self-contained +(see run.py: "can be moved to any directory") and runs from a venv that may not +have `unsloth_cli` on sys.path. Keep the two in sync. +""" + +from __future__ import annotations + +import os + +# Loopback aliases; any other bind address is treated as network-reachable. Only +# the exact aliases the rest of the stack assumes for loopback (health checks, +# banner URLs, run.py all hard-code 127.0.0.1), so other 127.0.0.0/8 addresses +# are deliberately left out -- they are not supported launch hosts. +_LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) + +# Whether a loopback launch in THIS process auto-enabled the gate. run_server +# normally runs once per process, but if it is reused with a different host +# (embedders, tests) a stale loopback default must not carry into a later +# public bind, so we only ever take back a value we set ourselves. +_auto_enabled = False + + +def is_external_host(host: str) -> bool: + """True when `host` is reachable from beyond loopback.""" + return host.lower() not in _LOOPBACK_HOSTS + + +def apply_stdio_mcp_loopback_default(host: str, *, is_colab: bool = False) -> None: + """Default stdio MCP servers on when bound to loopback. + + A loopback bind is the user's own machine -- the same trust boundary the + Tauri desktop app relies on (see main.py, which also binds 127.0.0.1 and + setdefaults this var). Colab is excluded: even its loopback is a hosted VM + reachable through Colab's proxy, so it stays off unless opted in. An explicit + operator value wins: a pre-set `UNSLOTH_STUDIO_ALLOW_STDIO_MCP=0` + force-disables and `=1` opts in, including on a network bind. We only ever + set or clear a default we applied ourselves, so reusing run_server with a + public host after a loopback one does not leave the gate on. + """ + global _auto_enabled + current = os.environ.get("UNSLOTH_STUDIO_ALLOW_STDIO_MCP") + # If our prior auto-default was changed out from under us (in-process reuse), + # relinquish ownership: an explicit =0 is then honored below as a sticky + # force-disable, while a cleared var falls back to the host default like a + # fresh process. + if _auto_enabled and current != "1": + _auto_enabled = False + # An explicit operator value is one we did not set; never touch it. + if current is not None and not _auto_enabled: + return + if is_colab or is_external_host(host): + if _auto_enabled: + os.environ.pop("UNSLOTH_STUDIO_ALLOW_STDIO_MCP", None) + _auto_enabled = False + else: + os.environ["UNSLOTH_STUDIO_ALLOW_STDIO_MCP"] = "1" + _auto_enabled = True + + +def loopback_default_active() -> bool: + """True when stdio MCP is on only because a loopback bind auto-enabled it, + rather than an explicit operator opt-in. Lets the gate tell the two apart.""" + return _auto_enabled + + +def _reset_loopback_default_state() -> None: + """Test hook: forget any auto-enable applied earlier in this process.""" + global _auto_enabled + _auto_enabled = False diff --git a/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx b/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx index 17d6d1e9ec..5f616d9290 100644 --- a/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx +++ b/studio/frontend/src/features/chat/chat-mcp-servers-dialog.tsx @@ -483,7 +483,7 @@ export function ChatMcpServersDialog({ /> An http(s) URL for a remote server, or a local command to run an - stdio server (desktop app only). + stdio server (local installs only). diff --git a/tests/python/test_unsloth_run_tool_policy_resolver.py b/tests/python/test_unsloth_run_tool_policy_resolver.py index 6e3e3a722d..356170fae8 100644 --- a/tests/python/test_unsloth_run_tool_policy_resolver.py +++ b/tests/python/test_unsloth_run_tool_policy_resolver.py @@ -145,7 +145,9 @@ class TestIsExternalHost: def test_loopback_aliases_are_local(self, host): assert is_external_host(host) is False - @pytest.mark.parametrize("host", ["0.0.0.0", "::", "192.168.1.5", "10.0.0.1", "example.com"]) + @pytest.mark.parametrize( + "host", ["0.0.0.0", "::", "127.0.0.2", "192.168.1.5", "10.0.0.1", "example.com"] + ) def test_non_loopback_is_external(self, host): assert is_external_host(host) is True diff --git a/unsloth_cli/_tool_policy.py b/unsloth_cli/_tool_policy.py index 58caacbff0..f74be6d461 100644 --- a/unsloth_cli/_tool_policy.py +++ b/unsloth_cli/_tool_policy.py @@ -14,6 +14,8 @@ import typer _PROMPT_FG = (217, 119, 87) # Loopback aliases; any other bind address is treated as network-reachable. +# Mirrored in studio/backend/utils/host_policy.py (kept separate because the +# backend is self-contained); keep the two in sync. _LOOPBACK_HOSTS = frozenset({"127.0.0.1", "localhost", "::1"}) diff --git a/unsloth_cli/commands/studio.py b/unsloth_cli/commands/studio.py index 4608457a96..400625363c 100644 --- a/unsloth_cli/commands/studio.py +++ b/unsloth_cli/commands/studio.py @@ -1093,6 +1093,13 @@ def run( run_mod = _load_run_module() run_server = run_mod.run_server + # Match the route handlers' import path: run.py adds studio/backend/ to + # sys.path, so they import as `state.tool_policy`. Set this before + # run_server() starts uvicorn; once sockets are bound, routes can be hit. + from state.tool_policy import set_tool_policy + + set_tool_policy(enable_tools) + run_kwargs = dict( host = host, port = port, @@ -1105,14 +1112,6 @@ def run( app = run_server(**run_kwargs) actual_port = getattr(app.state, "server_port", port) or port - # Match the route handlers' import path: run.py adds - # studio/backend/ to sys.path, so they import as `state.tool_policy`. - # Importing via `studio.backend.state.tool_policy` would cache a - # second module object whose flag the gates can't see. - from state.tool_policy import set_tool_policy - - set_tool_policy(enable_tools) - # Steps 3-5 can abort (health timeout, model-load error, or Ctrl+C during the # slow load); tear the server and its children (llama-server, cloudflared) down # on any abort so they never orphan. diff --git a/unsloth_cli/tests/test_studio_cloudflare_flag.py b/unsloth_cli/tests/test_studio_cloudflare_flag.py index 6ab1ce21ff..905512976e 100644 --- a/unsloth_cli/tests/test_studio_cloudflare_flag.py +++ b/unsloth_cli/tests/test_studio_cloudflare_flag.py @@ -214,6 +214,13 @@ def test_run_in_venv_passes_cloudflare_to_run_server(monkeypatch, user_flag, exp # mock as the cached run module so the stubbed run_server is used. monkeypatch.setattr(studio_mod, "_RUN_MODULE", fake_backend_run) + state_mod = types.ModuleType("state") + tp_mod = types.ModuleType("state.tool_policy") + tp_mod.set_tool_policy = lambda *a, **k: None + state_mod.tool_policy = tp_mod + monkeypatch.setitem(sys.modules, "state", state_mod) + monkeypatch.setitem(sys.modules, "state.tool_policy", tp_mod) + import typer as _typer app = _typer.Typer() @@ -296,3 +303,59 @@ def test_run_in_venv_shuts_down_on_startup_abort(monkeypatch): assert result.exit_code == 1, result.output assert len(shutdown_calls) == 1, "startup abort must call _graceful_shutdown" + + +def test_run_in_venv_sets_tool_policy_before_server_start(monkeypatch): + import types + + studio_mod = _studio() + fake_venv = Path("/fake/studio/venv/unsloth_studio") + monkeypatch.setattr(sys, "prefix", str(fake_venv)) + monkeypatch.setattr(studio_mod, "STUDIO_HOME", fake_venv.parent) + + from unsloth_cli import _tool_policy as _tp_mod + + monkeypatch.setattr( + _tp_mod, + "resolve_tool_policy", + lambda host, flag, yes, silent: False, + ) + + calls = [] + + class _App: + class state: + server_port = 8888 + + def _run_server(**_kwargs): + calls.append(("run_server", None)) + return _App() + + backend = types.ModuleType("studio.backend.run") + backend.run_server = _run_server + backend._resolve_external_ip = lambda: "1.2.3.4" + backend._server = object() + backend._shutdown_event = None + backend._graceful_shutdown = lambda server: calls.append(("shutdown", server)) + monkeypatch.setitem(sys.modules, "studio.backend.run", backend) + monkeypatch.setattr(studio_mod, "_RUN_MODULE", backend) + + state_mod = types.ModuleType("state") + tp_mod = types.ModuleType("state.tool_policy") + tp_mod.set_tool_policy = lambda value: calls.append(("policy", value)) + state_mod.tool_policy = tp_mod + monkeypatch.setitem(sys.modules, "state", state_mod) + monkeypatch.setitem(sys.modules, "state.tool_policy", tp_mod) + + monkeypatch.setattr(studio_mod, "_wait_for_server", lambda *a, **k: False) + + import typer as _typer + + app = _typer.Typer() + app.command( + context_settings = {"allow_extra_args": True, "ignore_unknown_options": True}, + )(studio_mod.run) + result = CliRunner().invoke(app, _BASE + ["--disable-tools"], catch_exceptions = True) + + assert result.exit_code == 1, result.output + assert calls[:2] == [("policy", False), ("run_server", None)]