diff --git a/proof/runner.py b/proof/runner.py index c95bfc6..c00efd9 100644 --- a/proof/runner.py +++ b/proof/runner.py @@ -27,6 +27,7 @@ import hashlib import json import os +import re import shutil import subprocess import sys @@ -272,6 +273,46 @@ def scan_diff_for_restricted(patch_text: str, restricted_patterns: list[str]) -> return violations +# --- Off-protocol input-injection scan (proof-forgery class) ------------------ +# +# A canonical recipe trains FROM SCRATCH and reads only the mounted canonical +# data; it never references a host path or loads pre-trained weights. A miner +# who patches train() to `torch.load("/home/.../checkpoint.pt")` (a model trained +# off-protocol, mounted on their own CC box) or points the data dir at +# `/mnt/.../data_50b` produces a bundle the attested run did NOT legitimately +# train — the attestation still signs because the canonical image DID run the +# recipe; the recipe just loaded a file. These patterns flag that class. +# +# This is a STATIC op1 gate (cheap, before any compute): necessary but NOT +# sufficient — a determined miner can stage inputs under a canonical mount path. +# The authoritative defense is sealed re-eval (re-run with no external mounts + +# require the checkpoint to reproduce). It does stop the current in-the-wild +# `torch.load("/home/.../checkpoint.pt")` + `/mnt/.../data` attempts. +_EXTERNAL_PATH_RE = re.compile(r"""['"`](?:~|\.\.)?/(?:home|root|mnt|media|srv|scratch|Users)\b""") +_WEIGHT_LOAD_RE = re.compile( + r"\b(?:torch\.load|pickle\.loads?|np\.load|numpy\.load|joblib\.load|load_file|safetensors\w*\.\w*load\w*)\s*\(" +) +_ABS_WEIGHT_PATH_RE = re.compile(r"""['"`]/[^'"`]*\.(?:pt|pth|ckpt|safetensors|bin|pkl|npz)\b""") + + +def scan_diff_for_exploit_patterns(patch_text: str) -> list[tuple[str, str]]: + """Flag ADDED recipe-patch lines that inject off-protocol inputs (the proof- + forgery class: load an externally-trained checkpoint or train on non-canonical + data the attested run never produced). Returns [(reason, offending_line)]; + empty list = clean. Both op1 (validator-side, authoritative) and the + in-container runner reject on any hit.""" + hits: list[tuple[str, str]] = [] + for raw in patch_text.splitlines(): + if not raw.startswith("+") or raw.startswith("+++"): + continue + body = raw[1:].strip() + if _EXTERNAL_PATH_RE.search(body): + hits.append(("references an external/host path (off-protocol input injection)", body[:200])) + elif _WEIGHT_LOAD_RE.search(body) and _ABS_WEIGHT_PATH_RE.search(body): + hits.append(("loads model weights from an absolute path (recipe must train from scratch)", body[:200])) + return hits + + def apply_patch(workdir: Path, patch_path: Path) -> None: """Apply a unified diff using `git apply` (no git repo needed with --3way? No, we use plain patch). We use `patch -p1` since it's simpler.""" @@ -342,6 +383,9 @@ def run_proof_test( violations = scan_diff_for_restricted(patch_text, restricted_patterns) if violations: raise RuntimeError(f"patch touches restricted paths: {violations}") + exploit_hits = scan_diff_for_exploit_patterns(patch_text) + if exploit_hits: + raise RuntimeError(f"patch injects off-protocol inputs: {exploit_hits}") # 3. Create a working copy of the canonical recipe and apply the patch. # The recipe (model/, recipe/, data/, configs/) lives in the sibling diff --git a/tests/test_restricted_scanner.py b/tests/test_restricted_scanner.py index d7a42ac..09f3bca 100644 --- a/tests/test_restricted_scanner.py +++ b/tests/test_restricted_scanner.py @@ -18,11 +18,70 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent)) import ralph_bootstrap # noqa: F401 -from proof.runner import _extract_diff_paths, scan_diff_for_restricted +from proof.runner import ( + _extract_diff_paths, + scan_diff_for_exploit_patterns, + scan_diff_for_restricted, +) RESTRICTED = ["eval/**", "calibration/**", "validator/**", "proof/**", "restricted_files.yaml"] +# --- scan_diff_for_exploit_patterns: off-protocol input-injection (proof-forgery) --- +def _added(*lines: str) -> str: + return "+++ b/recipe/train.py\n" + "\n".join("+" + ln for ln in lines) + "\n" + + +def test_warm_start_external_checkpoint_load_flagged(): + # PR#586-class: train() loads an off-protocol checkpoint from the miner's box. + hits = scan_diff_for_exploit_patterns( + _added('_ws = torch.load("/home/jovyan/v10sub/checkpoint.pt", weights_only=True)') + ) + assert hits and "external/host path" in hits[0][0] + + +def test_noncanonical_data_path_flagged(): + # PR#344-class: a config points the data dir at a host path -> bypasses the lock. + diff = '+++ b/configs/big.json\n+ "data_base_dir": "/mnt/scratch/SN40/data_50b",\n' + assert scan_diff_for_exploit_patterns(diff) + + +def test_host_toolchain_path_flagged(): + assert scan_diff_for_exploit_patterns(_added('_GCC = "/home/root/diony/toolchain/gcc"')) + + +def test_abs_checkpoint_load_under_canonical_mount_flagged(): + # staging weights under a canonical mount still loads a .pt by absolute path. + hits = scan_diff_for_exploit_patterns(_added('sd = torch.load("/data/staged/model.pt")')) + assert hits and "absolute path" in hits[0][0] + + +def test_legit_lr_schedule_returns_clean(): + # the false positives a naive return-scan produced (PR#585 / #413). + diff = _added( + " def lr_at(step):", + " return cfg.min_lr + (cfg.max_lr - cfg.min_lr) * frac", + " return cfg.max_lr", + ) + assert scan_diff_for_exploit_patterns(diff) == [] + + +def test_legit_relative_and_canonical_data_clean(): + diff = _added( + ' shards = sorted(Path("data/shards").glob("*.bin"))', + ' manifest = json.load(open("/data/data_manifest.json"))', + ' ckpt = torch.load(out_dir / "checkpoint.pt")', + ' os.environ.setdefault("TRITON_CACHE_DIR", "/tmp/ralph_triton")', + ) + assert scan_diff_for_exploit_patterns(diff) == [] + + +def test_removed_lines_not_scanned(): + # a removal (`-`) referencing a host path must not trip the scanner. + diff = '+++ b/recipe/train.py\n- x = torch.load("/home/old/ckpt.pt")\n' + assert scan_diff_for_exploit_patterns(diff) == [] + + def test_clean_diff_does_not_match(): diff = """diff --git a/configs/h100_default.json b/configs/h100_default.json --- a/configs/h100_default.json diff --git a/validator/validator.py b/validator/validator.py index 42bc593..37be324 100644 --- a/validator/validator.py +++ b/validator/validator.py @@ -39,7 +39,7 @@ from proof.real_attest import ( verify_attestation as verify_real_attestation, ) -from proof.runner import _load_restricted_paths, scan_diff_for_restricted +from proof.runner import _load_restricted_paths, scan_diff_for_exploit_patterns, scan_diff_for_restricted from proof.sources import compute_container_measurement # Hard-coded sanity bounds for the miner-submitted model config. The validator @@ -293,6 +293,9 @@ def op1_diff_and_integrity( violations = scan_diff_for_restricted(patch_text, patterns) if violations: return False, f"patch touches restricted paths: {violations}" + exploit_hits = scan_diff_for_exploit_patterns(patch_text) + if exploit_hits: + return False, f"patch injects off-protocol inputs: {exploit_hits[0][0]} :: {exploit_hits[0][1]}" return True, "ok"