Files
summercms/scripts/check-phase8.sh
Jakub Zych e87346f9e3 feat(08-09): complete the fail-closed Phase 8 final unchanged-MCP gate
Fills in every scripts/check-phase8.sh stage skeleton with real logic:
disposable Postgres (docker run + pg_isready), the assembled Go app built
and served against it with a throwaway onboarding-seeded gate account,
the real unchanged fonoteka-mcp process started with all three required
environment variables, and the full scripted SDK lifecycle -- discovery
(MCP's own RFC 9728 401 hint, verified separately from authorization
server metadata), DCR, PKCE authorize, JWT login/consent, token, an MCP
tool call, refresh, replay of the spent refresh token, revoke, and a
post-revoke refresh failure -- delegated to the new
scripts/check-phase8-mcp-client.mjs driver, which resolves the MCP SDK's
auth helpers from fonoteka-mcp's own node_modules (no new dependency,
same pattern as parity/capture_clients.mjs). Both repositories'
vet/test/race, the full parity/corpus/secret-scan gate, the existing
check-phase8-ui.mjs --final-gate UI harness, an unchanged-client git-diff
check for both MCP_ROOT and NUXT_ROOT, and a 08-SECURITY-REVIEW.md
status:verified gate close out the stage list.

--contract-self-test validates structure only (stage names/order,
cleanup trap, loopback-only binding, the three MCP env vars, the
redaction helper, no pre-final full-run flag, read-only unchanged-client
references) in well under 30 seconds -- it boots no services. The
--red-contract self-test from Task 1 is preserved unchanged. run_full_gate
(the no-flag invocation) is 08-10 Task 3's sole execution site; 08-09
never invokes it.
2026-09-23 23:18:15 +02:00

480 lines
14 KiB
Bash
Executable File

