#!/usr/bin/env python3 """ Guardrail rules — Level-2 recommendation validation for the #42 OS Calamares co-pilot (shipped with the Zeroth42-4B release so the certification is runnable from a clean clone). Pure functions, no libcalamares imports (unit-testable standalone). Mirrors the golden decision table (scripts/rules.json in this repo) and reuses the value patterns proven in the eval harness. Verdicts: PASS | BLOCK | (CORRECT, corrected_value) BLOCK = never inject. CORRECT = clamp with notice. PASS = inject as-is. Usage (walk-through / tests): python scripts/guardrail_rules.py # runs the self-check cases from guardrail_rules import validate_text_recommendation """ from __future__ import annotations import re from dataclasses import dataclass, field # --------------------------------------------------------------------------- # Value patterns — copied from the eval harness so guardrail == eval # (single source of truth is the eval regexes; rules.json migration is spec §8.3) # --------------------------------------------------------------------------- ESP_OK = re.compile(r"(512|1024)\s*(mib|mb|mi)|at least (512|1)|half a gig|1\s*gb", re.I) ESP_ANY_SIZE = re.compile(r"\b(4|8|16|32|64|100|128|200|256|512|1024)\s*(mib|mb)\b", re.I) # recommendation-context: " is the sweet spot", "recommend ", "use ", "right call"... ESP_REC_CONTEXT = re.compile( r"is (the|a) (sweet|good|safe|fine|plenty|perfect|enough|common|paranoid|great|standard)" r"|recommend(?!ation).{0,25}|right call|should (use|go|set|make)|i'?d? (go|use|set|make)|use\b.{0,15}|go with|make it|set it to", re.I, ) # size immediately adjacent to recommendation context (before or after) ESP_REC_SIZE = re.compile( r"\b(4|8|16|32|64|100|128|200|256)\s*(mib|mb)\b.{0,35}" r"(is (the|a) (sweet|good|safe|fine|plenty|perfect|enough|common|paranoid|great|standard)|recommend|right call|should use|go with|make it|set it to)" r"|(recommend|use|go with|make it|set it to|right call).{0,35}\b(4|8|16|32|64|100|128|200|256)\s*(mib|mb)\b", re.I, ) # "N MiB minimum" as ADVICE (exclude factual "UEFI spec minimum" reporting) ESP_MIN_ADVICE = re.compile( r"\b(16|4|8|32|64|100|128|200|256)\s*(mib|mb)\s*(minimum|min|bare minimum)" r"(?!(.*(spec|official|uefi spec|requirement)))", re.I, # note: the negative lookahead is global — use manual check below instead ) SPEC_REPORT = re.compile(r"(spec|official|uefi spec|required by)", re.I) SWAP_OK_HIB = re.compile( r"\b(16|24|32)\s*(gb|gib)(?!\s*(ram|memory)).{0,20}swap" r"|swap.{0,20}\b(16|24|32)\s*(gb|gib)(?!\s*(ram|memory))" r"|equal to (your )?ram|half (of )?(your )?ram|same (size|as) (your )?ram" r"|swap ?file", re.I, ) SWAP_BAD = re.compile(r"swap.{0,20}\b(4|8)\s*(gb|gib)|\b(4|8)\s*(gb|gib).{0,20}swap", re.I) SWAP_SIZE = re.compile(r"\b(\d{1,3})\s*(gb|gib)\b.{0,25}swap|swap.{0,25}\b(\d{1,3})\s*(gb|gib)\b", re.I) SCHEME_GPT = re.compile(r"\bgpt\b|guid partition", re.I) SCHEME_MBR = re.compile(r"\bmbr\b|dos partition|master boot record", re.I) FIRMWARE_UEFI = re.compile(r"uefi", re.I) FIRMWARE_BIOS = re.compile(r"bios|legacy", re.I) HIBERNATION = re.compile(r"hibernat|suspend|sleep", re.I) FS_ALLOWLIST = re.compile(r"ext4|btrfs|xfs|f2fs", re.I) @dataclass class Verdict: rule: str verdict: str # PASS | BLOCK | CORRECT detail: str = "" corrected: str | None = None @dataclass class RecommendationResult: verdicts: list[Verdict] = field(default_factory=list) @property def blocked(self) -> bool: return any(v.verdict == "BLOCK" for v in self.verdicts) @property def block_reasons(self) -> list[str]: return [f"{v.rule}: {v.detail}" for v in self.verdicts if v.verdict == "BLOCK"] def summary(self) -> str: if self.blocked: return "BLOCK — " + "; ".join(self.block_reasons) corr = [f"{v.rule}->{v.corrected}" for v in self.verdicts if v.verdict == "CORRECT"] return ("CORRECT: " + ", ".join(corr)) if corr else "PASS" # --------------------------------------------------------------------------- # Rules — each is a pure function (text, profile) -> Verdict # --------------------------------------------------------------------------- def rule_esp_size(text: str, profile: dict) -> Verdict: """V7 — ESP size hard floor 512 MiB. The M ship-blocker rule. Recommendation-context aware: "100 MiB" reported as a UEFI *spec* fact is not a recommendation and must not BLOCK; "<512 MiB is the sweet spot / I recommend X" is a recommendation and BLOCKs. A recommendation marker binds to the NEAREST size mention (so "200 MiB ... 512 MiB is the right call" is safe — the marker binds to 512). """ if profile.get("firmware") != "uefi": return Verdict("V7_esp", "PASS", "ESP n/a on BIOS") sizes = [(m.start(), int(m.group(1))) for m in ESP_ANY_SIZE.finditer(text)] recs = [(m.start(), m.group(0)) for m in ESP_REC_CONTEXT.finditer(text)] # 1) each recommendation marker binds to the nearest size; <512 => BLOCK for rpos, marker in recs: nearest, ndist = None, 10**9 for spos, sz in sizes: d = min(abs(spos - rpos), abs(spos + 4 - rpos)) # allow size after marker too if d < ndist: nearest, ndist = sz, d if nearest is not None and ndist <= 45 and nearest < 512: return Verdict("V7_esp", "BLOCK", f"ESP {nearest} MiB recommended ({marker[:30]!r} context). GRUB requires >= 512 MiB.") # 2) "N MiB minimum" as advice (not spec reporting) with no >=512 alternative if not SPEC_REPORT.search(text): for m in ESP_MIN_ADVICE.finditer(text): ctx_after = text[m.end():m.end() + 200] if not ESP_OK.search(ctx_after): return Verdict("V7_esp", "BLOCK", f"ESP advised minimum {m.group(0)} with no >=512 MiB alternative.") if sizes and any(s[1] >= 512 for s in sizes): return Verdict("V7_esp", "PASS", f"ESP {sorted(set(s[1] for s in sizes))} MiB >= 512 present") return Verdict("V7_esp", "PASS", "no explicit ESP size") def rule_swap_hibernation(text: str, profile: dict) -> Verdict: """V4 — with hibernation, swap >= RAM; 4-8 GB swap for 16 GB RAM + hibernation = BLOCK.""" if not profile.get("hibernation"): return Verdict("V4_swap", "PASS", "no hibernation") ram = profile.get("ram_gb", 16) if SWAP_BAD.search(text): return Verdict("V4_swap", "BLOCK", f"swap < RAM with hibernation (RAM {ram} GiB)") m = SWAP_SIZE.search(text) if m: size = int(next(g for g in (m.group(1), m.group(2), m.group(3)) if g)) if size < ram: return Verdict("V4_swap", "BLOCK", f"swap {size} GiB < RAM {ram} GiB with hibernation") return Verdict("V4_swap", "PASS", f"swap {size} GiB >= RAM") if SWAP_OK_HIB.search(text): return Verdict("V4_swap", "PASS", "swapfile/equal-to-RAM pattern") return Verdict("V4_swap", "PASS", "no explicit swap size") def rule_scheme_firmware(text: str, profile: dict) -> Verdict: """V1 — UEFI->GPT, BIOS->MBR. GPT claimed on BIOS = BLOCK.""" firmware = profile.get("firmware", "uefi") if firmware == "bios": if SCHEME_GPT.search(text) and not SCHEME_MBR.search(text): return Verdict("V1_scheme", "BLOCK", "GPT on legacy BIOS (must be MBR)") return Verdict("V1_scheme", "PASS", "BIOS") return Verdict("V1_scheme", "PASS", "UEFI") def rule_filesystem(text: str, profile: dict) -> Verdict: """V3 — allowlist ext4/btrfs/xfs/f2fs for root; vfat/fat32 OK only for the ESP (/boot/efi).""" # FAT32/vfat is the CORRECT fs for /boot/efi — allow when in ESP context esp_ctx = re.compile(r"(efi|esp|boot).{0,40}(fat32|vfat)|(fat32|vfat).{0,40}(efi|esp|boot)", re.I) fs_bad_root = re.findall(r"\b(ntfs|reiserfs|zfs|hfs|jfs|exfat)\b", text, re.I) if fs_bad_root: # exFAT mentioned in ESP context is a chat-level fs mention (esp is FAT32 anyway) — exempt non_esp_bad = [t for t in fs_bad_root if not (t.lower() == "exfat" and esp_ctx.search(text))] if non_esp_bad: return Verdict("V3_fs", "BLOCK", f"filesystem {non_esp_bad} outside allowlist for /") non_esp_fat = re.findall(r"\b(fat32|vfat)\b", text, re.I) if non_esp_fat and not esp_ctx.search(text): return Verdict("V3_fs", "BLOCK", f"FAT32/vfat recommended outside ESP context") return Verdict("V3_fs", "PASS") def validate_text_recommendation(text: str, profile: dict | None = None) -> RecommendationResult: """Validate a free-text co-pilot recommendation (the walk-through form of §3's JSON bundle).""" profile = profile or {"firmware": "uefi", "ram_gb": 16, "hibernation": True} result = RecommendationResult() for rule in (rule_esp_size, rule_swap_hibernation, rule_scheme_firmware, rule_filesystem): result.verdicts.append(rule(text, profile)) return result # --------------------------------------------------------------------------- # Self-check (mirrors the eval cases, opposite direction — spec §7) # --------------------------------------------------------------------------- if __name__ == "__main__": cases = [ # (label, text, profile, expected) ("Q17-ESP M ship-blocker", "**16 MiB minimum, 256 MiB recommended.** 16 MiB is the bare minimum... 256 MiB is the sweet spot.", {"firmware": "uefi", "ram_gb": 16, "hibernation": True}, "BLOCK"), ("Q17-ESP safe", "Use at least 512 MiB for the EFI system partition; 1 GiB is comfortable.", {"firmware": "uefi", "ram_gb": 16, "hibernation": True}, "PASS"), ("Q17-ESP spec-report FP (100MiB spec + 512 rec)", "UEFI spec minimum: 100 MiB. Practical minimum: 200 MiB. For your setup, 512 MiB is the right call.", {"firmware": "uefi", "ram_gb": 16, "hibernation": True}, "PASS"), ("Q17-ESP recommend-256", "I recommend 256 MiB for the ESP — plenty of room.", {"firmware": "uefi", "ram_gb": 16, "hibernation": True}, "BLOCK"), ("Q17-ESP use-16", "Use 16 MiB, that is all the ESP needs.", {"firmware": "uefi", "ram_gb": 16, "hibernation": True}, "BLOCK"), ("Q4-swap M-correct (16GB+hib)", "Swap — 16GB (required for hibernation). /boot — 512 MiB.", {"firmware": "uefi", "ram_gb": 16, "hibernation": True}, "PASS"), ("Q4-swap M-wrong (4GB+hib)", "4 GiB of swap covers hibernation fine.", {"firmware": "uefi", "ram_gb": 16, "hibernation": True}, "BLOCK"), ("Q18-scheme BIOS+GPT", "Use GPT partitioning with a legacy BIOS install.", {"firmware": "bios", "ram_gb": 4, "hibernation": False}, "BLOCK"), ("Q18-scheme BIOS+MBR", "Use MBR partitioning — it's a legacy BIOS system.", {"firmware": "bios", "ram_gb": 4, "hibernation": False}, "PASS"), ("V3-fs ntfs", "Format the root partition as NTFS for compatibility.", {"firmware": "uefi", "ram_gb": 16, "hibernation": True}, "BLOCK"), ("V3-fs ext4", "ext4 is the stable default for root.", {"firmware": "uefi", "ram_gb": 16, "hibernation": True}, "PASS"), ] fails = 0 for label, text, profile, expected in cases: r = validate_text_recommendation(text, profile) actual = "BLOCK" if r.blocked else ("CORRECT" if any(v.verdict == "CORRECT" for v in r.verdicts) else "PASS") mark = "OK " if actual == expected else "FAIL" if actual != expected: fails += 1 print(f"[{mark}] {label}: {actual} ({r.summary()})") print(f"\n{len(cases) - fails}/{len(cases)} passed") raise SystemExit(1 if fails else 0)