#!/usr/bin/env python3 # SPDX-License-Identifier: AGPL-3.0-only # Copyright 2026-present the Unsloth AI Inc. team. All rights reserved. """Fail CI on a new call site that loads code or objects in a way a model repo can steer. `lint_exec_literals.py` covers `exec`, `eval` and `compile`. These are the other shapes behind holes fixed here since July 2026, each as one rule: dynamic-import import_module / __import__ / import_from_string / get_class_from_dynamic_module / spec_from_file_location / runpy / pydoc.locate handed a name that is not written out (sentence-transformers modules.json, mlx `model_type`). trust-remote-code `trust_remote_code` switched on in code, rather than by the caller: a default, an assignment, a dict entry or a literal keyword (the Studio defaults that ran Hub code unasked). unpinned-code-fetch Python fetched from a branch head (`.../main/x.py`), a `git clone` with no checkout of a fixed revision, or a Hub download with no `revision` in a function that then puts the result on `sys.path` or loads a file from it (Spark-TTS). unsafe-deserialize pickle / dill / joblib / marshal / shelve, `torch.load` with `weights_only` not left at the safe default, `np.load` with `allow_pickle=True`, `yaml.load` without a safe loader. Every rule is one AST pass and a few set lookups, so the whole tree takes seconds. Existing call sites are recorded in a baseline beside this script with a reason each, keyed on the call's own text rather than its line, with a count, exactly as in `lint_exec_literals.py`: only a new site fails. python scripts/lint_risky_loaders.py # check, exit 1 on a new site python scripts/lint_risky_loaders.py --update # rewrite the baseline python scripts/lint_risky_loaders.py --self-test # prove the rules still fire """ from __future__ import annotations import argparse import ast import hashlib import json import re import sys from pathlib import Path REPO_ROOT = Path(__file__).resolve().parents[1] BASELINE_PATH = Path(__file__).resolve().parent / "risky_loaders_baseline.json" EXCLUDED_PARTS = frozenset( { "tests", "node_modules", "build", "dist", ".venv", "venv", "site-packages", ".git", ".tox", ".mypy_cache", ".pytest_cache", "__pycache__", ".ipynb_checkpoints", ".eggs", "unsloth_compiled_cache", } ) DYNAMIC_IMPORTS = { "importlib.import_module": "import_module", "importlib.__import__": "__import__", "importlib.util.spec_from_file_location": "spec_from_file_location", "importlib.machinery.SourceFileLoader": "SourceFileLoader", "runpy.run_path": "runpy.run_path", "runpy.run_module": "runpy.run_module", "pydoc.locate": "pydoc.locate", "pkgutil.resolve_name": "pkgutil.resolve_name", } # Matched on the last name alone: distinctive enough, and reached through several # import paths (`transformers.dynamic_module_utils`, `sentence_transformers.util`). DYNAMIC_IMPORT_TAILS = { "import_from_string": "import_from_string", "get_class_from_dynamic_module": "get_class_from_dynamic_module", "get_class_in_module": "get_class_in_module", } PICKLE_LIKE = { f"{module}.{function}" for module in ("pickle", "_pickle", "cPickle", "dill", "cloudpickle") for function in ("load", "loads", "Unpickler") } | { "joblib.load", "marshal.load", "marshal.loads", "shelve.open", "pandas.read_pickle", "yaml.unsafe_load", "yaml.unsafe_load_all", } SAFE_YAML_LOADERS = {"SafeLoader", "CSafeLoader", "BaseLoader", "CBaseLoader"} HUB_DOWNLOADS = {"snapshot_download", "hf_hub_download"} CODE_LOADERS = { "sys.path.insert", "sys.path.append", "importlib.util.spec_from_file_location", "importlib.machinery.SourceFileLoader", "runpy.run_path", } BRANCH_HEAD_PY = re.compile( r"(?:raw\.githubusercontent\.com/[^/\s]+/[^/\s]+/(?:refs/heads/)?(?:main|master)/" r"|github\.com/[^/\s]+/[^/\s]+/raw/(?:refs/heads/)?(?:main|master)/)\S*\.py\b" ) PIN_WORDS = {"checkout", "--revision", "reset"} def _imports(tree: ast.AST) -> dict: """Local name -> dotted origin, so aliases (`import importlib as il`) resolve.""" table = {} for node in ast.walk(tree): if isinstance(node, ast.ImportFrom) and node.module and not node.level: for alias in node.names: table[alias.asname or alias.name] = f"{node.module}.{alias.name}" elif isinstance(node, ast.Import): for alias in node.names: if alias.asname: table[alias.asname] = alias.name else: head = alias.name.split(".")[0] table[head] = head return table def _dotted(node: ast.AST) -> str: parts = [] while isinstance(node, ast.Attribute): parts.append(node.attr) node = node.value if not isinstance(node, ast.Name): return "" parts.append(node.id) return ".".join(reversed(parts)) def _qualified(func: ast.AST, table: dict) -> str: name = _dotted(func) if not name: return "" head, _, rest = name.partition(".") if head == "__import__": return "importlib.__import__" if head in table: return table[head] + ("." + rest if rest else "") return name def _is_written_out(node: ast.AST) -> bool: if isinstance(node, ast.Constant): return isinstance(node.value, (str, bytes)) if isinstance(node, ast.JoinedStr): return not any(isinstance(part, ast.FormattedValue) for part in node.values) if isinstance(node, ast.BinOp) and isinstance(node.op, ast.Add): return _is_written_out(node.left) and _is_written_out(node.right) return False def _is_true(node: ast.AST) -> bool: return isinstance(node, ast.Constant) and node.value is True def _keyword(call: ast.Call, name: str): for keyword in call.keywords: if keyword.arg == name: return keyword.value return None def _first_argument(call: ast.Call, keyword: str): if call.args: return call.args[0] return _keyword(call, keyword) # Sinks whose second argument is the file that gets executed, and its keyword. _PATH_KEYWORDS = { "spec_from_file_location": "location", "SourceFileLoader": "path", "get_class_in_module": "module_path", } _TARGET_KEYWORDS = { "runpy.run_path": "path_name", "runpy.run_module": "mod_name", "pydoc.locate": "path", } def _dynamic_import(call: ast.Call, qualified: str): sink = DYNAMIC_IMPORTS.get(qualified) or DYNAMIC_IMPORT_TAILS.get(qualified.split(".")[-1]) if sink is None: return None if sink in _PATH_KEYWORDS: keyword = _PATH_KEYWORDS[sink] target = call.args[1] if len(call.args) > 1 else _keyword(call, keyword) else: target = _first_argument(call, _TARGET_KEYWORDS.get(sink, "name")) if target is None or _is_written_out(target): return None return sink def _unsafe_deserialize(call: ast.Call, qualified: str): if qualified in PICKLE_LIKE: return qualified if qualified == "torch.load": # Omitted is unsafe too: torch < 2.6, still supported, defaults to weights_only=False. if not _is_true(_keyword(call, "weights_only")): return "torch.load" elif qualified == "numpy.load": if _is_true(_keyword(call, "allow_pickle")): return "numpy.load" elif qualified in ("yaml.load", "yaml.load_all"): loader = _keyword(call, "Loader") if loader is None and len(call.args) > 1: loader = call.args[1] if loader is None or _dotted(loader).split(".")[-1] not in SAFE_YAML_LOADERS: return qualified return None def _true_field_default(value: ast.AST) -> bool: """`Field(True, ...)` / `field(default = True)`: the schema default Studio's request models use.""" if not ( isinstance(value, ast.Call) and _dotted(value.func).split(".")[-1] in ("Field", "field") ): return False return (bool(value.args) and _is_true(value.args[0])) or _is_true(_keyword(value, "default")) def _trust_remote_code(node: ast.AST): """`trust_remote_code` turned on by the code itself.""" if isinstance(node, ast.Call): if _is_true(_keyword(node, "trust_remote_code")): return "keyword" func = node.func if ( isinstance(func, ast.Attribute) and func.attr == "setdefault" and len(node.args) == 2 and isinstance(node.args[0], ast.Constant) and node.args[0].value == "trust_remote_code" and _is_true(node.args[1]) ): return "setdefault" elif isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.Lambda)): arguments = node.args positional = arguments.posonlyargs + arguments.args pairs = list( zip(positional[len(positional) - len(arguments.defaults) :], arguments.defaults) ) pairs += [ (a, d) for a, d in zip(arguments.kwonlyargs, arguments.kw_defaults) if d is not None ] if any(a.arg == "trust_remote_code" and _is_true(d) for a, d in pairs): return "default" elif ( isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None and (_is_true(node.value) or _true_field_default(node.value)) ): targets = node.targets if isinstance(node, ast.Assign) else [node.target] for target in targets: if isinstance(target, (ast.Name, ast.Attribute)) and ( getattr(target, "id", None) == "trust_remote_code" or getattr(target, "attr", None) == "trust_remote_code" ): return "assignment" if ( isinstance(target, ast.Subscript) and isinstance(target.slice, ast.Constant) and target.slice.value == "trust_remote_code" ): return "item" elif isinstance(node, ast.Dict): for key, value in zip(node.keys, node.values): if ( isinstance(key, ast.Constant) and key.value == "trust_remote_code" and _is_true(value) ): return "dict" return None def _git_words(command) -> list: """The written-out words of a git command, from an argv list or a shell string; [] otherwise.""" if isinstance(command, (ast.List, ast.Tuple)): first = command.elts[0] if command.elts else None if not (isinstance(first, ast.Constant) and first.value == "git"): return [] words = [ e.value for e in command.elts if isinstance(e, ast.Constant) and isinstance(e.value, str) ] else: if isinstance(command, ast.JoinedStr) and command.values: command = command.values[0] if ( not (isinstance(command, ast.Constant) and isinstance(command.value, str)) or "git " not in command.value ): return [] # The first git command in a chain, so `cd llama.cpp && git reset --hard X` counts. segments = (part.split() for part in re.split(r"&&|;|\|\|", command.value)) words = next((w for w in segments if w[:1] == ["git"]), []) return words if words[:1] == ["git"] else [] def _unpinned_fetches(calls: list, git_commands: list) -> list: """Per function: a clone with no pin, or a revision-less Hub download feeding a code loader.""" found = [] loads_code = any(qualified in CODE_LOADERS for _, qualified in calls) for call, qualified in calls: if ( qualified.split(".")[-1] in HUB_DOWNLOADS and loads_code and _keyword(call, "revision") is None ): found.append((call, "hub-download-loaded-as-code")) # A pin is a git command that moves to a revision, not any string mentioning one. if not any(set(words) & PIN_WORDS for _, words in git_commands): found += [ (node, "git-clone-unpinned") for node, words in git_commands if words[1:2] == ["clone"] ] return found def _key(relative: str, rule: str, sink: str, node: ast.AST) -> dict: return { "file": relative, "rule": rule, "sink": sink, "digest": hashlib.sha256(ast.unparse(node).encode()).hexdigest()[:16], "line": getattr(node, "lineno", 0), } _FUNCTIONS = (ast.FunctionDef, ast.AsyncFunctionDef) _TRUST_SHAPES = (ast.Call, ast.Lambda, ast.Assign, ast.AnnAssign, ast.Dict) + _FUNCTIONS def _notebook_source(path: Path, relative: str) -> str: """A notebook's code cells as one module; `%magic` / `!command` lines are blanked to keep line numbers.""" try: document = json.loads(path.read_text(encoding = "utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: raise SystemExit(f"{relative}: could not be read ({error.__class__.__name__})") lines = [] for cell in document.get("cells") or []: if cell.get("cell_type") != "code": continue source = cell.get("source") or [] for line in (source if isinstance(source, str) else "".join(source)).splitlines(): lines.append("" if line.lstrip().startswith(("%", "!")) else line) lines.append("") return "\n".join(lines) def scan_file(path: Path, relative: str) -> list: source = _notebook_source(path, relative) if path.suffix == ".ipynb" else path.read_bytes() try: tree = ast.parse(source, filename = str(path)) except (SyntaxError, ValueError, MemoryError, RecursionError) as error: if path.suffix == ".ipynb": return [] # Unparsed is unchecked; reporting it clean would be a bypass. raise SystemExit(f"{relative}: could not be parsed ({error.__class__.__name__})") found = [] imports = [] calls_by_owner: dict = {} git_by_owner: dict = {} covered = set() stack = [(tree, tree)] while stack: node, owner = stack.pop() for child in ast.iter_child_nodes(node): if isinstance(child, _TRUST_SHAPES): shape = _trust_remote_code(child) if shape: subject = child.args if isinstance(child, _FUNCTIONS) else child entry = _key(relative, "trust-remote-code", shape, subject) entry["line"] = child.lineno found.append(entry) if isinstance(child, ast.Call): calls_by_owner.setdefault(owner, []).append(child) elif isinstance(child, _FUNCTIONS): stack.append((child, child)) continue elif isinstance(child, (ast.Import, ast.ImportFrom)): imports.append(child) elif isinstance(child, ast.Constant) and isinstance(child.value, str): if "/ma" in child.value and BRANCH_HEAD_PY.search(child.value): found.append(_key(relative, "unpinned-code-fetch", "branch-head-url", child)) if ( isinstance(child, (ast.List, ast.Tuple, ast.JoinedStr, ast.Constant)) and id(child) not in covered ): words = _git_words(child) if words: git_by_owner.setdefault(owner, []).append((child, words)) if isinstance(child, ast.JoinedStr): covered.add(id(child.values[0])) stack.append((child, owner)) table = _imports(ast.Module(body = imports, type_ignores = [])) for owner in list(calls_by_owner) + [o for o in git_by_owner if o not in calls_by_owner]: calls = [(call, _qualified(call.func, table)) for call in calls_by_owner.get(owner, ())] for call, qualified in calls: sink = _dynamic_import(call, qualified) if sink: found.append(_key(relative, "dynamic-import", sink, call)) sink = _unsafe_deserialize(call, qualified) if sink: found.append(_key(relative, "unsafe-deserialize", sink, call)) for call, sink in _unpinned_fetches(calls, git_by_owner.get(owner, [])): found.append(_key(relative, "unpinned-code-fetch", sink, call)) return found def collect(targets: list) -> list: found = [] seen = set() for target in targets: root = REPO_ROOT / target if root.is_file() and root.suffix in (".py", ".ipynb"): paths = [root] elif root.is_dir(): paths = sorted(q for suffix in ("*.py", "*.ipynb") for q in root.rglob(suffix)) else: raise SystemExit( f"{target}: scan target does not exist, so nothing under it was checked" ) for path in paths: # This file's self-test sources spell out every bad shape on purpose. if not path.is_file() or path.resolve() == Path(__file__).resolve(): continue relative = _relative(path) if relative in seen or EXCLUDED_PARTS & set(Path(relative).parts): continue seen.add(relative) found.extend(scan_file(path, relative)) return found def _relative(path: Path) -> str: try: return path.resolve().relative_to(REPO_ROOT).as_posix() except ValueError: return path.as_posix() def _identity(entry: dict) -> tuple: return (entry["file"], entry["rule"], entry["sink"], entry["digest"]) def _counted(entries: list) -> dict: counts: dict = {} for entry in entries: counts[_identity(entry)] = counts.get(_identity(entry), 0) + 1 return counts def main() -> int: parser = argparse.ArgumentParser( description = __doc__, formatter_class = argparse.RawDescriptionHelpFormatter ) parser.add_argument("--update", action = "store_true", help = "rewrite the baseline") parser.add_argument("--self-test", action = "store_true", help = "check the rules still fire") parser.add_argument("--paths", nargs = "*", help = "scan these instead of the defaults") arguments = parser.parse_args() if arguments.self_test: return self_test() if arguments.update and arguments.paths: parser.error("--update rewrites the whole baseline; run it without --paths") document = json.loads(BASELINE_PATH.read_text(encoding = "utf-8")) found = collect(arguments.paths or document["targets"]) baseline_rel = BASELINE_PATH.relative_to(REPO_ROOT).as_posix() if arguments.update: # Carry reasons over so a reviewed entry never becomes unreviewed. reasons = {_identity(e): e.get("reason", "") for e in document["entries"]} document["entries"] = sorted( ( { "file": f, "rule": r, "sink": s, "digest": d, "count": n, "reason": reasons.get((f, r, s, d), "REVIEW ME"), } for (f, r, s, d), n in _counted(found).items() ), key = _identity, ) BASELINE_PATH.write_text(json.dumps(document, indent = 2) + "\n", encoding = "utf-8") print(f"baseline: {len(document['entries'])} entries, {len(found)} call sites") return 0 entries = document["entries"] if arguments.paths: scopes = [_relative(REPO_ROOT / path) for path in arguments.paths] if "." not in scopes: entries = [ e for e in entries if any(e["file"] == p or e["file"].startswith(p.rstrip("/") + "/") for p in scopes) ] allowed = {_identity(e): e["count"] for e in entries} observed = _counted(found) lines = {_identity(e): e["line"] for e in found} new = sorted(k for k, n in observed.items() if n > allowed.get(k, 0)) if new: print(f"{len(new)} risky loader call site(s) not in the baseline:\n") for key in new: f, r, s, _ = key print(f" {f}:{lines[key]} [{r}] {s}") print( "\nEither load from a written-out name, a pinned revision or a safe format, or - if " "the input really is trusted - record it:\n" " 1. python scripts/lint_risky_loaders.py --update\n" f' 2. replace the new entr(y/ies)\' "REVIEW ME" reason in {baseline_rel} with why it is safe.' ) return 1 unreviewed = sorted( (e["file"], e["rule"], e["sink"]) for e in entries if e.get("reason", "REVIEW ME") in ("", "REVIEW ME") ) if unreviewed: print(f"{len(unreviewed)} baseline entr(y/ies) carry no justification:\n") for f, r, s in unreviewed: print(f" {f} [{r}] {s}") print(f"\nSay why the input is trusted in the entry's `reason` field in {baseline_rel}.") return 1 # Fewer calls than allowed counts too, so a removed duplicate cannot make room for a new one. stale = sorted(k for k in allowed if observed.get(k, 0) < allowed[k]) if stale: print(f"{len(stale)} baseline entr(y/ies) match fewer calls than recorded:\n") for f, r, s, d in stale: print(f" {f} [{r}] {s} {d}") print( "\nA risky call was removed or rewritten; run `python scripts/lint_risky_loaders.py --update`." ) return 1 print(f"ok: {len(found)} risky loader call site(s), all recorded") return 0 _BAD = { "dynamic-import": """ import importlib from importlib import import_module as im from sentence_transformers.util import import_from_string from transformers.dynamic_module_utils import get_class_from_dynamic_module def f(config, name, path): importlib.import_module(f"mlx_lm.models.{config.model_type}") im(name) __import__(name) import_from_string(config["type"]) get_class_from_dynamic_module(config.auto_map["AutoModel"], name) importlib.util.spec_from_file_location("x", path) importlib.machinery.SourceFileLoader("x", path) """, "trust-remote-code": """ def load(name, trust_remote_code = True): AutoConfig.from_pretrained(name, trust_remote_code = True) trust_remote_code = True kwargs["trust_remote_code"] = True kwargs.setdefault("trust_remote_code", True) return {"trust_remote_code": True} class Request(BaseModel): trust_remote_code: bool = Field(True, description = "x") """, "unpinned-code-fetch": """ import sys, subprocess from huggingface_hub import snapshot_download URL = "https://github.com/ggml-org/llama.cpp/raw/refs/heads/master/convert_hf_to_gguf.py" def codec(): path = snapshot_download("org/codec") sys.path.insert(0, path) def clone(): subprocess.run(["git", "clone", "https://github.com/org/repo"]) def shell_clone(folder): run(f"git clone https://github.com/org/repo {folder}") def held_clone(): commands = ["git clone --recursive https://github.com/org/repo", "pip install x"] try_execute(commands) """, "unsafe-deserialize": """ import pickle, torch, yaml, joblib import numpy as np def f(path, flag): pickle.loads(open(path, "rb").read()) torch.load(path, weights_only = False) torch.load(path, weights_only = flag) torch.load(path) np.load(path, allow_pickle = True) yaml.load(open(path)) joblib.load(path) """, } _BAD_COUNTS = { "dynamic-import": 7, "trust-remote-code": 7, "unpinned-code-fetch": 5, "unsafe-deserialize": 7, } _GOOD = """ import importlib, subprocess, sys, torch, yaml import numpy as np from huggingface_hub import snapshot_download URL = "https://github.com/ggml-org/llama.cpp/raw/b1234/convert_hf_to_gguf.py" def f(name, path, trust_remote_code = False): importlib.import_module("torch.nn") importlib.util.find_spec(name) __import__("os") AutoConfig.from_pretrained(name, trust_remote_code = trust_remote_code) torch.load(path, weights_only = True) np.load(path) yaml.load(open(path), Loader = yaml.SafeLoader) yaml.safe_load(open(path)) snapshot_download(name) def chained_pin(version): try_execute(["git clone https://github.com/org/repo", f"cd repo && git reset --hard {version}"]) def pinned(): subprocess.run(["git", "clone", "https://github.com/org/repo"]) subprocess.run(["git", "checkout", "0123abc"]) def codec(): path = snapshot_download("org/codec", revision = "0123abc") sys.path.insert(0, path) """ def self_test() -> int: """Each rule fires on its bad shapes and stays quiet on the good ones, on a runner with nothing but Python.""" import tempfile failures = [] with tempfile.TemporaryDirectory() as directory: cases = [(rule, source, _BAD_COUNTS[rule], rule) for rule, source in _BAD.items()] cases.append(("good", _GOOD, 0, None)) for label, source, expected, rule in cases: path = Path(directory) / f"{label.replace('-', '_')}.py" path.write_text(source, encoding = "utf-8") hits = scan_file(path, path.name) count = len([h for h in hits if rule is None or h["rule"] == rule]) if count != expected or (rule is not None and len(hits) != expected): failures.append( f"{label}: expected {expected} finding(s), got {[(h['rule'], h['sink']) for h in hits]}" ) if failures: print("self-test FAILED:\n " + "\n ".join(failures)) return 1 print("self-test: ok") return 0 if __name__ == "__main__": sys.exit(main())