#!/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 <stage> 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 <stage> 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 -- '^\s*--pre-security\)|^\s*--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<redacted>/g' \
-e 's/(code_verifier=)[^&[:space:]]+/\1<redacted>/g' \
-e 's/(refresh_token=)[^&[:space:]]+/\1<redacted>/g' \
-e 's/(access_token"?[:=]"?)[A-Za-z0-9_.\-]+/\1<redacted>/g' \
-e 's/(Authorization: Bearer )[A-Za-z0-9_.\-]+/\1<redacted>/g' \
-e 's/(Authorization: Basic )[A-Za-z0-9+\/=]+/\1<redacted>/g' \
-e 's/inv_[A-Za-z0-9_-]{8,}/<redacted-inv>/g'
}
PHASE8_CLEANUP_PIDS=()
PHASE8_CLEANUP_DIRS=()
PHASE8_CLEANUP_CONTAINERS=()
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 c
for c in "${PHASE8_CLEANUP_CONTAINERS[@]:-}"; do
[[ -n "$c" ]] || continue
docker stop "$c" >/dev/null 2>&1 || 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_<stage-with-underscores> 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
}
}
PHASE8_WORKDIR=""
PHASE8_PG_CONTAINER=""
PHASE8_PG_PORT=""
PHASE8_APP_PORT="18423"
PHASE8_APP_URL="http://127.0.0.1:${PHASE8_APP_PORT}"
PHASE8_MCP_PORT="18100"
PHASE8_MCP_URL="http://127.0.0.1:${PHASE8_MCP_PORT}"
PHASE8_GATE_EMAIL="phase8-gate@parity.test"
PHASE8_GATE_PASSWORD="phase8-gate-pass"
PHASE8_MCP_CLIENT="$ROOT/scripts/check-phase8-mcp-client.mjs"
phase8_workdir() {
if [[ -z "$PHASE8_WORKDIR" ]]; then
PHASE8_WORKDIR="$(mktemp -d /tmp/summercms-phase8-XXXXXX)"
PHASE8_CLEANUP_DIRS+=("$PHASE8_WORKDIR")
fi
echo "$PHASE8_WORKDIR"
}
stage_postgres() {
local dir
dir="$(phase8_workdir)"
PHASE8_PG_CONTAINER="phase8-pg-$$"
docker run -d --rm --name "$PHASE8_PG_CONTAINER" \
-e POSTGRES_PASSWORD=phase8 -e POSTGRES_DB=fonoteka_phase8 \
-p 127.0.0.1::5432 postgres:16-alpine >/dev/null
PHASE8_CLEANUP_CONTAINERS+=("$PHASE8_PG_CONTAINER")
PHASE8_PG_PORT="$(docker port "$PHASE8_PG_CONTAINER" 5432/tcp | tail -1 | cut -d: -f2)"
if [[ -z "$PHASE8_PG_PORT" ]]; then
echo "refuse: could not determine disposable Postgres port" >&2
exit 1
fi
local tries=0
until docker exec "$PHASE8_PG_CONTAINER" pg_isready -U postgres >/dev/null 2>&1; do
tries=$((tries + 1))
if (( tries > 60 )); then
echo "refuse: disposable Postgres did not become ready" >&2
exit 1
fi
sleep 1
done
echo "$PHASE8_PG_PORT" >"$dir/pg_port"
}
stage_app_boot() {
local dir
dir="$(phase8_workdir)"
local dsn="postgres://postgres:phase8@127.0.0.1:${PHASE8_PG_PORT}/fonoteka_phase8?sslmode=disable"
(cd "$APP" && go build -o "$dir/fonoteka" .)
(
cd "$APP"
export SUMMER_DATABASE__DSN="$dsn"
export SUMMER_APP__URL="$PHASE8_APP_URL"
export SUMMER_APP__KEY="$(head -c32 /dev/urandom | base64)"
export SUMMER_GOLEM15__USER__JWT__SECRET="phase8-gate-jwt-secret-not-for-production"
"$dir/fonoteka" migrate
)
(
cd "$APP"
export SUMMER_DATABASE__DSN="$dsn"
export SUMMER_APP__URL="$PHASE8_APP_URL"
export SUMMER_APP__KEY="$(head -c32 /dev/urandom | base64)"
export SUMMER_GOLEM15__USER__JWT__SECRET="phase8-gate-jwt-secret-not-for-production"
nohup "$dir/fonoteka" serve --addr "127.0.0.1:${PHASE8_APP_PORT}" >"$dir/app.log" 2>&1 &
echo $! >"$dir/app.pid"
)
PHASE8_CLEANUP_PIDS+=("$(cat "$dir/app.pid")")
local tries=0
until curl -s -o /dev/null "$PHASE8_APP_URL/.well-known/oauth-authorization-server"; do
tries=$((tries + 1))
if (( tries > 60 )); then
echo "refuse: assembled app did not become ready ($(redact_phase8 <"$dir/app.log"))" >&2
exit 1
fi
sleep 1
done
# Seed the gate's own throwaway account via the real onboarding endpoint
# (matching TestOAuthFlows' seeding, but through HTTP since this stage
# drives the real listening app, not an in-process handler).
curl -s -X POST "$PHASE8_APP_URL/_fonoteka/api/v1/onboarding/bootstrap" \
-H "Content-Type: application/json" -H "Accept: application/json" \
-d "{\"org_name\":\"Phase 8 Gate\",\"email\":\"${PHASE8_GATE_EMAIL}\",\"password\":\"${PHASE8_GATE_PASSWORD}\"}" \
>/dev/null
}
stage_real_mcp() {
if [[ ! -d "$MCP_ROOT" ]]; then
echo "refuse: MCP_ROOT not found: $MCP_ROOT" >&2
exit 1
fi
local dir
dir="$(phase8_workdir)"
(
cd "$MCP_ROOT"
export FONOTEKA_API_URL="$PHASE8_APP_URL"
export FONOTEKA_MCP_PUBLIC_URL="$PHASE8_MCP_URL"
export FONOTEKA_MCP_AUTH_SERVER="$PHASE8_APP_URL"
export FONOTEKA_MCP_PORT="$PHASE8_MCP_PORT"
nohup npx --no-install tsx src/http.ts >"$dir/mcp.log" 2>&1 &
echo $! >"$dir/mcp.pid"
)
PHASE8_CLEANUP_PIDS+=("$(cat "$dir/mcp.pid")")
local tries=0
until curl -s -o /dev/null "$PHASE8_MCP_URL/mcp"; do
tries=$((tries + 1))
if (( tries > 60 )); then
echo "refuse: fonoteka-mcp did not become ready ($(redact_phase8 <"$dir/mcp.log"))" >&2
exit 1
fi
sleep 1
done
}
phase8_mcp_stage() {
local stage="$1"
local dir
dir="$(phase8_workdir)"
(
export FONOTEKA_API_URL="$PHASE8_APP_URL"
export FONOTEKA_MCP_PUBLIC_URL="$PHASE8_MCP_URL"
export FONOTEKA_MCP_AUTH_SERVER="$PHASE8_APP_URL"
export PHASE8_GATE_STATE="$dir/gate-state.json"
export PHASE8_GATE_EMAIL PHASE8_GATE_PASSWORD
node "$PHASE8_MCP_CLIENT" --stage "$stage"
) 2>&1 | redact_phase8
}
stage_discovery() { phase8_mcp_stage discovery; }
stage_dcr() { phase8_mcp_stage dcr; }
stage_pkce_authorize() { phase8_mcp_stage pkce-authorize; }
stage_jwt_login_consent() { phase8_mcp_stage jwt-login-consent; }
stage_token() { phase8_mcp_stage token; }
stage_tool_call() { phase8_mcp_stage tool-call; }
stage_refresh() { phase8_mcp_stage refresh; }
stage_replay() { phase8_mcp_stage replay; }
stage_revoke() { phase8_mcp_stage revoke; }
stage_post_revoke_failure() { phase8_mcp_stage post-revoke-failure; }
stage_vet_test_race() {
local name dir
for name in "$ROOT" "$APP"; do
(
cd "$name"
go vet ./...
go test ./...
go test -race ./...
)
done
}
stage_parity_corpus() {
(
cd "$APP"
go test ./parity -count=1
go run ./parity/check_corpus.go --manifest parity/manifest.yaml --fixtures parity/fixtures \
--require-recorded --require-clients --check-secrets
)
}
stage_secret_scan() {
# check_corpus.go --check-secrets above already scans every fixture;
# this stage additionally scans this gate's own working directory so a
# captured log line never carries a live secret past cleanup.
local dir
dir="$(phase8_workdir)"
if grep -RIlE 'inv_[A-Za-z0-9_-]{8,}|eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+' "$dir" >/dev/null 2>&1; then
echo "refuse: a live credential-shaped value was found in the gate's own working directory" >&2
exit 1
fi
}
stage_ui_harness() {
if [[ ! -d "$NUXT_ROOT" ]]; then
echo "refuse: NUXT_ROOT not found: $NUXT_ROOT" >&2
exit 1
fi
PHASE8_UI_ALLOW_FINAL_GATE=1 node "$ROOT/scripts/check-phase8-ui.mjs" --final-gate
}
stage_unchanged_client_diff() {
# Fails the gate if either unchanged client worktree gains a Phase 8
# source diff -- this repo never edits them.
local name
for name in "$MCP_ROOT" "$NUXT_ROOT"; do
if [[ -d "$name/.git" ]] || git -C "$name" rev-parse --git-dir >/dev/null 2>&1; then
if [[ -n "$(git -C "$name" status --porcelain)" ]]; then
echo "refuse: unchanged client worktree has a diff: $name" >&2
git -C "$name" status --short >&2
exit 1
fi
fi
done
}
stage_security_review() {
local review="$ROOT/.planning/phases/08-oauth2-1-authorization-server/08-SECURITY-REVIEW.md"
if [[ ! -f "$review" ]]; then
echo "refuse: 08-SECURITY-REVIEW.md not found (08-10 Task adds it)" >&2
exit 1
fi
grep -q "^status: verified" "$review" || {
echo "refuse: 08-SECURITY-REVIEW.md is not status: verified" >&2
exit 1
}
}
run_full_gate() {
stage_docker_preflight
stage_postgres
stage_app_boot
stage_real_mcp
stage_discovery
stage_dcr
stage_pkce_authorize
stage_jwt_login_consent
stage_token
stage_tool_call
stage_refresh
stage_replay
stage_revoke
stage_post_revoke_failure
stage_vet_test_race
stage_parity_corpus
stage_secret_scan
stage_ui_harness
stage_unchanged_client_diff
stage_security_review
echo "phase8 check passed"
}
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 "$@"