mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-29 17:44:14 +00:00
* Studio: persistent per-user trust_remote_code approval cache The consent gate pins each approval to a content fingerprint (sha256 over every repo .py), but nothing was persisted, so the dialog reappeared on every fresh load of the same unchanged repo. This adds an on-disk, per-user approval cache that lets the gate skip the dialog when the same user reloads the same code, while keeping the safety guarantees intact. Two-tier validation, both must hold or the user is re-prompted: - Commit SHA (cheap, one HfApi.model_info().sha, no download): a match means a byte-identical tree to the approved revision, so the scan/download is skipped. - Content fingerprint (authoritative): used whenever the SHA is unavailable (local path / offline) and always recomputed on a SHA miss. A new or edited .py changes both the SHA and the fingerprint, so it is caught in every mode. Safety: - Keyed per subject; one user's approval never auto-runs code for another. - CRITICAL is never stored or honored (guarded on both write and read), so a hand-edited store cannot smuggle in an auto-approval. - The malware (HF unsafe-file) gate stays unconditional. - Fail-safe: a corrupt store, an unresolvable SHA, or any error degrades to "ask again", never to "auto-approve". UNSLOTH_TRC_APPROVAL_CACHE_DISABLE=1 turns the cache off entirely. New module utils/security/remote_code_approvals.py holds the store (studio_root()/security/remote_code_approvals.json, atomic write, 0600, RLock) plus the SHA resolvers. Recording happens at the single gate chokepoint when the caller supplies the matching fingerprint, so subject is just threaded through inference/training/export (orchestrators, routes, workers). The scan endpoint returns already_approved so the frontend can skip the dialog on a cache hit. Tests: new tests/test_trc_approval_cache.py covers cache miss, SHA-match skip, SHA-moved re-scan, new-file re-consent, CRITICAL never cached (write + forged read), disable flag, subject isolation, combined adapter+base key, corrupt store, and no-subject bypass. Full security suite: 101 passed. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Address review: make the approval cache skip only the prompt, never the scan Codex found that the SHA "no-scan" fast path could run untrusted code without re-consent. Removed it; the gate now always re-scans and the cache only seeds the authoritative fingerprint check, so it can skip the dialog but never the scan. - CRITICAL is hard-blocked on every load (the scan always runs), so a hand-edited store that downgrades a CRITICAL repo's severity can no longer auto-run it (P2: do not trust editable severity for SHA approvals). - The fingerprint covers external auto_map repos, so changed third-party code always re-prompts even when the primary commit SHA is unchanged; there is no longer a SHA path that bypasses the fingerprint (P1: external auto_map repos). - resolve_commit_sha is resolved fresh on every call (no memoization), so a repo whose default branch moves after approval re-prompts instead of reusing a stale cached SHA (P1: revalidate mutable Hub SHAs). The SHA is now only a conservative secondary gate: a fresh resolvable SHA must match the approved revision, else the seed is withheld; a None (local/offline) falls back to the fingerprint. - Approvals record the scanner ruleset version (SCAN_RULES_VERSION); the gate ignores approvals from an older ruleset so reclassified bytes are re-scanned and re-shown instead of silently auto-approved (P2: invalidate on scan-policy change). Tests: test_trc_approval_cache.py rewritten around the prompt-skip semantics (unchanged repo still scans; SHA move / changed code / scanner-version bump / disable flag all re-prompt; forged downgraded severity still blocks CRITICAL). 105 passed with test_consent_gate.py. * [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci * Trim comments to be more succinct * Keep run-owner subject out of persisted config; serialize approval writes Threading subject (the run owner's username / API-key id) into the training config meant _sanitize_db_config persisted it into config_json, which training-history GET returns to any authenticated user, leaking who started a run in multi-user installs. Filter subject alongside the token fields; the worker still receives it from the live config. The approval store's RLock only guards one process, but approvals are recorded from separate inference/export/training subprocesses, so concurrent writers could clobber each other on os.replace and drop an approval (re-prompt). Hold a best-effort cross-process file lock around the read-modify-write. * Fail safe on a malformed approval store A store with the right version but a non-dict shape (e.g. a hand-edited "subjects": []) passed _load()'s check, then lookup chained .get() on a list and raised, breaking every remote-code load until the file was removed. Validate that subjects is a dict in _load(), and tolerate a non-dict per-subject entry in lookup/record/forget, so a corrupt store fails safe (re-prompt) instead. * Keep subject out of the MLX W&B run config _run_mlx_training uploads the whole training config to W&B minus a sensitive set that only listed hf_token/wandb_token/s3_config, so the authenticated subject (username / API-key id) was sent to W&B as run config even though DB history already strips it. Add subject to the W&B-sensitive filter, mirroring training._sanitize_db_config. * Tighten the W&B subject-filter comment --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com>
247 lines
8.7 KiB
Python
247 lines
8.7 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
|
|
|
|
"""Persistent, per-user trust_remote_code approval cache.
|
|
|
|
Remembers a user's explicit approval so the consent gate can skip only the DIALOG on a
|
|
later load of the SAME unchanged code. The gate ALWAYS re-scans (the cache never skips the
|
|
scan), so CRITICAL is hard-blocked every time and a hand-edited store cannot auto-approve
|
|
malicious code. Keyed per subject; honored only when the content fingerprint matches AND
|
|
the scanner-rules version matches AND (when resolvable) the commit SHA matches. CRITICAL is
|
|
never stored or honored, and any store/SHA error degrades to "ask again", never auto-approve.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import json
|
|
import os
|
|
import threading
|
|
from dataclasses import dataclass
|
|
from datetime import datetime, timezone
|
|
from typing import Optional
|
|
|
|
from loggers import get_logger
|
|
from utils.paths import storage_roots
|
|
from utils.security.remote_code_scan import CRITICAL, SCAN_RULES_VERSION
|
|
|
|
logger = get_logger(__name__)
|
|
|
|
_SCHEMA_VERSION = 1
|
|
_lock = threading.RLock()
|
|
|
|
# Re-exported so the gate can compare a stored approval's ruleset to the live one.
|
|
SCANNER_VERSION = SCAN_RULES_VERSION
|
|
|
|
|
|
@dataclass
|
|
class StoredApproval:
|
|
commit_sha: Optional[str]
|
|
fingerprint: str
|
|
max_severity: Optional[str]
|
|
approved_at: str
|
|
scanner_version: int = 0
|
|
|
|
|
|
def cache_disabled() -> bool:
|
|
return os.environ.get("UNSLOTH_TRC_APPROVAL_CACHE_DISABLE", "").lower() in ("1", "true", "yes")
|
|
|
|
|
|
def _store_path():
|
|
return storage_roots.studio_root() / "security" / "remote_code_approvals.json"
|
|
|
|
|
|
def _env_offline() -> bool:
|
|
return os.environ.get("HF_HUB_OFFLINE", "").lower() in ("1", "true", "yes") or os.environ.get(
|
|
"TRANSFORMERS_OFFLINE", ""
|
|
).lower() in ("1", "true", "yes")
|
|
|
|
|
|
def approval_target_key(targets) -> str:
|
|
"""Stable key for the combined load unit (a LoRA pins adapter + base together), using
|
|
the same casing normalization as the fingerprint so identity never disagrees."""
|
|
from utils.security.consent import _fingerprint_target_key
|
|
|
|
keys = sorted(_fingerprint_target_key(t) for t in dict.fromkeys(targets) if t)
|
|
return "\x1f".join(keys)
|
|
|
|
|
|
def _load() -> dict:
|
|
"""Parsed store, or an empty skeleton on any error (fail-safe = re-prompt)."""
|
|
try:
|
|
with open(_store_path()) as f:
|
|
data = json.load(f)
|
|
# Validate the shape, not just the version: a hand-edited ``subjects`` that is not a
|
|
# dict (e.g. ``[]``) would otherwise crash lookup/record instead of failing safe.
|
|
if (
|
|
isinstance(data, dict)
|
|
and data.get("version") == _SCHEMA_VERSION
|
|
and isinstance(data.get("subjects"), dict)
|
|
):
|
|
return data
|
|
except FileNotFoundError:
|
|
pass
|
|
except Exception as exc:
|
|
logger.warning("Could not read remote-code approvals (%s); ignoring", exc)
|
|
return {"version": _SCHEMA_VERSION, "subjects": {}}
|
|
|
|
|
|
def _save(data: dict) -> None:
|
|
"""Atomic write (tmp + os.replace), best-effort 0600."""
|
|
path = _store_path()
|
|
storage_roots.ensure_dir(path.parent)
|
|
tmp = path.parent / f".{path.name}.tmp-{os.getpid()}"
|
|
try:
|
|
with open(tmp, "w") as f:
|
|
json.dump(data, f, indent = 2)
|
|
try:
|
|
os.chmod(tmp, 0o600)
|
|
except OSError:
|
|
pass
|
|
os.replace(tmp, path)
|
|
except Exception as exc:
|
|
logger.warning("Could not write remote-code approvals (%s)", exc)
|
|
try:
|
|
tmp.unlink(missing_ok = True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
@contextlib.contextmanager
|
|
def _file_lock():
|
|
"""Best-effort cross-process exclusive lock over the store. Inference/export/training
|
|
record approvals from separate subprocesses, so the in-process RLock is not enough: two
|
|
processes could each read the same JSON and clobber the other's entry on ``os.replace``.
|
|
Holding this around the read-modify-write serializes them. Degrades to a no-op if OS
|
|
locking is unavailable (the consequence is only an occasional extra prompt)."""
|
|
path = _store_path()
|
|
try:
|
|
storage_roots.ensure_dir(path.parent)
|
|
fd = os.open(str(path.parent / f"{path.name}.lock"), os.O_CREAT | os.O_RDWR, 0o600)
|
|
except Exception:
|
|
yield
|
|
return
|
|
try:
|
|
try:
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
msvcrt.locking(fd, msvcrt.LK_LOCK, 1)
|
|
else:
|
|
import fcntl
|
|
fcntl.flock(fd, fcntl.LOCK_EX)
|
|
except Exception:
|
|
pass # locking unavailable; the thread lock still applies
|
|
yield
|
|
finally:
|
|
try:
|
|
if os.name == "nt":
|
|
import msvcrt
|
|
with contextlib.suppress(Exception):
|
|
msvcrt.locking(fd, msvcrt.LK_UNLCK, 1)
|
|
else:
|
|
import fcntl
|
|
fcntl.flock(fd, fcntl.LOCK_UN)
|
|
finally:
|
|
os.close(fd)
|
|
|
|
|
|
def lookup(subject: str, target_key: str) -> Optional[StoredApproval]:
|
|
"""The stored approval for (subject, target_key), or None. A CRITICAL entry (e.g. a
|
|
hand-edited store) is refused so it can never seed an approval."""
|
|
if not subject or cache_disabled():
|
|
return None
|
|
with _lock:
|
|
subj = _load().get("subjects", {}).get(subject, {})
|
|
entry = subj.get(target_key) if isinstance(subj, dict) else None
|
|
if not isinstance(entry, dict) or not entry.get("fingerprint"):
|
|
return None
|
|
if entry.get("max_severity") == CRITICAL:
|
|
return None
|
|
return StoredApproval(
|
|
commit_sha = entry.get("commit_sha"),
|
|
fingerprint = entry["fingerprint"],
|
|
max_severity = entry.get("max_severity"),
|
|
approved_at = entry.get("approved_at", ""),
|
|
scanner_version = entry.get("scanner_version", 0),
|
|
)
|
|
|
|
|
|
def record(
|
|
subject: str,
|
|
target_key: str,
|
|
*,
|
|
commit_sha: Optional[str],
|
|
fingerprint: str,
|
|
max_severity: Optional[str],
|
|
scanner_version: int = SCANNER_VERSION,
|
|
) -> None:
|
|
"""Persist a user's explicit approval. CRITICAL is never stored."""
|
|
if not subject or not fingerprint or cache_disabled() or max_severity == CRITICAL:
|
|
return
|
|
with _lock, _file_lock():
|
|
data = _load()
|
|
subjects = data.setdefault("subjects", {})
|
|
subj = subjects.get(subject)
|
|
if not isinstance(subj, dict): # tolerate a hand-edited non-dict entry
|
|
subj = subjects[subject] = {}
|
|
subj[target_key] = {
|
|
"commit_sha": commit_sha,
|
|
"fingerprint": fingerprint,
|
|
"max_severity": max_severity,
|
|
"scanner_version": scanner_version,
|
|
"approved_at": datetime.now(timezone.utc).isoformat(),
|
|
}
|
|
_save(data)
|
|
|
|
|
|
def forget(subject: str, target_key: str) -> None:
|
|
"""Drop an approval (e.g. the user declined / discarded the download)."""
|
|
if not subject:
|
|
return
|
|
with _lock, _file_lock():
|
|
data = _load()
|
|
subj = data.get("subjects", {}).get(subject)
|
|
if isinstance(subj, dict) and subj.pop(target_key, None) is not None:
|
|
_save(data)
|
|
|
|
|
|
def clear() -> None:
|
|
"""Test helper: drop the on-disk store."""
|
|
with _lock:
|
|
try:
|
|
_store_path().unlink(missing_ok = True)
|
|
except OSError:
|
|
pass
|
|
|
|
|
|
def resolve_commit_sha(target: str, hf_token: Optional[str] = None) -> Optional[str]:
|
|
"""Current HF commit SHA for *target*, or None (local path / offline / error). Resolved
|
|
fresh every call: the default branch is mutable, so a cached SHA could mask a moved repo
|
|
and reuse stale consent. None falls back to the authoritative fingerprint (never fail-open).
|
|
"""
|
|
from utils.paths import is_local_path
|
|
try:
|
|
if is_local_path(target) or _env_offline():
|
|
return None
|
|
from huggingface_hub import HfApi
|
|
return HfApi().model_info(target, token = hf_token).sha
|
|
except Exception as exc:
|
|
logger.debug("Could not resolve commit sha for '%s': %s", target, exc)
|
|
return None
|
|
|
|
|
|
def resolve_combined_sha(targets, hf_token: Optional[str] = None) -> Optional[str]:
|
|
"""Combined SHA over the primary targets; None if ANY is unresolvable. A cheap secondary
|
|
gate only -- the fingerprint (which also covers external auto_map repos) stays
|
|
authoritative, so a None here just falls back to the fingerprint, never weakens it."""
|
|
from utils.security.consent import _fingerprint_target_key
|
|
|
|
parts = []
|
|
for target in dict.fromkeys(targets):
|
|
if not target:
|
|
continue
|
|
sha = resolve_commit_sha(target, hf_token)
|
|
if sha is None:
|
|
return None
|
|
parts.append(f"{_fingerprint_target_key(target)}={sha}")
|
|
return "\x1f".join(sorted(parts)) if parts else None
|