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.
This commit is contained in:
@@ -144,7 +144,7 @@ run_contract_self_test() {
|
||||
}
|
||||
|
||||
echo "==> no pre-final full-run mode is offered"
|
||||
if grep -qE -- '--pre-security|--pre-final' "$self"; then
|
||||
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
|
||||
@@ -180,6 +180,7 @@ redact_phase8() {
|
||||
|
||||
PHASE8_CLEANUP_PIDS=()
|
||||
PHASE8_CLEANUP_DIRS=()
|
||||
PHASE8_CLEANUP_CONTAINERS=()
|
||||
|
||||
cleanup_phase8() {
|
||||
local pid
|
||||
@@ -188,6 +189,11 @@ cleanup_phase8() {
|
||||
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
|
||||
@@ -215,14 +221,94 @@ stage_docker_preflight() {
|
||||
}
|
||||
}
|
||||
|
||||
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() {
|
||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
||||
exit 1
|
||||
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() {
|
||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
||||
exit 1
|
||||
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() {
|
||||
@@ -230,77 +316,86 @@ stage_real_mcp() {
|
||||
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
|
||||
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
|
||||
}
|
||||
|
||||
stage_discovery() {
|
||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
||||
exit 1
|
||||
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_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_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() {
|
||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
||||
exit 1
|
||||
local name dir
|
||||
for name in "$ROOT" "$APP"; do
|
||||
(
|
||||
cd "$name"
|
||||
go vet ./...
|
||||
go test ./...
|
||||
go test -race ./...
|
||||
)
|
||||
done
|
||||
}
|
||||
|
||||
stage_parity_corpus() {
|
||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
||||
exit 1
|
||||
(
|
||||
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() {
|
||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
||||
exit 1
|
||||
# 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() {
|
||||
@@ -308,25 +403,59 @@ stage_ui_harness() {
|
||||
echo "refuse: NUXT_ROOT not found: $NUXT_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
||||
exit 1
|
||||
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 (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
|
||||
# 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() {
|
||||
echo "not yet implemented outside 08-10 Task 3" >&2
|
||||
exit 1
|
||||
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() {
|
||||
echo "refuse: the complete gate runs only from 08-10 Task 3" >&2
|
||||
exit 1
|
||||
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() {
|
||||
|
||||
Reference in New Issue
Block a user