e66d4669d6
gates / gates (push) Failing after 47s
An app was six pages — details, install/settings, logs, export, the per-app backup panel and a
details card for moving data — and every "Vissza" on them went to the app LIST, so there was no way
back to the app you were looking at. It is now ONE page with four tab URLs under /apps/{slug}/,
one shared header and one set of banners.
settings Mukodes (Start/Restart/Stop, Stop confirms) + the cards that want something from the
household + the install-time and optional settings. The default for an installed app,
RENDERED at the bare URL and never redirected to.
data the data folders, move to another drive, the nightly copy, the 2nd-copy setting, export
guide tagline, screenshots, use cases, first steps, the catalog's guide, the FAQ, the docs
link — and the WHOLE page for an app that is not installed
logs the log, and the only tab whose poller runs
The tabs are links, not panels: a link in an alert has to land ON a tab, and the log poller must not
run on the other three. Start/Restart/Stop left the page header, where they read as menu buttons.
The old /stacks/{name}/{deploy,logs,export,backup} GETs answer 302 — never 301, which a browser
caches beyond anyone's reach — to the tab that now holds them, query string carried through, with
name->slug resolved through the stack list rather than assumed equal. Every POST on those paths is
untouched: the backup form, the app-email toggle and the reveal endpoints keep their URLs.
A value asked AFTER the app starts is now generic (D2). The catalog marks a field `after_start: true`
and supplies its label; the ACTION is Go, in a registry keyed by slug + env var. The catalog can
never declare what runs — a declared field with no registered action renders nothing, POSTs 404 and
logs one WARN with the env var KEY, never the value. A red-proof found the second half of that guard
unpinned: deleting the "the catalog must have DECLARED this field" check left the suite green, and a
POST for an undeclared field then reached Plex and opened the setup gate.
TestAfterStart_RegistryAloneIsNotAnInvitation pins it.
New catalog block app_info.guide (sections + faq + checked_version), parsed, localised field by
field and rendered with the household's own address filled in. It renders only when checked_version
names the image tag its author clicked through. No template carries one yet.
The Pi and "csak x86" badges are gone; PiCompatible and the catalog key stay this release, or a box
on 0.314.0 would read "csak x86" on every app (R-1067).
template_id_gate.py now follows {{template}} includes, with a scope decoy proving the walk did not
become vacuous.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0117kRTSgs3rJWUFGxyoWN6Y
512 lines
28 KiB
Python
512 lines
28 KiB
Python
#!/usr/bin/env python3
|
|
# -*- coding: utf-8 -*-
|
|
"""test_gate_decoys.py — can this gate be fooled by a LABEL? (R-421)
|
|
|
|
The controller half of the decoy sweep. Rationale, and the four failure shapes it hunts, are in
|
|
`felhom.eu/scripts/test_gate_decoys.py` and `documentation/audits/AUDIT-gate-decoys-2026-09-01.md`.
|
|
|
|
TWO HOLES THIS FILE PINS, both measured on 2026-09-01 and both fixed the same day:
|
|
|
|
* **Six gates decided their SCOPE with `os.listdir`**, one directory level. No template
|
|
subdirectory existed, so every one was green and correct — and would have stayed green the
|
|
moment anyone added `templates/partials/`, which is an ordinary act. `mojibake` and `docker-v`
|
|
already used `os.walk` and caught the same planted file, which is the control that proved the
|
|
cause was the listing and not the decoy.
|
|
* **`debug-routes` and `app-row-dedup` matched text inside COMMENTS.** A dispatcher case left in a
|
|
commented-out block counted as a live handler — which is R-400's original defect reached through
|
|
the one door its own gate could not see.
|
|
|
|
Run from `controller/`: python3 scripts/test_gate_decoys.py
|
|
Exit 0 all decoys rejected · 1 a decoy passed.
|
|
"""
|
|
import io
|
|
import os
|
|
import re
|
|
import subprocess
|
|
import sys
|
|
|
|
HERE = os.path.dirname(os.path.abspath(__file__))
|
|
CTRL = os.path.dirname(HERE)
|
|
TPL = os.path.join(CTRL, "internal", "web", "templates")
|
|
SUB = os.path.join(TPL, "partials")
|
|
|
|
# ── WHAT THIS FILE COVERS ────────────────────────────────────────────────────────────────────────
|
|
# AST-parsed by felhom.eu/scripts/decoy_coverage_gate.py. See that file for why it is a declaration
|
|
# and not a grep.
|
|
COVERS = {
|
|
"emoji": "an emoji in templates/partials/ (scope was os.listdir)",
|
|
"native-confirm": "a native confirm() in templates/partials/",
|
|
"app-row-dedup": "hand-rolled row markup in partials/, AND a commented-out partial call",
|
|
"template-id": "a JS reference to a missing id, in partials/",
|
|
"secret-markup": "a secret templated into markup, in partials/",
|
|
"retrieval-promise": "an unregistered retrieval promise, in partials/; R-325: the shared vocabulary "
|
|
"absent (INCONCLUSIVE) and an invented stem in it convicting",
|
|
"mojibake": "CONTROL: already walked; proves the planted file is really reachable",
|
|
"debug-routes": "a live dispatcher case commented out - the button survives, the handler dies",
|
|
"golden-notice": "R-410 in the other direction: an empty dir must not count as a bake",
|
|
"minagent-header": "the word MinAgent in prose / a code span, not the header line (test_minagent_header_gate.py)",
|
|
"i18n": "an undefined marker key, a pleading English value, a shrinking/growing gap (v0.247.0)",
|
|
"go-parity": "a Go-side key REWORDED, a key citing text no base literal has, and a converted "
|
|
"key left out of the map (v0.252.0, R-557); a call that lost an argument, and a key glued "
|
|
"to more text with + (R-576)",
|
|
"gofmt": "R-454: a planted unformatted .go file in internal/ is convicted; the clean tree passes",
|
|
"offbox-rename": "R-425: NAS branding in a NEW backups*.html, and in a bundle value an offbox Go file "
|
|
"names; control: the same token in the network-storage feature's copy is accepted",
|
|
"docker-v": "R-426: an unallowlisted `-v /etc:/x` in a NEW .go file two directories down "
|
|
"(internal/ and cmd/) convicts; controls: the clean tree, the same line in a _test.go",
|
|
"reuse-refs": "R-426: a cited .go and a cited .md path that do not exist, planted in THIS repo's "
|
|
"REUSE.md - vs the real file",
|
|
"instructions": "R-426: a component version literal in THIS repo's CLAUDE.md effective text - vs the "
|
|
"same sentence inside an HTML comment",
|
|
"observations": "R-426: R-419 in THIS repo's REPORT.md - an Observations note SAYING it carries no "
|
|
"marker - vs the two genuine markers",
|
|
}
|
|
|
|
fails = []
|
|
ran = 0
|
|
|
|
|
|
def gate(script):
|
|
p = subprocess.run([sys.executable, os.path.join("scripts", script)],
|
|
cwd=CTRL, capture_output=True, text=True)
|
|
return p.returncode, p.stdout + p.stderr
|
|
|
|
|
|
def in_subdir(name, script, content):
|
|
"""Plant a template one directory down and assert the gate still sees it."""
|
|
global ran
|
|
ran += 1
|
|
made = not os.path.isdir(SUB)
|
|
if made:
|
|
os.makedirs(SUB)
|
|
f = os.path.join(SUB, "decoy.html")
|
|
io.open(f, "w", encoding="utf-8").write(content)
|
|
try:
|
|
rc, out = gate(script)
|
|
finally:
|
|
os.remove(f)
|
|
if made and os.path.isdir(SUB) and not os.listdir(SUB):
|
|
os.rmdir(SUB)
|
|
if rc == 0:
|
|
fails.append("%s: a planted template in templates/partials/ PASSED — the gate's scope is a "
|
|
"directory listing, not the set of templates (R-421)\n%s" % (name, out[-400:]))
|
|
else:
|
|
print(" ok %-20s sees templates at any depth" % name)
|
|
|
|
|
|
def swapped(name, script, path, transform, expect="convict"):
|
|
global ran
|
|
ran += 1
|
|
b = io.open(path, encoding="utf-8").read()
|
|
try:
|
|
io.open(path, "w", encoding="utf-8").write(transform(b))
|
|
rc, out = gate(script)
|
|
finally:
|
|
io.open(path, "w", encoding="utf-8").write(b)
|
|
if (rc != 0) != (expect == "convict"):
|
|
fails.append("%s: rc=%d, expected %s\n%s" % (name, rc, expect, out[-400:]))
|
|
else:
|
|
print(" ok %-20s %s" % (name, "decoy rejected" if expect == "convict" else "genuine accepted"))
|
|
|
|
|
|
print("decoys — felhom-controller")
|
|
|
|
# --- SCOPE: the six listdir gates, each with content that actually triggers it -----------------
|
|
in_subdir("emoji", "emoji_gate.py", u"<p>Kesz \U0001F600</p>\n")
|
|
in_subdir("native-confirm", "native_confirm_gate.py",
|
|
u"<button onclick=\"confirm('biztos?')\">x</button>\n")
|
|
in_subdir("app-row-dedup", "app_row_dedup_gate.py", u'<div class="app-row ">hand-rolled</div>\n')
|
|
in_subdir("template-id", "template_id_gate.py",
|
|
u'<div id="realOne"></div>\n<script>document.getElementById("noSuchId").x=1;</script>\n')
|
|
in_subdir("secret-markup", "secret_in_markup_gate.py",
|
|
u'<input type="password" value="{{ .RetrievalPassword }}">\n')
|
|
in_subdir("retrieval-promise", "retrieval_promise_gate.py",
|
|
u"<p>A jelszavat barmikor visszaallithatja innen.</p>\n"
|
|
u"<p>Bovebben: visszaállítható a kóddal.</p>\n")
|
|
|
|
# --- SCOPE OF THE ID GATE'S INCLUDE-FOLLOWING (v0.315.0) ---------------------------------------
|
|
# The gate now unions in the ids of every template a page {{template}}s, because the app page's form
|
|
# fields live in one partial rendered by two pages. The hazard that buys is a VACUOUS check: if the
|
|
# include walk reached every file, every id would resolve everywhere and the gate would convict
|
|
# nothing. So: plant, in deploy.html, a reference to an id defined only in app_data.html — a file
|
|
# deploy.html does not include. The gate must still convict.
|
|
swapped("template-id scope", "template_id_gate.py",
|
|
os.path.join(TPL, "deploy.html"),
|
|
lambda b: b.replace("<script>",
|
|
"<script>document.getElementById('estimateBox').x=1;", 1))
|
|
|
|
# --- CONTROL: two gates already walked. If these ever fail, the decoy is wrong, not the gate ----
|
|
in_subdir("mojibake (CONTROL)", "mojibake_gate.py", u"<p>árvÃztuquotrÅ‘</p>\n")
|
|
|
|
# --- COMMENTS ARE NOT CODE (R-421) -------------------------------------------------------------
|
|
DISPATCH = os.path.join(CTRL, "internal", "web", "handler_debug.go")
|
|
DEBUG_TPL = os.path.join(CTRL, "internal", "web", "templates", "debug.html")
|
|
|
|
|
|
def _comment_out_a_real_case(src):
|
|
"""Take a LIVE dispatcher case and comment it out. The button stays; the handler dies."""
|
|
m = re.search(r'^(\s*)(case subpath == "[A-Za-z0-9/_-]+".*:)$', src, re.M)
|
|
assert m, "no dispatcher case found — the decoy cannot be built"
|
|
return src[:m.start()] + m.group(1) + "// " + m.group(2) + src[m.end():]
|
|
|
|
|
|
swapped("debug-routes/comment", "debug_route_gate.py", DISPATCH, _comment_out_a_real_case)
|
|
|
|
|
|
def _comment_out_the_partial(src):
|
|
return re.sub(r'(\{\{template "app_list_row".*?\}\})', r'<!-- was: \1 -->', src)
|
|
|
|
|
|
swapped("app-row-dedup/comment", "app_row_dedup_gate.py",
|
|
os.path.join(TPL, "dashboard.html"), _comment_out_the_partial)
|
|
|
|
# --- i18n (v0.247.0): copy moved OUT of the templates into the bundles. Two families of decoy: ---
|
|
# --- the new gate itself, and every copy gate that must still see copy that now lives there. ---
|
|
LOC = os.path.join(CTRL, "internal", "i18n", "locales")
|
|
EN_JSON, HU_JSON = os.path.join(LOC, "en.json"), os.path.join(LOC, "hu.json")
|
|
LAUNCHER = os.path.join(TPL, "launcher.html")
|
|
|
|
|
|
def _one(old, new):
|
|
def t(src):
|
|
assert src.count(old) == 1, "decoy anchor %r not found exactly once — the decoy cannot be built" % old
|
|
return src.replace(old, new)
|
|
return t
|
|
|
|
|
|
swapped("i18n/undefined-key", "i18n_missing_gate.py", LAUNCHER,
|
|
_one('{{T "launcher.link_masolasa"}}', '{{T "launcher.no_such_key"}}'))
|
|
swapped("i18n/pleading", "i18n_missing_gate.py", EN_JSON,
|
|
_one('"launcher.link_masolasa": "Copy link"', '"launcher.link_masolasa": "Please copy the link"'))
|
|
swapped("i18n/gap-grew", "i18n_missing_gate.py", EN_JSON,
|
|
_one(' "launcher.link_masolasa": "Copy link",\n', ''))
|
|
swapped("i18n/new-formal-form", "i18n_missing_gate.py", HU_JSON,
|
|
_one('"launcher.link_masolasa": "Link másolása"', '"launcher.link_masolasa": "Kattintson a link másolásához"'))
|
|
# R-516 (2026-10-06): the widened stems -- a form the original list did not see convicts, its te-form
|
|
# twin and the one listed third-person statement pass.
|
|
swapped("i18n/new-formal-wide", "i18n_missing_gate.py", HU_JSON,
|
|
_one('"launcher.link_masolasa": "Link másolása"', '"launcher.link_masolasa": "Írja be a link nevét"'))
|
|
swapped("i18n/te-form-wide", "i18n_missing_gate.py", HU_JSON,
|
|
_one('"launcher.link_masolasa": "Link másolása"', '"launcher.link_masolasa": "Írd be a link nevét"'),
|
|
expect="accept")
|
|
swapped("i18n/third-person-ok", "i18n_missing_gate.py", HU_JSON,
|
|
_one('"launcher.link_masolasa": "Link másolása"', '"launcher.link_masolasa": "A Megosztás oldal sora adja meg"'),
|
|
expect="accept")
|
|
# Scope: the copy gates read the page AS RENDERED, so copy that lives in a bundle is still judged.
|
|
swapped("emoji/bundle", "emoji_gate.py", EN_JSON,
|
|
_one('"launcher.link_masolasa": "Copy link"', '"launcher.link_masolasa": "Copy link \U0001F600"'))
|
|
swapped("native-confirm/bundle", "native_confirm_gate.py", EN_JSON,
|
|
_one('"launcher.masolva": "Copied"', '"launcher.masolva": "Copied\u0027+confirm(\u0027x\u0027)+\u0027"'))
|
|
swapped("retrieval-promise/bundle", "retrieval_promise_gate.py", HU_JSON,
|
|
_one('"launcher.link_masolasa": "Link másolása"', '"launcher.link_masolasa": "A mentésed bármikor visszaállítható."'))
|
|
# Slice 1 (R-556): an English retrieval PROMISE is judged like a Hungarian one — and the same place
|
|
# carrying a sentence that promises nothing is accepted (the gate registers claims, not words).
|
|
swapped("retrieval-promise/en", "retrieval_promise_gate.py", EN_JSON,
|
|
_one('"launcher.link_masolasa": "Copy link"', '"launcher.link_masolasa": "Your old backups can be restored at any time."'))
|
|
swapped("retrieval-promise/en-ok", "retrieval_promise_gate.py", EN_JSON,
|
|
_one('"launcher.link_masolasa": "Copy link"', '"launcher.link_masolasa": "Your old backups are listed on the restore page."'),
|
|
expect="accept")
|
|
# R-564: the Hungarian SPLIT verb („állíthatók vissza") is the same claim as the joined stem; a planted
|
|
# split-verb promise must convict, and a plain „Vissza" (a back link, no claim) must not.
|
|
swapped("retrieval-promise/split-verb", "retrieval_promise_gate.py", HU_JSON,
|
|
_one('"launcher.link_masolasa": "Link másolása"', '"launcher.link_masolasa": "A régi mentéseid a kóddal bármikor állíthatók vissza."'))
|
|
swapped("retrieval-promise/split-ok", "retrieval_promise_gate.py", HU_JSON,
|
|
_one('"launcher.link_masolasa": "Link másolása"', '"launcher.link_masolasa": "Vissza a listához"'),
|
|
expect="accept")
|
|
swapped("secret-markup/bundle", "secret_in_markup_gate.py", EN_JSON,
|
|
_one('"launcher.link_masolasa": "Copy link"', '"launcher.link_masolasa": "Copy {{.RetrievalPassword}}"'))
|
|
|
|
# --- offbox-rename (R-425): the scope is discovered by pattern, and bundle copy is judged. ---------
|
|
ran += 1
|
|
_extra = os.path.join(TPL, "backups_offbox_extra.html")
|
|
io.open(_extra, "w", encoding="utf-8").write(u"<p>A NAS-mentés beállítása</p>\n")
|
|
try:
|
|
_rc, _out = gate("offbox_rename_gate.py")
|
|
finally:
|
|
os.remove(_extra)
|
|
if _rc == 0:
|
|
fails.append("offbox-rename/new-file: NAS branding in a NEW backups*.html PASSED — the scope is a "
|
|
"fixed list again (R-425)\n%s" % _out[-400:])
|
|
else:
|
|
print(" ok %-20s decoy rejected" % "offbox-rename/new-file")
|
|
swapped("offbox-rename/bundle", "offbox_rename_gate.py", HU_JSON,
|
|
_one('"flash.offbox.run_started": "', '"flash.offbox.run_started": "Mentés a NAS-ra: '))
|
|
swapped("offbox-rename/other-feature-ok", "offbox_rename_gate.py", HU_JSON,
|
|
_one('"storage_network.mit_kell_beallitani_a_nas": "Mit kell beállítani a NAS-on?"',
|
|
'"storage_network.mit_kell_beallitani_a_nas": "Mit kell beállítani a NAS-on? Ez a NAS-ra vonatkozik."'),
|
|
expect="accept")
|
|
|
|
# --- gofmt (R-454): an unformatted Go file anywhere under internal/ is convicted. ----------------
|
|
ran += 1
|
|
_gf = os.path.join(CTRL, "internal", "zz_gofmt_decoy_tmp.go")
|
|
io.open(_gf, "w", encoding="utf-8").write(u"package internal\nfunc decoy( ) { }\n")
|
|
try:
|
|
_rc, _out = gate("gofmt_gate.py")
|
|
finally:
|
|
os.remove(_gf)
|
|
if "NOT CHECKED in CI" in _out:
|
|
print(" -- %-20s not runnable here (CI, no Go toolchain) — the two cases below pin that mode" % "gofmt")
|
|
elif _rc != 1 or "zz_gofmt_decoy_tmp.go" not in _out:
|
|
fails.append("gofmt: an unformatted planted file was not convicted (rc=%d)\n%s" % (_rc, _out[-400:]))
|
|
else:
|
|
print(" ok %-20s decoy rejected" % "gofmt")
|
|
|
|
# The gate's two no-Go modes (2026-10-05): with no gofmt reachable, CI passes OUT LOUD and a dev machine is
|
|
# INCONCLUSIVE. PATH is emptied of Go for both; only the CI marker differs.
|
|
import tempfile as _tf
|
|
_EMPTY_PATH_DIR = _tf.mkdtemp(prefix="nogo-")
|
|
def _gofmt_without_go(ci):
|
|
env = {k: v for k, v in os.environ.items() if k not in ("GITEA_ACTIONS", "GITHUB_ACTIONS", "GOROOT")}
|
|
env["PATH"] = _EMPTY_PATH_DIR # nothing on it: sys.executable is called by its full path
|
|
if ci:
|
|
env["GITEA_ACTIONS"] = "true"
|
|
p = subprocess.run([sys.executable, os.path.join("scripts", "gofmt_gate.py")], cwd=CTRL, env=env,
|
|
capture_output=True, text=True)
|
|
return p.returncode, p.stdout + p.stderr
|
|
for _ci, _want_rc, _want_txt, _name in ((True, 0, "NOT CHECKED in CI", "gofmt/ci-without-go"),
|
|
(False, 2, "INCONCLUSIVE", "gofmt/dev-without-go")):
|
|
ran += 1
|
|
_rc, _out = _gofmt_without_go(_ci)
|
|
if _rc != _want_rc or _want_txt not in _out:
|
|
fails.append("%s: want rc=%d and %r, got rc=%d\n%s" % (_name, _want_rc, _want_txt, _rc, _out[-300:]))
|
|
else:
|
|
print(" ok %-20s %s" % (_name, "passes out loud" if _ci else "stays undetermined"))
|
|
|
|
# --- go-parity (v0.252.0, R-557): a Go-side message key may only carry base-commit text. ---
|
|
# --- Three shapes, because the gate makes three different claims. ---
|
|
GO_KEYS = os.path.join(HERE, "i18n_go_keys.json")
|
|
_A_KEY = '"flash.share.enabled": "A megosztás bekapcsolva."'
|
|
_A_HU = '"flash.share.enabled": "A megosztás bekapcsolva."'
|
|
|
|
# 1. name-for-fact: the KEY is still listed and still named in Go, and its text was reworded by one
|
|
# word. A gate that only checked "is this key accounted for" passes this.
|
|
swapped("go-parity/reworded", "i18n_go_parity.py", HU_JSON,
|
|
_one(_A_HU, '"flash.share.enabled": "A megosztás most bekapcsolva."'))
|
|
# 2. constant-for-measurement: the key map cites text nobody ever wrote. A gate that compared
|
|
# hu.json against the MAP alone (rather than against the frozen capture) passes this.
|
|
swapped("go-parity/invented", "i18n_go_parity.py", GO_KEYS,
|
|
_one(_A_KEY, '"flash.share.enabled": "Ez a mondat sosem létezett."'))
|
|
# 3. declaration-for-reachability: a converted key is NAMED by Go and quietly dropped from the map.
|
|
# A gate that only walked the map passes this -- which is how a conversion escapes review.
|
|
swapped("go-parity/unlisted", "i18n_go_parity.py", GO_KEYS,
|
|
_one(_A_KEY + ",\n", ""))
|
|
|
|
# 4-5. R-576 -- the CALL lost text while every surviving fragment stayed real (2026-09-18, 7
|
|
# producers, this gate green). Built on a real producer: the update-already-running refusal.
|
|
UPDATE_GO = os.path.join(CTRL, "internal", "stacks", "update.go")
|
|
_R576 = 'util.MsgError("update.refusal.already", name), "lost the race'
|
|
# the argument dropped -- the key's text is still byte-equal, only the call is short.
|
|
swapped("go-parity/lost-argument", "i18n_go_parity.py", UPDATE_GO,
|
|
_one(_R576, 'util.MsgError("update.refusal.already"), "lost the race'))
|
|
# the key glued to a continuation with + -- the converter's exact shape.
|
|
swapped("go-parity/key-concat", "i18n_go_parity.py", UPDATE_GO,
|
|
_one(_R576, 'util.MsgError("update.refusal.already" + suffix, name), "lost the race'))
|
|
# CONTROL: one argument that itself holds parens and commas is still ONE argument.
|
|
swapped("go-parity/nested-arg-ok", "i18n_go_parity.py", UPDATE_GO,
|
|
_one(_R576, 'util.MsgError("update.refusal.already", fmt.Sprintf("%s,%s", f(a, b), c)), "lost the race'),
|
|
expect="accept")
|
|
|
|
# --- retrieval-promise, R-325: the stems are IMPORTED from felhom.eu/scripts/customer_copy_vocab.py. ---
|
|
# Two shapes. (1) The vocabulary absent must be INCONCLUSIVE, never a pass on an empty list.
|
|
# (2) declaration-for-reachability: a gate that still carried a private literal would ignore the
|
|
# shared file. A doctored vocabulary with one invented stem, and a template using only that stem,
|
|
# must convict — proof the shared list is the one in force.
|
|
import shutil # noqa: E402
|
|
import tempfile # noqa: E402
|
|
|
|
|
|
def _retrieval_with_vocab(vocab_src):
|
|
d = tempfile.mkdtemp(prefix="r325-")
|
|
try:
|
|
if vocab_src is not None:
|
|
io.open(os.path.join(d, "customer_copy_vocab.py"), "w", encoding="utf-8").write(vocab_src)
|
|
env = dict(os.environ, FELHOM_SHARED_SCRIPTS=d)
|
|
p = subprocess.run([sys.executable, os.path.join("scripts", "retrieval_promise_gate.py")],
|
|
cwd=CTRL, env=env, capture_output=True, text=True)
|
|
return p.returncode, p.stdout + p.stderr
|
|
finally:
|
|
shutil.rmtree(d, ignore_errors=True)
|
|
|
|
|
|
ran += 1
|
|
_rc, _out = _retrieval_with_vocab(None)
|
|
if _rc != 2 or "INCONCLUSIVE" not in _out:
|
|
fails.append("retrieval-promise/vocab-absent: want rc=2 INCONCLUSIVE, got rc=%d\n%s" % (_rc, _out[-300:]))
|
|
else:
|
|
print(" ok %-20s %s" % ("retrieval-promise/vocab-absent", "stays undetermined"))
|
|
|
|
ran += 1
|
|
_made = not os.path.isdir(SUB)
|
|
if _made:
|
|
os.makedirs(SUB)
|
|
_planted = os.path.join(SUB, "decoy-r325.html")
|
|
io.open(_planted, "w", encoding="utf-8").write(u"<p>Minden adatod felhomdecoyszohato marad.</p>\n")
|
|
try:
|
|
_rc, _out = _retrieval_with_vocab(u'RETRIEVAL_STEMS = ["visszaállíthat", "felhomdecoyszo"]\n')
|
|
finally:
|
|
os.remove(_planted)
|
|
if _made and os.path.isdir(SUB) and not os.listdir(SUB):
|
|
os.rmdir(SUB)
|
|
if _rc != 1 or "felhomdecoyszo" not in _out:
|
|
fails.append("retrieval-promise/vocab-is-live: an invented stem in the SHARED list did not convict — the "
|
|
"gate is not reading customer_copy_vocab.py (rc=%d)\n%s" % (_rc, _out[-300:]))
|
|
else:
|
|
print(" ok %-20s %s" % ("retrieval-promise/vocab-is-live", "decoy rejected"))
|
|
|
|
# --- golden-notice: R-410's decoy, in the other direction. It is ADVISORY, so rc is never the ---
|
|
# --- question — what it COUNTED is. ---
|
|
ran += 1
|
|
EV = os.path.join(os.path.dirname(os.path.dirname(CTRL)), "felhom.eu", "documentation", "tests",
|
|
"golden-9.9.9-2026-01-01")
|
|
if os.path.isdir(os.path.dirname(EV)):
|
|
os.makedirs(EV)
|
|
try:
|
|
p = subprocess.run([sys.executable, os.path.join("scripts", "golden_notice.py"),
|
|
os.path.dirname(CTRL)], cwd=CTRL, capture_output=True, text=True)
|
|
out = p.stdout + p.stderr
|
|
finally:
|
|
os.rmdir(EV)
|
|
if "9.9.9" in out and "NOT counted" not in out:
|
|
fails.append("golden-notice: an EMPTY directory was counted as a bake (R-410 regressed)")
|
|
else:
|
|
print(" ok %-20s empty dir not counted as a bake" % "golden-notice")
|
|
else:
|
|
print(" -- %-20s SKIPPED: no felhom.eu sibling clone" % "golden-notice")
|
|
|
|
# --- docker-v (R-426): a NEW file with an unreviewed host-path mount, anywhere under the roots ------
|
|
def _plant_go(rel, body):
|
|
path = os.path.join(CTRL, rel)
|
|
made = []
|
|
d = os.path.dirname(path)
|
|
while not os.path.isdir(d):
|
|
made.append(d)
|
|
d = os.path.dirname(d)
|
|
for m in reversed(made):
|
|
os.mkdir(m)
|
|
io.open(path, "w", encoding="utf-8").write(body)
|
|
return path, made
|
|
|
|
|
|
def _unplant(path, made):
|
|
os.remove(path)
|
|
for m in made:
|
|
os.rmdir(m)
|
|
|
|
|
|
DOCKER_V_LINE = u'package decoy\n\nvar args = []string{"run", "-v", "/etc:/x", "alpine"}\n'
|
|
for _label, _rel, _expect in (
|
|
("docker-v/new-internal-file", os.path.join("internal", "decoydockerv", "deep", "decoy.go"), "convict"),
|
|
("docker-v/new-cmd-file", os.path.join("cmd", "decoydockerv", "decoy.go"), "convict"),
|
|
("docker-v/_test.go (CONTROL)", os.path.join("internal", "decoydockerv", "decoy_test.go"), "accept")):
|
|
ran += 1
|
|
_p, _made = _plant_go(_rel, DOCKER_V_LINE)
|
|
try:
|
|
_rc, _out = gate("docker_run_volume_path_gate.py")
|
|
finally:
|
|
_unplant(_p, _made)
|
|
if (_rc != 0) != (_expect == "convict") or (_expect == "convict" and "/etc:/x" not in _out):
|
|
fails.append("%s: rc=%d, expected %s\n%s" % (_label, _rc, _expect, _out[-400:]))
|
|
else:
|
|
print(" ok %-30s %s" % (_label, "decoy rejected" if _expect == "convict" else "genuine accepted"))
|
|
ran += 1
|
|
_rc, _out = gate("docker_run_volume_path_gate.py")
|
|
if _rc != 0:
|
|
fails.append("docker-v/clean tree (CONTROL): rc=%d\n%s" % (_rc, _out[-400:]))
|
|
else:
|
|
print(" ok %-30s %s" % ("docker-v/clean tree (CONTROL)", "genuine accepted"))
|
|
|
|
|
|
# --- the SHARED felhom.eu gates (R-426), against THIS repo's inputs --------------------------------
|
|
# A scratch WORKSPACE: a clone of this repo, named as the main clone, beside symlinks to the siblings,
|
|
# because the shared scripts reach across (REUSE.md cites felhom.eu paths; instructions_gate reads the
|
|
# workspace CLAUDE.md). The plants go into the clone, never into this tree. Mirrors felhom-agent b78a0ff.
|
|
# DECOY_SHARED_DIR exists for ONE purpose, the red-proof: it lets a mutated COPY of the shared scripts be
|
|
# judged without editing the felhom.eu clone. Unset, the suite judges the real shared scripts.
|
|
import shutil
|
|
import tempfile
|
|
|
|
REPO = os.path.dirname(CTRL)
|
|
PARENT = os.path.dirname(REPO)
|
|
SHARED = os.environ.get("DECOY_SHARED_DIR") or os.path.join(PARENT, "felhom.eu", "scripts")
|
|
|
|
|
|
def _run(argv, cwd):
|
|
p = subprocess.run(argv, cwd=cwd, capture_output=True, text=True, input="")
|
|
return p.returncode, p.stdout + p.stderr
|
|
|
|
|
|
def shared_cases():
|
|
global ran
|
|
for g in ("reuse_refs_check.py", "instructions_gate.py", "observations_gate.py"):
|
|
if not os.path.isfile(os.path.join(SHARED, g)):
|
|
fails.append("shared gate %s is MISSING beside this clone (tried %s) - a failure, never a skip"
|
|
% (g, SHARED))
|
|
return
|
|
ws = tempfile.mkdtemp(prefix="ctrl-decoy-shared-")
|
|
try:
|
|
space = os.path.join(ws, "workspace")
|
|
os.makedirs(space)
|
|
for entry in sorted(os.listdir(PARENT)):
|
|
if entry in ("felhom.eu", "felhom-agent", "app-catalog-felhom.eu", "homelab-manifests",
|
|
"CLAUDE.md", ".claude-memory"):
|
|
os.symlink(os.path.join(PARENT, entry), os.path.join(space, entry))
|
|
repo = os.path.join(space, "felhom-controller")
|
|
rc, out = _run(["git", "clone", "-q", "--no-tags", "file://" + REPO, repo], ws)
|
|
if rc != 0:
|
|
fails.append("shared: could not clone this repo into the scratch workspace\n" + out[-400:])
|
|
return
|
|
# the WORKING-TREE inputs the plants go into, so a case judges today's file
|
|
for f in ("REUSE.md", "CLAUDE.md", "REPORT.md"):
|
|
shutil.copy(os.path.join(REPO, f), os.path.join(repo, f))
|
|
|
|
def case(name, script, relpath, extra, expect_rc, must=()):
|
|
global ran
|
|
ran += 1
|
|
p = os.path.join(repo, relpath)
|
|
backup = io.open(p, encoding="utf-8").read()
|
|
try:
|
|
if extra:
|
|
io.open(p, "w", encoding="utf-8").write(backup + extra)
|
|
rc, out = _run([sys.executable, os.path.join(SHARED, script), repo], repo)
|
|
finally:
|
|
io.open(p, "w", encoding="utf-8").write(backup)
|
|
missing = [m for m in must if m not in out]
|
|
if rc == expect_rc and not missing:
|
|
print(" ok %-62s rc=%d" % (name, rc))
|
|
else:
|
|
hole = " - LIVE HOLE" if expect_rc != 0 and rc == 0 else ""
|
|
fails.append("%s: rc=%d expected %d%s; missing %s\n%s" % (name, rc, expect_rc, hole, missing,
|
|
out[-900:]))
|
|
|
|
case("reuse-refs: GENUINE: this repo's REUSE.md", "reuse_refs_check.py", "REUSE.md", "", 0, ("FAILED 0",))
|
|
case("reuse-refs: FACT: a cited .go path that does not exist", "reuse_refs_check.py", "REUSE.md",
|
|
u"\n- see `controller/internal/web/does_not_exist.go`\n", 1, ("does_not_exist.go",))
|
|
case("reuse-refs: FACT: a cited .md path that does not exist", "reuse_refs_check.py", "REUSE.md",
|
|
u"\n- see `docs/99-does-not-exist.md`\n", 1, ("99-does-not-exist.md",))
|
|
case("instructions: GENUINE: this repo's CLAUDE.md", "instructions_gate.py", "CLAUDE.md", "", 0,
|
|
("instructions_gate: OK",))
|
|
case("instructions: FACT: a version literal in effective text", "instructions_gate.py", "CLAUDE.md",
|
|
u"\nThe controller runs v0.298.0 today.\n", 1, ("v0.298.0",))
|
|
case("instructions: GENUINE: the same sentence in an HTML comment", "instructions_gate.py", "CLAUDE.md",
|
|
u"\n<!--\nThe controller ran v0.298.0 on 2026-10-06.\n-->\n", 0, ("instructions_gate: OK",))
|
|
case("observations: FACT: R-419, prose SAYING it has no marker", "observations_gate.py", "REPORT.md",
|
|
u"\n## Observations\n\n1. **A real finding.** It carries no `FILED:` marker and no "
|
|
u"`NOT-A-FINDING:` marker, deliberately.\n", 1)
|
|
case("observations: GENUINE: a FILED marker", "observations_gate.py", "REPORT.md",
|
|
u"\n## Observations\n\n1. **A real finding.** Something broke. **FILED: R-419**\n", 0)
|
|
case("observations: GENUINE: a NOT-A-FINDING marker", "observations_gate.py", "REPORT.md",
|
|
u"\n## Observations\n\n1. **A real finding.** Odd. **NOT-A-FINDING: my own typo, corrected in "
|
|
u"the same minute.**\n", 0)
|
|
finally:
|
|
shutil.rmtree(ws, ignore_errors=True)
|
|
|
|
|
|
shared_cases()
|
|
|
|
print()
|
|
if fails:
|
|
for f in fails:
|
|
print("FAIL: %s" % f)
|
|
sys.exit(1)
|
|
print("all %d controller decoys behaved — labels do not satisfy these gates" % ran)
|