#!/usr/bin/env bash # Phase 10.1 fail-closed gate (runtime admin extension point, ADMIN-07). # Every stage exits non-zero on a failing command, a go test run that fails, # skips or matches zero tests, a named test that did not pass, OpenAPI or # dist drift, a hygiene violation or an evidence gap. --self-test proves each # detector and each Phase 10.1 hygiene rule fails closed. # # Allow-list: none. The fonoteka.go parity failures TestMigrateSeedsCanonicalGenres # and TestSchemaMatchesPHPSnapshot, accepted until then, pass since fonoteka.go # 21c0f12 (fix(09): update parity expectations for the backend admin schema), # and the detector refuses an allow-listed failure that passes. KNOWN_APP_FAILURES # stays as the one place to name a future known failure, with a reason. set -euo pipefail ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" APP="$(cd "$ROOT/../fonoteka.go" && pwd)" PHASE_DIR="$ROOT/.planning/phases/10.1-runtime-admin-extension-point" REVIEW="$PHASE_DIR/10.1-SECURITY-REVIEW.md" VALIDATION="$PHASE_DIR/10.1-VALIDATION.md" APP_PLUGINS=(./plugins/golem15/fonoteka/... ./plugins/golem15/user/...) KNOWN_APP_FAILURES="" usage() { cat >&2 <<'EOF' usage: check-phase10.1.sh --self-test check-phase10.1.sh --go check-phase10.1.sh --security check-phase10.1.sh --postgres check-phase10.1.sh --spa check-phase10.1.sh --openapi check-phase10.1.sh --dist check-phase10.1.sh --hygiene check-phase10.1.sh --evidence check-phase10.1.sh --all EOF exit 2 } # phase101_detect reads go test -json. Exit 1 fail, 2 skip, 3 zero tests, # 4 non-JSON, 5 a required test did not pass, 6 an allow-listed failure now # passes. PHASE101_REQUIRE lists test names that must pass; PHASE101_ALLOW # lists "package:Test" failures that are accepted (and must still fail). phase101_detect() { python3 - "$1" <<'PY' import json, os, sys path = sys.argv[1] allow = set(os.environ.get("PHASE101_ALLOW", "").split()) require = set(os.environ.get("PHASE101_REQUIRE", "").split()) passed = set() failed_tests = {} failed_pkgs = [] build_failed = False with open(path, encoding="utf-8", errors="replace") as fh: for raw in fh: line = raw.strip() if not line.startswith("{"): continue try: ev = json.loads(line) except json.JSONDecodeError: print("refuse: non-json test output", file=sys.stderr) sys.exit(4) action = ev.get("Action") test = ev.get("Test") or "" pkg = ev.get("Package") or "" if action == "build-fail": build_failed = True if action == "skip" and test: print(f"refuse: skipped {pkg} {test}", file=sys.stderr) sys.exit(2) if action == "fail": if ev.get("FailedBuild"): build_failed = True if test: failed_tests.setdefault(pkg, []).append(test) else: failed_pkgs.append(pkg) if action == "pass" and test: passed.add(test) top = test.split("/", 1)[0] if f"{pkg}:{top}" in allow and "/" not in test: print(f"refuse: allow-listed failure {pkg} {test} now passes; remove it from the gate", file=sys.stderr) sys.exit(6) if build_failed: print("refuse: build failed", file=sys.stderr) sys.exit(1) accepted = [] for pkg, tests in failed_tests.items(): for test in tests: top = test.split("/", 1)[0] if f"{pkg}:{top}" in allow: accepted.append(f"{pkg} {test}") continue print(f"refuse: failed {pkg} {test}", file=sys.stderr) sys.exit(1) for pkg in failed_pkgs: if not failed_tests.get(pkg): print(f"refuse: failed {pkg or 'unknown package'}", file=sys.stderr) sys.exit(1) for item in sorted(set(accepted)): print(f"known pre-existing failure (deferred-items.md): {item}", file=sys.stderr) missing = sorted(name for name in require if name not in passed) if missing: print("refuse: required tests did not pass: " + ", ".join(missing), file=sys.stderr) sys.exit(5) if not passed: print("refuse: zero tests", file=sys.stderr) sys.exit(3) PY } # phase101_go DIR PKGS... [-run REGEX] runs go test -json through the # detector. The go test exit status is trusted only when no failure was # allow-listed. phase101_go() { local dir="$1" shift local log err log="$(mktemp)" err="$(mktemp)" set +e (cd "$dir" && go test -json -count=1 "$@") >"$log" 2>"$err" local rc=$? set -e local dc=0 phase101_detect "$log" || dc=$? if [[ "$dc" -ne 0 || ("$rc" -ne 0 && -z "${PHASE101_ALLOW:-}") ]]; then cat "$err" >&2 || true tail -n 40 "$log" >&2 || true rm -f "$log" "$err" echo "refuse: go test $* in $dir (test=$rc detect=$dc)" >&2 exit 1 fi rm -f "$log" "$err" } # phase101_tests DIR PKG TEST... requires every named test to run and pass. phase101_tests() { local dir="$1" pkg="$2" shift 2 local names="$*" local regex="^($(tr ' ' '|' <<<"$names"))\$" PHASE101_REQUIRE="$names" phase101_go "$dir" "$pkg" -run "$regex" } expect_detect() { local name="$1" want="$2" payload="$3" local log dc=0 log="$(mktemp)" printf '%s\n' "$payload" >"$log" phase101_detect "$log" 2>/dev/null || dc=$? rm -f "$log" if [[ "$dc" -ne "$want" ]]; then echo "refuse: self-test $name: detector exit $dc, want $want" >&2 exit 1 fi } # The hygiene_101 refusal reasons; the self-test checks each plant is refused # for its own reason only. REASON_PARSER="HTML-string parser in admin/src" REASON_ASSET="network, cookie or storage access in application plugin asset JS" REASON_PARTIAL="script or event-handler markup in an application partial template" # hygiene_101 TREE APPTREE: the Phase 10.1 extension rules (D-04, D-05, D-17; # T-10.1-08, T-10.1-10, T-10.1-14, T-10.1-20). The Phase 10 rules run first # through check-phase10.sh --hygiene. hygiene_101() { local tree="$1" app="$2" bad=0 hits fail101() { echo "refuse: hygiene: $*" >&2 bad=1 } # The SPA never parses a string as markup: partial nodes are built with h(). hits="$(cd "$tree" && grep -rnE 'setHTML(Unsafe)?\b|createContextualFragment|DOMParser|srcdoc|document\.write' admin/src 2>/dev/null || true)" [[ -z "$hits" ]] || fail101 "$REASON_PARSER: $hits" # Plugin elements signal through summer-action; the SPA owns HTTP and the # session cookie is HttpOnly (D-05, D-08). local js=() if [[ -d "$app/plugins" ]]; then mapfile -t js < <(find "$app/plugins" -mindepth 3 -path '*/assets/*' -type f \( -name '*.js' -o -name '*.mjs' \) | sort) fi if [[ "${#js[@]}" -gt 0 ]]; then hits="$(grep -nHE '\bfetch[[:space:]]*\(|XMLHttpRequest|document\.cookie|localStorage|sessionStorage|indexedDB|sendBeacon|\bWebSocket\b|\bEventSource\b' "${js[@]}" || true)" [[ -z "$hits" ]] || fail101 "$REASON_ASSET: $hits" fi # Partial templates carry markup only; behaviour lives in plugin JS. local partials=() if [[ -d "$app/plugins" ]]; then mapfile -t partials < <(find "$app/plugins" -mindepth 3 -path '*/controllers/*' -type f -name '_*.htm' | sort) fi if [[ "${#partials[@]}" -gt 0 ]]; then hits="$(grep -nHiE '\n' >"$app/plugins/acme/demo/controllers/gadgets/_plant.htm" want="$REASON_PARTIAL" ;; style) printf '
{{ .Data.Name }}
\n' >"$app/plugins/acme/demo/controllers/gadgets/_plant.htm" want="$REASON_PARTIAL" ;; handler) printf 'x\n' >"$app/plugins/acme/demo/controllers/gadgets/_plant.htm" want="$REASON_PARTIAL" ;; esac if out="$( (hygiene_101 "$scratch/fw" "$app") 2>&1)"; then echo "refuse: self-test hygiene_101 accepted a planted $plant" >&2 exit 1 fi if ! grep -qF "$want" <<<"$out"; then echo "refuse: self-test hygiene_101 rejected the $plant plant without naming its rule: $out" >&2 exit 1 fi for reason in "$REASON_PARSER" "$REASON_ASSET" "$REASON_PARTIAL"; do if [[ "$reason" != "$want" ]] && grep -qF "$reason" <<<"$out"; then echo "refuse: self-test hygiene_101 rejected the $plant plant for another rule: $out" >&2 exit 1 fi done done # Markup that only resembles a handler or style attribute is not refused. rm -rf "$scratch/fw/admin/src/__plant" "$app/plugins/acme" mkdir -p "$app/plugins/acme/demo/controllers/gadgets" printf '{{ trans "acme.demo::lang.button" }}
\n' >"$app/plugins/acme/demo/controllers/gadgets/_clean.htm" (hygiene_101 "$scratch/fw" "$app") >/dev/null 2>&1 || { echo "refuse: self-test hygiene_101 rejected clean partial markup" >&2 exit 1 } echo "phase10.1 self-test passed" } run_go() { (cd "$ROOT" && go vet ./...) phase101_go "$ROOT" ./... (cd "$APP" && go vet ./... "${APP_PLUGINS[@]}") PHASE101_ALLOW="$KNOWN_APP_FAILURES" phase101_go "$APP" ./... "${APP_PLUGINS[@]}" echo "phase10.1 go passed" } run_security() { phase101_tests "$ROOT" ./modules/cabana TestPhase10CSRF TestPhase09PermissionMatrix TestPhase101Assets \ TestPhase101PartialSanitizer TestPhase101Actions TestPhase101FormExtensionSchema TestPhase101PartialSchema TestPhase101Toolbar PHASE101_REQUIRE="TestPhase101BoardwalkExports" phase101_go "$ROOT" ./modules/boardwalk phase101_tests "$APP" ./plugins/golem15/fonoteka TestPhase09SecurityRoutes echo "phase10.1 security passed" } run_postgres() { phase101_tests "$ROOT" ./modules/cabana TestPhase10OpenAPIConformance TestPhase101Actions phase101_tests "$APP" ./plugins/golem15/fonoteka TestPhase101AlbumsExtension TestPhase101AlbumsSmoke TestPhase10Controllers \ TestPhase10ControllerCopy TestAlbumsAdminForm TestAlbumsAdminList TestPhase10AssembledAcceptance echo "phase10.1 postgres passed" } run_spa() { npm --prefix "$ROOT/admin" ci --no-audit --no-fund npm --prefix "$ROOT/admin" run typecheck local log log="$(mktemp)" set +e npm --prefix "$ROOT/admin" test >"$log" 2>&1 local rc=$? set -e if [[ "$rc" -ne 0 ]] || grep -qE 'No test files found|Unhandled (Errors|Rejection)|FAIL ' "$log"; then tail -n 60 "$log" >&2 rm -f "$log" echo "refuse: admin Vitest run failed (exit $rc)" >&2 exit 1 fi grep -E 'Test Files|Tests ' "$log" || true rm -f "$log" echo "phase10.1 spa passed" } run_openapi() { "$ROOT/scripts/check-admin-openapi.sh" --check phase101_tests "$ROOT" ./modules/cabana TestPhase10OpenAPIConformance TestPhase09ContractInventory echo "phase10.1 openapi passed" } run_dist() { "$ROOT/scripts/check-admin-dist.sh" echo "phase10.1 dist passed" } run_hygiene() { "$ROOT/scripts/check-phase10.sh" --hygiene hygiene_101 "$ROOT" "$APP" echo "phase10.1 hygiene passed" } run_evidence() { [[ -f "$REVIEW" && -f "$VALIDATION" ]] || { echo "refuse: security review or validation file is missing" >&2 exit 1 } python3 - "$REVIEW" "$VALIDATION" <<'PY' import pathlib, re, sys review = pathlib.Path(sys.argv[1]).read_text() validation = pathlib.Path(sys.argv[2]).read_text() required = [f"T-10.1-{i:02d}" for i in range(1, 23)] + ["T-10.1-SC"] lines = review.splitlines() removal = [line for line in lines if line.startswith("| RC-")] for item in required: rows = [line for line in lines if line.startswith("| " + item + " ")] if len(rows) != 1: print(f"refuse: review has {len(rows)} threat rows for {item}, want 1", file=sys.stderr) sys.exit(1) row = rows[0] cells = [cell.strip().lower() for cell in row.strip("|").split("|")] high = "high" in cells mitigated = "mitigate" in cells if high and mitigated: if not re.search(r"Test[A-Z][A-Za-z0-9]+|check-phase[\d.]+\.sh|check-admin-|tests/", row): print(f"refuse: high threat {item} names no failing-when-broken test or gate stage", file=sys.stderr) sys.exit(1) if not any(re.search(re.escape(item) + r"(?![0-9A-Za-z])", line) for line in removal): print(f"refuse: high threat {item} has no removal check row", file=sys.stderr) sys.exit(1) if not re.search(r"^nyquist_compliant: true$", validation, re.M): print("refuse: validation is not nyquist_compliant", file=sys.stderr) sys.exit(1) for line in validation.splitlines(): if line.startswith("|") and "pending" in line.lower(): print("refuse: validation row still pending: " + line, file=sys.stderr) sys.exit(1) if "ADMIN-07" not in validation: print("refuse: validation does not name ADMIN-07", file=sys.stderr) sys.exit(1) print("phase10.1 evidence files passed") PY run_security run_postgres run_openapi echo "phase10.1 evidence passed" } case "${1:-}" in --self-test) run_self_test ;; --go) run_go ;; --security) run_security ;; --postgres) run_postgres ;; --spa) run_spa ;; --openapi) run_openapi ;; --dist) run_dist ;; --hygiene) run_hygiene ;; --evidence) run_evidence ;; --all) run_self_test run_go run_security run_postgres run_spa run_openapi run_dist run_hygiene run_evidence echo "phase10.1 all passed" ;; *) usage ;; esac