08-10 Task 3 is the first time this gate has actually been executed against real Docker/Postgres/the real fonoteka CLI/the real fonoteka-mcp process. Four independent, previously-undetected defects surfaced: - stage_postgres never set POSTGRES_INITDB_ARGS for the ICU pl-PL locale lagoon.Use requires (every other Postgres testcontainer in this project already does); the app failed to boot at all. - stage_app_boot's seed step POSTed to /_fonoteka/api/v1/onboarding/bootstrap, a route routes.go never mounts (its own comment marks that group deliberately empty, pending a later phase). The gate's test user/collection are now seeded directly with SQL, matching every app-level OAuth test's own real-Postgres seeding. - phase8_workdir() assigned PHASE8_WORKDIR from inside a function body that is always invoked via command substitution (a subshell): the assignment never escaped back to the calling shell, so every separate caller (stage_postgres, stage_app_boot, each phase8_mcp_stage call, ...) minted its own fresh mktemp directory. This silently fragmented one run's state (app.log, the MCP client's gate-state.json) across dozens of directories that never saw each other's writes -- the MCP client's dcr stage could never see discovery's saved metadata. PHASE8_WORKDIR is now set once, directly, in run_full_gate before any stage runs. - gate-state.json (the MCP client's shared cross-invocation state) holds raw live secrets by design and is never redacted; stage_secret_scan correctly flagged it. It is now deleted once the MCP lifecycle stages are done with it, before the scan runs -- the scan itself stays exactly as strict as it already was. stage_security_review also now refuses a nonzero threats_open count or a missing required T-08-* row, not just a missing/unverified file, and gains --security-review-only, a focused mode for Task 2's own verify command.
563 lines
18 KiB
Bash
Executable File
563 lines
18 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 --security-review-only fail-closed 08-SECURITY-REVIEW.md checks only (08-10 Task 2)
|
|
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"
|
|
# bcrypt(cost=10) of PHASE8_GATE_PASSWORD, precomputed via
|
|
# golang.org/x/crypto/bcrypt (bouncer.HashPassword's own algorithm) so the
|
|
# app-boot seed step below needs no Go toolchain of its own.
|
|
PHASE8_GATE_PASSWORD_HASH='$2a$10$cwulxI1xSv3pH0AzpT/AquBokra0uv73b41bkpy6jG.le2JOmJHJG'
|
|
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-$$"
|
|
# ICU pl-PL locale matches every other Go test's testcontainers Postgres
|
|
# in this project (e.g. plugins/golem15/fonoteka/updates's own
|
|
# startOAuthUpdatesPostgres): lagoon.Use refuses any other locale
|
|
# provider/locale at boot.
|
|
docker run -d --rm --name "$PHASE8_PG_CONTAINER" \
|
|
-e POSTGRES_PASSWORD=phase8 -e POSTGRES_DB=fonoteka_phase8 \
|
|
-e POSTGRES_INITDB_ARGS="--locale-provider=icu --icu-locale=pl-PL --encoding=UTF8" \
|
|
-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 and its default collection
|
|
# directly in the disposable Postgres database, matching TestOAuthFlows'
|
|
# real-Postgres seeding (../fonoteka.go/parity/oauth_flow_test.go). No
|
|
# /_fonoteka/api/v1/onboarding/bootstrap route is mounted anywhere in
|
|
# routes.go (routes.go's own comment marks that group's builder
|
|
# deliberately empty, "pending its real handler in a later phase") --
|
|
# the account is seeded with SQL instead, exactly like every other
|
|
# app-level OAuth test in this phase seeds its user/collection.
|
|
# PHASE8_GATE_PASSWORD_HASH is the fixed bcrypt (cost 10) hash of
|
|
# PHASE8_GATE_PASSWORD, matching bouncer.HashPassword's own algorithm
|
|
# (golang.org/x/crypto/bcrypt), precomputed once so this stage needs no
|
|
# Go toolchain of its own.
|
|
docker exec -i "$PHASE8_PG_CONTAINER" psql -U postgres -d fonoteka_phase8 -v ON_ERROR_STOP=1 \
|
|
-v email="$PHASE8_GATE_EMAIL" -v hash="$PHASE8_GATE_PASSWORD_HASH" <<'SQL' >/dev/null
|
|
WITH new_user AS (
|
|
INSERT INTO users (email, password, is_activated, activated_at)
|
|
VALUES (:'email', :'hash', true, NOW())
|
|
RETURNING id
|
|
)
|
|
INSERT INTO golem15_fonoteka_collections (owner_id, name)
|
|
SELECT id, 'Phase 8 Gate Collection' FROM new_user;
|
|
SQL
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
PHASE8_REQUIRED_THREATS=(
|
|
T-08-PKCE
|
|
T-08-CODE-REPLAY
|
|
T-08-REFRESH-REPLAY
|
|
T-08-OPEN-REDIRECT
|
|
T-08-SECRET-TIMING
|
|
T-08-SCOPE-CEILING
|
|
T-08-CROSS-USER
|
|
T-08-REQUEST-LEAK
|
|
T-08-DCR-FLOOD
|
|
T-08-SURFACE
|
|
T-08-SC
|
|
)
|
|
|
|
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
|
|
}
|
|
grep -qE "^threats_open: 0$" "$review" || {
|
|
echo "refuse: 08-SECURITY-REVIEW.md does not declare threats_open: 0" >&2
|
|
exit 1
|
|
}
|
|
local t
|
|
for t in "${PHASE8_REQUIRED_THREATS[@]}"; do
|
|
grep -q -- "$t" "$review" || {
|
|
echo "refuse: 08-SECURITY-REVIEW.md is missing required threat row $t" >&2
|
|
exit 1
|
|
}
|
|
done
|
|
}
|
|
|
|
run_full_gate() {
|
|
# Every stage/helper that needs the shared workdir calls
|
|
# dir="$(phase8_workdir)" -- command substitution, which always forks a
|
|
# subshell. If PHASE8_WORKDIR were first assigned *inside* that
|
|
# subshelled function body, the assignment would never escape back to
|
|
# this (the real, top-level) shell: every separate caller would then
|
|
# see PHASE8_WORKDIR still empty and mint its own fresh mktemp
|
|
# directory, silently fragmenting one run's state (app.log, the MCP
|
|
# client's gate-state.json, etc.) across many directories that never
|
|
# see each other's writes. Setting it here, directly (not through a
|
|
# command substitution), makes every later `phase8_workdir()` call see
|
|
# it already populated and simply echo the same value back.
|
|
PHASE8_WORKDIR="$(mktemp -d /tmp/summercms-phase8-XXXXXX)"
|
|
PHASE8_CLEANUP_DIRS+=("$PHASE8_WORKDIR")
|
|
|
|
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
|
|
|
|
# gate-state.json (the MCP client's shared cross-invocation state file)
|
|
# has done its job: no later stage reads it, and it necessarily holds
|
|
# raw live secrets (access/refresh tokens, the JWT) the MCP SDK's own
|
|
# helpers returned during the stages above -- unlike every stage's own
|
|
# stdout/stderr, it is never piped through redact_phase8. Removing it
|
|
# now, before stage_secret_scan's directory-wide scan, is the fix (not
|
|
# loosening that scan): nothing legitimate needs this file anymore.
|
|
rm -f "$(phase8_workdir)/gate-state.json"
|
|
|
|
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
|
|
;;
|
|
--security-review-only)
|
|
# 08-10-PLAN.md Task 2's own focused verify: the fail-closed
|
|
# 08-SECURITY-REVIEW.md checks only, no services booted. Not part
|
|
# of the complete gate's stage sequence; kept out of
|
|
# PHASE8_STAGES/run_full_gate so --contract-self-test's "no
|
|
# pre-final full-run mode" check does not need to special-case it.
|
|
stage_security_review
|
|
echo "phase8 security-review-only check passed"
|
|
;;
|
|
--red-contract)
|
|
[[ $# -ge 2 ]] || usage
|
|
run_red_contract "$2"
|
|
;;
|
|
*)
|
|
usage
|
|
;;
|
|
esac
|
|
}
|
|
|
|
main "$@"
|