File size: 11,876 Bytes
845bbae
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
#!/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: "<size> is the sweet spot", "recommend <size>", "use <size>", "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)