* Studio: hide infra models from the hub cached inventory The hub inventory scans behind /api/hub/cached-gguf and /api/hub/cached-models returned the llama.cpp install validation probe (ggml-org/models) and the RAG embedder (unsloth/bge-small-en-v1.5[-GGUF]) as on-device models. Share the hidden-model check from routes/models.py via utils/models/hidden_models.py and apply it in both scans. A GGUF infra repo stays visible when the user explicitly downloaded a variant through the Hub, since variant manifests only exist for user-initiated downloads. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Studio: make On Device trust the hub inventory, match repo ids exactly, lighten the hidden-model import Follow-up on the hub cached-inventory hidden-model change, addressing the review. On Device now trusts the Hub inventory API for cached rows. The backend already hides the RAG embedder and the llama.cpp probe and re-includes a GGUF infra repo once the user downloads a variant through the Hub, but the frontend was re-hiding it by repo id, so the user-downloaded variant never appeared in the On Device list or the count. isVisibleInventoryRow now short-circuits cached rows (kind === "cache") to visible and keeps client-side needle hiding only for local filesystem rows and Discover. is_hidden_model matches Hub repo ids exactly (case-insensitive) against the probe plus the effective embedder and its GGUF companion, instead of substring matching the configured-embedder basename. A custom embedder with a generic basename like org/model no longer hides unrelated cached repos such as user/model-chat or org/model-instruct. The probe filename and local-path embedders keep exact matching. The helper moves to utils/hidden_models.py and is imported at module scope in the hub cache scanner, so it no longer pulls in utils/models/__init__ (the eager model-config/checkpoint stack) and a broken import fails at startup instead of being swallowed per-repo and silently emptying the inventory. routes.models keeps the _is_hidden_model and _safe_resolve aliases and drops the unused _HF_REPO_ID_RE re-export that was failing source lint. Tests: exact repo-id matching with a custom embedder, the cached-models scan keeping an unrelated repo, and a clean-interpreter check that the helper imports without the model-config stack. * Studio: match the llama.cpp probe filename on both path separators The hidden-model check compared the probe's on-disk filename with Path(value).name, which on a POSIX interpreter does not split a Windows-style path ("...\stories260K.gguf") and would let the probe through. Split on both separators so the probe is matched regardless of which OS produced the path, matching the tolerance of the previous substring check. Adds a Windows-path assertion to the probe test. * Studio: harden hidden infra model handling * Fix hidden cache row confirmation * Fix hidden local rows and confirmed hint merges * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Handle snapshot-configured hidden models * Hide basename-only default embedders * Fix dynamic embedder inventory filtering * Studio: hide the configured RAG embedder from Discover and feed rows --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: danielhanchen <unslothshared@gmail.com> Co-authored-by: Daniel Han <23090290+danielhanchen@users.noreply.github.com>
382 lines
17 KiB
Python
382 lines
17 KiB
Python
# SPDX-License-Identifier: AGPL-3.0-only
|
|
# Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. See /studio/LICENSE.AGPL-3.0
|
|
|
|
"""The RAG embedding model must pass the malware/pickle gate before it is persisted or
|
|
loaded. A flagged repo (or any repo saved with force) previously reached
|
|
SentenceTransformer unscanned, bypassing the normal model-load protections."""
|
|
|
|
from pathlib import Path
|
|
import sys
|
|
import types as _types
|
|
|
|
|
|
_BACKEND_DIR = str(Path(__file__).resolve().parent.parent)
|
|
if _BACKEND_DIR not in sys.path:
|
|
sys.path.insert(0, _BACKEND_DIR)
|
|
|
|
_loggers_stub = _types.ModuleType("loggers")
|
|
_loggers_stub.get_logger = lambda name: __import__("logging").getLogger(name)
|
|
sys.modules.setdefault("loggers", _loggers_stub)
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
import routes.settings as settings
|
|
|
|
|
|
class _Decision:
|
|
def __init__(self, blocked):
|
|
self.blocked = blocked
|
|
|
|
|
|
def _security_stub(blocked):
|
|
mod = _types.ModuleType("utils.security")
|
|
mod.evaluate_file_security = lambda *a, **k: _Decision(blocked)
|
|
mod.security_load_subdirs = lambda *a, **k: ()
|
|
return mod
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch):
|
|
# The settings scan unions in the ST module dirs read from modules.json; keep it
|
|
# offline and deterministic for the endpoint tests that use this fixture.
|
|
import core.rag.embeddings as embeddings
|
|
|
|
monkeypatch.setattr(embeddings, "_st_module_subdirs", lambda name, token = None: ())
|
|
saved: dict = {}
|
|
monkeypatch.setattr(settings, "default_embedding_model", lambda: "unsloth/default-embed")
|
|
monkeypatch.setattr(settings, "validate_embedding_model", lambda v: v)
|
|
monkeypatch.setattr(settings, "set_rag_embedding_model", lambda v: saved.setdefault("model", v))
|
|
monkeypatch.setattr(settings, "_llama_backend_active", lambda: False)
|
|
monkeypatch.setattr(settings, "_resolves_as_local_gguf", lambda m: False)
|
|
monkeypatch.setattr(settings, "get_rag_embedding_model", lambda: saved.get("model", ""))
|
|
monkeypatch.setattr(settings, "get_stored_embedding_model", lambda: saved.get("model"))
|
|
monkeypatch.setattr(
|
|
settings,
|
|
"effective_gguf_repo",
|
|
lambda: f"{saved.get('model', 'unsloth/default-embed')}-GGUF",
|
|
)
|
|
monkeypatch.setattr(
|
|
settings,
|
|
"default_gguf_repo",
|
|
lambda: "unsloth/default-embed-GGUF",
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(settings.router)
|
|
app.dependency_overrides[settings.get_current_subject] = lambda: "admin"
|
|
return TestClient(app, raise_server_exceptions = False), saved
|
|
|
|
|
|
def test_flagged_repo_is_blocked_even_with_force(client, monkeypatch):
|
|
c, saved = client
|
|
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = True))
|
|
r = c.put(
|
|
"/embedding-model", json = {"embedding_model": "attacker/malicious-embed", "force": True}
|
|
)
|
|
# 403, not the forceable 409, so the client does not offer "save anyway".
|
|
assert r.status_code == 403
|
|
assert "model" not in saved # force must not persist a flagged repo
|
|
|
|
|
|
def test_flagged_repo_is_blocked_without_force(client, monkeypatch):
|
|
c, saved = client
|
|
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = True))
|
|
r = c.put("/embedding-model", json = {"embedding_model": "attacker/malicious-embed"})
|
|
assert r.status_code == 403
|
|
assert "model" not in saved
|
|
|
|
|
|
def test_hard_block_uses_non_forceable_status(client, monkeypatch):
|
|
# The forceable verification path uses 409; the hard security block must be distinct
|
|
# (403) so the frontend never routes it into the "save anyway" force flow.
|
|
c, _saved = client
|
|
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = True))
|
|
blocked = c.put("/embedding-model", json = {"embedding_model": "attacker/malicious-embed"})
|
|
assert blocked.status_code == 403
|
|
|
|
# A verification failure (not-an-embedding-model) stays forceable at 409.
|
|
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = False))
|
|
monkeypatch.setattr(settings, "is_embedding_model", lambda *a, **k: False, raising = False)
|
|
import utils.models as _models
|
|
|
|
monkeypatch.setattr(_models, "is_embedding_model", lambda *a, **k: False)
|
|
unverified = c.put("/embedding-model", json = {"embedding_model": "acme/not-an-embedder"})
|
|
assert unverified.status_code == 409
|
|
|
|
|
|
def test_llama_backend_skips_the_st_pickle_scan(monkeypatch):
|
|
# On the llama-server backend the embedder loads GGUF (inert), not the ST repo's
|
|
# pickle, so a flagged ST repo with a clean GGUF companion must not be rejected here.
|
|
saved: dict = {}
|
|
monkeypatch.setattr(settings, "default_embedding_model", lambda: "unsloth/default-embed")
|
|
monkeypatch.setattr(settings, "validate_embedding_model", lambda v: v)
|
|
monkeypatch.setattr(settings, "set_rag_embedding_model", lambda v: saved.setdefault("model", v))
|
|
monkeypatch.setattr(settings, "_llama_backend_active", lambda: True)
|
|
monkeypatch.setattr(settings, "_resolves_as_local_gguf", lambda m: False)
|
|
monkeypatch.setattr(settings, "get_rag_embedding_model", lambda: saved.get("model", ""))
|
|
monkeypatch.setattr(settings, "get_stored_embedding_model", lambda: saved.get("model"))
|
|
# force skips the GGUF availability checks; the ST pickle gate is what we assert is skipped.
|
|
called = {"scanned": False}
|
|
mod = _types.ModuleType("utils.security")
|
|
|
|
def _fail(*a, **k):
|
|
called["scanned"] = True
|
|
return _Decision(True)
|
|
|
|
mod.evaluate_file_security = _fail
|
|
mod.security_load_subdirs = lambda *a, **k: ()
|
|
monkeypatch.setitem(sys.modules, "utils.security", mod)
|
|
|
|
app = FastAPI()
|
|
app.include_router(settings.router)
|
|
app.dependency_overrides[settings.get_current_subject] = lambda: "admin"
|
|
c = TestClient(app, raise_server_exceptions = False)
|
|
r = c.put(
|
|
"/embedding-model",
|
|
json = {"embedding_model": "attacker/flagged-st-clean-gguf", "force": True},
|
|
)
|
|
assert r.status_code == 200
|
|
assert called["scanned"] is False # the ST pickle scan never ran on the llama path
|
|
assert saved.get("model") == "attacker/flagged-st-clean-gguf"
|
|
|
|
|
|
def test_runtime_llama_fallback_skips_the_st_pickle_scan(monkeypatch):
|
|
# auto resolves to sentence-transformers (GPU present) but the embedder fell back to
|
|
# llama-server at runtime (torch/CUDA load or encode failure), so the process now loads
|
|
# only inert GGUF. The real _llama_backend_active() must reflect that cached fallback,
|
|
# so a flagged ST repo with a clean GGUF companion must not be hard-blocked here.
|
|
import core.rag.embeddings as embeddings
|
|
from core.rag.embed_llama_server import LlamaServerBackend
|
|
|
|
# Simulate the runtime fallback: the process-wide backend is a LlamaServerBackend even
|
|
# though the auto resolver would still say sentence-transformers.
|
|
monkeypatch.setattr(embeddings, "_backend", LlamaServerBackend())
|
|
monkeypatch.setattr(embeddings, "_resolve_auto", lambda: "sentence-transformers")
|
|
monkeypatch.setattr(embeddings, "_st_module_subdirs", lambda name, token = None: ())
|
|
|
|
saved: dict = {}
|
|
monkeypatch.setattr(settings, "default_embedding_model", lambda: "unsloth/default-embed")
|
|
monkeypatch.setattr(settings, "validate_embedding_model", lambda v: v)
|
|
monkeypatch.setattr(settings, "set_rag_embedding_model", lambda v: saved.setdefault("model", v))
|
|
# Deliberately do NOT monkeypatch settings._llama_backend_active: this test exercises the
|
|
# real delegation to embeddings.active_backend_is_llama() so the cached fallback is honored.
|
|
monkeypatch.setattr(settings, "_resolves_as_local_gguf", lambda m: False)
|
|
monkeypatch.setattr(settings, "get_rag_embedding_model", lambda: saved.get("model", ""))
|
|
monkeypatch.setattr(settings, "get_stored_embedding_model", lambda: saved.get("model"))
|
|
|
|
called = {"scanned": False}
|
|
mod = _types.ModuleType("utils.security")
|
|
|
|
def _fail(*a, **k):
|
|
called["scanned"] = True
|
|
return _Decision(True)
|
|
|
|
mod.evaluate_file_security = _fail
|
|
mod.security_load_subdirs = lambda *a, **k: ()
|
|
monkeypatch.setitem(sys.modules, "utils.security", mod)
|
|
|
|
app = FastAPI()
|
|
app.include_router(settings.router)
|
|
app.dependency_overrides[settings.get_current_subject] = lambda: "admin"
|
|
c = TestClient(app, raise_server_exceptions = False)
|
|
r = c.put(
|
|
"/embedding-model",
|
|
json = {"embedding_model": "attacker/flagged-st-clean-gguf", "force": True},
|
|
)
|
|
assert r.status_code == 200
|
|
assert called["scanned"] is False # the ST pickle scan never ran on the llama fallback
|
|
assert saved.get("model") == "attacker/flagged-st-clean-gguf"
|
|
|
|
|
|
def test_active_backend_is_llama_reflects_cache_and_resolver(monkeypatch):
|
|
# active_backend_is_llama() reports the ACTUAL built backend when one exists, and defers
|
|
# to the resolver (fresh-process behavior) when none has been built yet.
|
|
import core.rag.embeddings as embeddings
|
|
import core.rag.config as rag_config
|
|
from core.rag.embed_llama_server import LlamaServerBackend
|
|
|
|
# A cached llama backend wins even when auto would resolve to sentence-transformers.
|
|
monkeypatch.setattr(rag_config, "EMBED_BACKEND", "auto")
|
|
monkeypatch.setattr(embeddings, "_resolve_auto", lambda: "sentence-transformers")
|
|
monkeypatch.setattr(embeddings, "_backend", LlamaServerBackend())
|
|
assert embeddings.active_backend_is_llama() is True
|
|
|
|
# A cached ST backend reports False even when the resolver now picks llama, so its
|
|
# pickle stays gated (the cached backend, not the resolver, is what actually embeds).
|
|
monkeypatch.setattr(embeddings, "_resolve_auto", lambda: "llama-server")
|
|
monkeypatch.setattr(embeddings, "_backend", embeddings._SentenceTransformersBackend())
|
|
assert embeddings.active_backend_is_llama() is False
|
|
|
|
# No cached backend -> the resolver decides, unchanged from before.
|
|
monkeypatch.setattr(embeddings, "_resolve_auto", lambda: "sentence-transformers")
|
|
monkeypatch.setattr(embeddings, "_backend", None)
|
|
assert embeddings.active_backend_is_llama() is False # auto -> sentence-transformers
|
|
|
|
monkeypatch.setattr(embeddings, "_resolve_auto", lambda: "llama-server")
|
|
assert embeddings.active_backend_is_llama() is True # auto -> llama-server
|
|
|
|
# An explicit (non-auto) key is honored verbatim without a cached backend.
|
|
monkeypatch.setattr(rag_config, "EMBED_BACKEND", "llama-server")
|
|
assert embeddings.active_backend_is_llama() is True
|
|
|
|
|
|
def test_settings_scan_scopes_module_subdirs(monkeypatch):
|
|
# The settings scan must pass the ST module dirs (0_Transformer/) as load roots so a
|
|
# pickle directly under one blocks; assert those subdirs reach evaluate_file_security.
|
|
saved: dict = {}
|
|
monkeypatch.setattr(settings, "default_embedding_model", lambda: "unsloth/default-embed")
|
|
monkeypatch.setattr(settings, "validate_embedding_model", lambda v: v)
|
|
monkeypatch.setattr(settings, "set_rag_embedding_model", lambda v: saved.setdefault("model", v))
|
|
monkeypatch.setattr(settings, "_llama_backend_active", lambda: False)
|
|
monkeypatch.setattr(settings, "_resolves_as_local_gguf", lambda m: False)
|
|
monkeypatch.setattr(settings, "get_rag_embedding_model", lambda: saved.get("model", ""))
|
|
monkeypatch.setattr(settings, "get_stored_embedding_model", lambda: saved.get("model"))
|
|
|
|
import core.rag.embeddings as embeddings
|
|
|
|
monkeypatch.setattr(
|
|
embeddings, "_st_module_subdirs", lambda name, token = None: ("0_Transformer",)
|
|
)
|
|
seen = {}
|
|
|
|
def _capture(*a, **k):
|
|
seen["subdirs"] = tuple(k.get("load_subdirs") or ())
|
|
return _Decision(False)
|
|
|
|
mod = _types.ModuleType("utils.security")
|
|
mod.security_load_subdirs = lambda *a, **k: ()
|
|
mod.evaluate_file_security = _capture
|
|
monkeypatch.setitem(sys.modules, "utils.security", mod)
|
|
|
|
app = FastAPI()
|
|
app.include_router(settings.router)
|
|
app.dependency_overrides[settings.get_current_subject] = lambda: "admin"
|
|
c = TestClient(app, raise_server_exceptions = False)
|
|
r = c.put(
|
|
"/embedding-model", json = {"embedding_model": "acme/embed-with-module-dir", "force": True}
|
|
)
|
|
assert r.status_code == 200
|
|
assert "0_Transformer" in seen["subdirs"]
|
|
|
|
|
|
def test_clean_repo_saves_under_force(client, monkeypatch):
|
|
c, saved = client
|
|
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = False))
|
|
r = c.put("/embedding-model", json = {"embedding_model": "acme/clean-embed", "force": True})
|
|
assert r.status_code == 200
|
|
assert saved.get("model") == "acme/clean-embed"
|
|
assert r.json() == {
|
|
"embedding_model": "acme/clean-embed",
|
|
"embedding_gguf_repo": "acme/clean-embed-GGUF",
|
|
"default_embedding_model": "unsloth/default-embed",
|
|
"default_embedding_gguf_repo": "unsloth/default-embed-GGUF",
|
|
"is_custom": True,
|
|
}
|
|
|
|
|
|
def test_load_sink_refuses_flagged_model(monkeypatch):
|
|
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = True))
|
|
import core.rag.embeddings as embeddings
|
|
with pytest.raises(embeddings.UnsafeEmbeddingModelError):
|
|
embeddings._guard_model_security("attacker/malicious-embed")
|
|
|
|
|
|
def test_load_sink_allows_clean_model(monkeypatch):
|
|
monkeypatch.setitem(sys.modules, "utils.security", _security_stub(blocked = False))
|
|
import core.rag.embeddings as embeddings
|
|
embeddings._guard_model_security("acme/clean-embed") # no raise
|
|
|
|
|
|
def test_sink_threads_ambient_token_into_scan(monkeypatch):
|
|
# A gated repo set via env/default has no request token; the guard must feed the
|
|
# loader's own token to the scan, or it fails open for the repo that still loads.
|
|
seen = {}
|
|
mod = _types.ModuleType("utils.security")
|
|
mod.security_load_subdirs = (
|
|
lambda name, token = None: seen.setdefault("subdirs_token", token) or ()
|
|
)
|
|
mod.evaluate_file_security = lambda *a, **k: seen.setdefault(
|
|
"scan_token", k.get("hf_token")
|
|
) or _Decision(False)
|
|
monkeypatch.setitem(sys.modules, "utils.security", mod)
|
|
import core.rag.embeddings as embeddings
|
|
|
|
monkeypatch.setattr(embeddings, "_ambient_hf_token", lambda: "hf_ambient")
|
|
embeddings._guard_model_security("acme/gated-embed")
|
|
assert seen["scan_token"] == "hf_ambient"
|
|
assert seen["subdirs_token"] == "hf_ambient"
|
|
|
|
|
|
def test_sink_scopes_st_module_subdirs_into_scan(monkeypatch):
|
|
# A flagged pickle directly under a Transformer module dir (0_Transformer/) must
|
|
# reach the scan as a load root; assert the guard unions the module dirs into
|
|
# load_subdirs so evaluate_file_security treats such a pickle as root-level.
|
|
seen = {}
|
|
|
|
def _capture(*a, **k):
|
|
seen["subdirs"] = tuple(k.get("load_subdirs") or ())
|
|
return _Decision(False)
|
|
|
|
mod = _types.ModuleType("utils.security")
|
|
mod.security_load_subdirs = lambda name, token = None: ()
|
|
mod.evaluate_file_security = _capture
|
|
monkeypatch.setitem(sys.modules, "utils.security", mod)
|
|
import core.rag.embeddings as embeddings
|
|
|
|
monkeypatch.setattr(embeddings, "_ambient_hf_token", lambda: None)
|
|
monkeypatch.setattr(
|
|
embeddings, "_st_module_subdirs", lambda name, token = None: ("0_Transformer",)
|
|
)
|
|
embeddings._guard_model_security("acme/embed-with-module-dir")
|
|
assert "0_Transformer" in seen["subdirs"]
|
|
|
|
|
|
def test_st_module_subdirs_reads_local_modules_json(tmp_path, monkeypatch):
|
|
# The helper must parse each module's non-empty "path" from a local repo's
|
|
# modules.json and drop the root-level ("") Transformer entry.
|
|
import json
|
|
import core.rag.embeddings as embeddings
|
|
|
|
(tmp_path / "modules.json").write_text(
|
|
json.dumps(
|
|
[
|
|
{"idx": 0, "name": "0", "path": "0_Transformer", "type": "..."},
|
|
{"idx": 1, "name": "1", "path": "1_Pooling", "type": "..."},
|
|
{"idx": 2, "name": "2", "path": "", "type": "..."},
|
|
]
|
|
)
|
|
)
|
|
subdirs = embeddings._st_module_subdirs(str(tmp_path), None)
|
|
assert subdirs == ("0_Transformer", "1_Pooling")
|
|
|
|
|
|
def test_st_module_subdirs_swallows_errors(monkeypatch):
|
|
# Any failure (no modules.json, offline, malformed) returns () so the guard never
|
|
# bricks the embedder.
|
|
import huggingface_hub
|
|
import core.rag.embeddings as embeddings
|
|
|
|
def _boom(*a, **k):
|
|
raise RuntimeError("offline")
|
|
|
|
monkeypatch.setattr(huggingface_hub, "hf_hub_download", _boom)
|
|
assert embeddings._st_module_subdirs("acme/no-such-repo-xyz", None) == ()
|
|
|
|
|
|
def test_security_block_is_not_swallowed_by_llama_fallback(monkeypatch):
|
|
# The ST encode fallback must re-raise a security block, not swap to llama-server.
|
|
import core.rag.embeddings as embeddings
|
|
|
|
def _boom(*a, **k):
|
|
raise embeddings.UnsafeEmbeddingModelError("flagged")
|
|
|
|
monkeypatch.setattr(embeddings, "_st_encode", _boom)
|
|
monkeypatch.setattr(
|
|
embeddings,
|
|
"_switch_to_llama_fallback",
|
|
lambda err: pytest.fail("security block must not fall back to llama-server"),
|
|
)
|
|
with pytest.raises(embeddings.UnsafeEmbeddingModelError):
|
|
embeddings._SentenceTransformersBackend().encode(["hi"])
|