From 246a488412fe7d9690cfc3e107ab94e990bf4856 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 23 Sep 2026 22:44:20 +0200 Subject: [PATCH] test(08-09): add check-phase8.sh gate skeleton with RED self-test - Declares the ordered Phase 8 stage list and stage function skeletons - --red-contract is a permanent RED-harness self-test hook (exit 86, PHASE8_STAGE::FAIL:PHASE8_RED:real-mcp-stage) - --contract-self-test and the full gate are completed in Task 3/08-10 --- scripts/check-phase8.sh | 350 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 350 insertions(+) create mode 100755 scripts/check-phase8.sh diff --git a/scripts/check-phase8.sh b/scripts/check-phase8.sh new file mode 100755 index 0000000..173bc64 --- /dev/null +++ b/scripts/check-phase8.sh @@ -0,0 +1,350 @@ +#!/usr/bin/env bash +# Phase 8 final unchanged-MCP acceptance gate (08-CONTEXT.md D-14; 08-09-PLAN.md +# Task 1/Task 3; 08-10-PLAN.md Task 3 is the sole execution site for the full +# suite). Boots disposable Postgres and the assembled Go app, starts the real +# unchanged Node fonoteka-mcp against the three required environment +# variables, and drives the full scripted SDK lifecycle: discovery, DCR, +# PKCE authorize, JWT login/consent, token, an MCP tool call, refresh, +# replay, and revoke. Both repositories' vet/test/race, parity/corpus, +# secret-scan, full UI harness, and unchanged Nuxt/MCP diffs are also gated +# here. fonoteka-mcp and the Nuxt app are never modified. +# +# Modes: +# (no flags) run the complete gate -- 08-10 Task 3 only. +# --contract-self-test syntax/source assertions only, no services booted. +# Designed for well under 30 seconds (08-09 Task 3). +# --red-contract deliberately fail the named stage with the fixed +# PHASE8_RED sentinel and exit 86 (08-09 Task 1 RED +# harness self-test; never used outside that proof). +set -euo pipefail + +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +APP="$(cd "$ROOT/../fonoteka.go" && pwd)" +MCP_ROOT="${MCP_ROOT:-/media/nvme/dev/golem15/fonoteka/fonoteka-mcp}" +NUXT_ROOT="${NUXT_ROOT:-/media/nvme/dev/golem15/fonoteka/vue-fonoteka-app}" + +# Ordered, fail-closed stage names. --contract-self-test asserts every one of +# these appears, in this order, in the script source (08-09-PLAN.md Task 3 +# acceptance: "required real-MCP lifecycle, replay/revoke, two-repository +# vet/test/race, parity, UI, secret-scan, security-review, and +# unchanged-client stages in fail-closed order"). +PHASE8_STAGES=( + docker-preflight + postgres + app-boot + real-mcp + discovery + dcr + pkce-authorize + jwt-login-consent + token + tool-call + refresh + replay + revoke + post-revoke-failure + vet-test-race + parity-corpus + secret-scan + ui-harness + unchanged-client-diff + security-review +) + +usage() { + cat >&2 <<'EOF' +usage: + check-phase8.sh run the complete gate (08-10 Task 3 only) + check-phase8.sh --contract-self-test syntax/source assertions only + check-phase8.sh --red-contract deliberate RED self-test (08-09 Task 1) +EOF + exit 2 +} + +# --------------------------------------------------------------------------- +# --red-contract: a permanent, deliberate self-test hook proving the RED +# harness (scripts/check-phase8-red.sh) correctly rejects anything other than +# the exact one-stage, one-sentinel, exit-86 shape. It never calls a real +# stage function -- it exists purely to anchor 08-09 Task 1's fail-closed +# proof and is not part of the executable gate's stage sequence above. +# --------------------------------------------------------------------------- +run_red_contract() { + local stage="$1" + local found=false + for s in "${PHASE8_STAGES[@]}"; do + if [[ "$s" == "$stage" ]]; then + found=true + break + fi + done + if [[ "$found" != true ]]; then + echo "refuse: --red-contract stage %q is not a declared stage: $stage" >&2 + exit 2 + fi + echo "PHASE8_STAGE:${stage}:FAIL:PHASE8_RED:real-mcp-stage" + exit 86 +} + +# --------------------------------------------------------------------------- +# --contract-self-test: source/structure assertions only. No Docker, no +# Postgres, no app boot, no Node process, no network beyond loopback binding +# checks against the script's own source. Must stay well under 30 seconds +# (08-09-PLAN.md Task 3 acceptance). +# --------------------------------------------------------------------------- +run_contract_self_test() { + local self="${BASH_SOURCE[0]}" + + echo "==> bash -n" + bash -n "$self" + + echo "==> required stage names present, in declared order" + local last_line=0 + for stage in "${PHASE8_STAGES[@]}"; do + local line + line="$(grep -n "stage_${stage//-/_}" "$self" | head -1 | cut -d: -f1 || true)" + if [[ -z "$line" ]]; then + echo "refuse: stage function for '${stage}' not found in source" >&2 + exit 1 + fi + if (( line < last_line )); then + echo "refuse: stage '${stage}' is declared out of order" >&2 + exit 1 + fi + last_line="$line" + done + + echo "==> cleanup trap present" + grep -q "^trap cleanup_phase8 EXIT" "$self" || { + echo "refuse: missing cleanup trap" >&2 + exit 1 + } + + echo "==> loopback-only service binding" + if grep -qE "0\.0\.0\.0|--host[= ]0\.0\.0\.0" "$self"; then + echo "refuse: non-loopback bind address found in source" >&2 + exit 1 + fi + grep -q "127.0.0.1" "$self" || { + echo "refuse: expected loopback address in source" >&2 + exit 1 + } + + echo "==> three MCP environment variables are exported" + for var in FONOTEKA_API_URL FONOTEKA_MCP_PUBLIC_URL FONOTEKA_MCP_AUTH_SERVER; do + grep -q "$var" "$self" || { + echo "refuse: missing $var reference" >&2 + exit 1 + } + done + + echo "==> redaction helper present (no raw secret/token/code/verifier echoed)" + grep -q "redact_phase8" "$self" || { + echo "refuse: missing redact_phase8 helper" >&2 + exit 1 + } + + echo "==> no pre-final full-run mode is offered" + if grep -qE -- '--pre-security|--pre-final' "$self"; then + echo "refuse: a pre-final full-run mode is offered" >&2 + exit 1 + fi + + echo "==> unchanged-client worktrees are only read, never written" + grep -q "MCP_ROOT" "$self" || { + echo "refuse: missing MCP_ROOT reference" >&2 + exit 1 + } + grep -q "NUXT_ROOT" "$self" || { + echo "refuse: missing NUXT_ROOT reference" >&2 + exit 1 + } + + echo "phase8 contract-self-test passed" +} + +# --------------------------------------------------------------------------- +# redact_phase8: strips anything credential-shaped before it reaches stdout. +# Every stage function must pipe its own diagnostic output through this +# before printing (T-08-REQUEST-LEAK). +# --------------------------------------------------------------------------- +redact_phase8() { + sed -E \ + -e 's/(client_secret=)[^&[:space:]]+/\1/g' \ + -e 's/(code_verifier=)[^&[:space:]]+/\1/g' \ + -e 's/(refresh_token=)[^&[:space:]]+/\1/g' \ + -e 's/(access_token"?[:=]"?)[A-Za-z0-9_.\-]+/\1/g' \ + -e 's/(Authorization: Bearer )[A-Za-z0-9_.\-]+/\1/g' \ + -e 's/(Authorization: Basic )[A-Za-z0-9+\/=]+/\1/g' \ + -e 's/inv_[A-Za-z0-9_-]{8,}//g' +} + +PHASE8_CLEANUP_PIDS=() +PHASE8_CLEANUP_DIRS=() + +cleanup_phase8() { + local pid + for pid in "${PHASE8_CLEANUP_PIDS[@]:-}"; do + [[ -n "$pid" ]] || continue + kill "$pid" 2>/dev/null || true + wait "$pid" 2>/dev/null || true + done + local dir + for dir in "${PHASE8_CLEANUP_DIRS[@]:-}"; do + [[ -n "$dir" ]] || continue + rm -rf "$dir" + done +} +trap cleanup_phase8 EXIT + +# --------------------------------------------------------------------------- +# Stage functions. Each is named stage_ so +# --contract-self-test can locate it by source grep, in declared order. +# Bodies are completed by 08-09-PLAN.md Task 3; execution is gated to +# 08-10 Task 3 only (main() below never runs stages unless invoked with no +# flags, which 08-09 never does). +# --------------------------------------------------------------------------- + +stage_docker_preflight() { + command -v docker >/dev/null 2>&1 || { + echo "refuse: docker is required" >&2 + exit 1 + } + docker info >/dev/null 2>&1 || { + echo "refuse: docker daemon is not available" >&2 + exit 1 + } +} + +stage_postgres() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_app_boot() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_real_mcp() { + if [[ ! -d "$MCP_ROOT" ]]; then + echo "refuse: MCP_ROOT not found: $MCP_ROOT" >&2 + exit 1 + fi + # FONOTEKA_API_URL, FONOTEKA_MCP_PUBLIC_URL, FONOTEKA_MCP_AUTH_SERVER are + # exported here (only into the fonoteka-mcp child process, never into the + # gate's own persistent environment) once the app/Postgres stages above + # are live; 127.0.0.1-only. + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_discovery() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_dcr() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_pkce_authorize() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_jwt_login_consent() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_token() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_tool_call() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_refresh() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_replay() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_revoke() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_post_revoke_failure() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_vet_test_race() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_parity_corpus() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_secret_scan() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_ui_harness() { + if [[ ! -d "$NUXT_ROOT" ]]; then + echo "refuse: NUXT_ROOT not found: $NUXT_ROOT" >&2 + exit 1 + fi + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_unchanged_client_diff() { + # Fails the gate if either unchanged client worktree (MCP_ROOT/NUXT_ROOT) + # gains a Phase 8 source diff -- this repo never edits them. + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +stage_security_review() { + echo "not yet implemented outside 08-10 Task 3" >&2 + exit 1 +} + +run_full_gate() { + echo "refuse: the complete gate runs only from 08-10 Task 3" >&2 + exit 1 +} + +main() { + case "${1:-}" in + "") + run_full_gate + ;; + --contract-self-test) + run_contract_self_test + ;; + --red-contract) + [[ $# -ge 2 ]] || usage + run_red_contract "$2" + ;; + *) + usage + ;; + esac +} + +main "$@"