diff --git a/.github/workflows/security-pr.yml b/.github/workflows/security-pr.yml index 81beb257..d174433b 100644 --- a/.github/workflows/security-pr.yml +++ b/.github/workflows/security-pr.yml @@ -10,8 +10,8 @@ on: workflow_dispatch: inputs: pr_number: - description: 'PR number to scan (optional)' - required: false + description: 'PR number to scan' + required: true type: string pull_request: push: @@ -27,17 +27,18 @@ jobs: name: Trivy Binary Scan runs-on: ubuntu-latest timeout-minutes: 10 - # Run for: manual dispatch, PR builds, or any push builds from docker-build + # Run for manual dispatch, direct PR/push, or successful upstream workflow_run if: >- github.event_name == 'workflow_dispatch' || github.event_name == 'pull_request' || + github.event_name == 'push' || (github.event_name == 'workflow_run' && - (github.event.workflow_run.event == 'push' || github.event.workflow_run.event == 'pull_request') && - (github.event.workflow_run.status != 'completed' || github.event.workflow_run.conclusion == 'success')) + github.event.workflow_run.event == 'pull_request' && + github.event.workflow_run.status == 'completed' && + github.event.workflow_run.conclusion == 'success') permissions: contents: read - pull-requests: write security-events: write actions: read @@ -53,18 +54,56 @@ jobs: env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - # Manual dispatch - use input or fail gracefully - if [[ -n "${{ inputs.pr_number }}" ]]; then - echo "pr_number=${{ inputs.pr_number }}" >> "$GITHUB_OUTPUT" - echo "✅ Using manually provided PR number: ${{ inputs.pr_number }}" - else - echo "⚠️ No PR number provided for manual dispatch" - echo "pr_number=" >> "$GITHUB_OUTPUT" - fi + if [[ "${{ github.event_name }}" == "push" ]]; then + echo "pr_number=" >> "$GITHUB_OUTPUT" + echo "is_push=true" >> "$GITHUB_OUTPUT" + echo "✅ Push event detected; using local image path" exit 0 fi + if [[ "${{ github.event_name }}" == "pull_request" ]]; then + echo "pr_number=${{ github.event.pull_request.number }}" >> "$GITHUB_OUTPUT" + echo "is_push=false" >> "$GITHUB_OUTPUT" + echo "✅ Pull request event detected: PR #${{ github.event.pull_request.number }}" + exit 0 + fi + + if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then + INPUT_PR_NUMBER="${{ inputs.pr_number }}" + if [[ -z "${INPUT_PR_NUMBER}" ]]; then + echo "❌ workflow_dispatch requires inputs.pr_number" + exit 1 + fi + + if [[ ! "${INPUT_PR_NUMBER}" =~ ^[0-9]+$ ]]; then + echo "❌ reason_category=invalid_input" + echo "reason=workflow_dispatch pr_number must be digits-only" + exit 1 + fi + + PR_NUMBER="${INPUT_PR_NUMBER}" + echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + echo "is_push=false" >> "$GITHUB_OUTPUT" + echo "✅ Using manually provided PR number: ${PR_NUMBER}" + exit 0 + fi + + if [[ "${{ github.event_name }}" == "workflow_run" ]]; then + if [[ "${{ github.event.workflow_run.event }}" != "pull_request" ]]; then + # Explicit contract validation happens in the dedicated guard step. + echo "pr_number=" >> "$GITHUB_OUTPUT" + echo "is_push=false" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ -n "${{ github.event.workflow_run.pull_requests[0].number || '' }}" ]]; then + echo "pr_number=${{ github.event.workflow_run.pull_requests[0].number }}" >> "$GITHUB_OUTPUT" + echo "is_push=false" >> "$GITHUB_OUTPUT" + echo "✅ Found PR number from workflow_run payload: ${{ github.event.workflow_run.pull_requests[0].number }}" + exit 0 + fi + fi + # Extract PR number from context HEAD_SHA="${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}" echo "🔍 Looking for PR with head SHA: ${HEAD_SHA}" @@ -78,21 +117,38 @@ jobs: if [[ -n "${PR_NUMBER}" ]]; then echo "pr_number=${PR_NUMBER}" >> "$GITHUB_OUTPUT" + echo "is_push=false" >> "$GITHUB_OUTPUT" echo "✅ Found PR number: ${PR_NUMBER}" else - echo "⚠️ Could not find PR number for SHA: ${HEAD_SHA}" - echo "pr_number=" >> "$GITHUB_OUTPUT" + echo "❌ Could not determine PR number for workflow_run SHA: ${HEAD_SHA}" + exit 1 fi - # Check if this is a push event (not a PR) - if [[ "${{ github.event_name }}" == "push" || "${{ github.event_name == 'workflow_run' && github.event.workflow_run.event || '' }}" == "push" || -z "${PR_NUMBER}" ]]; then - HEAD_BRANCH="${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_branch || github.ref_name }}" - echo "is_push=true" >> "$GITHUB_OUTPUT" - echo "✅ Detected push build from branch: ${HEAD_BRANCH}" - else - echo "is_push=false" >> "$GITHUB_OUTPUT" + - name: Validate workflow_run trust boundary and event contract + if: github.event_name == 'workflow_run' + run: | + if [[ "${{ github.event.workflow_run.name }}" != "Docker Build, Publish & Test" ]]; then + echo "❌ reason_category=unexpected_upstream_workflow" + echo "workflow_name=${{ github.event.workflow_run.name }}" + exit 1 fi + if [[ "${{ github.event.workflow_run.event }}" != "pull_request" ]]; then + echo "❌ reason_category=unsupported_upstream_event" + echo "upstream_event=${{ github.event.workflow_run.event }}" + echo "run_id=${{ github.event.workflow_run.id }}" + exit 1 + fi + + if [[ "${{ github.event.workflow_run.head_repository.full_name }}" != "${{ github.repository }}" ]]; then + echo "❌ reason_category=untrusted_upstream_repository" + echo "upstream_head_repository=${{ github.event.workflow_run.head_repository.full_name }}" + echo "expected_repository=${{ github.repository }}" + exit 1 + fi + + echo "✅ workflow_run trust boundary and event contract validated" + - name: Build Docker image (Local) if: github.event_name == 'push' || github.event_name == 'pull_request' run: | @@ -102,107 +158,149 @@ jobs: - name: Check for PR image artifact id: check-artifact - if: (steps.pr-info.outputs.pr_number != '' || steps.pr-info.outputs.is_push == 'true') && github.event_name != 'push' && github.event_name != 'pull_request' + if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch' env: GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} run: | - # Determine artifact name based on event type - if [[ "${{ steps.pr-info.outputs.is_push }}" == "true" ]]; then - ARTIFACT_NAME="push-image" - else - PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}" - ARTIFACT_NAME="pr-image-${PR_NUMBER}" + PR_NUMBER="${{ steps.pr-info.outputs.pr_number }}" + if [[ ! "${PR_NUMBER}" =~ ^[0-9]+$ ]]; then + echo "❌ reason_category=invalid_input" + echo "reason=Resolved PR number must be digits-only" + exit 1 fi + + ARTIFACT_NAME="pr-image-${PR_NUMBER}" RUN_ID="${{ github.event_name == 'workflow_run' && github.event.workflow_run.id || '' }}" echo "🔍 Checking for artifact: ${ARTIFACT_NAME}" if [[ "${{ github.event_name }}" == "workflow_dispatch" ]]; then - # For manual dispatch, find the most recent workflow run with this artifact - RUN_ID=$(gh api \ + # Manual replay path: find latest successful docker-build pull_request run for this PR. + RUNS_JSON=$(gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?status=success&per_page=10" \ - --jq '.workflow_runs[0].id // empty' 2>/dev/null || echo "") + "/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?event=pull_request&status=success&per_page=100" 2>&1) + RUNS_STATUS=$? + + if [[ ${RUNS_STATUS} -ne 0 ]]; then + echo "❌ reason_category=api_error" + echo "reason=Failed to query workflow runs for PR lookup" + echo "upstream_run_id=unknown" + echo "artifact_name=${ARTIFACT_NAME}" + echo "api_output=${RUNS_JSON}" + exit 1 + fi + + RUN_ID=$(printf '%s' "${RUNS_JSON}" | jq -r --argjson pr "${PR_NUMBER}" '.workflow_runs[] | select((.pull_requests // []) | any(.number == $pr)) | .id' | head -n 1) if [[ -z "${RUN_ID}" ]]; then - echo "⚠️ No successful workflow runs found" - echo "artifact_exists=false" >> "$GITHUB_OUTPUT" - exit 0 + echo "❌ reason_category=not_found" + echo "reason=No successful docker-build pull_request run found for PR #${PR_NUMBER}" + echo "upstream_run_id=unknown" + echo "artifact_name=${ARTIFACT_NAME}" + exit 1 fi - elif [[ -z "${RUN_ID}" ]]; then - # If triggered by push/pull_request, RUN_ID is empty. Find recent run for this commit. - HEAD_SHA="${{ github.event_name == 'workflow_run' && github.event.workflow_run.head_sha || github.event.pull_request.head.sha || github.sha }}" - echo "🔍 Searching for workflow run for SHA: ${HEAD_SHA}" - # Retry a few times as the run might be just starting or finishing - for i in {1..3}; do - RUN_ID=$(gh api \ - -H "Accept: application/vnd.github+json" \ - -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${{ github.repository }}/actions/workflows/docker-build.yml/runs?head_sha=${HEAD_SHA}&status=success&per_page=1" \ - --jq '.workflow_runs[0].id // empty' 2>/dev/null || echo "") - if [[ -n "${RUN_ID}" ]]; then break; fi - echo "⏳ Waiting for workflow run to appear/complete... ($i/3)" - sleep 5 - done fi echo "run_id=${RUN_ID}" >> "$GITHUB_OUTPUT" # Check if the artifact exists in the workflow run - ARTIFACT_ID=$(gh api \ + ARTIFACTS_JSON=$(gh api \ -H "Accept: application/vnd.github+json" \ -H "X-GitHub-Api-Version: 2022-11-28" \ - "/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" \ - --jq ".artifacts[] | select(.name == \"${ARTIFACT_NAME}\") | .id" 2>/dev/null || echo "") + "/repos/${{ github.repository }}/actions/runs/${RUN_ID}/artifacts" 2>&1) + ARTIFACTS_STATUS=$? - if [[ -n "${ARTIFACT_ID}" ]]; then - echo "artifact_exists=true" >> "$GITHUB_OUTPUT" - echo "artifact_id=${ARTIFACT_ID}" >> "$GITHUB_OUTPUT" - echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})" - else - echo "artifact_exists=false" >> "$GITHUB_OUTPUT" - echo "⚠️ Artifact not found: ${ARTIFACT_NAME}" - echo "ℹ️ This is expected for non-PR builds or if the image was not uploaded" + if [[ ${ARTIFACTS_STATUS} -ne 0 ]]; then + echo "❌ reason_category=api_error" + echo "reason=Failed to query artifacts for upstream run" + echo "upstream_run_id=${RUN_ID}" + echo "artifact_name=${ARTIFACT_NAME}" + echo "api_output=${ARTIFACTS_JSON}" + exit 1 fi - - name: Skip if no artifact - if: ((steps.pr-info.outputs.pr_number == '' && steps.pr-info.outputs.is_push != 'true') || steps.check-artifact.outputs.artifact_exists != 'true') && github.event_name != 'push' && github.event_name != 'pull_request' - run: | - echo "ℹ️ Skipping security scan - no PR image artifact available" - echo "This is expected for:" - echo " - Pushes to main/release branches" - echo " - PRs where Docker build failed" - echo " - Manual dispatch without PR number" - exit 0 + ARTIFACT_ID=$(printf '%s' "${ARTIFACTS_JSON}" | jq -r --arg name "${ARTIFACT_NAME}" '.artifacts[] | select(.name == $name) | .id' | head -n 1) + + if [[ -z "${ARTIFACT_ID}" ]]; then + echo "❌ reason_category=not_found" + echo "reason=Required artifact was not found" + echo "upstream_run_id=${RUN_ID}" + echo "artifact_name=${ARTIFACT_NAME}" + exit 1 + fi + + { + echo "artifact_exists=true" + echo "artifact_id=${ARTIFACT_ID}" + echo "artifact_name=${ARTIFACT_NAME}" + } >> "$GITHUB_OUTPUT" + echo "✅ Found artifact: ${ARTIFACT_NAME} (ID: ${ARTIFACT_ID})" - name: Download PR image artifact - if: steps.check-artifact.outputs.artifact_exists == 'true' + if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch' # actions/download-artifact v4.1.8 uses: actions/download-artifact@70fc10c6e5e1ce46ad2ea6f2b72d43f7d47b13c3 with: - name: ${{ steps.pr-info.outputs.is_push == 'true' && 'push-image' || format('pr-image-{0}', steps.pr-info.outputs.pr_number) }} + name: ${{ steps.check-artifact.outputs.artifact_name }} run-id: ${{ steps.check-artifact.outputs.run_id }} github-token: ${{ secrets.GITHUB_TOKEN }} - name: Load Docker image - if: steps.check-artifact.outputs.artifact_exists == 'true' + if: github.event_name == 'workflow_run' || github.event_name == 'workflow_dispatch' id: load-image run: | echo "📦 Loading Docker image..." - SOURCE_IMAGE_REF=$(tar -xOf charon-pr-image.tar manifest.json | jq -r '.[0].RepoTags[0] // empty') - if [[ -z "${SOURCE_IMAGE_REF}" ]]; then - echo "❌ ERROR: Could not determine image tag from artifact manifest" + + if [[ ! -r "charon-pr-image.tar" ]]; then + echo "❌ ERROR: Artifact image tar is missing or unreadable" + exit 1 + fi + + MANIFEST_TAGS="" + if tar -tf charon-pr-image.tar | grep -qx "manifest.json"; then + MANIFEST_TAGS=$(tar -xOf charon-pr-image.tar manifest.json 2>/dev/null | jq -r '.[]?.RepoTags[]?' 2>/dev/null | sed '/^$/d' || true) + else + echo "⚠️ manifest.json not found in artifact tar; will try docker-load-image-id fallback" + fi + + LOAD_OUTPUT=$(docker load < charon-pr-image.tar 2>&1) + echo "${LOAD_OUTPUT}" + + SOURCE_IMAGE_REF="" + SOURCE_RESOLUTION_MODE="" + + while IFS= read -r tag; do + [[ -z "${tag}" ]] && continue + if docker image inspect "${tag}" >/dev/null 2>&1; then + SOURCE_IMAGE_REF="${tag}" + SOURCE_RESOLUTION_MODE="manifest_tag" + break + fi + done <<< "${MANIFEST_TAGS}" + + if [[ -z "${SOURCE_IMAGE_REF}" ]]; then + LOAD_IMAGE_ID=$(printf '%s\n' "${LOAD_OUTPUT}" | sed -nE 's/^Loaded image ID: (sha256:[0-9a-f]+)$/\1/p' | head -n1) + if [[ -n "${LOAD_IMAGE_ID}" ]] && docker image inspect "${LOAD_IMAGE_ID}" >/dev/null 2>&1; then + SOURCE_IMAGE_REF="${LOAD_IMAGE_ID}" + SOURCE_RESOLUTION_MODE="load_image_id" + fi + fi + + if [[ -z "${SOURCE_IMAGE_REF}" ]]; then + echo "❌ ERROR: Could not resolve a valid image reference from manifest tags or docker load image ID" exit 1 fi - docker load < charon-pr-image.tar docker tag "${SOURCE_IMAGE_REF}" "charon:artifact" - echo "source_image_ref=${SOURCE_IMAGE_REF}" >> "$GITHUB_OUTPUT" - echo "image_ref=charon:artifact" >> "$GITHUB_OUTPUT" + { + echo "source_image_ref=${SOURCE_IMAGE_REF}" + echo "source_resolution_mode=${SOURCE_RESOLUTION_MODE}" + echo "image_ref=charon:artifact" + } >> "$GITHUB_OUTPUT" - echo "✅ Docker image loaded and tagged as charon:artifact" + echo "✅ Docker image resolved via ${SOURCE_RESOLUTION_MODE} and tagged as charon:artifact" docker images | grep charon - name: Extract charon binary from container diff --git a/docs/issues/manual_test_security_pr_event_gating_artifact_resolution.md b/docs/issues/manual_test_security_pr_event_gating_artifact_resolution.md new file mode 100644 index 00000000..c714743a --- /dev/null +++ b/docs/issues/manual_test_security_pr_event_gating_artifact_resolution.md @@ -0,0 +1,142 @@ +--- +title: Manual Test Plan - Security Scan PR Event Gating and Artifact Resolution +status: Open +priority: High +assignee: DevOps +labels: testing, workflows, security, ci/cd +--- + +## Goal +Validate that `Security Scan (PR)` in `.github/workflows/security-pr.yml` behaves deterministically for trigger gating, PR artifact resolution, and trust-boundary checks. + +## Scope +- Event gating for `workflow_run`, `workflow_dispatch`, `pull_request`, and `push` +- PR artifact lookup and image loading path +- Failure behavior for missing/corrupt artifacts +- Permission and trust-boundary protection paths + +## Preconditions +- You can run workflows in this repository. +- You can view workflow logs in GitHub Actions. +- At least one recent PR exists with a successful `Docker Build, Publish & Test` run and published `pr-image-` artifact. +- Use a test branch or draft PR for negative testing. + +## Evidence to Capture +- Run URL for each scenario +- Job status (`success`, `failure`, `skipped`) +- Exact failure line when expected +- `reason_category` value when present + +## Manual Test Checklist + +### 1. `workflow_run` from upstream `pull_request` (happy path) +- [ ] Trigger a PR build by pushing a commit to an open PR. +- [ ] Wait for `Docker Build, Publish & Test` to complete successfully. +- [ ] Confirm `Security Scan (PR)` starts from `workflow_run`. +- [ ] Confirm job `Trivy Binary Scan` runs. +- [ ] Confirm logs show trust-boundary validation success. +- [ ] Confirm artifact `pr-image-` is found and downloaded. +- [ ] Confirm `Load Docker image` resolves to `charon:artifact`. +- [ ] Confirm binary extraction and Trivy scan steps execute. + +Expected outcome: +- Workflow succeeds or fails only on real security findings, not on event/artifact resolution. + +Failure signals: +- `reason_category=unsupported_upstream_event` on a PR-triggered upstream run. +- Artifact lookup fails for a known valid PR artifact. +- `Load Docker image` cannot resolve image ref despite valid artifact. + +### 2. `workflow_run` from upstream `push` (should not run) +- [ ] Push directly to a branch that triggers `Docker Build, Publish & Test` as `push` (for example, `main` in a controlled test window). +- [ ] Open `Security Scan (PR)` run created by `workflow_run`. +- [ ] Verify `Trivy Binary Scan` is skipped by job-level gating. +- [ ] Verify no artifact lookup/download steps were executed. + +Expected outcome: +- `Security Scan (PR)` job does not run for upstream `push`. + +Failure signals: +- `Trivy Binary Scan` executes for upstream `push`. +- Any artifact resolution step runs under upstream `push`. + +### 3. `workflow_dispatch` with valid `pr_number` +- [ ] Open `Security Scan (PR)` and click `Run workflow`. +- [ ] Provide a numeric `pr_number` that has a successful docker-build artifact. +- [ ] Start run and inspect logs. +- [ ] Confirm PR number validation passes. +- [ ] Confirm run lookup resolves a successful `docker-build.yml` run for that PR. +- [ ] Confirm artifact download, image load, extraction, and Trivy steps run. + +Expected outcome: +- Workflow executes artifact-only replay path and proceeds to scan. + +Failure signals: +- Dispatch falls back to local image build. +- `reason_category=not_found` for a PR known to have valid artifact. + +### 4. `workflow_dispatch` without `pr_number` (input validation) +- [ ] Open `Run workflow` for `Security Scan (PR)`. +- [ ] Attempt run with empty `pr_number` (or non-numeric value if UI blocks empty). +- [ ] Inspect early step logs. + +Expected outcome: +- Job fails fast before artifact lookup/load. +- Clear validation message indicates missing/invalid `pr_number`. + +Failure signals: +- Workflow continues to artifact lookup with invalid input. +- Error message is ambiguous or missing reason category. + +### 5. Artifact missing case +- [ ] Run `workflow_dispatch` with a numeric PR that does not have a successful docker-build artifact. +- [ ] Inspect `Check for PR image artifact` logs. + +Expected outcome: +- Hard fail with a clear error. +- Log includes `reason_category=not_found`, run context, and artifact name. + +Failure signals: +- Step silently skips or succeeds without artifact. +- Workflow proceeds to download/load steps. + +### 6. Artifact corrupt/unreadable case +- [ ] Use a controlled test branch to simulate bad artifact content for `charon-pr-image.tar` (for example, tar missing `manifest.json` and no usable load image ID, or unreadable tar). +- [ ] Trigger path through `workflow_run` or `workflow_dispatch`. +- [ ] Inspect `Load Docker image` logs. + +Expected outcome: +- Job fails in `Load Docker image` before extraction when image cannot be resolved. +- Error states artifact is missing/unreadable, or valid image reference cannot be resolved. + +Failure signals: +- Job continues to extraction with empty/invalid image ref. +- `docker create` fails later due to unresolved image (late failure indicates missed validation). + +### 7. Trust-boundary and permission guard failures +- [ ] Verify `permissions` in run metadata are minimal: `contents: read`, `actions: read`, `security-events: write`. +- [ ] For `workflow_run`, inspect guard step output. +- [ ] Confirm guard fails when any of the following are invalid: + - Upstream workflow name mismatch + - Upstream event not `pull_request` + - Upstream head repository not equal to current repository + +Expected outcome: +- Guard fails early with explicit `reason_category`. +- No artifact lookup/load/extract occurs after guard failure. + +Failure signals: +- Guard passes with mismatched trust-boundary values. +- Workflow attempts artifact operations after trust-boundary failure. +- Unexpected write permissions are present. + +## Regression Watchlist +- Event-gating changes accidentally allow `workflow_run` from `push` to execute scan. +- Manual dispatch path silently accepts non-numeric or empty PR input. +- Artifact resolver relies on a single tag and breaks on alternate load output formats. +- Trust-boundary checks are bypassed due to conditional logic drift. + +## Exit Criteria +- All scenarios pass with expected behavior. +- Any failure signal is logged as a bug with run URL and exact failing step. +- No ambiguous skip behavior remains for required hard-fail paths. diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index c20f6017..5acf098a 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -1,260 +1,383 @@ -# Caddy Import Tests Reorganization: Move from Security Shard to Core - -**Date:** 2026-02-26 -**Status:** Ready for Implementation - ---- +# Security Scan (PR) Deterministic Artifact Policy - Supervisor Remediation Plan ## 1. Introduction ### Overview -The 5 Caddyfile import UI test files were manually moved from -`tests/security-enforcement/zzz-caddy-imports/` to `tests/core/caddy-import/`. -These tests verify Caddyfile parsing/import UI functionality and do **not** -require Cerberus middleware — they belong in the non-security (core) shard. +`Security Scan (PR)` failed because `.github/workflows/security-pr.yml` loaded +an artifact image tag (`pr-718-385081f`) and later attempted extraction with a +different synthesized tag (`pr-718`). + +Supervisor conflict resolution in this plan selects Option A: +`workflow_run` artifact handling is restricted to upstream +`pull_request` events only. + +### Root-Cause Clarity (Preserved) + +The failure was not a Docker load failure. It was a source-of-truth violation in +image selection: + +1. Artifact load path succeeded. +2. Extraction path reconstructed an alternate reference. +3. Alternate reference did not exist, causing `docker create ... not found`. + +This plan keeps scope strictly on `.github/workflows/security-pr.yml`. ### Objectives -1. Update CI workflow to reflect the new file locations. -2. Simplify the Playwright config by removing the now-unnecessary - `crossBrowserCaddyImportSpec` / `securityEnforcementExceptCrossBrowser` - special-case regex logic. -3. Fix one broken relative import in the moved test files. -4. Confirm all security UI tests remain in the security shard untouched. +1. Remove all ambiguous behavior for artifact absence on `workflow_run`. +2. Remove `workflow_run` support for upstream `push` events to align with PR + artifact naming contract (`pr-image-`). +3. Codify one deterministic `workflow_dispatch` policy in SHALL form. +4. Harden image selection so it is not brittle on `RepoTags[0]`. +5. Add CI security hardening requirements for permissions and trust boundary. +6. Expand validation matrix to include `pull_request` and negative paths. --- ## 2. Research Findings -### 2.1 Current File State +### 2.1 Failure Evidence -**Moved to `tests/core/caddy-import/` (confirmed present):** +Source: `.github/logs/ci_failure.log` -| File | Description | -|------|-------------| -| `caddy-import-cross-browser.spec.ts` | Cross-browser Caddyfile import scenarios | -| `caddy-import-debug.spec.ts` | Diagnostic/debug tests for import flow | -| `caddy-import-firefox.spec.ts` | Firefox-specific edge cases | -| `caddy-import-gaps.spec.ts` | Gap coverage (conflict details, session resume, etc.) | -| `caddy-import-webkit.spec.ts` | WebKit-specific edge cases | +Observed facts: -**Old directory `tests/security-enforcement/zzz-caddy-imports/`:** Fully removed (confirmed via filesystem scan). +1. Artifact `pr-image-718` was found and downloaded from run `22164807859`. +2. `docker load` reported: `Loaded image: ghcr.io/wikid82/charon:pr-718-385081f`. +3. Extraction attempted: `docker create ghcr.io/wikid82/charon:pr-718`. +4. Docker reported: `... pr-718: not found`. -### 2.2 Security Shard — Intact (No Changes Needed) +### 2.2 Producer Contract -**`tests/security-enforcement/`** (17 files + 1 subdirectory): -- `acl-enforcement.spec.ts`, `acl-waf-layering.spec.ts`, `auth-api-enforcement.spec.ts`, - `auth-middleware-cascade.spec.ts`, `authorization-rbac.spec.ts`, - `combined-enforcement.spec.ts`, `crowdsec-enforcement.spec.ts`, - `emergency-reset.spec.ts`, `emergency-server/`, `emergency-token.spec.ts`, - `multi-component-security-workflows.spec.ts`, `rate-limit-enforcement.spec.ts`, - `security-headers-enforcement.spec.ts`, `waf-enforcement.spec.ts`, - `waf-rate-limit-interaction.spec.ts`, `zzz-admin-whitelist-blocking.spec.ts`, - `zzzz-break-glass-recovery.spec.ts` +Source: `.github/workflows/docker-build.yml` -**`tests/security-enforcement/zzz-security-ui/`** (5 files): -- `access-lists-crud.spec.ts`, `crowdsec-import.spec.ts`, - `encryption-management.spec.ts`, `real-time-logs.spec.ts`, - `system-security-settings.spec.ts` +Producer emits immutable PR tags with SHA suffix (`pr--`). Consumer +must consume artifact metadata/load output, not reconstruct mutable tags. -**`tests/security/`** (15 files): -- `acl-integration.spec.ts`, `audit-logs.spec.ts`, `crowdsec-config.spec.ts`, - `crowdsec-console-enrollment.spec.ts`, `crowdsec-decisions.spec.ts`, - `crowdsec-diagnostics.spec.ts`, `crowdsec-import.spec.ts`, - `emergency-operations.spec.ts`, `rate-limiting.spec.ts`, - `security-dashboard.spec.ts`, `security-headers.spec.ts`, - `suite-integration.spec.ts`, `system-settings-feature-toggles.spec.ts`, - `waf-config.spec.ts`, `workflow-security.spec.ts` +### 2.3 Current Consumer Gaps -All of these require Cerberus ON and stay in the security shard. +Source: `.github/workflows/security-pr.yml` -### 2.3 Broken Import +Current consumer contains ambiguous policy points: -In `tests/core/caddy-import/caddy-import-gaps.spec.ts` (line 20): - -```typescript -import type { TestDataManager } from '../utils/TestDataManager'; -``` - -This resolves to `tests/core/utils/TestDataManager` — **does not exist**. -The actual file is at `tests/utils/TestDataManager.ts`. - -**Fix:** Change to `../../utils/TestDataManager`. - -All other imports (`../../fixtures/auth-fixtures`) resolve correctly from the -new location. +1. `workflow_run` artifact absence behavior can be interpreted as skip or fail. +2. `workflow_dispatch` policy is not single-path deterministic. +3. Image identification relies on single `RepoTags[0]` assumption. +4. Trust boundary and permission minimization are not explicitly codified as + requirements. --- ## 3. Technical Specifications -### 3.1 CI Workflow Changes +### 3.1 Deterministic EARS Requirements (Blocking) -**File:** `.github/workflows/e2e-tests-split.yml` +1. WHEN `security-pr.yml` is triggered by `workflow_run` with + `conclusion == success` and upstream event `pull_request`, THE SYSTEM SHALL + require the expected image artifact to exist and SHALL hard fail the job if + the artifact is missing. -The non-security shards explicitly list test directories. Since they already -include `tests/core`, the new `tests/core/caddy-import/` directory is -**automatically picked up** — no CI changes needed for test path inclusion. +2. WHEN `security-pr.yml` is triggered by `workflow_run` and artifact lookup + fails, THEN THE SYSTEM SHALL exit non-zero with a diagnostic that includes: + upstream run id, expected artifact name, and reason category (`not found` or + `api/error`). -The security shards explicitly list `tests/security-enforcement/` and -`tests/security/`. Since `zzz-caddy-imports/` was removed from -`tests/security-enforcement/`, the caddy import tests are **automatically -excluded** from the security shard — no CI changes needed. +3. WHEN `security-pr.yml` is triggered by `workflow_run` and upstream event is + not `pull_request`, THEN THE SYSTEM SHALL hard fail immediately with reason + category `unsupported_upstream_event` and SHALL NOT attempt artifact lookup, + image load, or extraction. -**Verification matrix:** +4. WHEN `security-pr.yml` is triggered by `workflow_dispatch`, THE SYSTEM SHALL + require `inputs.pr_number` and SHALL hard fail immediately if input is empty. -| Shard Type | Test Paths in Workflow | Picks Up `tests/core/caddy-import/`? | +5. WHEN `security-pr.yml` is triggered by `workflow_dispatch` with valid + `inputs.pr_number`, THE SYSTEM SHALL resolve artifact `pr-image-` + from the latest successful `docker-build.yml` run for that PR and SHALL hard + fail if artifact resolution or download fails. + +6. WHEN artifact image is loaded, THE SYSTEM SHALL derive a canonical local + image alias (`charon:artifact`) from validated load result and SHALL use only + that alias for `docker create` in artifact-based paths. + +7. WHEN artifact metadata parsing is required, THE SYSTEM SHALL NOT depend only + on `RepoTags[0]`; it SHALL validate all available repo tags and SHALL support + fallback selection using docker load image ID when tags are absent/corrupt. + +8. IF no valid tag and no valid load image ID can be resolved, THEN THE SYSTEM + SHALL hard fail before extraction. + +9. WHEN event is `pull_request` or `push`, THE SYSTEM SHALL build and use + `charon:local` only and SHALL NOT execute artifact lookup/load logic. + +### 3.2 Deterministic Policy Decisions + +#### Policy A: `workflow_run` Missing Artifact + +Decision: hard fail only. + +No skip behavior is allowed for upstream-success `workflow_run`. + +#### Policy A1: `workflow_run` Upstream Event Contract + +Decision: upstream event MUST be `pull_request`. + +If upstream event is `push` or any non-PR event, fail immediately with +`unsupported_upstream_event`; no artifact path execution is allowed. + +#### Policy B: `workflow_dispatch` + +Decision: artifact-only manual replay. + +No local-build fallback is allowed for `workflow_dispatch`. Required input is +`pr_number`; missing input is immediate hard fail. + +### 3.3 Image Selection Hardening Contract + +For step `Load Docker image` in `.github/workflows/security-pr.yml`: + +1. Validate artifact file exists and is readable tar. +2. Parse `manifest.json` and iterate all candidate tags under `RepoTags[]`. +3. Run `docker load` and capture structured output. +4. Resolve source image by deterministic priority: + - First valid tag from `RepoTags[]` that exists locally after load. + - Else image ID extracted from `docker load` output (if present). + - Else fail. +5. Retag resolved source to `charon:artifact`. +6. Emit outputs: + - `image_ref=charon:artifact` + - `source_image_ref=` + - `source_resolution_mode=manifest_tag|load_image_id` + +### 3.4 CI Security Hardening Requirements + +For job `security-scan` in `.github/workflows/security-pr.yml`: + +1. THE SYSTEM SHALL enforce least-privilege permissions by default: + - `contents: read` + - `actions: read` + - `security-events: write` + - No additional write scopes unless explicitly required. + +2. THE SYSTEM SHALL restrict `pull-requests: write` usage to only steps that + require PR annotations/comments. If no such step exists, this permission + SHALL be removed. + +3. THE SYSTEM SHALL enforce workflow_run trust boundary guards: + - Upstream workflow name must match expected producer. + - Upstream conclusion must be `success`. + - Upstream event must be `pull_request` only. + - Upstream head repository must equal `${{ github.repository }}` (same-repo + trust boundary), otherwise hard fail. + +4. THE SYSTEM SHALL NOT use untrusted `workflow_run` payload values to build + shell commands without validation and quoting. + +### 3.5 Step-Level Scope in `security-pr.yml` + +Targeted steps: + +1. `Extract PR number from workflow_run` +2. `Validate workflow_run upstream event contract` +3. `Check for PR image artifact` +4. `Skip if no artifact` (to be converted to deterministic fail paths for + `workflow_run` and `workflow_dispatch`) +5. `Load Docker image` +6. `Extract charon binary from container` + +### 3.6 Event Data Flow (Deterministic) + +```text +pull_request/push + -> Build Docker image (Local) + -> image_ref=charon:local + -> Extract /app/charon + -> Trivy scan + +workflow_run (upstream success only) + -> Assert upstream event == pull_request (hard fail if false) + -> Require artifact exists (hard fail if missing) + -> Load/validate image + -> image_ref=charon:artifact + -> Extract /app/charon + -> Trivy scan + +workflow_dispatch + -> Require pr_number input (hard fail if missing) + -> Resolve pr-image- artifact (hard fail if missing) + -> Load/validate image + -> image_ref=charon:artifact + -> Extract /app/charon + -> Trivy scan +``` + +### 3.7 Error Handling Matrix + +| Step | Condition | Required Behavior | |---|---|---| -| Security (Chromium, line 331-333) | `tests/security-enforcement/`, `tests/security/`, `tests/integration/multi-feature-workflows.spec.ts` | No | -| Security (Firefox, line 540-542) | Same pattern | No | -| Security (WebKit, line 749-751) | Same pattern | No | -| Non-Security Chromium (line 945-952) | `tests/core`, `tests/dns-provider-crud.spec.ts`, `tests/dns-provider-types.spec.ts`, `tests/integration`, `tests/manual-dns-provider.spec.ts`, `tests/monitoring`, `tests/settings`, `tests/tasks` | **Yes** (via `tests/core`) | -| Non-Security Firefox (line 1157-1164) | Same pattern | **Yes** | -| Non-Security WebKit (line 1369-1376) | Same pattern | **Yes** | +| Validate workflow_run upstream event contract | `workflow_run` upstream event is not `pull_request` | Hard fail with `unsupported_upstream_event`; stop before artifact lookup | +| Check for PR image artifact | `workflow_run` upstream success but artifact missing | Hard fail with run id + artifact name | +| Extract PR number from workflow_run | `workflow_dispatch` and empty `inputs.pr_number` | Hard fail with input requirement message | +| Load Docker image | Missing/corrupt `charon-pr-image.tar` | Hard fail before `docker load` | +| Load Docker image | Missing/corrupt `manifest.json` | Attempt load-image-id fallback; fail if unresolved | +| Load Docker image | No valid `RepoTags[]` and no load image id | Hard fail | +| Extract charon binary from container | Empty/invalid `image_ref` | Hard fail before `docker create` | +| Extract charon binary from container | `/app/charon` missing | Hard fail with chosen image reference | -**Result: No CI workflow file changes required.** +### 3.8 API/DB Changes -### 3.2 Playwright Config Changes - -**File:** `playwright.config.js` - -The config has special-case regex logic (lines 38-41) that was created to -handle the old `zzz-caddy-imports` location within `security-enforcement/`: - -```javascript -// CURRENT (lines 38-41) — references old, non-existent path -const crossBrowserCaddyImportSpec = - /security-enforcement\/zzz-caddy-imports\/caddy-import-cross-browser\.spec\.(ts|js)$/; -const securityEnforcementExceptCrossBrowser = - /security-enforcement\/(?!zzz-caddy-imports\/caddy-import-cross-browser\.spec\.(ts|js)$).*/; -``` - -Now that the caddy import tests live under `tests/core/caddy-import/`: -- `crossBrowserCaddyImportSpec` no longer matches any file — dead code. -- `securityEnforcementExceptCrossBrowser` negative lookahead is now - unnecessary — all files in `security-enforcement/` are security tests. -- The browser projects' `testIgnore` already includes `'**/security/**'` and - the simplified `security-enforcement` pattern will exclude all security tests. - -**Required change:** Remove the special-case variables and simplify `testIgnore` -to use a plain `**/security-enforcement/**` glob. - -#### Diff: `playwright.config.js` - -```diff - const skipSecurityDeps = process.env.PLAYWRIGHT_SKIP_SECURITY_DEPS !== '0'; - const browserDependencies = skipSecurityDeps ? ['setup'] : ['setup', 'security-tests']; --const crossBrowserCaddyImportSpec = -- /security-enforcement\/zzz-caddy-imports\/caddy-import-cross-browser\.spec\.(ts|js)$/; --const securityEnforcementExceptCrossBrowser = -- /security-enforcement\/(?!zzz-caddy-imports\/caddy-import-cross-browser\.spec\.(ts|js)$).*/; -``` - -For each of the 3 browser projects (chromium, firefox, webkit), change: - -```diff -- testMatch: [crossBrowserCaddyImportSpec, /.*\.spec\.(ts|js)$/], -- testIgnore: ['**/frontend/**', '**/node_modules/**', '**/backend/**', securityEnforcementExceptCrossBrowser, '**/security/**'], -+ testMatch: /.*\.spec\.(ts|js)$/, -+ testIgnore: ['**/frontend/**', '**/node_modules/**', '**/backend/**', '**/security-enforcement/**', '**/security/**'], -``` - -**Rationale:** The `crossBrowserCaddyImportSpec` regex was a workaround to -include one specific file from the security-enforcement directory in cross-browser -runs. Now that all caddy import tests are under `tests/core/`, they are -naturally included by the default `.*\.spec\.(ts|js)$` pattern and naturally -excluded from the security ignore patterns. - -### 3.3 Broken Import Fix - -**File:** `tests/core/caddy-import/caddy-import-gaps.spec.ts` (line 20) - -```diff --import type { TestDataManager } from '../utils/TestDataManager'; -+import type { TestDataManager } from '../../utils/TestDataManager'; -``` - -**Rationale:** From the new location `tests/core/caddy-import/`, the correct -relative path to `tests/utils/TestDataManager.ts` is `../../utils/TestDataManager`. +No backend API, frontend, or database schema changes. --- ## 4. Implementation Plan -### Phase 1: Fix Broken Import (1 file) +### Phase 1: Playwright Impact Check -| Task | File | Change | -|------|------|--------| -| Fix `TestDataManager` import path | `tests/core/caddy-import/caddy-import-gaps.spec.ts:20` | `../utils/TestDataManager` → `../../utils/TestDataManager` | +1. Mark Playwright scope as N/A because this change is workflow-only. +2. Record N/A rationale in PR description. -### Phase 2: Simplify Playwright Config (1 file, 4 locations) +### Phase 2: Deterministic Event Policies -| Task | File | Lines | Change | -|------|------|-------|--------| -| Remove `crossBrowserCaddyImportSpec` variable | `playwright.config.js` | 38-39 | Delete | -| Remove `securityEnforcementExceptCrossBrowser` variable | `playwright.config.js` | 40-41 | Delete | -| Simplify Chromium project config | `playwright.config.js` | 269-270 | Replace `testMatch`/`testIgnore` | -| Simplify Firefox project config | `playwright.config.js` | 280-281 | Replace `testMatch`/`testIgnore` | -| Simplify WebKit project config | `playwright.config.js` | 291-292 | Replace `testMatch`/`testIgnore` | +File: `.github/workflows/security-pr.yml` -### Phase 3: Validation +1. Convert ambiguous skip/fail logic to hard-fail policy for + `workflow_run` missing artifact after upstream success. +2. Enforce deterministic `workflow_dispatch` policy: + - Required `pr_number` input. + - Artifact-only replay path. + - No local fallback. +3. Enforce PR-only `workflow_run` event contract: + - Upstream event must be `pull_request`. + - Upstream `push` or any non-PR event hard fails with + `unsupported_upstream_event`. -| Task | Command | Expected Result | -|------|---------|-----------------| -| Run caddy import tests locally (Firefox) | `npx playwright test --project=firefox tests/core/caddy-import/` | All 5 files discovered, tests execute | -| Run caddy import tests locally (all browsers) | `npx playwright test tests/core/caddy-import/` | Tests run on chromium, firefox, webkit | -| Verify security tests excluded from non-security run | `npx playwright test --project=firefox --list tests/core` | No security-enforcement files listed | -| Verify security shard unchanged | `npx playwright test --project=security-tests --list` | All security-enforcement + security files listed | +### Phase 3: Image Selection Hardening -### Phase 4: Documentation +File: `.github/workflows/security-pr.yml` -No external documentation changes needed. The archive docs in -`docs/reports/archive/` reference old paths but are historical records -and should not be updated. +1. Harden `Load Docker image` with manifest validation and multi-tag handling. +2. Add fallback resolution via docker load image ID. +3. Emit explicit outputs for traceability (`source_resolution_mode`). +4. Ensure extraction consumes only selected alias (`charon:artifact`). + +### Phase 4: CI Security Hardening + +File: `.github/workflows/security-pr.yml` + +1. Reduce job permissions to least privilege. +2. Remove/conditionalize `pull-requests: write` if not required. +3. Add workflow_run trust-boundary guard conditions and explicit fail messages. + +### Phase 5: Validation + +1. `pre-commit run actionlint --files .github/workflows/security-pr.yml` +2. Simulate deterministic paths (or equivalent CI replay) for all matrix cases. +3. Verify logs show chosen `source_image_ref` and `source_resolution_mode`. --- -## 5. Acceptance Criteria +## 5. Validation Matrix -- [ ] `tests/core/caddy-import/` contains all 5 caddy import test files. -- [ ] `tests/security-enforcement/zzz-caddy-imports/` no longer exists. -- [ ] All security UI tests remain in `tests/security-enforcement/zzz-security-ui/` and `tests/security/`. -- [ ] `caddy-import-gaps.spec.ts` import path resolves correctly. -- [ ] `playwright.config.js` has no references to `zzz-caddy-imports`. -- [ ] Non-security shards automatically pick up `tests/core/caddy-import/` via `tests/core`. -- [ ] Security shards do not run caddy import tests. -- [ ] No CI workflow file changes needed (paths already correct). -- [ ] Playwright test discovery lists caddy import files under all 3 browser projects. +| ID | Trigger Path | Scenario | Expected Result | +|---|---|---|---| +| V1 | `workflow_run` | Upstream success + artifact present | Pass, uses `charon:artifact` | +| V2 | `workflow_run` | Upstream success + artifact missing | Hard fail (non-zero) | +| V3 | `workflow_run` | Upstream success + artifact manifest corrupted | Hard fail after validation/fallback attempt | +| V4 | `workflow_run` | Upstream success + upstream event `push` | Hard fail with `unsupported_upstream_event` | +| V5 | `pull_request` | Direct PR trigger | Pass, uses `charon:local`, no artifact lookup | +| V6 | `push` | Direct push trigger | Pass, uses `charon:local`, no artifact lookup | +| V7 | `workflow_dispatch` | Missing `pr_number` input | Hard fail immediately | +| V8 | `workflow_dispatch` | Valid `pr_number` + artifact exists | Pass, uses `charon:artifact` | +| V9 | `workflow_dispatch` | Valid `pr_number` + artifact missing | Hard fail | +| V10 | `workflow_run` | Upstream from untrusted repository context | Hard fail by trust-boundary guard | --- -## 6. PR Slicing Strategy +## 6. Acceptance Criteria -**Decision:** Single PR. - -**Rationale:** -- Small scope: 2 files changed (1 import fix + 1 config simplification). -- Low risk: Test-only changes, no production code affected. -- No cross-domain concerns. -- Fully reversible. - -### PR-1: Caddy Import Test Reorganization Cleanup - -| Attribute | Value | -|-----------|-------| -| Scope | Fix broken import + simplify playwright config | -| Files | `tests/core/caddy-import/caddy-import-gaps.spec.ts`, `playwright.config.js` | -| Dependencies | None (file move already done manually) | -| Validation | Run `npx playwright test --project=firefox tests/core/caddy-import/` | -| Rollback | Revert the 2-file change | +1. Plan states unambiguous hard-fail behavior for missing artifact on + `workflow_run` after upstream `pull_request` success. +2. Plan states `workflow_run` event contract is PR-only and that upstream + `push` is a deterministic hard-fail contract violation. +3. Plan states one deterministic `workflow_dispatch` policy in SHALL terms: + required `pr_number`, artifact-only path, no local fallback. +4. Plan defines robust image resolution beyond `RepoTags[0]`, including + load-image-id fallback and deterministic aliasing. +5. Plan includes least-privilege permissions and explicit workflow_run trust + boundary constraints. +6. Plan includes validation coverage for `pull_request` and direct `push` local + paths plus negative paths: unsupported upstream event, missing dispatch + input, missing artifact, corrupted/missing manifest. +7. Root cause remains explicit: image-reference mismatch inside + `.github/workflows/security-pr.yml` after successful artifact load. --- -## 7. Risk Assessment +## 7. Risks and Mitigations -| Risk | Likelihood | Impact | Mitigation | -|------|-----------|--------|------------| -| Caddy import tests silently dropped from CI | Low | High | Verify with `--list` that files are discovered | -| Security tests accidentally run in non-security shard | Low | Medium | `testIgnore` patterns verified against all security paths | -| Other tests break from playwright config change | Very Low | Medium | Only `testMatch`/`testIgnore` simplified; no new exclusions added | +| Risk | Impact | Mitigation | +|---|---|---| +| Overly strict dispatch policy blocks ad-hoc scans | Medium | Document explicit manual replay contract in workflow description | +| PR-only workflow_run contract fails upstream push-triggered runs | Medium | Intentional contract enforcement; document `unsupported_upstream_event` and route push scans through direct push path | +| Manifest parsing edge cases | Medium | Multi-source resolver with load-image-id fallback | +| Permission tightening breaks optional PR annotations | Low | Make PR-write permission step-scoped only if needed | +| Trust-boundary guards reject valid internal events | Medium | Add clear diagnostics and test cases V1/V10 | + +--- + +## 8. PR Slicing Strategy + +### Decision + +Single PR. + +### Trigger Reasons + +1. Change is isolated to one workflow (`security-pr.yml`). +2. Deterministic policy + hardening are tightly coupled and safest together. +3. Split PRs would create temporary policy inconsistency. + +### Ordered Slice + +#### PR-1: Deterministic Policy and Security Hardening for `security-pr.yml` + +Scope: + +1. Deterministic missing-artifact handling (`workflow_run` hard fail). +2. Deterministic `workflow_dispatch` artifact-only policy. +3. Hardened image resolution and aliasing. +4. Least-privilege + trust-boundary constraints. +5. Validation matrix execution evidence. + +Files: + +1. `.github/workflows/security-pr.yml` +2. `docs/plans/current_spec.md` + +Dependencies: + +1. `.github/workflows/docker-build.yml` artifact naming contract unchanged. + +Validation Gates: + +1. actionlint passes. +2. Validation matrix V1-V10 results captured. +3. No regression to `ghcr.io/...:pr- not found` pattern. + +Rollback / Contingency: + +1. Revert PR-1 if trust-boundary guards block legitimate same-repo runs. +2. Keep hard-fail semantics; adjust guard predicate, not policy. + +--- + +## 9. Handoff + +After approval, implementation handoff to Supervisor SHALL include: + +1. Exact step-level edits required in `.github/workflows/security-pr.yml`. +2. Proof logs for each failed/pass matrix case. +3. Confirmation that no files outside plan scope were required. +3. Require explicit evidence that artifact path no longer performs GHCR PR tag + reconstruction.