Files
app-catalog-felhom.eu/scripts/check-family-gate.py
T
admin 96829d0d0f
gates / gates (push) Successful in 3s
Grimmory and MeTube published behind the family gate (controller >= 0.287.0, decisions 63/64), with complete records
- family_gate / family_gate_except / min_controller format (README, REUSE); gate family-gate + decoys
- templates/grimmory (v3.5.0, exceptions OPDS/Kobo/KOReader/Komga) + onboarding/grimmory.md
- templates/metube (2026.09.29, no exceptions; ladder .28 -> .29) + onboarding/metube.md; fixture MeTube
- volume-persistence gate: routed port read from the label NAME (R-801, red-proofed); APP_EXERCISE (R-788)
- box_walk: family_cookie; no cached "not gated" while an app has no router

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
2026-10-02 09:36:49 +02:00

147 lines
6.5 KiB
Python

#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check-family-gate.py — the family gate's template fields are safe to publish (`09` §3 decisions 63/64, controller 0.287.0).
A template with `family_gate: true` is published ONLY behind the household's family gate. Three ways that goes wrong, each
refused here:
1. an exception (`family_gate_except:`) that is not a LITERAL path prefix — a regex, a traefik matcher, `..`, `//`, or `/`
itself. The controller anchors each prefix at a path-segment boundary (`^/prefix(/|$)`, finding F1 of the spike: an
unanchored `PathPrefix(/api/v1/opds)` let `/api/v1/opdsx` past the gate) and refuses the install on anything else —
this gate refuses it at push time instead of at a household's install;
2. `family_gate: true` without `min_controller: "0.287.0"` (or newer) — the controller refuses a template that needs a
newer box, but only if the template SAYS so;
3. `family_gate: true` while the newest baked golden is older than 0.287.0 — a box installed from that golden runs a
controller that does not know `family_gate` and would publish the app OPEN. Read from the sibling `felhom.eu`
checkout (`documentation/tests/golden-<VER>-<DATE>/` holding a bake log with a `GOLDEN_SHA256=` line — a directory
NAME is not a bake, R-410). The sibling absent (CI's single clone) → printed as NOT CHECKED, never as a pass of rule 3.
Also refused: `family_gate_except:` on a template without `family_gate: true` (an exception list with no gate is a label
without the fact).
Line-based on purpose: the catalog's CI has NO PyYAML. Only TOP-LEVEL, uncommented keys count; a key inside a comment, a
README or a tagline is not the field.
Run from the repo root: python3 scripts/check-family-gate.py [--root=<catalog>] [--felhom-eu=<sibling>]
Exit 0 clean · 1 refused. Decoys: scripts/test_gate_decoys.py (family-gate).
"""
import io
import os
import re
import sys
MIN_VERSION = (0, 287, 0)
LITERAL = re.compile(r"^/[A-Za-z0-9._~/-]*$")
TOP_TRUE = re.compile(r"^family_gate:\s*true\s*(#.*)?$")
TOP_EXCEPT = re.compile(r"^family_gate_except:\s*(#.*)?$")
TOP_MINC = re.compile(r'^min_controller:\s*"?([0-9]+\.[0-9]+\.[0-9]+)"?\s*(#.*)?$')
ITEM = re.compile(r'^\s+-\s*(?:"([^"]*)"|\'([^\']*)\'|(\S+))\s*(#.*)?$')
GOLDEN_DIR = re.compile(r"^golden-(\d+)\.(\d+)\.(\d+)-\d{4}-\d{2}-\d{2}$")
GOLDEN_SHA = re.compile(r"GOLDEN_SHA256=[0-9a-f]{64}")
BAKE_LOGS = ("bake.log", "06-bake.log", "bake-clean.log", "06-bake-clean.log")
def arg(name, default):
for a in sys.argv[1:]:
if a.startswith(name + "="):
return a.split("=", 1)[1]
return default
def parse(text):
"""(family_gate, excepts or None, min_controller tuple or None) from a .felhom.yml text."""
gate, excepts, minc = False, None, None
lines = text.splitlines()
for i, ln in enumerate(lines):
if TOP_TRUE.match(ln):
gate = True
m = TOP_MINC.match(ln)
if m:
minc = tuple(int(x) for x in m.group(1).split("."))
if TOP_EXCEPT.match(ln):
excepts = []
for nxt in lines[i + 1:]:
if not nxt.strip() or nxt.lstrip().startswith("#"):
continue
mi = ITEM.match(nxt)
if not mi:
break
excepts.append(next(g for g in mi.groups()[:3] if g is not None))
return gate, excepts, minc
def literal_ok(p):
if not LITERAL.match(p) or "//" in p or "/../" in p or p.endswith("/..") or p.strip("/") == "":
return False
return True
def newest_golden(sibling):
tests = os.path.join(sibling, "documentation", "tests")
if not os.path.isdir(tests):
return None
best = None
for name in os.listdir(tests):
m = GOLDEN_DIR.match(name)
if not m:
continue
d = os.path.join(tests, name)
baked = False
for log in BAKE_LOGS:
p = os.path.join(d, log)
if os.path.isfile(p) and GOLDEN_SHA.search(io.open(p, encoding="utf-8", errors="replace").read()):
baked = True
break
if baked:
v = tuple(int(x) for x in m.groups())
best = v if best is None or v > best else best
return best
def main():
root = arg("--root", os.getcwd())
sibling = arg("--felhom-eu", os.path.join(os.path.dirname(os.path.abspath(root)), "felhom.eu"))
tdir = os.path.join(root, "templates")
fails, gated, unchecked = [], [], False
for app in sorted(os.listdir(tdir)):
fy = os.path.join(tdir, app, ".felhom.yml")
if not os.path.isfile(fy):
continue
gate, excepts, minc = parse(io.open(fy, encoding="utf-8").read())
if excepts is not None and not gate:
fails.append("%s: family_gate_except without family_gate: true — an exception list with no gate" % app)
if not gate:
continue
gated.append(app)
for p in excepts or []:
if not literal_ok(p):
fails.append("%s: family_gate_except %r is not a literal path prefix (the controller anchors "
"^/prefix(/|$) and refuses anything else — F1)" % (app, p))
if minc is None or minc < MIN_VERSION:
fails.append("%s: family_gate needs min_controller: \"%d.%d.%d\" or newer (got %s)"
% ((app,) + MIN_VERSION + (minc,)))
if gated:
g = newest_golden(sibling)
if g is None:
print("family-gate: rule 3 NOT CHECKED — no felhom.eu sibling with a baked golden at %s" % sibling)
unchecked = True
elif g < MIN_VERSION:
fails.append("family_gate on %s while the newest baked golden is %s — a box installed from it would publish "
"the app OPEN; bake a golden >= %d.%d.%d first" % ((", ".join(gated), "%d.%d.%d" % g) + MIN_VERSION))
else:
print("family-gate: newest baked golden %d.%d.%d >= %d.%d.%d" % (g + MIN_VERSION))
for f in fails:
print(" REFUSED " + f)
if fails:
print("family-gate gate: %d refusal(s)" % len(fails))
return 1
# The summary line carries the gap: an "OK" read alone must not stand for rule 3 when rule 3 was not checked (the
# 2026-10-02 bench run read exactly that). Exit stays 0 — CI's single clone can never check it; the hook does.
print("family-gate gate OK: %d family-gated template(s) %s%s" % (len(gated), gated,
" — rule 3 (golden >= 0.287.0) NOT CHECKED here" if unchecked else ""))
return 0
if __name__ == "__main__":
sys.exit(main())