diff --git a/scripts/check-phase8-mcp-client.mjs b/scripts/check-phase8-mcp-client.mjs new file mode 100755 index 0000000..d43c79d --- /dev/null +++ b/scripts/check-phase8-mcp-client.mjs @@ -0,0 +1,317 @@ +#!/usr/bin/env node +/** + * check-phase8-mcp-client.mjs -- the scripted MCP client D-14 requires: + * discovery, DCR, PKCE authorize, JWT login/consent, token, an MCP tool + * call, refresh, replay, revoke, and post-revoke failure, driven against + * the REAL unchanged fonoteka-mcp process and the assembled Go backend. + * Never modifies fonoteka-mcp or Nuxt source; resolves the MCP SDK's auth + * helpers from fonoteka-mcp's own node_modules exactly like + * parity/capture_clients.mjs does (no new dependency in either repo). + * + * Invoked once per named stage by scripts/check-phase8.sh's stage_* + * functions, each stage reading/writing a small JSON state file so later + * stages (refresh, revoke) can reuse earlier captures (tokens, request + * ids) without re-running the whole sequence. Only 08-10 Task 3 invokes + * this end to end; 08-09 never runs it. + * + * Required env: + * FONOTEKA_API_URL Go app origin (personal-token API, and the + * same origin's JWT-group user/session API). + * FONOTEKA_MCP_PUBLIC_URL The MCP resource server's own base URL. + * FONOTEKA_MCP_AUTH_SERVER The Go authorization server's base URL + * (normally identical to FONOTEKA_API_URL). + * PHASE8_GATE_STATE Path to the JSON state file. + * PHASE8_GATE_EMAIL/PASSWORD Credentials for the JWT login/consent + * steps, seeded by the app-boot stage. + * + * Every raw secret/token/code/verifier this script would otherwise print + * is redacted before it reaches stdout/stderr (T-08-REQUEST-LEAK). + */ +import { createRequire } from 'node:module' +import { pathToFileURL } from 'node:url' +import { readFileSync, writeFileSync, existsSync } from 'node:fs' + +const MCP_PKG = '/media/nvme/dev/golem15/fonoteka/fonoteka-mcp/package.json' + +function redact(s) { + return String(s) + .replace(/inv_[A-Za-z0-9_-]{8,}/g, '') + .replace(/eyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+/g, '') + .replace(/("access_token"|"refresh_token"|"code_verifier"|"client_secret")\s*:\s*"[^"]*"/g, '$1:""') +} + +function log(msg) { + process.stderr.write(`[check-phase8-mcp-client] ${redact(msg)}\n`) +} + +function fail(msg) { + log(`FAIL: ${msg}`) + process.exit(1) +} + +function env(name, required = true) { + const v = process.env[name] + if (required && (!v || !v.trim())) fail(`missing required env ${name}`) + return v +} + +function loadState(path) { + if (!existsSync(path)) return {} + return JSON.parse(readFileSync(path, 'utf8')) +} + +function saveState(path, state) { + writeFileSync(path, JSON.stringify(state, null, 2), { mode: 0o600 }) +} + +async function loadAuthHelpers() { + const req = createRequire(MCP_PKG) + const mod = await import(pathToFileURL(req.resolve('@modelcontextprotocol/sdk/client/auth.js')).href) + return mod +} + +async function loadClientTransport() { + const req = createRequire(MCP_PKG) + const clientMod = await import(pathToFileURL(req.resolve('@modelcontextprotocol/sdk/client/index.js')).href) + const httpMod = await import(pathToFileURL(req.resolve('@modelcontextprotocol/sdk/client/streamableHttp.js')).href) + return { Client: clientMod.Client, StreamableHTTPClientTransport: httpMod.StreamableHTTPClientTransport } +} + +const REDIRECT_URI = 'http://127.0.0.1:8424/oauth/callback' + +async function stageDiscovery(state) { + const mcpPublic = env('FONOTEKA_MCP_PUBLIC_URL') + const authServer = env('FONOTEKA_MCP_AUTH_SERVER') + const auth = await loadAuthHelpers() + + // The resource server's own 401 hint (RFC 9728), owned by fonoteka-mcp, + // not the backend (D-12) -- verified separately from the backend's own + // exact Basic/no-challenge responses (stage_token below). + const probe = await fetch(new URL('/mcp', mcpPublic), { method: 'GET' }) + if (probe.status !== 401) fail(`expected 401 from unauthenticated MCP endpoint, got ${probe.status}`) + const params = auth.extractWWWAuthenticateParams + ? auth.extractWWWAuthenticateParams(probe) + : null + if (!params || !params.resourceMetadataUrl) { + fail('MCP 401 response is missing a resource_metadata WWW-Authenticate hint') + } + + const resourceMetadata = await auth.discoverOAuthProtectedResourceMetadata(mcpPublic) + const metadata = await auth.discoverAuthorizationServerMetadata(authServer) + if (!metadata) fail('authorization server metadata discovery failed') + + state.resourceMetadata = resourceMetadata + state.metadata = metadata + log('discovery OK') +} + +async function stageDCR(state) { + const authServer = env('FONOTEKA_MCP_AUTH_SERVER') + const auth = await loadAuthHelpers() + const clientInformation = await auth.registerClient(authServer, { + metadata: state.metadata, + clientMetadata: { + client_name: 'Phase 8 final gate client', + redirect_uris: [REDIRECT_URI], + grant_types: ['authorization_code', 'refresh_token'], + response_types: ['code'], + token_endpoint_auth_method: 'none', + }, + }) + state.clientInformation = clientInformation + log('dcr OK') +} + +async function stagePKCEAuthorize(state) { + const authServer = env('FONOTEKA_MCP_AUTH_SERVER') + const auth = await loadAuthHelpers() + const { authorizationUrl, codeVerifier } = await auth.startAuthorization(authServer, { + metadata: state.metadata, + clientInformation: state.clientInformation, + redirectUrl: REDIRECT_URI, + scope: 'read write', + state: 'phase8-gate', + }) + const res = await fetch(authorizationUrl, { redirect: 'manual' }) + if (res.status !== 302 && res.status !== 303 && res.status !== 307) { + fail(`authorize did not redirect (status ${res.status})`) + } + const location = res.headers.get('location') + if (!location) fail('authorize redirect has no Location header') + const requestId = new URL(location).searchParams.get('request') + if (!requestId) fail('authorize redirect is missing ?request=') + state.codeVerifier = codeVerifier + state.requestId = requestId + log('pkce-authorize OK') +} + +async function stageJWTLoginConsent(state) { + const apiURL = env('FONOTEKA_API_URL') + const email = env('PHASE8_GATE_EMAIL') + const password = env('PHASE8_GATE_PASSWORD') + + const loginRes = await fetch(new URL('/_user/api/v1/login', apiURL), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + body: JSON.stringify({ email, password }), + }) + if (loginRes.status !== 200) fail(`JWT login failed (status ${loginRes.status})`) + const { token } = await loginRes.json() + if (!token) fail('JWT login response has no token') + + const showRes = await fetch(new URL(`/_fonoteka/api/v1/oauth/request/${state.requestId}`, apiURL), { + headers: { Accept: 'application/json', Authorization: `Bearer ${token}` }, + }) + if (showRes.status !== 200) fail(`consent request lookup failed (status ${showRes.status})`) + + const consentRes = await fetch(new URL('/_fonoteka/api/v1/oauth/consent', apiURL), { + method: 'POST', + headers: { 'Content-Type': 'application/json', Accept: 'application/json', Authorization: `Bearer ${token}` }, + body: JSON.stringify({ request_id: state.requestId, scopes: ['read', 'write'] }), + }) + if (consentRes.status !== 200) fail(`consent failed (status ${consentRes.status})`) + const consentBody = await consentRes.json() + const redirectTo = consentBody?.data?.redirect_to + if (!redirectTo) fail('consent response has no redirect_to') + const code = new URL(redirectTo).searchParams.get('code') + if (!code) fail('consent redirect_to has no code') + + state.jwt = token + state.code = code + log('jwt-login-consent OK') +} + +async function stageToken(state) { + const authServer = env('FONOTEKA_MCP_AUTH_SERVER') + const auth = await loadAuthHelpers() + const tokens = await auth.exchangeAuthorization(authServer, { + metadata: state.metadata, + clientInformation: state.clientInformation, + authorizationCode: state.code, + codeVerifier: state.codeVerifier, + redirectUri: REDIRECT_URI, + }) + if (!tokens.access_token || !tokens.refresh_token) fail('token exchange did not return both tokens') + state.accessToken = tokens.access_token + state.refreshToken = tokens.refresh_token + state.spentRefreshToken = tokens.refresh_token + log('token OK') +} + +async function stageToolCall(state) { + const mcpPublic = env('FONOTEKA_MCP_PUBLIC_URL') + const { Client, StreamableHTTPClientTransport } = await loadClientTransport() + const transport = new StreamableHTTPClientTransport(new URL('/mcp', mcpPublic), { + requestInit: { headers: { Authorization: `Bearer ${state.accessToken}` } }, + }) + const client = new Client({ name: 'phase8-gate', version: '0.0.1' }) + await client.connect(transport) + try { + const tools = await client.listTools() + if (!Array.isArray(tools.tools) || tools.tools.length === 0) fail('MCP tool list is empty') + const listGenres = tools.tools.find((t) => t.name === 'list_genres') + if (!listGenres) fail('list_genres tool not found') + const result = await client.callTool({ name: 'list_genres', arguments: {} }) + if (result.isError) fail(`list_genres tool call reported an error: ${JSON.stringify(result)}`) + } finally { + await client.close().catch(() => {}) + } + log('tool-call OK') +} + +async function stageRefresh(state) { + const authServer = env('FONOTEKA_MCP_AUTH_SERVER') + const auth = await loadAuthHelpers() + const tokens = await auth.refreshAuthorization(authServer, { + metadata: state.metadata, + clientInformation: state.clientInformation, + refreshToken: state.refreshToken, + }) + if (!tokens.access_token) fail('refresh did not return a new access token') + state.spentRefreshToken = state.refreshToken + state.accessToken = tokens.access_token + state.refreshToken = tokens.refresh_token || state.refreshToken + log('refresh OK') +} + +async function stageReplay(state) { + const authServer = env('FONOTEKA_MCP_AUTH_SERVER') + const auth = await loadAuthHelpers() + let threw = false + try { + await auth.refreshAuthorization(authServer, { + metadata: state.metadata, + clientInformation: state.clientInformation, + refreshToken: state.spentRefreshToken, + }) + } catch { + threw = true + } + if (!threw) fail('replaying the spent refresh token unexpectedly succeeded') + log('replay OK (spent refresh token correctly rejected)') +} + +async function stageRevoke(state) { + const apiURL = env('FONOTEKA_API_URL') + const listRes = await fetch(new URL('/_fonoteka/api/v1/oauth/connected-apps', apiURL), { + headers: { Accept: 'application/json', Authorization: `Bearer ${state.jwt}` }, + }) + if (listRes.status !== 200) fail(`connected-apps list failed (status ${listRes.status})`) + const listBody = await listRes.json() + const app = (listBody.data || []).find((a) => a.name === 'Phase 8 final gate client') + if (!app) fail('connected-apps list does not show this gate client') + const delRes = await fetch(new URL(`/_fonoteka/api/v1/oauth/connected-apps/${app.id}`, apiURL), { + method: 'DELETE', + headers: { Accept: 'application/json', Authorization: `Bearer ${state.jwt}` }, + }) + if (delRes.status !== 200) fail(`connected-apps revoke failed (status ${delRes.status})`) + state.revokedAppId = app.id + log('revoke OK') +} + +async function stagePostRevokeFailure(state) { + const authServer = env('FONOTEKA_MCP_AUTH_SERVER') + const auth = await loadAuthHelpers() + let threw = false + try { + await auth.refreshAuthorization(authServer, { + metadata: state.metadata, + clientInformation: state.clientInformation, + refreshToken: state.refreshToken, + }) + } catch { + threw = true + } + if (!threw) fail('refreshing after revoke unexpectedly succeeded') + log('post-revoke-failure OK') +} + +const STAGES = { + discovery: stageDiscovery, + dcr: stageDCR, + 'pkce-authorize': stagePKCEAuthorize, + 'jwt-login-consent': stageJWTLoginConsent, + token: stageToken, + 'tool-call': stageToolCall, + refresh: stageRefresh, + replay: stageReplay, + revoke: stageRevoke, + 'post-revoke-failure': stagePostRevokeFailure, +} + +async function main() { + const stageArgIdx = process.argv.indexOf('--stage') + if (stageArgIdx === -1 || !process.argv[stageArgIdx + 1]) { + fail('usage: check-phase8-mcp-client.mjs --stage ') + } + const stageName = process.argv[stageArgIdx + 1] + const fn = STAGES[stageName] + if (!fn) fail(`unknown stage ${stageName}`) + + const statePath = env('PHASE8_GATE_STATE') + const state = loadState(statePath) + await fn(state) + saveState(statePath, state) +} + +main().catch((err) => fail(err?.stack || String(err))) diff --git a/scripts/check-phase8.sh b/scripts/check-phase8.sh index 173bc64..e8f3b3a 100755 --- a/scripts/check-phase8.sh +++ b/scripts/check-phase8.sh @@ -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() {