diff --git a/.github/workflows/control-plane-qa.yml b/.github/workflows/control-plane-qa.yml index 1b35bc2..919e222 100644 --- a/.github/workflows/control-plane-qa.yml +++ b/.github/workflows/control-plane-qa.yml @@ -2,6 +2,10 @@ name: Control Plane QA on: workflow_dispatch: + push: + branches: + - "fix/**" + - "automation/**" pull_request: branches: [main] paths: @@ -26,6 +30,8 @@ jobs: timeout-minutes: 10 steps: - uses: actions/checkout@v4 + with: + persist-credentials: false - uses: actions/setup-node@v4 with: @@ -51,12 +57,40 @@ jobs: set -euo pipefail ! grep -RInE '^\s*pull_request_target:' .github/workflows ! grep -RInE 'uses: MelodicBloom/.github/.+@(main|master)$' examples/callers .github/workflows - ! grep -RInE '(sk-[A-Za-z0-9_-]{16,}|github_pat_[A-Za-z0-9_]{16,})' . \ + ! grep -RInE '(sk-[A-Za-z0-9_-]{16,}|github_pat_[A-Za-z0-9_]{16,}|gh[pousr]_[A-Za-z0-9_]{16,})' . \ --exclude-dir=.git --exclude='*.md' grep -q 'store: false' scripts/automation/openai-evidence-review.mjs grep -q 'human_approval_required' scripts/automation/openai-evidence-review.mjs grep -q 'contents: read' .github/workflows/reusable-node-verify-v2.yml - grep -q 'if: ${{ always() }}' .github/workflows/reusable-node-verify-v2.yml + grep -Fq 'always()' .github/workflows/reusable-node-verify-v2.yml + + - name: Verify OpenAI evidence boundary hardening + shell: bash + run: | + set -euo pipefail + workflow=.github/workflows/reusable-openai-evidence-review.yml + analyzer=scripts/automation/openai-evidence-review.mjs + schema=schemas/intelligence/ecosystem-recommendation.schema.json + + test "$(grep -c 'persist-credentials: false' "$workflow")" -ge 2 + grep -Fq 'candidate="$(realpath -m "$EVIDENCE_PATH")"' "$workflow" + grep -Fq 'Evidence may not read Git metadata.' "$workflow" + grep -Fq 'x-access-token:' "$workflow" + grep -Fq 'gh[pousr]_' "$workflow" + + grep -Fq 'x-access-token:[REDACTED]' "$analyzer" + grep -Fq 'const contentItems = Array.isArray(payload.output)' "$analyzer" + grep -Fq 'typeof content.text?.value === "string"' "$analyzer" + + python - "$schema" <<'PY' + import json, sys + with open(sys.argv[1], encoding="utf-8") as fh: + schema = json.load(fh) + props = schema["properties"] + assert props["findings"].get("items", {}).get("type") == "object" + assert props["opportunities"].get("items", {}).get("type") == "object" + assert props["human_approval_required"].get("const") is True + PY - name: Record QA receipt if: ${{ always() }} diff --git a/.github/workflows/reusable-openai-evidence-review.yml b/.github/workflows/reusable-openai-evidence-review.yml index 54344b7..98a38fd 100644 --- a/.github/workflows/reusable-openai-evidence-review.yml +++ b/.github/workflows/reusable-openai-evidence-review.yml @@ -96,6 +96,7 @@ jobs: uses: actions/checkout@v4 with: path: source + persist-credentials: false - name: Checkout immutable called-workflow implementation uses: actions/checkout@v4 @@ -103,6 +104,7 @@ jobs: repository: ${{ steps.workflow-provenance.outputs.workflow_repository }} ref: ${{ steps.workflow-provenance.outputs.workflow_sha }} path: .control-plane + persist-credentials: false - name: Set up Node uses: actions/setup-node@v4 @@ -114,16 +116,42 @@ jobs: run: | set -euo pipefail mkdir -p "$OUTPUT_DIR" - test -f "$EVIDENCE_PATH" + + source_root="$(realpath source)" + candidate="$(realpath -m "$EVIDENCE_PATH")" + case "$candidate" in + "$source_root"/*) ;; + *) + echo "::error::Evidence path must resolve inside the caller checkout." + exit 1 + ;; + esac + + relative="${candidate#"$source_root"/}" + if [[ "$relative" == ".git" || "$relative" == .git/* || "$relative" == */.git/* ]]; then + echo "::error::Evidence may not read Git metadata." + exit 1 + fi + + if [[ ! -f "$candidate" ]]; then + echo "::error::Evidence path must resolve to a regular file." + exit 1 + fi + + EVIDENCE_PATH="$candidate" + echo "EVIDENCE_PATH=$EVIDENCE_PATH" >> "$GITHUB_ENV" + bytes=$(wc -c < "$EVIDENCE_PATH") if (( bytes == 0 || bytes > MAX_INPUT_BYTES )); then echo "::error::Evidence must contain 1-${MAX_INPUT_BYTES} bytes; found ${bytes}." exit 1 fi - if grep -Eqi '(sk-[A-Za-z0-9_-]{16,}|OPENAI_API_KEY[=:][^[:space:]]+|github_pat_[A-Za-z0-9_]+)' "$EVIDENCE_PATH"; then + + if grep -Eqi '(sk-[A-Za-z0-9_-]{16,}|OPENAI_API_KEY[[:space:]]*[=:][[:space:]]*[^[:space:]]+|github_pat_[A-Za-z0-9_]{16,}|gh[pousr]_[A-Za-z0-9_]{16,}|x-access-token:[^[:space:]]+|authorization:[[:space:]]*bearer[[:space:]]+[^[:space:]]+)' "$EVIDENCE_PATH"; then echo "::error::Evidence appears to contain a credential. Redact it before analysis." exit 1 fi + sha256sum "$EVIDENCE_PATH" | tee "$OUTPUT_DIR/evidence.sha256" - name: Record skipped analysis when key is absent diff --git a/schemas/intelligence/ecosystem-recommendation.schema.json b/schemas/intelligence/ecosystem-recommendation.schema.json index 738864c..de6b2e4 100644 --- a/schemas/intelligence/ecosystem-recommendation.schema.json +++ b/schemas/intelligence/ecosystem-recommendation.schema.json @@ -2,6 +2,7 @@ "$schema": "https://json-schema.org/draft/2020-12/schema", "$id": "https://melodicbloom.dev/schemas/intelligence/ecosystem-recommendation.schema.json", "title": "Ecosystem Evidence Review", + "description": "Documentation schema for the strict structured-output contract enforced by scripts/automation/openai-evidence-review.mjs. Keep both definitions synchronized; this schema is intentionally explicit so downstream consumers do not treat unconstrained arrays as authoritative.", "type": "object", "additionalProperties": false, "required": [ @@ -18,19 +19,231 @@ "human_approval_required" ], "properties": { - "schema_version": { "type": "integer", "enum": [1] }, + "schema_version": { + "type": "integer", + "enum": [ + 1 + ] + }, "mode": { "type": "string", - "enum": ["health", "product", "conversion", "affiliate", "ads", "creative", "integration", "design_system"] - }, - "repository": { "type": "string", "minLength": 3 }, - "executive_summary": { "type": "string", "minLength": 1 }, - "findings": { "type": "array" }, - "opportunities": { "type": "array" }, - "contradictions": { "type": "array", "items": { "type": "string" } }, - "unknowns": { "type": "array", "items": { "type": "string" } }, - "rejected_options": { "type": "array", "items": { "type": "string" } }, - "recommended_sequence": { "type": "array", "items": { "type": "string" } }, - "human_approval_required": { "type": "boolean" } + "enum": [ + "health", + "product", + "conversion", + "affiliate", + "ads", + "creative", + "integration", + "design_system" + ] + }, + "repository": { + "type": "string", + "minLength": 3 + }, + "executive_summary": { + "type": "string", + "minLength": 1 + }, + "findings": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "category", + "severity", + "confidence", + "evidence_refs", + "observation", + "interpretation", + "recommended_action", + "allowed_paths", + "forbidden_paths", + "verification", + "dependencies", + "risk", + "rollback" + ], + "properties": { + "id": { + "type": "string" + }, + "category": { + "type": "string" + }, + "severity": { + "type": "string", + "enum": [ + "critical", + "high", + "medium", + "low", + "opportunity" + ] + }, + "confidence": { + "type": "number", + "minimum": 0, + "maximum": 1 + }, + "evidence_refs": { + "type": "array", + "items": { + "type": "string" + } + }, + "observation": { + "type": "string" + }, + "interpretation": { + "type": "string" + }, + "recommended_action": { + "type": "string" + }, + "allowed_paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "forbidden_paths": { + "type": "array", + "items": { + "type": "string" + } + }, + "verification": { + "type": "array", + "items": { + "type": "string" + } + }, + "dependencies": { + "type": "array", + "items": { + "type": "string" + } + }, + "risk": { + "type": "string" + }, + "rollback": { + "type": "string" + } + } + } + }, + "opportunities": { + "type": "array", + "items": { + "type": "object", + "additionalProperties": false, + "required": [ + "id", + "class", + "thesis", + "target_audience", + "evidence_refs", + "value_exchange", + "implementation_slice", + "measurement", + "compliance", + "guardrails", + "next_gate" + ], + "properties": { + "id": { + "type": "string" + }, + "class": { + "type": "string", + "enum": [ + "product", + "conversion", + "affiliate", + "ads", + "generative_media", + "integration", + "design_system", + "platform" + ] + }, + "thesis": { + "type": "string" + }, + "target_audience": { + "type": "string" + }, + "evidence_refs": { + "type": "array", + "items": { + "type": "string" + } + }, + "value_exchange": { + "type": "string" + }, + "implementation_slice": { + "type": "array", + "items": { + "type": "string" + } + }, + "measurement": { + "type": "array", + "items": { + "type": "string" + } + }, + "compliance": { + "type": "array", + "items": { + "type": "string" + } + }, + "guardrails": { + "type": "array", + "items": { + "type": "string" + } + }, + "next_gate": { + "type": "string" + } + } + } + }, + "contradictions": { + "type": "array", + "items": { + "type": "string" + } + }, + "unknowns": { + "type": "array", + "items": { + "type": "string" + } + }, + "rejected_options": { + "type": "array", + "items": { + "type": "string" + } + }, + "recommended_sequence": { + "type": "array", + "items": { + "type": "string" + } + }, + "human_approval_required": { + "type": "boolean", + "const": true + } } } diff --git a/scripts/automation/openai-evidence-review.mjs b/scripts/automation/openai-evidence-review.mjs index dcb4257..e427a03 100644 --- a/scripts/automation/openai-evidence-review.mjs +++ b/scripts/automation/openai-evidence-review.mjs @@ -5,6 +5,8 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; const redact = (value) => String(value ?? "Unknown failure") .replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED_OPENAI_KEY]") .replace(/github_pat_[A-Za-z0-9_]+/g, "[REDACTED_GITHUB_TOKEN]") + .replace(/\bgh[pousr]_[A-Za-z0-9_]+\b/g, "[REDACTED_GITHUB_TOKEN]") + .replace(/x-access-token:[^\s@]+/gi, "x-access-token:[REDACTED]") .replace(/Bearer\s+[^\s]+/gi, "Bearer [REDACTED]") .slice(0, 2000); @@ -198,14 +200,23 @@ const response = await fetch("https://api.openai.com/v1/responses", { }); const responseText = await response.text(); if (!response.ok) { - const safeMessage = responseText.slice(0, 2000).replace(/sk-[A-Za-z0-9_-]+/g, "[REDACTED]"); + const safeMessage = redact(responseText); throw new Error(`OpenAI request failed (${response.status}): ${safeMessage}`); } const payload = JSON.parse(responseText); -const outputText = payload.output - ?.flatMap((item) => item.type === "message" ? item.content ?? [] : []) - .find((content) => content.type === "output_text") - ?.text; +const contentItems = Array.isArray(payload.output) + ? payload.output.flatMap((item) => Array.isArray(item?.content) ? item.content : []) + : []; +const outputText = typeof payload.output_text === "string" + ? payload.output_text + : contentItems + .filter((content) => content?.type === "output_text") + .map((content) => { + if (typeof content.text === "string") return content.text; + if (typeof content.text?.value === "string") return content.text.value; + return ""; + }) + .find(Boolean); if (!outputText) throw new Error("OpenAI response did not contain output_text"); const analysis = JSON.parse(outputText); if (analysis.schema_version !== 1) throw new Error("Structured output schema_version mismatch");