R-426: image-pins gate gets a decoy suite (and three holes closed)

check-image-pins.py now refuses a QUOTED `"image":` key (was not read at
all), an interpolated `${APP_IMAGE:-nginx}` ref (the tag cannot be read),
and `@sha256:` with no 64-hex digest behind it (the label of a pin). It
takes --root=<dir> (the decoy seam) and accepts the runner's --all.

test_gate_decoys.py: 17 image-pins cases — nine facts that must be
refused (untagged, a registry port read as a tag, quoted/capital :latest,
:edge, a comment claiming a pin, the quoted key, interpolation, a fake
digest), seven inert/genuine shapes that must pass, and the real catalog.
COVERS gains "image-pins".

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-06 01:24:22 +02:00
parent c265b371e2
commit d3e14eb961
2 changed files with 91 additions and 5 deletions
+28 -5
View File
@@ -7,10 +7,17 @@ Scans every templates/*/docker-compose.yml `image:` line and fails (exit 1) on:
- a floating alias tag (`dev`, `nightly`, `edge`, `main`, `master`),
- a missing tag entirely (`image: nginx` → implicit :latest).
A digest reference (`repo@sha256:...`) counts as pinned. Registry ports
A digest reference (`repo@sha256:<64 hex>`) counts as pinned — only a REAL digest: the label
`@sha256:` followed by anything else is not a pin (R-426 decoy). Registry ports
(`host:5000/img:1.2`) are handled: the tag is what follows the LAST colon of the
LAST path segment.
Also refused (R-426, found by the decoy suite `scripts/test_gate_decoys.py`): a QUOTED key
(`"image": nginx` is the same Compose field and was not read at all), and an INTERPOLATED ref
(`${APP_IMAGE:-nginx}` — the tag the box will run cannot be read from the file; write a literal).
`--root=<dir>` judges another checkout (the decoy suite's seam); default is this repo.
Standing rule (CLAUDE.md): never :latest or untagged images in templates — pin a
concrete version tag; deployed apps pin to their running digest.
"""
@@ -19,7 +26,8 @@ import sys
from pathlib import Path
BANNED_TAGS = {"latest", "dev", "nightly", "edge", "main", "master"}
IMAGE_RE = re.compile(r"^\s*image:\s*[\"']?([^\s\"'#]+)")
IMAGE_RE = re.compile(r"^\s*[\"']?image[\"']?\s*:\s*[\"']?([^\s\"'#]+)")
DIGEST_RE = re.compile(r"@sha256:[0-9a-f]{64}$")
def check(root: Path) -> int:
failures = []
@@ -33,8 +41,14 @@ def check(root: Path) -> int:
if not m:
continue
ref = m.group(1)
if "@sha256:" in ref:
continue # digest-pinned — strongest pin there is
if "$" in ref:
failures.append((f, lineno, ref, "INTERPOLATED ref (the pin cannot be read; write a literal)"))
continue
if "@" in ref:
if DIGEST_RE.search(ref):
continue # digest-pinned — strongest pin there is
failures.append((f, lineno, ref, "NOT A DIGEST (@sha256: needs 64 hex characters)"))
continue
last_seg = ref.rsplit("/", 1)[-1]
if ":" not in last_seg:
failures.append((f, lineno, ref, "NO TAG (implicit :latest)"))
@@ -51,4 +65,13 @@ def check(root: Path) -> int:
return 0
if __name__ == "__main__":
sys.exit(check(Path(__file__).resolve().parent.parent))
root = Path(__file__).resolve().parent.parent
for a in sys.argv[1:]:
if a.startswith("--root="):
root = Path(a.split("=", 1)[1])
elif a == "--all":
pass # the runner passes it to every gate; this gate already reads every template, hidden ones too
else:
print(f"unknown argument: {a} (usage: check-image-pins.py [--root=<dir>])", file=sys.stderr)
sys.exit(2)
sys.exit(check(root))
+63
View File
@@ -47,6 +47,7 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
# Read by felhom.eu/scripts/decoy_coverage_gate.py, which AST-parses this literal. A gate named here
# MUST have a decoy below that has been seen to fail.
COVERS = {
"image-pins": "the pin's LABEL without the pin: a registry PORT (`:5000`) read as a tag, `@sha256:` with no digest behind it, a QUOTED `\"image\":` key, an interpolated `${APP_IMAGE:-nginx}`, `:LATEST`/`:latest` in quotes - vs the inert shapes that must pass (a commented `# image: nginx`, an `x-image:` extension field, a README line) and the genuine pins (a tag, a real 64-hex digest, `:latest@sha256:` pinned by its digest, a port with a tag) (R-426)",
"engine-major": "the major moved in a comment/env var/README/app image, not on an engine's image: line",
"catalog-since": "the date bumped in a comment/README while .felhom.yml's field stayed; or only a comment/env moved, no image (R-452)",
"probe-matches-compose": "the probe TARGET resolves by exact name, explicit `container`, or a UNIQUE prefix - an ambiguity is refused, not guessed (R-630); the DEGRADED no-PyYAML mode CI actually runs; the port/path moved in a COMMENT, in traefik's loadbalancer label, in "
@@ -688,6 +689,67 @@ def family_gate_cases():
shutil.rmtree(ws, ignore_errors=True)
def image_pins_cases():
"""check-image-pins.py reads templates/*/docker-compose.yml under --root (R-426).
Each case is ONE planted template in a scratch catalog, so a case's verdict is that line's verdict
and nothing else. The real tree is also judged (it must pass), so a gate that convicts everything
fails here too.
"""
global ran
ws = tempfile.mkdtemp(prefix="catalog-pins-")
DIG = "@sha256:" + "0123456789abcdef" * 4
try:
def run(name, image_line, expect_rc, must=(), extra_file=None):
global ran
cat = os.path.join(ws, "cat")
shutil.rmtree(cat, ignore_errors=True)
d = os.path.join(cat, "templates", "demo")
os.makedirs(d)
io.open(os.path.join(d, "docker-compose.yml"), "w", encoding="utf-8").write(
"services:\n demo:\n image: demo/demo:1.0\n demo-web:\n" + image_line + "\n"
" restart: unless-stopped\n")
if extra_file:
io.open(os.path.join(d, extra_file[0]), "w", encoding="utf-8").write(extra_file[1])
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-image-pins.py"), "--root=" + cat], ROOT)
out = r.stdout + r.stderr
ran += 1
if r.returncode == expect_rc and all(m in out for m in must):
print(" ok %-52s rc=%d (expected %d)" % ("image-pins: " + name, r.returncode, expect_rc))
else:
fails.append("image-pins: %s: rc=%d expected %d; missing %s\n%s" % (
name, r.returncode, expect_rc, [m for m in must if m not in out], out[-600:]))
# ── THE FACTS: an image the box would pull floating. Each must be REFUSED. ───────────────
run("FACT: untagged", " image: nginx", 1, ("NO TAG",))
run("FACT: a registry PORT is not a tag", " image: registry.local:5000/nginx", 1, ("NO TAG",))
run("FACT: quoted :latest", ' image: "nginx:latest"', 1, ("floating tag :latest",))
run("FACT: :LATEST in capitals", " image: nginx:LATEST", 1, ("floating tag",))
run("FACT: a floating alias (:edge)", " image: alpine:edge", 1, ("floating tag :edge",))
run("FACT: untagged with a comment saying pinned", " image: nginx # pinned 1.27", 1, ("NO TAG",))
run("FACT: a QUOTED key is the same field", ' "image": nginx', 1, ("NO TAG",))
run("FACT: an interpolated ref cannot be read", " image: ${APP_IMAGE:-nginx}", 1, ("INTERPOLATED",))
run("FACT: @sha256: with no digest behind it", " image: nginx@sha256:pinned", 1, ("NOT A DIGEST",))
# ── INERT / GENUINE: must PASS ───────────────────────────────────────────────────────────
run("INERT: a commented-out untagged image", " # image: nginx", 0, ("image-pin gate OK",))
run("INERT: an x- extension field (Compose ignores it)", " x-image: nginx", 0, ("image-pin gate OK",))
run("INERT: an untagged image in README.md", " image: nginx:1.27.3", 0, ("image-pin gate OK",),
extra_file=("README.md", "image: nginx\n"))
run("GENUINE: a concrete tag", " image: nginx:1.27.3-alpine", 0, ("image-pin gate OK",))
run("GENUINE: a registry port WITH a tag", " image: registry.local:5000/nginx:1.27", 0, ("image-pin gate OK",))
run("GENUINE: a real digest", " image: nginx" + DIG, 0, ("image-pin gate OK",))
run("GENUINE: :latest pinned by its digest", " image: nginx:latest" + DIG, 0, ("image-pin gate OK",))
# the real catalog must pass too — a gate that convicts everything is not a gate
r = sh([sys.executable, os.path.join(ROOT, "scripts", "check-image-pins.py")], ROOT)
ran += 1
if r.returncode == 0:
print(" ok %-52s rc=0 (expected 0)" % "image-pins: GENUINE: the real catalog")
else:
fails.append("image-pins: the real catalog was refused rc=%d\n%s" % (r.returncode, (r.stdout + r.stderr)[-600:]))
finally:
shutil.rmtree(ws, ignore_errors=True)
def mem_sum_cases():
"""check-mem-limit-sum.py reads FILES: templates/*/docker-compose.yml + .felhom.yml, via --root (R-758)."""
global ran
@@ -1311,6 +1373,7 @@ i18n:
finally:
shutil.rmtree(clone, ignore_errors=True)
image_pins_cases()
onboarding_cases()
family_gate_cases()
mem_sum_cases()