diff --git a/.github/review-notes/dependency-hotfix-admission.md b/.github/review-notes/dependency-hotfix-admission.md new file mode 100644 index 000000000..2c23747bf --- /dev/null +++ b/.github/review-notes/dependency-hotfix-admission.md @@ -0,0 +1,34 @@ +# Scoped dependency hotfix admission follow-up + +This follow-up implements the Requirements admission requested after +[nold-ai/specfact-cli#782](https://github.com/nold-ai/specfact-cli/pull/782) +merged into dev. Its merge commit is +`3cf72e848a2b5a233d3f599898d6983319d375de`. The original three Requirements +checks remain failed. Security Audit passed with the exact temporary MCP +advisory exception. A merge does not convert the failed provenance into proof. + +The companion protected-policy proposal is +[nold-ai/.github#4](https://github.com/nold-ai/.github/pull/4). +The implementation evaluates a separately approved HumanApprovedLateCycle +state at all three core CI boundaries. The original native report remains +failed and unchanged. Every boundary independently checks the exact source, +head/tree, policy revision, evidence, live human permission and expiry. +A previous decision sidecar grants no authority to another run. + +The original eleven dependency cases remain required. Three new workflow +control cases cover native report preservation, enforced policy failures, +scoped routing and an isolated CI-only native evidence attester. Their real +pre-code workflow refusal is retained in the change's TDD evidence. The +admission implementation has independent technical review and local verification. +Protected deployment and live CI attestation verification remain pending. + +This follow-up changes CI controls and their specification and tests. It does +not change application runtime, dependency pins, core version or signed module +assets. It does not supply historical native RED, suppress other findings or +authorize a protected deployment or merge. Signing is CI/CD-only. The owner +will review the exact deployment and grant payloads after implementation. + +A later dependency-only package hotfix still starts from published v0.55.4 +commit `d579970565530c3fd7b98bad4de90cf874c2a99d`, with a release payload check +that excludes unrelated unreleased dev runtime changes. This CI follow-up is +not a package release or a promotion of all dev commits. diff --git a/.github/workflows/requirements-evidence.yml b/.github/workflows/requirements-evidence.yml index 2fa337b20..b6cbaae9b 100644 --- a/.github/workflows/requirements-evidence.yml +++ b/.github/workflows/requirements-evidence.yml @@ -11,6 +11,9 @@ permissions: contents: read issues: read +env: + LATE_CYCLE_POLICY_REVISION: 5cd61c45303bc5da174a39ff79945fafad77ea3b + jobs: requirements-evidence-producer: name: Requirements evidence producer @@ -19,6 +22,7 @@ jobs: checks: read contents: read issues: read + pull-requests: read outputs: artifact-id: ${{ steps.upload-requirements-evidence.outputs.artifact-id }} prior-red-run-id: ${{ steps.prior-red-run.outputs.run-id }} @@ -926,6 +930,9 @@ jobs: "${reconciliation_arguments[@]}" exit_code=$? set -e + if [[ "$run_stage" == "final" ]]; then + printf 'native-reconciled=true\nselected-change=%s\n' "$selected_change" >> "$GITHUB_OUTPUT" + fi failure_diagnostic="Requirements proof reconciliation exited with status $exit_code before writing reports." if [[ "$run_stage" == "red" && "$exit_code" -eq 0 ]]; then if ! binding_error="$("${isolated_python[@]}" scripts/requirements_proof_provenance.py \ @@ -955,6 +962,36 @@ jobs: fi exit "$exit_code" + - name: Evaluate scoped late-cycle admission + id: late-cycle-admission + continue-on-error: true + if: >- + always() && steps.run-evidence.outcome == 'failure' && + steps.run-evidence.outputs.native-reconciled == 'true' && + steps.run-evidence.outputs.selected-change == 'isolate-semgrep-security-runtime' && + github.repository == 'nold-ai/specfact-cli' && + github.event_name == 'pull_request' && github.event.pull_request.number > 0 && + github.event.pull_request.head.repo.full_name == github.repository && + github.base_ref == 'dev' && github.head_ref == 'codex/dependency-hotfix-admission' + env: + GH_TOKEN: ${{ github.token }} + NATIVE_REPORT: artifacts/requirements-evidence/requirements-evidence.json + NATIVE_PLAN: artifacts/requirements-evidence/requirements-evidence-plan.json + NATIVE_JUNIT: artifacts/requirements-evidence/requirements-proof.xml + ADMISSION_AUDIT: artifacts/requirements-evidence/late-cycle-admission.json + shell: bash + run: | + set -euo pipefail + [[ "$LATE_CYCLE_POLICY_REVISION" =~ ^[0-9a-f]{40}$ ]] + LATE_CYCLE_POLICY_ROOT="$(mktemp -d "${RUNNER_TEMP}/late-cycle-policy.XXXXXX")" + mkdir -p "$LATE_CYCLE_POLICY_ROOT/.github/scripts" + curl --fail --silent --show-error --max-filesize 1048576 --connect-timeout 10 --max-time 60 --proto '=https' --tlsv1.2 \ + "https://raw.githubusercontent.com/nold-ai/.github/${LATE_CYCLE_POLICY_REVISION}/.github/scripts/late_cycle_admission.py" \ + --output "$LATE_CYCLE_POLICY_ROOT/.github/scripts/late_cycle_admission.py" + python3 -I -S "$LATE_CYCLE_POLICY_ROOT/.github/scripts/late_cycle_admission.py" decide \ + --event "$GITHUB_EVENT_PATH" --report "$NATIVE_REPORT" --plan "$NATIVE_PLAN" --junit "$NATIVE_JUNIT" \ + --policy-revision "$LATE_CYCLE_POLICY_REVISION" --output "$ADMISSION_AUDIT" + - name: Publish Requirements evidence summary if: always() run: | @@ -978,9 +1015,110 @@ jobs: artifacts/requirements-evidence/legacy-tdd-evidence.json if-no-files-found: warn - - name: Enforce Requirements evidence producer verdict - if: steps.run-evidence.outcome == 'failure' - run: exit 1 + - name: Upload producer admission audit + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: requirements-late-cycle-producer-audit + path: artifacts/requirements-evidence/late-cycle-admission.json + if-no-files-found: ignore + + - name: Enforce native or admitted Requirements verdict + if: always() + env: + NATIVE_OUTCOME: ${{ steps.run-evidence.outcome }} + ADMISSION_OUTCOME: ${{ steps.late-cycle-admission.outcome }} + shell: bash + run: | + [[ "$NATIVE_OUTCOME" == "success" || "$ADMISSION_OUTCOME" == "success" ]] + + requirements-native-origin: + name: Native Requirements Origin + needs: [requirements-evidence-producer] + if: >- + always() && needs.requirements-evidence-producer.outputs.artifact-id != '' && + github.repository == 'nold-ai/specfact-cli' && + github.event_name == 'pull_request' && github.event.pull_request.number > 0 && + github.event.pull_request.head.repo.full_name == github.repository && + github.base_ref == 'dev' && github.head_ref == 'codex/dependency-hotfix-admission' + runs-on: ubuntu-latest + permissions: + actions: read + contents: read + id-token: write + attestations: write + steps: + - name: Bind native artifact to executed workflow + env: + GH_TOKEN: ${{ github.token }} + NATIVE_ARTIFACT_ID: ${{ needs.requirements-evidence-producer.outputs.artifact-id }} + NATIVE_HEAD_COMMIT: ${{ github.event.pull_request.head.sha }} + NATIVE_WORKFLOW_SHA: ${{ github.workflow_sha }} + shell: bash + run: | + set -euo pipefail + [[ "$GITHUB_REPOSITORY" == "nold-ai/specfact-cli" ]] + [[ "$NATIVE_ARTIFACT_ID" =~ ^[0-9]+$ ]] + [[ "$NATIVE_HEAD_COMMIT" =~ ^[0-9a-f]{40}$ ]] + [[ "$NATIVE_WORKFLOW_SHA" =~ ^[0-9a-f]{40}$ ]] + origin_root="${RUNNER_TEMP}/requirements-native-origin" + mkdir -p "$origin_root" + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${NATIVE_ARTIFACT_ID}" > "$origin_root/artifact.json" + gh api "repos/${GITHUB_REPOSITORY}/actions/artifacts/${NATIVE_ARTIFACT_ID}/zip" > "$origin_root/native.zip" + python3 -I -S - "$origin_root" <<'PY' + import hashlib + import json + import os + import sys + from pathlib import Path + + root = Path(sys.argv[1]) + metadata = json.loads((root / "artifact.json").read_text()) + digest = "sha256:" + hashlib.sha256((root / "native.zip").read_bytes()).hexdigest() + artifact_id = int(os.environ["NATIVE_ARTIFACT_ID"]) + run_id = int(os.environ["GITHUB_RUN_ID"]) + if (metadata["id"] != artifact_id or metadata["name"] != "requirements-evidence" + or metadata["expired"] or metadata["workflow_run"]["id"] != run_id + or metadata["workflow_run"]["head_sha"] != os.environ["NATIVE_HEAD_COMMIT"] + or metadata["digest"] != digest): + raise SystemExit("Native artifact association or ZIP digest mismatch") + manifest = { + "schema_version": 1, + "repository": os.environ["GITHUB_REPOSITORY"], + "run_id": run_id, + "run_attempt": int(os.environ["GITHUB_RUN_ATTEMPT"]), + "artifact_id": artifact_id, + "artifact_digest": digest, + "head_commit": os.environ["NATIVE_HEAD_COMMIT"], + "workflow_path": ".github/workflows/requirements-evidence.yml", + "workflow_sha": os.environ["NATIVE_WORKFLOW_SHA"], + } + (root / "origin.json").write_text(json.dumps(manifest, sort_keys=True, separators=(",", ":"))) + PY + + - name: Attest native origin manifest + id: attest-native-origin + uses: actions/attest@7a37fbbab9669658a566371acd1cf2f8c7a40d32 + with: + subject-path: ${{ runner.temp }}/requirements-native-origin/origin.json + + - name: Retain native origin verification bundle + env: + ORIGIN_BUNDLE_PATH: ${{ steps.attest-native-origin.outputs.bundle-path }} + shell: bash + run: | + set -euo pipefail + test -s "$ORIGIN_BUNDLE_PATH" + cp "$ORIGIN_BUNDLE_PATH" "${RUNNER_TEMP}/requirements-native-origin/origin.bundle.jsonl" + + - name: Upload authenticated native origin + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: requirements-native-origin-${{ github.run_id }}-${{ github.run_attempt }} + path: | + ${{ runner.temp }}/requirements-native-origin/origin.json + ${{ runner.temp }}/requirements-native-origin/origin.bundle.jsonl + if-no-files-found: error requirements-evidence: name: Requirements evidence execution @@ -991,6 +1129,7 @@ jobs: checks: read contents: read issues: read + pull-requests: read outputs: artifact-id: ${{ steps.upload-fresh-requirements-evidence.outputs.artifact-id }} artifact-digest: ${{ steps.upload-fresh-requirements-evidence.outputs.artifact-digest }} @@ -1283,10 +1422,296 @@ jobs: echo "PYLINT_WRAPPER=$pylint_wrapper" >> "$GITHUB_ENV" - name: Restore Requirements evidence for Code Review - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c - with: - artifact-ids: ${{ needs.requirements-evidence-producer.outputs.artifact-id }} - path: artifacts/requirements-evidence + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ needs.requirements-evidence-producer.outputs.artifact-id }} + ARTIFACT_NAME: requirements-evidence + ARTIFACT_REPOSITORY: ${{ github.repository }} + ARTIFACT_RUN_ID: ${{ github.run_id }} + ARTIFACT_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + ARTIFACT_EXPECTED_SHA256: '' + ARTIFACT_DESTINATION: ${{ github.workspace }}/artifacts/requirements-evidence + shell: bash + run: &restore_exact_requirements_artifact | + set -euo pipefail + /usr/bin/python3 -I -S - <<'PYTHON' + import hashlib + import io + import json + import os + import re + import secrets + import signal + import stat + import time + import urllib.error + import urllib.parse + import urllib.request + import zipfile + from dataclasses import dataclass + from pathlib import Path + + MAX_ARCHIVE = 16 * 1024 * 1024 + MAX_FILE = 16 * 1024 * 1024 + MAX_EXPANDED = 64 * 1024 * 1024 + DIRECTORY_FLAGS = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW + + + @dataclass(frozen=True) + class ArtifactSubject: + artifact_id: int + name: str + repository: str + repository_id: int + run_id: int + head_sha: str + expected_sha256: str + destination: Path + allow_existing: bool + + + def positive_id(value): + if not re.fullmatch(r"[1-9][0-9]*", value): + raise ValueError("Invalid subject ID") + return int(value) + + + def parse_subject(): + env = os.environ + name = env["ARTIFACT_NAME"] + digest = env["ARTIFACT_EXPECTED_SHA256"].removeprefix("sha256:") + repository = env["ARTIFACT_REPOSITORY"] + head = env["ARTIFACT_HEAD_SHA"] + if not re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repository): + raise ValueError("Invalid repository") + if not re.fullmatch(r"[0-9a-f]{40}", head): + raise ValueError("Invalid head SHA") + if digest and not re.fullmatch(r"[0-9a-f]{64}", digest): + raise ValueError("Invalid independent digest") + if name not in ("requirements-evidence", "requirements-evidence-execution"): + raise ValueError("Invalid artifact name") + if name == "requirements-evidence-execution" and not digest: + raise ValueError("Execution upload digest is required") + workspace = Path(env["GITHUB_WORKSPACE"]) + runner = Path(env["RUNNER_TEMP"]) + destination = Path(env["ARTIFACT_DESTINATION"]) + paths = (workspace, runner, destination) + if any(not p.is_absolute() or ".." in p.parts for p in paths): + raise ValueError("Invalid runner path") + review = workspace / "artifacts/requirements-evidence" + allowed = (runner / "requirements-execution",) if name.endswith("-execution") else (review, runner / "requirements-producer") + if destination not in allowed: + raise ValueError("Unexpected artifact destination") + return ArtifactSubject( + positive_id(env["ARTIFACT_ID"]), name, repository, + positive_id(env["GITHUB_REPOSITORY_ID"]), positive_id(env["ARTIFACT_RUN_ID"]), + head, digest, destination, destination == review, + ) + + + class NoRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + return None + + + def read_bounded(response, limit, deadline): + declared = response.headers.get("Content-Length") + if declared is not None and (not declared.isdecimal() or int(declared) > limit): + raise ValueError("Oversized or malformed response length") + chunks = bytearray() + while True: + if time.monotonic() >= deadline: + raise TimeoutError("Artifact transfer deadline exceeded") + chunk = response.read(min(65536, limit + 1 - len(chunks))) + if not chunk: + break + chunks.extend(chunk) + if len(chunks) > limit: + raise ValueError("Response exceeds size limit") + if declared is not None and len(chunks) != int(declared): + raise ValueError("Truncated response") + return bytes(chunks) + + + def fetch(url, token, limit, deadline, redirects=False): + opener = urllib.request.build_opener(NoRedirect()) + for hop in range(5): + parsed = urllib.parse.urlsplit(url) + if parsed.scheme != "https" or not parsed.hostname or parsed.username or parsed.password or parsed.fragment: + raise ValueError("Unsafe artifact redirect") + headers = {"Accept": "application/vnd.github+json", "X-GitHub-Api-Version": "2022-11-28"} + if hop == 0: + if parsed.netloc != "api.github.com": + raise ValueError("Credentials require the fixed GitHub API") + headers["Authorization"] = "Bearer " + token + remaining = deadline - time.monotonic() + if remaining <= 0: + raise TimeoutError("Artifact transfer deadline exceeded") + request = urllib.request.Request(url, headers=headers) + try: + response = opener.open(request, timeout=min(15, remaining)) + except urllib.error.HTTPError as error: + response = error + with response: + if response.status in (301, 302, 303, 307, 308) and redirects: + location = response.headers.get("Location") + if not location: + raise ValueError("Missing artifact redirect") + url = urllib.parse.urljoin(url, location) + continue + if response.status != 200: + raise ValueError("Artifact HTTP request refused") + return read_bounded(response, limit, deadline) + raise ValueError("Too many artifact redirects") + + + def unique_object(pairs): + result = {} + for key, value in pairs: + if key in result: + raise ValueError("Duplicate metadata field") + result[key] = value + return result + + + def authenticate_metadata(raw, subject): + value = json.loads(raw, object_pairs_hook=unique_object) + if not isinstance(value, dict): + raise ValueError("Invalid artifact metadata") + run = value.get("workflow_run") + if not isinstance(run, dict): + raise ValueError("Missing workflow association") + for actual, expected in ( + (value.get("id"), subject.artifact_id), (run.get("id"), subject.run_id), + (run.get("repository_id"), subject.repository_id), + ): + if type(actual) is not int or actual != expected: + raise ValueError("Artifact identity mismatch") + if value.get("name") != subject.name or value.get("expired") is not False or run.get("head_sha") != subject.head_sha: + raise ValueError("Artifact association mismatch") + digest = value.get("digest") + size = value.get("size_in_bytes") + if not isinstance(digest, str) or not re.fullmatch(r"sha256:[0-9a-f]{64}", digest): + raise ValueError("Missing authenticated SHA-256") + if type(size) is not int or not 0 < size <= MAX_ARCHIVE: + raise ValueError("Invalid artifact size") + if subject.expected_sha256 and subject.expected_sha256 != digest[7:]: + raise ValueError("Independent upload digest mismatch") + return digest[7:], size + + + def validated_members(archive): + members = archive.infolist() + if not 0 < len(members) <= 128: + raise ValueError("Invalid archive member count") + names = set() + expanded = 0 + for member in members: + name = member.orig_filename + kind = stat.S_IFMT(member.external_attr >> 16) + if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,239}", name) or name != member.filename: + raise ValueError("Unsafe archive filename") + if name.casefold() in names or member.is_dir() or member.external_attr & 0x10 or member.flag_bits & 1: + raise ValueError("Duplicate, directory or encrypted archive member") + if kind not in (0, stat.S_IFREG) or member.compress_type not in (zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED): + raise ValueError("Unsupported archive member") + if not 0 <= member.file_size <= MAX_FILE: + raise ValueError("Expanded member exceeds limit") + expanded += member.file_size + if expanded > MAX_EXPANDED: + raise ValueError("Expanded archive exceeds limit") + names.add(name.casefold()) + return members + + + def open_destination(subject): + descriptor = os.open("/", DIRECTORY_FLAGS) + try: + for index, part in enumerate(subject.destination.parts[1:]): + final = index == len(subject.destination.parts) - 2 + try: + os.mkdir(part, 0o700, dir_fd=descriptor) + except FileExistsError: + if final and not subject.allow_existing: + raise ValueError("Fresh artifact destination already exists") from None + child = os.open(part, DIRECTORY_FLAGS, dir_fd=descriptor) + os.close(descriptor) + descriptor = child + return descriptor + except BaseException: + os.close(descriptor) + raise + + + def publish_archive(payload, subject): + with zipfile.ZipFile(io.BytesIO(payload)) as archive: + members = validated_members(archive) + destination = open_destination(subject) + staging_name = ".artifact-stage-" + secrets.token_hex(16) + staging = None + try: + existing = {name.casefold() for name in os.listdir(destination)} + if any(member.filename.casefold() in existing for member in members): + raise ValueError("Artifact destination collision") + os.mkdir(staging_name, 0o700, dir_fd=destination) + staging = os.open(staging_name, DIRECTORY_FLAGS, dir_fd=destination) + expanded = 0 + for member in members: + descriptor = os.open(member.filename, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600, dir_fd=staging) + with os.fdopen(descriptor, "wb") as output, archive.open(member) as source: + size = 0 + while chunk := source.read(65536): + size += len(chunk) + expanded += len(chunk) + if size > MAX_FILE or expanded > MAX_EXPANDED or size > member.file_size: + raise ValueError("Actual expansion exceeds limit") + output.write(chunk) + if size != member.file_size: + raise ValueError("Truncated archive member") + for member in members: + os.link(member.filename, member.filename, src_dir_fd=staging, dst_dir_fd=destination, follow_symlinks=False) + return len(members), expanded + finally: + if staging is not None: + for name in os.listdir(staging): + os.unlink(name, dir_fd=staging) + os.close(staging) + os.rmdir(staging_name, dir_fd=destination) + os.close(destination) + + + def deadline_expired(signum, frame): + raise TimeoutError("Artifact transfer deadline exceeded") + + + def main(): + subject = parse_subject() + token = os.environ["GH_TOKEN"] + if not token: + raise ValueError("Missing artifact read credential") + previous = signal.signal(signal.SIGALRM, deadline_expired) + signal.alarm(90) + try: + deadline = time.monotonic() + 90 + url = f"https://api.github.com/repos/{subject.repository}/actions/artifacts/{subject.artifact_id}" + digest, size = authenticate_metadata(fetch(url, token, 1024 * 1024, deadline), subject) + payload = fetch(url + "/zip", token, size, deadline, redirects=True) + if len(payload) != size or hashlib.sha256(payload).hexdigest() != digest: + raise ValueError("Artifact ZIP size or SHA-256 mismatch") + count, expanded = publish_archive(payload, subject) + print(f"Restored artifact {subject.artifact_id}: sha256:{digest}, {size} bytes, {count} files, {expanded} expanded bytes") + finally: + signal.alarm(0) + signal.signal(signal.SIGALRM, previous) + + + if __name__ == "__main__": + try: + main() + except Exception: + # Network exceptions can contain signed URLs; keep failure output inert. + raise SystemExit("Exact artifact restore refused; identity, transport or archive validation failed") from None + PYTHON - name: Checkout trusted promotion authority policy for fresh execution if: >- @@ -1598,8 +2023,12 @@ jobs: run-id: ${{ needs.requirements-evidence-producer.outputs.prior-red-run-id }} - name: Reconcile Requirements evidence on fresh runner + id: run-native-evidence + continue-on-error: true timeout-minutes: 12 env: + EVIDENCE_PULL_REQUEST: ${{ github.event.pull_request.number }} + EVIDENCE_HEAD_BRANCH: ${{ github.head_ref }} EVIDENCE_PROMOTION_REUSE: ${{ steps.execution-promotion.outputs.active }} LATE_RED_ACTIVE: ${{ github.event.pull_request.number == 717 && github.head_ref == 'bugfix/692-promotion-final-context' && hashFiles('openspec/changes/fix-release-promotion-requirements-parity/requirements-proof/late-red-evidence.json') != '' }} LATE_RED_CYCLE_BASE: ${{ steps.late-red.outputs.cycle-base-ref }} @@ -1929,13 +2358,59 @@ jobs: .plan_digest == $plan_digest' \ "$legacy_tdd_evidence" > /dev/null reconciliation_arguments+=(--legacy-tdd-evidence "$legacy_tdd_evidence") + elif [[ "$GITHUB_REPOSITORY" == "nold-ai/specfact-cli" && "$EVIDENCE_PULL_REQUEST" =~ ^[1-9][0-9]*$ && "$EVIDENCE_HEAD_BRANCH" == "codex/dependency-hotfix-admission" && "$selected_change" == "isolate-semgrep-security-runtime" ]]; then + : else printf 'Fresh final reconciliation requires authenticated red or legacy evidence.\n' >&2 exit 1 fi - "${reconciliation_arguments[@]}" + native_exit=0 + "${reconciliation_arguments[@]}" || native_exit=$? + printf 'native-reconciled=true\nselected-change=%s\n' "$selected_change" >> "$GITHUB_OUTPUT" + if [[ "$native_exit" -ne 0 ]]; then + exit "$native_exit" + fi + jq -e '.gate_decision == "pass" and .verdict == "passed"' "$consumer_report" > /dev/null fi + - name: Evaluate scoped late-cycle admission + id: late-cycle-admission + continue-on-error: true + if: >- + always() && steps.run-native-evidence.outcome == 'failure' && + steps.run-native-evidence.outputs.native-reconciled == 'true' && + steps.run-native-evidence.outputs.selected-change == 'isolate-semgrep-security-runtime' && + github.repository == 'nold-ai/specfact-cli' && + github.event_name == 'pull_request' && github.event.pull_request.number > 0 && + github.event.pull_request.head.repo.full_name == github.repository && + github.base_ref == 'dev' && github.head_ref == 'codex/dependency-hotfix-admission' + env: + GH_TOKEN: ${{ github.token }} + NATIVE_REPORT: artifacts/requirements-evidence/requirements-evidence-consumer.json + NATIVE_PLAN: artifacts/requirements-evidence/requirements-evidence-consumer-plan.json + NATIVE_JUNIT: ${{ runner.temp }}/requirements-proof-consumer.xml + ADMISSION_AUDIT: artifacts/requirements-evidence/late-cycle-admission-consumer.json + shell: bash + run: | + set -euo pipefail + [[ "$LATE_CYCLE_POLICY_REVISION" =~ ^[0-9a-f]{40}$ ]] + LATE_CYCLE_POLICY_ROOT="$(mktemp -d "${RUNNER_TEMP}/late-cycle-policy.XXXXXX")" + mkdir -p "$LATE_CYCLE_POLICY_ROOT/.github/scripts" + curl --fail --silent --show-error --max-filesize 1048576 --connect-timeout 10 --max-time 60 --proto '=https' --tlsv1.2 \ + "https://raw.githubusercontent.com/nold-ai/.github/${LATE_CYCLE_POLICY_REVISION}/.github/scripts/late_cycle_admission.py" \ + --output "$LATE_CYCLE_POLICY_ROOT/.github/scripts/late_cycle_admission.py" + python3 -I -S "$LATE_CYCLE_POLICY_ROOT/.github/scripts/late_cycle_admission.py" decide \ + --event "$GITHUB_EVENT_PATH" --report "$NATIVE_REPORT" --plan "$NATIVE_PLAN" --junit "$NATIVE_JUNIT" \ + --policy-revision "$LATE_CYCLE_POLICY_REVISION" --output "$ADMISSION_AUDIT" + + - name: Upload execution admission audit + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: requirements-late-cycle-execution-audit + path: artifacts/requirements-evidence/late-cycle-admission-consumer.json + if-no-files-found: ignore + - name: Prepare fresh Requirements execution artifact if: always() env: @@ -2024,6 +2499,15 @@ jobs: path: artifacts/requirements-evidence/code-review.json if-no-files-found: warn + - name: Enforce native or admitted Requirements verdict + if: always() + env: + NATIVE_OUTCOME: ${{ steps.run-native-evidence.outcome }} + ADMISSION_OUTCOME: ${{ steps.late-cycle-admission.outcome }} + shell: bash + run: | + [[ "$NATIVE_OUTCOME" == "success" || "$ADMISSION_OUTCOME" == "success" ]] + - name: Enforce requirements evidence verdict if: steps.run-code-review.outcome == 'failure' run: exit 1 @@ -2037,6 +2521,7 @@ jobs: checks: read contents: read issues: read + pull-requests: read runs-on: ubuntu-latest steps: - name: Enforce trusted Requirements execution success @@ -2326,10 +2811,17 @@ jobs: } >> "$GITHUB_OUTPUT" - name: Restore fresh Requirements execution artifact - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c - with: - artifact-ids: ${{ needs.requirements-evidence.outputs.artifact-id }} - path: ${{ runner.temp }}/requirements-execution + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ needs.requirements-evidence.outputs.artifact-id }} + ARTIFACT_NAME: requirements-evidence-execution + ARTIFACT_REPOSITORY: ${{ github.repository }} + ARTIFACT_RUN_ID: ${{ github.run_id }} + ARTIFACT_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + ARTIFACT_EXPECTED_SHA256: ${{ needs.requirements-evidence.outputs.artifact-digest }} + ARTIFACT_DESTINATION: ${{ runner.temp }}/requirements-execution + shell: bash + run: *restore_exact_requirements_artifact - name: Checkout trusted promotion authority policy for final verdict if: >- @@ -2463,10 +2955,17 @@ jobs: printf 'active=true\n' >> "$GITHUB_OUTPUT" - name: Restore producer Requirements evidence for final verdict - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c - with: - artifact-ids: ${{ needs.requirements-evidence.outputs.producer-artifact-id }} - path: ${{ runner.temp }}/requirements-producer + env: + GH_TOKEN: ${{ github.token }} + ARTIFACT_ID: ${{ needs.requirements-evidence.outputs.producer-artifact-id }} + ARTIFACT_NAME: requirements-evidence + ARTIFACT_REPOSITORY: ${{ github.repository }} + ARTIFACT_RUN_ID: ${{ github.run_id }} + ARTIFACT_HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} + ARTIFACT_EXPECTED_SHA256: '' + ARTIFACT_DESTINATION: ${{ runner.temp }}/requirements-producer + shell: bash + run: *restore_exact_requirements_artifact - name: Download exact late RED artifact for final verification if: github.event.pull_request.number == 717 && github.head_ref == 'bugfix/692-promotion-final-context' && hashFiles('openspec/changes/fix-release-promotion-requirements-parity/requirements-proof/late-red-evidence.json') != '' @@ -2568,7 +3067,11 @@ jobs: run-id: ${{ needs.requirements-evidence.outputs.prior-red-run-id }} - name: Reconcile final Requirements verdict on fresh runner + id: run-final-native-evidence + continue-on-error: true env: + EVIDENCE_PULL_REQUEST: ${{ github.event.pull_request.number }} + EVIDENCE_HEAD_BRANCH: ${{ github.head_ref }} EVIDENCE_PROMOTION_REUSE: ${{ steps.final-promotion.outputs.active }} LATE_RED_ACTIVE: ${{ github.event.pull_request.number == 717 && github.head_ref == 'bugfix/692-promotion-final-context' && hashFiles('openspec/changes/fix-release-promotion-requirements-parity/requirements-proof/late-red-evidence.json') != '' }} PRIOR_RED_HEAD_SHA: ${{ steps.final-retained-red.outputs.head-sha }} @@ -2783,14 +3286,59 @@ jobs: .plan_digest == $plan_digest' \ "$legacy_tdd_evidence" > /dev/null reconciliation_arguments+=(--legacy-tdd-evidence "$legacy_tdd_evidence") + elif [[ "$GITHUB_REPOSITORY" == "nold-ai/specfact-cli" && "$EVIDENCE_PULL_REQUEST" =~ ^[1-9][0-9]*$ && "$EVIDENCE_HEAD_BRANCH" == "codex/dependency-hotfix-admission" && "$selected_change" == "isolate-semgrep-security-runtime" ]]; then + : else printf 'Final reconciliation requires authenticated red or legacy evidence.\n' >&2 exit 1 fi - "${reconciliation_arguments[@]}" + native_exit=0 + "${reconciliation_arguments[@]}" || native_exit=$? + printf 'native-reconciled=true\nselected-change=%s\n' "$selected_change" >> "$GITHUB_OUTPUT" + if [[ "$native_exit" -ne 0 ]]; then + exit "$native_exit" + fi jq -e '.gate_decision == "pass" and .verdict == "passed"' "$final_report" > /dev/null cat "$final_summary" >> "$GITHUB_STEP_SUMMARY" + - name: Evaluate scoped late-cycle admission + id: late-cycle-admission + continue-on-error: true + if: >- + always() && steps.run-final-native-evidence.outcome == 'failure' && + steps.run-final-native-evidence.outputs.native-reconciled == 'true' && + steps.run-final-native-evidence.outputs.selected-change == 'isolate-semgrep-security-runtime' && + github.repository == 'nold-ai/specfact-cli' && + github.event_name == 'pull_request' && github.event.pull_request.number > 0 && + github.event.pull_request.head.repo.full_name == github.repository && + github.base_ref == 'dev' && github.head_ref == 'codex/dependency-hotfix-admission' + env: + GH_TOKEN: ${{ github.token }} + NATIVE_REPORT: ${{ runner.temp }}/final-requirements.json + NATIVE_PLAN: ${{ runner.temp }}/final-requirements-plan.json + NATIVE_JUNIT: ${{ runner.temp }}/requirements-execution/requirements-proof.xml + ADMISSION_AUDIT: ${{ runner.temp }}/final-late-cycle-admission.json + shell: bash + run: | + set -euo pipefail + [[ "$LATE_CYCLE_POLICY_REVISION" =~ ^[0-9a-f]{40}$ ]] + LATE_CYCLE_POLICY_ROOT="$(mktemp -d "${RUNNER_TEMP}/late-cycle-policy.XXXXXX")" + mkdir -p "$LATE_CYCLE_POLICY_ROOT/.github/scripts" + curl --fail --silent --show-error --max-filesize 1048576 --connect-timeout 10 --max-time 60 --proto '=https' --tlsv1.2 \ + "https://raw.githubusercontent.com/nold-ai/.github/${LATE_CYCLE_POLICY_REVISION}/.github/scripts/late_cycle_admission.py" \ + --output "$LATE_CYCLE_POLICY_ROOT/.github/scripts/late_cycle_admission.py" + python3 -I -S "$LATE_CYCLE_POLICY_ROOT/.github/scripts/late_cycle_admission.py" decide \ + --event "$GITHUB_EVENT_PATH" --report "$NATIVE_REPORT" --plan "$NATIVE_PLAN" --junit "$NATIVE_JUNIT" \ + --policy-revision "$LATE_CYCLE_POLICY_REVISION" --output "$ADMISSION_AUDIT" + + - name: Upload final admission audit + if: always() + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: requirements-late-cycle-final-audit + path: ${{ runner.temp }}/final-late-cycle-admission.json + if-no-files-found: ignore + - name: Set up reviewed Code Review Node runtime for final verdict uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e with: @@ -2890,6 +3438,15 @@ jobs: path: ${{ runner.temp }}/final-code-review.json if-no-files-found: error + - name: Enforce native or admitted Requirements verdict + if: always() + env: + NATIVE_OUTCOME: ${{ steps.run-final-native-evidence.outcome }} + ADMISSION_OUTCOME: ${{ steps.late-cycle-admission.outcome }} + shell: bash + run: | + [[ "$NATIVE_OUTCOME" == "success" || "$ADMISSION_OUTCOME" == "success" ]] + - name: Enforce final Code Review verdict if: steps.run-final-code-review.outcome == 'failure' run: exit 1 diff --git a/openspec/changes/isolate-semgrep-security-runtime/TDD_EVIDENCE.md b/openspec/changes/isolate-semgrep-security-runtime/TDD_EVIDENCE.md index 4491ffc14..5f12440db 100644 --- a/openspec/changes/isolate-semgrep-security-runtime/TDD_EVIDENCE.md +++ b/openspec/changes/isolate-semgrep-security-runtime/TDD_EVIDENCE.md @@ -143,3 +143,310 @@ nor technical mapping acceptance fabricates those protected approvals. Direct yamllint on the changed mapping passes. The repository-wide wrapper prints unrelated baseline errors while returning success, already tracked by issue #781. No wrapper behavior is changed in this dependency hotfix. + +## Owner-authorized scoped admission implementation, 2026-10-21 + +The owner authorized actual implementation in policy PR #4 and core PR #782, +with an explicit pause before protected deployment or merging. This supersedes +the previous documentation-only policy checkpoint. Native historical proof +remains missing. No original failed artifact is relabelled RED or modified. + +The new workflow wiring test was authored after the updated specification and +before implementation. It extracts and executes the actual final reconciliation +shell from requirements-evidence.yml with a synthetic policy-boundary adapter. +The adapter isolates workflow routing; it does not claim authentic GitHub +approval or signing. The raw failed native packet must remain byte-identical. + +The pre-code command was `python -m pytest -q + tests/unit/scripts/test_requirements_late_cycle_workflow.py` in the existing +full tools environment. Actual result was 1 failed. The behavioral disagreement +was `assert 1 == 0` with stderr: + +```text +Final reconciliation requires authenticated red or legacy evidence. +``` + +This is evidence of the real early-refusal branch, not a missing-module/import +failure. The complete retained log is +`/private/tmp/specfact-dependency-audit-20261020/official-only/implementation/workflow-red.txt`. +The protected policy baseline independently passes all 66 existing tests before +new implementation. Hostile-input policy tests and all three workflow paths are +separate required verification. Final CI and new exact-head grants remain open. + +## Separate follow-up verification, 2026-10-21 + +After PR #782 merged, the owner authorized a separate CI-only core follow-up +with policy PR #4. The fourteen exact mapped selectors pass, including the +actual frozen certifi artifact test. The protected policy suite passes all +179 tests. Its historical-attempt regression first failed against the old +collector and then passed after explicit approved-attempt binding. + +The shell tests execute each actual producer, fresh and final boundary using +transport and policy doubles. They prove preserved raw native bytes, ordinary +success, enforced policy refusal, scope guards and isolated CI origin wiring. +They do not prove authentic GitHub attestation or human approval. The final +clean-code review of the refactored test reports zero findings. A fresh +independent reviewer accepted the final fourteen-case mapping under its own +identity in requirements-proof/review-evidence.json. + +All three strict frozen audits pass with only the owner-approved MCP exception. +The required contract command exits zero with no modified source detected. +This is a CI-only change; that command does not establish contract execution. +Actual mapped test and policy results are retained separately. Full smart-test, +normal hooks, final native CI origin verification, protected deployment and +new exact-head human approvals remain separate gates. + +Policy revision pinned in the workflow is `ea0509fc5261e394cbc8de918b85ea02ac5f4126`. +It remains inactive until separately authorized protected deployment. +Native failed reports remain immutable and missing historical RED stays missing. + +### Normal-hook and existing contract corrections + +The normal commit hook first rejected job.workflow_sha because the pinned +workflow linter does not recognize that new context. This origin job is directly +defined in the workflow. GitHub's context reference documents the equivalent +github.workflow_sha for this case. An added assertion first failed on the old +context, then all three shell controls passed with the supported declaration. +The final normal commit hooks passed, including zero findings for the new test. + +The first full follow-up smart suite recorded 7 failed, 3447 passed and 10 +skipped. Every failure was an existing workflow contract tied to the replaced +enforcement name or previous exact read-permission set. The two existing test +files now preserve their original mapped parameter IDs, exercise native/admission +exit combinations in all three lanes, retain upload ordering and independently +require both Code Review failure guards. The targeted suite passes all 30 cases. + +Narrow baseline quality exception: reviewing the two existing test files reports +four pytest.mark/parametrize unknown-member typing warnings and four fixture-length +informational findings. An actual unchanged-HEAD helper run records the same +severity, rule and message for every finding. No new warning or information +finding remains. The unchanged expanded fixture builders express authenticated +artifact/run contracts and remain outside this focused CI admission correction. +No suppression or tool configuration change is introduced. This exception covers +only these eight baseline findings, not future regressions. + +Evidence is retained in the implementation audit as origin-context-red.log, +origin-context-green.log, core-commit-hooks-final.log, followup-smart-final.log, +existing-workflow-tests-red.log, existing-workflow-tests-green.log and the actual +baseline/current review JSON files. The final full-suite rerun remains separately +required; the targeted result does not turn the earlier failed run into a pass. + +### Full-suite pass and fork-attester review fix + +At core head 89bf62e8, the corrected full smart suite exited zero with 3455 +passed, 9 skipped and 2 dependency-deprecation warnings in 251.58 seconds. +The final-head native origin job also succeeded. The actual protected-policy +historical collector and official gh verifier authenticated its origin bundle, +exact workflow revision, native ZIP and fourteen passing selectors. The only +native finding is prior-red-proof-missing. This authenticates the observed +failed report; it does not supply historical RED or a human grant. + +PR #784 review identified that the new attester could start on fork PRs without +the required write permissions. The exact scoped-job contract first failed +because its same-repository, pull-request, dev-base and branch guards were absent. +The job now requires the same exact subject as late-cycle admission. It remains +always() after producer failure but skips forks and unrelated subjects. No +permission change, separate CI status check or artifact mutation is used to +address the finding. +The focused new and existing workflow suite passes all 33 tests after the fix. +Evidence is retained in origin-scope-red.log and origin-scope-green.log. + +### Actual rerun retention check + +Review claimed that fixed artifact names conflict across workflow reruns. +The exact locked artifact client documents reruns as a supported duplicate-name +case. A controlled retry of Requirements run 39588065523 on 21 October 2026 +confirmed the actual backend behavior. Attempt 2 successfully uploaded the same +requirements-evidence name as new artifact 12341235127 and the same producer +audit name as new artifact 12341410036. The original native artifact 12339828168 +and origin artifact 12340872780 remained accessible and unexpired. + +After the run advanced to attempt 2, the actual reviewed historical collector +and official gh verifier again authenticated attempt 1's original signed origin +and native ZIP. Its bytes, fourteen selectors and sole missing-RED finding were +unchanged. The existing consumers bind explicit artifact IDs. No overwrite, +deletion or artifact naming change is required. The retry remains refused for +the absent distinct human grant. Successful uploads do not admit provenance. + +The exact retry job is +. +Evidence is retained in pr784-after-rerun-artifacts.json, +pr784-rerun-producer.log and pr784-retained-attempt1-verification.log. + +### Exact-ID artifact transfer correction, 2026-10-21 (Europe/Berlin) + +Specification was amended before the local behavioral probe and implementation. +The actual pinned download-action runtime at +`3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c` applies `filterLatest` before explicit +ID filtering. A Node probe executed that function verbatim against the six-record +authenticated REST fixture for run `39589983181`. Requested producer artifact +`12341352657` exists, but older same-name `12341630570` has the larger numeric ID. +The explicit-ID assertion failed with selected `[]`, `0 !== 1`, and exit 1 before +production edits at `d88310de977bfc5d080e7e03c75256434ed937ea`. +This retains genuine selection failure, not a missing-helper or empty-step error. + +Retained local evidence under +`/private/tmp/specfact-dependency-audit-20261020/official-only/implementation/`: +`artifact-design/reproduce-selection-red.js`, `selection-red.log` and +`selection-red-report.md` (the latter two in the same artifact-design directory), +and `review/artifact-download-rerun-sources/` with the source and REST fixture. +Source SHA-256: +`f4a6b7046eb834bed98bb687f6d08feb0bfe6fb32570e9da7de0707884c08b31`. +Fixture SHA-256: +`91bf7c28202053d63a64feb5562486b27f2507e0b110bd49308b8aa916ca55b3`. +The real producer ZIP digest is +`2d857d60225b8e0e1073488936d7fc62cee43a58283072ae67ae07c78f05b927`. + +The correction uses one YAML scalar with two aliases, invoked through +`/usr/bin/python3 -I -S`. Tests execute that actual resolved Python body with +transport-only doubles. The three names/destinations and the fourteen-case +mapping remain unchanged. Existing workflow tests now inspect the equivalent +exact-ID environment binding instead of obsolete action inputs. + +Passing command (Python 3.13.14, pytest 9.1.1, established run-hatch environment): + +```sh +sh /private/tmp/specfact-dependency-audit-20261020/official-only/run-hatch.sh python -m pytest -o addopts='' -q -p no:cacheprovider tests/unit/workflows/test_exact_artifact_transfer.py tests/unit/workflows/test_requirements_evidence_delivery_workflow.py tests/unit/security/test_release_promotion_security_gates.py tests/unit/scripts/test_requirements_late_cycle_workflow.py +``` + +Result: **114 passed**, with no warnings, retained in +`artifact-design/focused-tests.log`. Controls cover both exact requested IDs, +independent execution digest, metadata association/type/expiry/size/digest, +duplicate JSON, refusal statuses, truncation, HTTPS redirects and credential +stripping across redirect chains, total deadline, digest-before-ZIP ordering, +flat paths, raw NUL, encryption, links/devices/directories, CRC failure, member and +expansion limits, existing and concurrent collisions, symlink parents, fresh +exclusive destinations and preservation of unrelated context. Ruff checks pass +for the three touched test files. Strict OpenSpec validation passes. + +The parent independently executed the real workflow program with local +`/usr/bin/python3` 3.9.6 against authenticated live GitHub endpoints, verifying +producer `12341352657` in both producer roles (6,582 ZIP bytes; four files; +70,093 expanded bytes). Retained execution artifact `12302080806` from run +`39502448590`, head `66ee3d58959e7798a6a7a5a1ed5c41d1db37fbc0`, restored two flat +files (1,913 ZIP bytes; 4,102 expanded bytes) with independent digest +`0e20e44875b9a43c4653940ca90a788d541e9ed085fa5c613648f1c41ae9abe5`. +Byte comparisons and unrelated context preservation passed. Evidence is +`live-exact-transfer-proof.json` and `.log`; the parent refreshes it if the +workflow body changes. Historical execution proves transport/layout, not +current-head native success. Existing grants remain historical. Fresh grants, +normal hooks, independent review and current-head CI remain delivery gates. + +The program keeps verified compressed bytes in bounded memory rather than a +private ZIP temporary file; extraction still stages every validated member on +the destination filesystem before exclusive publication. Each file publishes +atomically; whole-set atomicity is not claimed. Interruption or a late collision +may leave partial new evidence, and retry refuses it. Existing evidence is never +overwritten. Rollback is reverting the workflow correction; no artifact deletion, +protected-policy change, signing or dependency changes are required. + +### Final transport review and local gates + +Independent reviewer `Codex:/root/artifact_transfer_security_review` returned +PASS for exact program SHA-256 +`f9a09798fed949615bbd8845fca26044ca53a16fe95c85853ad04590f67e6b8a`. +It independently passed 114 focused tests, eight additional hostile probe groups, +compared all workflow YAML outside the three changed steps with the base, and +compared the live restored files with genuine ZIPs. The review and evidence are +retained in `review/artifact-transfer-security.md` and adjacent machine records +under the local evidence root above. No grant, deployment or merge authority is +implied by that technical review. + +The new test file has zero SpecFact findings. The full affected-file review has +one warning and six informational findings in unchanged existing functions. +The same independent reviewer explicitly accepted a narrow exception for exactly +those baseline diagnostics. It compared both complete source and AST against +`git show d88310de:`, independently of the parent's comparison. The warning +is `banned-generic-public-names` on +`test_invalid_utf8_authority_is_stable_metadata_failure`. The six +`ai-bloat.loc-vs-complexity` findings concern that same function, +`test_fresh_consumer_reexecutes_trusted_plan`, `_write_late_red_artifact`, +`_write_late_red_metadata`, `_late_red_fixture`, and +`test_workflow_revalidates_promotion_reuse_in_all_stages`. No rule, threshold, +suppression or function body was changed to accept them. The explicit exception +is limited to these unchanged symbols for this correction; it permits no new +finding or changed body. Evidence is `artifact-transfer-self-review.json`, +`artifact-transfer-baseline-findings.json`, and the reviewer's independent +`review/artifact-transfer-security-baseline.json`. + +The review used the committed pinned BasedPyright runner with the actual Hatch +Python path. The global pyenv shim previously could not resolve pytest and +produced false unknown-member diagnostics. The isolated executable adapter +preserves every caller argument and adds only the correct `--pythonpath`; it +changes no repository configuration or check. The required authoritative full +project type run reports zero errors and 1,456 warnings, exactly the baseline. + +Required formatter passed with all 1,128 files unchanged. Full lint, YAML lint, +actual pinned actionlint 1.7.11, strict OpenSpec validation and stable fourteen-case +mapping/plan identity checks passed. The smart-test gate passed its selected 111 +workflow/security tests; its unit-scope coverage notice is not a full-suite +coverage result. The contract gate exited zero with no modified source scope, +using its cached result. Fresh complete CI remains required at the new head. + +The comment review's two private docstrings and changed-line type suppression +were removed. Its only kept comment explains redaction of signed URLs emitted +by the external urllib exception formatter. No production change followed the +final live transfer proof or independent security verdict. The core dependency, +version and signed-module inputs remain unchanged. Signing stays in CI/CD. + +### Equivalent signed synthetic merge origin, 2026-10-21 (Europe/Berlin) + +Normal-hook commit `4b25241ef5548d29b6ad78be6cd2d26dda6afa77` published the +reviewed exact-ID transfer. All ordinary CI checks passed at that head. The +new native artifact `12344301975` and signed origin `12342973880` in run +`39595683488`, attempt 1, retain fourteen passing cases and the sole missing-RED +finding. The canonical request operation refused with `origin-source` before +any new human request was issued. + +Authenticated GitHub commit records show actual attested workflow revision +`21cca82b7d32c567bbea6f496184cbdf633cab13` and current PR merge revision +`6636108be140545670ab348e503187102dded6e7`. Both have the exact reviewed tree +`c131fc747fa4cd827a06ffd6a1ec164e146a986c`, the ordered parents +`3cf72e848a2b5a233d3f599898d6983319d375de` and the current head, and valid +signatures. Their signed payloads encode author/committer time zones differently. +The deployed literal source-ID guard rejects this equivalent signed merge. +No origin or artifact was rewritten to work around that refusal. + +The specification preceded the scoped policy regression. The new test +`test_historical_equivalent_signed_merge_preserves_attested_source` first failed +with `late_cycle_admission.Refused: origin-source`. Two subsequent ordered-parent +negative controls first failed with `DID NOT RAISE Refused` before the candidate +was tightened to a distinct first parent and exact current-head second parent. +The final policy suite passed 264 tests, independently repeated by +`Codex:/root/policy_identity_security_review`. Its exact source SHA-256 is +`bdd671842d0f0c1d252a759a7aa8230b2b94d6d97e44bebc760b9b13e7c967f0`. +Formatting/lint, comment review and diff checks passed. The independent technical +verdict permits normal commit/publication, not protected deployment or grants. + +The candidate accepts a different signed merge only when both authenticated +full identities match their requests, both signatures are valid, both trees +match the exact reviewed head tree, and both ordered two-parent lists agree +as distinct base/current-head. It retains workflow-byte equality and verifies +the actual original signer/source digest through official gh. All native, +selector, immutable-artifact, live subject/permission/expiry and deployment checks +remain unchanged. + +Parent technical verification called the real candidate historical collector +against the actual API and signed origin, without replacing its official gh +verifier. It accepted the genuine native ZIP and sole missing-RED finding, while +retaining the original workflow revision. This was evidence verification only; +no request, grant or decision was issued. The unchanged `deployed()` restriction +continues to refuse an unmerged candidate. + +The policy correction is proposed as +, exact commit +`5cd61c45303bc5da174a39ff79945fafad77ea3b`. Core now pins that immutable revision +for all three existing evaluator calls. This pin is inactive for admission until +the owner explicitly approves protected deployment and the revision is reachable +from policy main. The earlier policy4 deployment approval does not apply to this +new change. No policy5 deployment, new human grant, core merge or local signing +has occurred. Fresh core evidence and separate exact owner grants remain required +following approved deployment. Reverting the pin and policy correction restores +the prior refusal without altering retained evidence. + +Local evidence is under the previously identified implementation directory, +`policy-identity/`: `actual-deployed-red.log`, `regression-red.log`, +`ordered-parent-red.log`, `final-tests.log`, `security-review.md`, +`candidate-live-origin-proof.json` and its producer script in the parent directory. +The original authenticated synthetic commit responses and origin manifest are +retained adjacent to those records. diff --git a/openspec/changes/isolate-semgrep-security-runtime/design.md b/openspec/changes/isolate-semgrep-security-runtime/design.md index 00eb855b5..dec8e70c7 100644 --- a/openspec/changes/isolate-semgrep-security-runtime/design.md +++ b/openspec/changes/isolate-semgrep-security-runtime/design.md @@ -2,8 +2,9 @@ ## Decision -Reuse the existing exact advisory register. Do not alter the audit engine, -workflow requirements, package identities or resolver behavior. Delete the +Reuse the existing exact advisory register. Do not alter the audit engine, package identities or resolver behavior. +The owner separately authorized narrowly scoped Requirements admission and +core CI integration on 2026-10-21. Delete the rejected fork rather than maintain inactive producer infrastructure. ## Data @@ -40,3 +41,111 @@ Earlier native replacement planning and the rejected producer remain in Git history. Their tests, mappings, candidate attestations and publication approvals do not establish acceptance for this scope. Companion-module migration is only needed if a future change replaces/removes the official integration. + +## Scoped CI admission + +Policy PR #4 owns one live evaluator in immutable protected policy code. Core +invokes it at the producer, fresh and final Requirements reconciliation +boundaries. Its output is audit data only. Every boundary independently reads +the live exact-head grant, permission, current subject and retained evidence. +No consumer trusts an earlier sidecar as a bearer credential. + +The domain has distinct NativeProvenance, HumanApprovedLateCycle and Refused +outcomes. Admission preserves the byte-identical failed native report, including +incomplete maturity and missing historical RED. Code Review consumes that real +failed report through its existing signed schema-2 interface. Its own failures +remain blocking. No signed module changes are required. + +The canonical separate grant binds repository, the live numeric direct follow-up PR, issue780, change, dev +base, head branch/commit/tree, immutable policy revision, exact eleven dependency selectors and three admission controls, +source/aggregate mapping and plan identities, native run/attempt/artifact and +raw hashes, authenticated executed producer source, explicitly missing native +RED, the exact allowed provenance finding and expiry. It is unedited, unique, +issued by a currently authorized write/admin human, and at most seven days old. +The current execution independently passes every selector and matches semantic +plan/mapping identities. Historical and current byte hashes remain separate. + +All JSON, archive, XML and network inputs are bounded. Duplicate/unknown fields, +unsafe archive members, malformed or skipped tests, extra findings, changed +subject/policy, stale/revoked authority, network uncertainty and expiry refuse. +No API run head SHA is claimed to prove executed workflow revision. Use verified +GitHub CI provenance when the API does not authenticate that identity. Signing +and attestation generation occur only in CI/CD. No local signing is permitted. + +The implementation is prepared and reviewed before protected activation. It +must refuse before owner-approved deployment and the distinct final human grant. +Rollback disables the scoped protected policy and restores refusal while keeping +all native reports and ordinary CI gates. The generic exact-head authority and +the MCP advisory register remain separate capabilities. + +Implementation sequence is updated specification, behavioral failing workflow +fixture, policy/core implementation, passing hostile-input and native-path +controls, independent review, normal hooks and CI. No immutable failed artifact +is relabelled RED or rewritten. The final deployment packet records exact code +revisions, new evidence, approval payloads, rollback and remaining owner actions. + +The owner merged dependency PR #782 before this CI implementation was ready, +then authorized a separate follow-up. Current subject is the unique open dev PR +on `codex/dependency-hotfix-admission`, independently obtained through GitHub. +Its numeric PR identity is an exact canonical grant field. It inherits no old +head/tree authority. The still-open issue #780 and this active change retain +scope and tracking. The merged dependency payload remains separate from the +CI control continuation. + +The isolated origin attester is restricted to the same-repository dev follow-up +on the exact admission branch. Forks and unrelated subjects skip that extra job +and retain their ordinary native Requirements checks. GitHub does not grant +fork pull-request jobs the attestation write permissions required by this job. + +## Exact-ID artifact transport correction (2026-10-21) + +The pinned download action applies greatest-ID-per-name filtering before explicit +ID selection. Authenticated run 39589983181 has fresh producer ID 12341352657 +below old same-name ID 12341630570. One YAML-anchored inline standard-library +Python program replaces the three current-run transfers. It runs through +`/usr/bin/python3 -I -S`, never imports candidate checkout code, and preserves +artifact names, destinations, native consumers and the fourteen-case mapping. + +A typed artifact subject binds repository name and numeric ID, run, head, name, +ID, destination and optional upload digest. REST metadata and exact ZIP are +fetched directly by ID. Execution artifacts require the existing independent +upload digest; producer artifacts require authenticated REST SHA-256. Transport +has HTTPS-only bounded redirects with no forwarded credentials, size bounds, +read timeouts and a total deadline. Digest verification precedes ZIP parsing. + +Only portable flat regular files enter private staging; compressed bytes are +limited to 16 MiB, each file to 16 MiB, total expansion to 64 MiB, and membership +to 128 files. These exceed the observed producer's 6,582-byte ZIP and 70,093-byte +expansion; retained execution is 1,913 compressed bytes and 4,102 expanded bytes. +The verified ZIP stays in bounded memory (at most 16 MiB of archive bytes); this +avoids another filesystem lifecycle before extraction. Directory +file descriptors and no-follow opens protect parents. All members validate +before exclusive hard-link publication; existing unrelated context survives. +Publication is atomic per file, not for the whole set. An interrupted run may +leave partial evidence and retries then refuse collisions. Fresh jobs are the +recovery path. No new helper, dependency, protected policy or permission exists. + +Tests execute the resolved YAML body with test-only HTTP doubles. Local RED +replays the actual pinned filter over authenticated REST records. Final live +exact-ID verification, independent review, normal hooks and fresh exact-head +authority remain required before CI acceptance. Parent owns internal wiki sync. + +## Attested synthetic merge identity correction (2026-10-21) + +At core head `4b25241e`, authenticated native origin records workflow revision +`21cca82b`, while the live PR merge revision is `6636108b`. Both have the exact +head, tree and ordered base/head parents, and valid GitHub signatures. Their +unsigned signed-payload text differs only in the author/committer time-zone +encoding, producing different commit IDs. The deployed policy rejects this at +`origin-source`, before a new grant can even be prepared. + +Prepare a separately reviewed protected-policy correction. The equivalent-merge +path must bind both authenticated commit IDs, valid signatures, the exact +reviewed full tree and ordered two-parent ancestry containing the current head. +It retains actual attested signer/source digest and unchanged workflow bytes. +It never rewrites origin metadata, substitutes workflow SHA or weakens the human +subject binding. Strict negative controls cover wrong parents, tree, head, +signature, metadata and workflow bytes. Genuine current CI refusal is retained +before code. A new immutable core policy pin follows review, and owner approval +is required before protected deployment. The original deployed revision remains +unchanged until that approval. diff --git a/openspec/changes/isolate-semgrep-security-runtime/proposal.md b/openspec/changes/isolate-semgrep-security-runtime/proposal.md index ae86eb3d6..74e8a2af8 100644 --- a/openspec/changes/isolate-semgrep-security-runtime/proposal.md +++ b/openspec/changes/isolate-semgrep-security-runtime/proposal.md @@ -21,6 +21,15 @@ prevents a compatible upgrade. Official Semgrep 1.182.0 still requires release exists. Expiry, unrelated advisories and other versions fail closed. - Preserve strict audits of all three committed requirement graphs, independent SAST, module signatures, protected authority and CI/CD-only signing. +- Implement the owner-requested scoped late-cycle admission in policy PR #4 and + its CI-only core integration. Keep the failed native report intact. A separate + exact-head human grant may admit only missing historical failing-first proof + for the eleven dependency scenarios and three new admission control cases. It grants no dependency-audit waiver, + native RED claim, merge, release or local signing. +- Authenticate the retained native evidence and execute the same immutable + protected evaluator at producer, fresh and final reconciliation. Every + consumer independently verifies live scope, approval, permission and expiry. + Policy activation, protected deployment and merging require owner approval. ## Capabilities @@ -31,7 +40,8 @@ prevents a compatible upgrade. Official Semgrep 1.182.0 still requires ### Modified Capabilities -None. +- CI Requirements admission for only issue #780 and the direct admission follow-up to merged PR #782. Application runtime + and the ordinary native-provenance path retain their existing contracts. ## Impact @@ -63,6 +73,21 @@ promotion is authorized. Final commit/tree authority remains separate. - Only the exact reviewed MCP finding is waived before expiry. Every other advisory, audit error, skipped dependency or expired record remains blocking. - Required quality, review, CI and human authority gates remain enforced. +- Scoped admission is distinct from native proof, refuses all extra findings and + cannot transfer across heads, trees, policies, mappings or expired approvals. +- Genuine pre-code failures and adversarial controls verify the new admission + implementation separately from the dependency scenarios being admitted. Sources checked 2026-10-21: [official Semgrep metadata](https://pypi.org/pypi/semgrep/json), [upstream MCP advisory](https://github.com/modelcontextprotocol/python-sdk/security/advisories/GHSA-84m7-p3x7-pcfv). + +## Direct follow-up after dependency merge + +On 2026-10-21 the owner merged dependency PR #782 as dev commit +`3cf72e848a2b5a233d3f599898d6983319d375de`, then explicitly authorized a +separate core admission follow-up alongside policy PR #4. This continuation +uses branch `codex/dependency-hotfix-admission`, the same still-open issue #780, +and the exact numeric follow-up PR from live GitHub metadata. Its separate +human grant must bind that PR's own head/tree and authentic evidence. The old +PR #782 grant cannot transfer. The follow-up changes CI controls and their +verification, without changing merged dependency pins or application runtime. diff --git a/openspec/changes/isolate-semgrep-security-runtime/requirements-evidence.yaml b/openspec/changes/isolate-semgrep-security-runtime/requirements-evidence.yaml index 86be19fbe..b3fa10281 100644 --- a/openspec/changes/isolate-semgrep-security-runtime/requirements-evidence.yaml +++ b/openspec/changes/isolate-semgrep-security-runtime/requirements-evidence.yaml @@ -125,3 +125,39 @@ requirements: selector: runner: pytest node_id: tests/unit/scripts/test_security_audit_gate.py::test_committed_mcp_exception_is_exact_and_time_bounded[future-review] + openspec:isolate-semgrep-security-runtime:isolated-scanner-runtime:admit-only-the-explicitly-approved-late-cycle-provenance-gap: + rationale: Enforce the owner-authorized CI-only admission independently from the dependency advisory exception and native proof. + stakeholder_refs: + - nold-ai/specfact-cli#780 + touchpoints: + - id: requirements-workflow + kind: config_file + locator: .github/workflows/requirements-evidence.yml + - id: scoped-admission-controls + kind: test + locator: tests/unit/scripts/test_requirements_late_cycle_workflow.py + verification_cases: + - case_id: ADMISSION-NATIVE-BYTES-AND-ENFORCEMENT + scenario_id: admit-only-the-explicitly-approved-late-cycle-provenance-gap + method: test + intent: Exercise all three real reconciliation boundaries, preserve raw native bytes and enforce policy exit codes. + observable: Native success remains independent and scoped failure proceeds only through the enforced separate decision. + selector: + runner: pytest + node_id: tests/unit/scripts/test_requirements_late_cycle_workflow.py::test_all_three_lanes_preserve_native_bytes_and_enforce_policy_exit + - case_id: ADMISSION-SCOPE-AND-PROOF-GUARDS + scenario_id: admit-only-the-explicitly-approved-late-cycle-provenance-gap + method: test + intent: Reject invalid PR contexts and other branches and preserve invalid native RED refusal. + observable: Unapproved scope and invalid native proof cannot silently acquire admission. + selector: + runner: pytest + node_id: tests/unit/scripts/test_requirements_late_cycle_workflow.py::test_fresh_and_final_preserve_scope_and_invalid_red_guards + - case_id: ADMISSION-ISOLATED-NATIVE-ORIGIN + scenario_id: admit-only-the-explicitly-approved-late-cycle-provenance-gap + method: test + intent: Require an isolated CI-only origin attester with immutable official actions and no candidate execution. + observable: The manifest validates native artifact metadata and CI wires an isolated attestation step after producer failure. + selector: + runner: pytest + node_id: tests/unit/scripts/test_requirements_late_cycle_workflow.py::test_origin_attestation_survives_producer_failure diff --git a/openspec/changes/isolate-semgrep-security-runtime/requirements-proof/review-evidence.json b/openspec/changes/isolate-semgrep-security-runtime/requirements-proof/review-evidence.json index d4514037e..1f6bb66f4 100644 --- a/openspec/changes/isolate-semgrep-security-runtime/requirements-proof/review-evidence.json +++ b/openspec/changes/isolate-semgrep-security-runtime/requirements-proof/review-evidence.json @@ -1,9 +1,9 @@ { "schema_version": "1", "decision": "accepted", - "reviewer_id": "Codex:/root/expanded_selector_map_review", - "reviewer_role": "independent AI code reviewer", - "recorded_at": "2026-10-21T08:58:05.627991+00:00", - "reference": "https://github.com/nold-ai/specfact-cli/pull/782", - "mapping_digest": "sha256:9be3a10b220655e55c299b9b3d69c2be0c0b029b1db1c0942abfea1ea6c4f86d" + "reviewer_id": "Codex:/root/admission_final_mapping_acceptance", + "reviewer_role": "independent AI technical mapping reviewer", + "recorded_at": "2026-10-21T10:34:11.586358+00:00", + "reference": "https://github.com/nold-ai/specfact-cli/issues/780", + "mapping_digest": "sha256:c3fd8ebfa8b872f5ba12eee146ae2bdf9fa60a4c9c9a318450d528c4b4215318" } diff --git a/openspec/changes/isolate-semgrep-security-runtime/specs/isolated-scanner-runtime/spec.md b/openspec/changes/isolate-semgrep-security-runtime/specs/isolated-scanner-runtime/spec.md index d80fd1aa4..338c7011c 100644 --- a/openspec/changes/isolate-semgrep-security-runtime/specs/isolated-scanner-runtime/spec.md +++ b/openspec/changes/isolate-semgrep-security-runtime/specs/isolated-scanner-runtime/spec.md @@ -34,3 +34,57 @@ or expiry. No Semgrep fork or conflicting dependency override SHALL be adopted. - **GIVEN** the reviewed record with a different version, advisory, skipped subject or date after expiry - **WHEN** the existing audit gate evaluates that input - **THEN** it returns a failure instead of suppressing the finding. + +### Requirement: Admit only the explicitly approved late-cycle provenance gap + +For only issue #780 and the owner-authorized direct follow-up to merged PR #782 +on branch `codex/dependency-hotfix-admission`, CI MAY +admit missing historical failing-first proof through a separately approved +HumanApprovedLateCycle decision from immutable protected policy code. Every +producer, fresh and final boundary SHALL independently authenticate its inputs, +exact subject and policy, live human permission and unedited bounded approval. +The native report SHALL remain failed and byte-identical. Admission SHALL NOT +claim native RED, verified provenance, waive other findings, alter application +runtime, bypass Code Review, or authorize protected deployment or merge. + +#### Scenario: Exact scoped admission retains the failed native report + +- **GIVEN** authenticated exact-head evidence, all eleven dependency selectors and three admission controls passing, and a separate unexpired human grant +- **WHEN** the immutable protected evaluator runs independently at each CI boundary +- **THEN** it emits HumanApprovedLateCycle with the raw failed native report unchanged and keeps the independent Code Review gate enforced. + +#### Scenario: Substitution or expired authority remains blocking + +- **GIVEN** missing or changed policy, subject, evidence, selectors, permission, grant, expiry, or an additional failed obligation +- **WHEN** any CI boundary evaluates admission +- **THEN** it refuses and preserves the original report and every required check. + +#### Scenario: Ordinary native proof retains its existing gate + +- **GIVEN** valid complete native failing-first proof outside the scoped exception +- **WHEN** Requirements reconciles it +- **THEN** the existing native success path remains independent of late-cycle approval. + +#### Scenario: Unrelated and fork pull requests do not start the attester + +- **GIVEN** a fork, another repository, base or branch, or an absent native artifact +- **WHEN** CI evaluates the origin attester job +- **THEN** it skips that job and retains ordinary Requirements enforcement without requesting signing permissions for that subject. + +#### Scenario: Exact artifact IDs survive same-name reruns + +- **GIVEN** the bound producer or execution artifact ID and another artifact with the same name and a greater numeric ID +- **WHEN** any of the three current-run evidence transfers restores its artifact +- **THEN** it retrieves the requested immutable ID directly, authenticates repository numeric identity, run, head, name, expiry, SHA-256 and size, and requires the independent execution upload digest. + +#### Scenario: Artifact bytes enter only as bounded inert evidence + +- **GIVEN** authenticated exact-ID metadata and ZIP bytes +- **WHEN** the shared isolated workflow-owned program restores evidence +- **THEN** credentialed transport remains on the fixed GitHub API, HTTPS redirects never forward the token, digest verification precedes bounded flat regular-file extraction, all files validate in staging before exclusive publication, and unsafe paths, links, collisions or uncertain inputs fail closed while preserving unrelated context. + +#### Scenario: Authenticated equivalent synthetic merge revisions + +- **GIVEN** an attested workflow revision and current GitHub PR merge revision have different IDs but the same exact reviewed full tree and ordered two-parent ancestry +- **WHEN** the scoped policy verifies historical native origin for the unchanged head +- **THEN** it may accept that equivalent signed merge only after authenticated commit identity, valid signatures, exact head/tree/parents, unchanged workflow bytes and the existing exact source-digest attestation verification pass; mismatched ancestry, content, signatures or source still refuse. diff --git a/openspec/changes/isolate-semgrep-security-runtime/tasks.md b/openspec/changes/isolate-semgrep-security-runtime/tasks.md index a6b7da361..b1cdf7168 100644 --- a/openspec/changes/isolate-semgrep-security-runtime/tasks.md +++ b/openspec/changes/isolate-semgrep-security-runtime/tasks.md @@ -29,3 +29,34 @@ - [ ] Prepare any later dependency-only release from published v0.55.4. - [ ] Link verified coverage before closing related dependency PRs. - [ ] Archive through OpenSpec only after completion and merge. + +## 5. Implement owner-authorized scoped admission + +- [x] Ground all three core consumers and compare distinct implementation designs. +- [x] Specify independent live policy evaluation and distinct admission semantics. +- [x] Preserve genuine behavioral failing-before workflow evidence. +- [x] Implement bounded policy, exact human grant and authenticated native origin. +- [x] Integrate the immutable evaluator into producer, fresh and final CI paths. +- [x] Prove hostile input refusal, unchanged native success and pinned Code Review compatibility. +- [ ] Complete independent review, normal hooks and pre-deployment CI. +- [x] Present exact protected deployment and new authority requests; owner approved the original d883 head through comments 6314609869 and 6314563334, and approved policy merge b328ace4 at reviewed 78c2. +- [x] Activate the approved immutable policy; attempt 2 producer and origin passed. +- [ ] Verify all required CI after the exact-ID transport correction and renewed head grants, without merging core. + +## 6. Correct exact-ID artifact transport + +- [x] Specify one workflow-owned transfer program and retain mapping identities. +- [x] Retain authentic pinned-action failing selection before production edits. +- [x] Exercise actual YAML-resolved code against valid metadata and hostile inputs. +- [x] Replace all three current-run transfers without changing names or destinations. +- [x] Verify genuine producer and retained execution layouts, independent execution digest and live exact-ID transfer; current-head CI remains pending. +- [ ] Complete independent review, normal hooks, wiki alignment and fresh exact-head grants before CI acceptance. + +## 7. Preserve authenticated synthetic merge equivalence + +- [x] Retain exact `origin-source` refusal and both authenticated signed commit records. +- [x] Specify strict equivalent-merge admission with unchanged attestation and human authority. +- [x] Add meaningful failing-before regression and hostile substitution controls. +- [x] Implement and independently review the scoped protected-policy correction. +- [ ] Update the immutable core pin through normal hooks and verify fresh CI. +- [ ] Request exact owner approval before protected deployment, then prepare fresh head-bound human grants. diff --git a/tests/unit/scripts/test_requirements_late_cycle_workflow.py b/tests/unit/scripts/test_requirements_late_cycle_workflow.py new file mode 100644 index 000000000..e256fb05c --- /dev/null +++ b/tests/unit/scripts/test_requirements_late_cycle_workflow.py @@ -0,0 +1,357 @@ +from __future__ import annotations + +import hashlib +import json +import os +import subprocess +from dataclasses import dataclass +from pathlib import Path +from typing import Any, NamedTuple + +import yaml + + +ROOT = Path(__file__).resolve().parents[3] +WORKFLOW = ROOT / ".github/workflows/requirements-evidence.yml" +LANES = { + "final": "Reconcile final Requirements verdict on fresh runner", + "fresh": "Reconcile Requirements evidence on fresh runner", + "producer": "Run Requirements evidence gate", +} + + +def _boundary(lane: str) -> str: + workflow = yaml.safe_load(WORKFLOW.read_text()) + shell = next( + step["run"] for job in workflow["jobs"].values() for step in job["steps"] if step.get("name") == LANES[lane] + ) + if lane == "producer": + start = shell.index('set +e\n "${reconciliation_arguments[@]}"') + end = shell.index('failure_diagnostic="Requirements proof reconciliation exited with status $exit_code', start) + return shell[start:end] + '\nexit "$exit_code"' + if lane == "fresh": + return shell[shell.index(' test -s "$consumer_junit"') :].rsplit("\nfi", 1)[0] + return shell[shell.index('test -s "$execution_junit"') :].split('cat "$final_summary"')[0] + + +@dataclass(frozen=True) +class WorkflowProofCase: + lane: str + policy_exit: int + native_exit: int + scope: str = "scoped" + extra_failure: bool = False + + +class BoundaryArtifacts(NamedTuple): + report: Path + junit: Path + plan: Path + summary: Path + policy: Path + called: Path + bin_dir: Path + event: Path + original: bytes + + +def _write_policy_double(policy: Path, policy_exit: int) -> None: + policy.write_text( + "import argparse, json, os, sys\nfrom pathlib import Path\n" + "p=argparse.ArgumentParser(); p.add_argument('operation'); " + "p.add_argument('--output'); p.add_argument('--report'); p.add_argument('--plan'); " + "p.add_argument('--junit'); p.add_argument('--event'); p.add_argument('--policy-revision'); a=p.parse_args()\n" + "assert a.operation == 'decide'\n" + "assert Path(a.report).read_bytes() == Path(os.environ['EXPECTED_REPORT']).read_bytes()\n" + "assert Path(a.plan).is_file() and Path(a.junit).is_file() and Path(a.event).is_file()\n" + "Path(os.environ['CALLED']).write_text(json.dumps(vars(a)))\n" + "Path(a.output).write_text(json.dumps({'outcome':'HumanApprovedLateCycle'}))\n" + f"sys.exit({policy_exit})\n" + ) + + +def _native_artifacts(tmp_path: Path, case: WorkflowProofCase) -> BoundaryArtifacts: + artifact = tmp_path / "artifacts/requirements-evidence" + artifact.mkdir(parents=True) + report = artifact / "requirements-evidence.json" + findings = ["prior-red-proof-missing"] if case.native_exit else [] + if case.extra_failure: + findings.append("failed-test") + raw = { + "schema_version": "2", + "gate_decision": "fail" if case.native_exit else "pass", + "verdict": "failed" if case.native_exit else "passed", + "findings": findings, + } + original = json.dumps(raw, sort_keys=True).encode() + report.write_bytes(original) + junit = artifact / "requirements-proof.xml" + junit.write_text('') + plan = artifact / "requirements-evidence-plan.json" + plan.write_text("{}") + summary = tmp_path / "summary.md" + summary.write_text("Native result.\n") + policy = tmp_path / "policy.py" + called = tmp_path / "policy-called.json" + _write_policy_double(policy, case.policy_exit) + bin_dir = tmp_path / "bin" + bin_dir.mkdir() + curl = bin_dir / "curl" + curl.write_text( + '#!/usr/bin/env bash\nset -eu\nwhile [[ "$1" != --output ]]; do shift; done\ncp "$POLICY_DOUBLE" "$2"\n' + ) + curl.chmod(0o755) + event = tmp_path / "event.json" + event.write_text("{}") + return BoundaryArtifacts(report, junit, plan, summary, policy, called, bin_dir, event, original) + + +def _native_prelude(case: WorkflowProofCase, paths: BoundaryArtifacts) -> str: + report, junit, plan, summary = paths[:4] + return "\n".join( + [ + "set -euo pipefail", + f'isolated_specfact=(bash -c "exit {case.native_exit}" native)', + f'reconciliation_arguments=(bash -c "exit {case.native_exit}" native)', + f'execution_junit="{junit}"', + f'consumer_junit="{junit}"', + f'final_plan="{plan}"', + f'consumer_plan="{plan}"', + f'final_report="{report}"', + f'consumer_report="{report}"', + f'final_summary="{summary}"', + f'consumer_summary="{summary}"', + 'source_ref="0123456789abcdef0123456789abcdef01234567"', + 'LATE_RED_PROVENANCE="${RUNNER_TEMP}/late-red-trusted-provenance/scripts/requirements_proof_provenance.py"', + 'FINAL_LATE_RED_PROVENANCE="$LATE_RED_PROVENANCE"', + 'LATE_RED_CYCLE_BASE="0123456789abcdef0123456789abcdef01234567"', + f"LATE_RED_ACTIVE={'true' if case.scope == 'invalid-proof' else 'false'}", + 'PRIOR_RED_HEAD_SHA=""', + "run_stage=final", + "selected_change=isolate-semgrep-security-runtime", + 'evidence_base_commit="0123456789abcdef0123456789abcdef01234567"', + f"EVIDENCE_PULL_REQUEST={0 if case.scope == 'invalid-pr' else 900}", + f"EVIDENCE_HEAD_BRANCH={'other-branch' if case.scope == 'other-branch' else 'codex/dependency-hotfix-admission'}", + ] + ) + + +def _boundary_environment(tmp_path: Path, paths: BoundaryArtifacts) -> dict[str, str]: + report, _, _, _, policy, called, bin_dir, event, _ = paths + return { + **os.environ, + "PATH": f"{bin_dir}:{os.environ['PATH']}", + "RUNNER_TEMP": str(tmp_path), + "GITHUB_WORKSPACE": str(tmp_path), + "GITHUB_EVENT_PATH": str(event), + "GITHUB_REPOSITORY": "nold-ai/specfact-cli", + "LATE_CYCLE_POLICY_REVISION": "0123456789abcdef0123456789abcdef01234567", + "POLICY_DOUBLE": str(policy), + "CALLED": str(called), + "EXPECTED_REPORT": str(report), + } + + +def _admission_steps(case: WorkflowProofCase) -> tuple[dict[str, Any], dict[str, Any]]: + workflow = yaml.safe_load(WORKFLOW.read_text()) + job = next( + job for job in workflow["jobs"].values() if any(step.get("name") == LANES[case.lane] for step in job["steps"]) + ) + steps = job["steps"] + native = next(step for step in steps if step.get("name") == LANES[case.lane]) + admission = next(step for step in steps if step.get("id") == "late-cycle-admission") + enforcement = next(step for step in steps if step.get("name") == "Enforce native or admitted Requirements verdict") + _assert_admission_isolation(steps, native, admission, enforcement) + return admission, enforcement + + +def _assert_admission_isolation( + steps: list[dict[str, Any]], native: dict[str, Any], admission: dict[str, Any], enforcement: dict[str, Any] +) -> None: + assert native["continue-on-error"] is True + assert "GH_TOKEN" not in native.get("env", {}) + assert "curl " not in native["run"] + assert admission["env"]["GH_TOKEN"] == "${{ github.token }}" + assert steps.index(native) < steps.index(admission) < steps.index(enforcement) + assert "native-reconciled == 'true'" in admission["if"] + assert "outcome == 'failure'" in admission["if"] + assert "selected-change == 'isolate-semgrep-security-runtime'" in admission["if"] + assert "codex/dependency-hotfix-admission" in admission["if"] + + +def _run_admission( + tmp_path: Path, + case: WorkflowProofCase, + admission: dict[str, Any], + environment: dict[str, str], + native: tuple[subprocess.CompletedProcess[str], bool], +) -> str: + result, marker = native + admission_outcome = "skipped" + if not (result.returncode and marker and case.scope == "scoped"): + return admission_outcome + admission_environment = {**environment, "GH_TOKEN": "transport-double-token"} + paths = { + "NATIVE_REPORT": environment["EXPECTED_REPORT"], + "NATIVE_PLAN": str(tmp_path / "artifacts/requirements-evidence/requirements-evidence-plan.json"), + "NATIVE_JUNIT": str(tmp_path / "artifacts/requirements-evidence/requirements-proof.xml"), + "ADMISSION_AUDIT": str(tmp_path / "admission.json"), + } + admission_result = subprocess.run( + ["bash", "-c", admission["run"]], + cwd=tmp_path, + env={**admission_environment, **paths}, + capture_output=True, + text=True, + check=False, + ) + return "success" if admission_result.returncode == 0 else "failure" + + +def _run_boundary(tmp_path: Path, case: WorkflowProofCase) -> None: + paths = _native_artifacts(tmp_path, case) + report, _, _, summary, _, called, _, _, original = paths + prelude = _native_prelude(case, paths) + environment = _boundary_environment(tmp_path, paths) + admission, enforcement = _admission_steps(case) + output = tmp_path / "native-output" + environment["GITHUB_OUTPUT"] = str(output) + environment["GITHUB_STEP_SUMMARY"] = str(summary) + environment.pop("GH_TOKEN", None) + environment.pop("GITHUB_TOKEN", None) + result = subprocess.run( + ["bash", "-c", prelude + "\n" + _boundary(case.lane)], + cwd=tmp_path, + env=environment, + capture_output=True, + text=True, + check=False, + ) + marker = output.exists() and "native-reconciled=true" in output.read_text() + assert marker == (case.scope != "invalid-proof" and (case.lane == "producer" or case.scope == "scoped")) + admission_outcome = _run_admission(tmp_path, case, admission, environment, (result, marker)) + verdict = subprocess.run( + ["bash", "-c", enforcement["run"]], + cwd=tmp_path, + env={ + **environment, + "NATIVE_OUTCOME": "failure" if result.returncode else "success", + "ADMISSION_OUTCOME": admission_outcome, + }, + capture_output=True, + text=True, + check=False, + ) + expected_success = case.scope == "scoped" and (case.native_exit == 0 or case.policy_exit == 0) + assert (verdict.returncode == 0) == expected_success, result.stderr + assert report.read_bytes() == original + assert called.exists() == (bool(case.native_exit) and case.scope == "scoped") + + +def test_all_three_lanes_preserve_native_bytes_and_enforce_policy_exit(tmp_path: Path) -> None: + workflow = yaml.safe_load(WORKFLOW.read_text()) + for name in ("requirements-evidence-producer", "requirements-evidence", "requirements-evidence-final"): + assert workflow["jobs"][name]["permissions"].get("pull-requests") == "read" + for lane in LANES: + for policy_exit in (0, 1, 7): + for native_exit in (0, 1): + _run_boundary( + tmp_path / f"{lane}-{policy_exit}-{native_exit}", WorkflowProofCase(lane, policy_exit, native_exit) + ) + _run_boundary(tmp_path / f"{lane}-additional-native-failure", WorkflowProofCase(lane, 1, 1, extra_failure=True)) + + +def test_fresh_and_final_preserve_scope_and_invalid_red_guards(tmp_path: Path) -> None: + for lane in ("fresh", "final"): + for scope in ("invalid-pr", "other-branch", "invalid-proof"): + _run_boundary(tmp_path / f"{lane}-{scope}", WorkflowProofCase(lane, 0, 1, scope=scope)) + + +def _origin_fixture(tmp_path: Path, mismatch: str | None) -> tuple[Path, dict[str, Any], dict[str, str]]: + native_zip = b"inert native archive, never executed" + head = "1" * 40 + workflow_sha = "2" * 40 + root = tmp_path / str(mismatch) + root.mkdir() + (root / "native.zip").write_bytes(native_zip) + metadata = { + "id": 123, + "name": "requirements-evidence", + "expired": False, + "workflow_run": {"id": 457 if mismatch == "run" else 456, "head_sha": head}, + "digest": "sha256:" + ("0" * 64 if mismatch == "digest" else hashlib.sha256(native_zip).hexdigest()), + } + (root / "artifact.json").write_text(json.dumps(metadata)) + gh = root / "gh" + gh.write_text( + '#!/usr/bin/env bash\nset -eu\ncase "$2" in */zip) cat "$FIXTURE/native.zip";; *) cat "$FIXTURE/artifact.json";; esac\n' + ) + gh.chmod(0o755) + environment = { + **os.environ, + "PATH": f"{root}:{os.environ['PATH']}", + "FIXTURE": str(root), + "GITHUB_REPOSITORY": "nold-ai/specfact-cli", + "RUNNER_TEMP": str(root), + "GITHUB_RUN_ID": "456", + "GITHUB_RUN_ATTEMPT": "3", + "NATIVE_ARTIFACT_ID": "123", + "NATIVE_HEAD_COMMIT": head, + "NATIVE_WORKFLOW_SHA": workflow_sha, + } + return root, metadata, environment + + +def _assert_origin_attestation(origin: dict[str, Any]) -> None: + assert "always()" in origin["if"] + assert origin["permissions"]["id-token"] == "write" + assert all("checkout" not in step.get("uses", "") for step in origin["steps"]) + attest = next(step for step in origin["steps"] if step.get("uses", "").startswith("actions/attest@")) + assert attest["with"]["subject-path"].endswith("/origin.json") + assert len(attest["uses"].split("@")[1]) == 40 + + +def _origin_binding() -> dict[str, Any]: + workflow = yaml.safe_load(WORKFLOW.read_text()) + jobs = list(workflow["jobs"].values()) + origins = [job for job in jobs if job.get("name") == "Native Requirements Origin"] + assert len(origins) == 1, "A failed producer must still generate independently authenticated origin metadata" + origin = origins[0] + assert {term.strip() for term in origin["if"].split("&&")} == { + "always()", + "needs.requirements-evidence-producer.outputs.artifact-id != ''", + "github.repository == 'nold-ai/specfact-cli'", + "github.event_name == 'pull_request'", + "github.event.pull_request.number > 0", + "github.event.pull_request.head.repo.full_name == github.repository", + "github.base_ref == 'dev'", + "github.head_ref == 'codex/dependency-hotfix-admission'", + } + _assert_origin_attestation(origin) + return next(step for step in origin["steps"] if step.get("name") == "Bind native artifact to executed workflow") + + +def test_origin_attestation_survives_producer_failure(tmp_path: Path) -> None: + binding = _origin_binding() + assert binding["env"]["NATIVE_WORKFLOW_SHA"] == "${{ github.workflow_sha }}" + for mismatch in (None, "digest", "run"): + root, metadata, environment = _origin_fixture(tmp_path, mismatch) + result = subprocess.run( + ["bash", "-c", binding["run"]], env=environment, capture_output=True, text=True, check=False + ) + assert (result.returncode == 0) == (mismatch is None), result.stderr + manifest = root / "requirements-native-origin/origin.json" + assert manifest.exists() == (mismatch is None) + if mismatch is None: + actual = json.loads(manifest.read_bytes()) + assert actual == { + "schema_version": 1, + "repository": "nold-ai/specfact-cli", + "run_id": 456, + "run_attempt": 3, + "artifact_id": 123, + "artifact_digest": metadata["digest"], + "head_commit": "1" * 40, + "workflow_path": ".github/workflows/requirements-evidence.yml", + "workflow_sha": "2" * 40, + } diff --git a/tests/unit/security/test_release_promotion_security_gates.py b/tests/unit/security/test_release_promotion_security_gates.py index f211aedf6..e1f0aac25 100644 --- a/tests/unit/security/test_release_promotion_security_gates.py +++ b/tests/unit/security/test_release_promotion_security_gates.py @@ -164,7 +164,7 @@ def _assert_review_tool_and_artifact_order(review: dict[str, Any]) -> None: "Restore Requirements evidence for Code Review" ) restore = _named_step(review, "Restore Requirements evidence for Code Review") - assert restore["with"]["artifact-ids"] == "${{ needs.requirements-evidence-producer.outputs.artifact-id }}" # type: ignore[index] + assert restore["env"]["ARTIFACT_ID"] == "${{ needs.requirements-evidence-producer.outputs.artifact-id }}" def _assert_review_diff_is_fail_closed(review: dict[str, Any]) -> None: diff --git a/tests/unit/workflows/test_exact_artifact_transfer.py b/tests/unit/workflows/test_exact_artifact_transfer.py new file mode 100644 index 000000000..a0f9ffd21 --- /dev/null +++ b/tests/unit/workflows/test_exact_artifact_transfer.py @@ -0,0 +1,443 @@ +from __future__ import annotations + +import hashlib +import importlib.util +import io +import json +import os +import stat +import sys +import types +import zipfile +from collections.abc import Callable +from contextlib import nullcontext +from pathlib import Path +from typing import Any, NamedTuple +from unittest.mock import patch +from urllib.request import Request + +import pytest +import yaml + + +ROOT = Path(__file__).resolve().parents[3] +STEP_NAMES = ( + "Restore Requirements evidence for Code Review", + "Restore fresh Requirements execution artifact", + "Restore producer Requirements evidence for final verdict", +) + + +def transfer_steps() -> list[dict[str, Any]]: + workflow = yaml.safe_load((ROOT / ".github/workflows/requirements-evidence.yml").read_text()) + return [next(s for j in workflow["jobs"].values() for s in j["steps"] if s.get("name") == n) for n in STEP_NAMES] + + +def archive_bytes(entries: list[tuple[str | zipfile.ZipInfo, bytes]] | None = None) -> bytes: + output = io.BytesIO() + with zipfile.ZipFile(output, "w", zipfile.ZIP_DEFLATED) as archive: + for name, content in entries or [ + ("requirements-evidence.json", b'{"native":"failed"}'), + ("proof.xml", b""), + ]: + archive.writestr(name, content) + return output.getvalue() + + +class Response(io.BytesIO): + def __init__(self, body: bytes = b"", status: int = 200, headers: dict[str, str] | None = None) -> None: + super().__init__(body) + self.status = status + self.headers = headers or {} + + +class TransferHarness(NamedTuple): + run: Callable[..., Path] + destination: Path + runner: Path + requests: list[Request] + module: types.ModuleType + + +@pytest.fixture +def transfer(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> TransferHarness: + root = tmp_path.resolve() + workspace, runner = root / "workspace", root / "runner" + workspace.mkdir() + runner.mkdir() + destination = workspace / "artifacts/requirements-evidence" + env = { + "GH_TOKEN": "test-secret-never-forward", + "GITHUB_WORKSPACE": str(workspace), + "RUNNER_TEMP": str(runner), + "GITHUB_REPOSITORY_ID": "1085706003", + "ARTIFACT_REPOSITORY": "nold-ai/specfact-cli", + "ARTIFACT_ID": "12341352657", + "ARTIFACT_NAME": "requirements-evidence", + "ARTIFACT_RUN_ID": "39589983181", + "ARTIFACT_HEAD_SHA": "d88310de977bfc5d080e7e03c75256434ed937ea", + "ARTIFACT_EXPECTED_SHA256": "", + "ARTIFACT_DESTINATION": str(destination), + } + body = transfer_steps()[0]["run"].split("<<'PYTHON'\n", 1)[1].rsplit("\nPYTHON", 1)[0] + program = root / "workflow_artifact_transfer.py" + program.write_text(body) + spec = importlib.util.spec_from_file_location("workflow_artifact_transfer", program) + assert spec is not None and spec.loader is not None + module = importlib.util.module_from_spec(spec) + monkeypatch.setitem(sys.modules, module.__name__, module) + spec.loader.exec_module(module) + requests: list[Request] = [] + + def run( + payload: bytes | None = None, + metadata_update: dict[str, Any] | None = None, + raw_metadata: bytes | None = None, + responses: list[Response | Exception] | None = None, + changes: dict[str, str] | None = None, + ) -> Path: + payload = archive_bytes() if payload is None else payload + selected = dict(env, **(changes or {})) + metadata: dict[str, Any] = { + "id": int(selected["ARTIFACT_ID"]), + "name": selected["ARTIFACT_NAME"], + "expired": False, + "size_in_bytes": len(payload), + "digest": "sha256:" + hashlib.sha256(payload).hexdigest(), + "workflow_run": {"id": 39589983181, "repository_id": 1085706003, "head_sha": env["ARTIFACT_HEAD_SHA"]}, + } + metadata.update(metadata_update or {}) + queue: list[Response | Exception] = [Response(raw_metadata or json.dumps(metadata).encode())] + queue += ( + responses + if responses is not None + else [ + Response(status=302, headers={"Location": "https://storage.example/artifact?sig=secret"}), + Response(payload), + ] + ) + + def open_response(_opener: Any, request: Request, timeout: float) -> Response: + requests.append(request) + assert timeout > 0 + assert queue, "Unexpected request, possibly a listing or retry" + result = queue.pop(0) + if isinstance(result, Exception): + raise result + return result + + with patch.dict(os.environ, selected, clear=True), patch("urllib.request.OpenerDirector.open", open_response): + module.main() + return Path(selected["ARTIFACT_DESTINATION"]) + + return TransferHarness(run, destination, runner, requests, module) + + +def test_three_transfers_share_program_and_fixed_subjects() -> None: + steps = transfer_steps() + assert len({step["run"] for step in steps}) == 1 + assert all("/usr/bin/python3 -I -S" in step["run"] for step in steps) + assert [s["env"]["ARTIFACT_NAME"] for s in steps] == [ + "requirements-evidence", + "requirements-evidence-execution", + "requirements-evidence", + ] + assert steps[1]["env"]["ARTIFACT_EXPECTED_SHA256"] == "${{ needs.requirements-evidence.outputs.artifact-digest }}" + + +@pytest.mark.parametrize("artifact_id", ["12341352657", "12341630570"]) +def test_exact_id_restores_inert_files_without_listing_or_token_forwarding( + transfer: TransferHarness, artifact_id: str +) -> None: + run, destination, _, requests, _ = transfer + destination.mkdir(parents=True) + (destination / "unrelated-context.json").write_bytes(b"keep") + run(changes={"ARTIFACT_ID": artifact_id}) + assert (destination / "requirements-evidence.json").read_bytes() == b'{"native":"failed"}' + assert (destination / "unrelated-context.json").read_bytes() == b"keep" + assert all( + stat.S_IMODE(p.stat().st_mode) == 0o600 for p in destination.iterdir() if p.name != "unrelated-context.json" + ) + assert [r.full_url for r in requests[:2]] == [ + f"https://api.github.com/repos/nold-ai/specfact-cli/actions/artifacts/{artifact_id}", + f"https://api.github.com/repos/nold-ai/specfact-cli/actions/artifacts/{artifact_id}/zip", + ] + assert all(r.get_header("Authorization") for r in requests[:2]) + assert requests[2].get_header("Authorization") is None + + +def test_execution_layout_requires_independent_digest(transfer: TransferHarness) -> None: + run, _, runner, _, _ = transfer + payload = archive_bytes( + [ + ("requirements-evidence.json", b"{}"), + ("requirements-proof.xml", b""), + ("requirements-evidence-plan.json", b"{}"), + ] + ) + changes = { + "ARTIFACT_NAME": "requirements-evidence-execution", + "ARTIFACT_DESTINATION": str(runner / "requirements-execution"), + } + with pytest.raises(ValueError): + run(payload, changes=changes) + changes["ARTIFACT_EXPECTED_SHA256"] = hashlib.sha256(payload).hexdigest() + destination = run(payload, changes=changes) + assert sorted(p.name for p in destination.iterdir()) == [ + "requirements-evidence-plan.json", + "requirements-evidence.json", + "requirements-proof.xml", + ] + + +@pytest.mark.parametrize( + "update", + [ + {"id": 1}, + {"id": True}, + {"name": "wrong"}, + {"expired": True}, + {"expired": 0}, + {"digest": ""}, + {"digest": "md5:" + "a" * 32}, + {"digest": "sha256:" + "0" * 64}, + {"size_in_bytes": 0}, + {"size_in_bytes": True}, + {"size_in_bytes": 16777217}, + {"size_in_bytes": 1}, + { + "workflow_run": { + "id": 1, + "repository_id": 1085706003, + "head_sha": "d88310de977bfc5d080e7e03c75256434ed937ea", + } + }, + { + "workflow_run": { + "id": 39589983181, + "repository_id": 1, + "head_sha": "d88310de977bfc5d080e7e03c75256434ed937ea", + } + }, + {"workflow_run": {"id": 39589983181, "repository_id": 1085706003, "head_sha": "0" * 40}}, + ], +) +def test_artifact_association_mismatch_never_publishes(transfer: TransferHarness, update: dict[str, Any]) -> None: + run, destination, _, _, _ = transfer + with pytest.raises((ValueError, OSError)): + run(metadata_update=update) + assert not destination.exists() + + +@pytest.mark.parametrize( + "raw", [b'{"id":1,"id":2}', b"{", b"[]", b"x" * 1048577], ids=["duplicate", "truncated", "wrong-shape", "oversized"] +) +def test_invalid_rest_json_fails_closed(transfer: TransferHarness, raw: bytes) -> None: + run, destination, _, _, _ = transfer + with pytest.raises(ValueError): + run(raw_metadata=raw) + assert not destination.exists() + + +@pytest.mark.parametrize( + "url", + [ + "http://storage.example/a", + "https://user:pass@storage.example/a", + "file:///tmp/a", + "https://storage.example:443/a#fragment", + ], +) +def test_unsafe_redirect_never_receives_credentials(transfer: TransferHarness, url: str) -> None: + run, destination, _, requests, _ = transfer + with pytest.raises(ValueError): + run(responses=[Response(status=302, headers={"Location": url})]) + assert len(requests) == 2 + assert not destination.exists() + + +@pytest.mark.parametrize( + "responses", + [ + [Response(status=404)], + [Response(status=403)], + [Response(status=410)], + [Response(status=302)], + [Response(b"truncated")], + [Response(status=302, headers={"Location": "https://storage.example/a"}) for _ in range(6)], + [TimeoutError("timeout")], + [Response(b"", headers={"Content-Length": "999999999"})], + ], +) +def test_transport_failure_never_publishes(transfer: TransferHarness, responses: list[Response | Exception]) -> None: + run, destination, _, _, _ = transfer + with pytest.raises((ValueError, OSError)): + run(responses=responses) + assert not destination.exists() + + +@pytest.mark.parametrize( + "name", ["../escape", "/absolute", "C:drive", "a\\b", "nested/a", ".", "..", "folder/", "bad\nname"] +) +def test_unsafe_archive_paths_are_inert(transfer: TransferHarness, name: str) -> None: + run, destination, _, _, _ = transfer + with pytest.raises(ValueError): + run(archive_bytes([("good.json", b"ok"), (name, b"bad")])) + assert not destination.exists() + + +@pytest.mark.parametrize("kind", [stat.S_IFLNK, stat.S_IFIFO, stat.S_IFCHR, stat.S_IFDIR, stat.S_IFSOCK]) +def test_nonregular_members_never_publish(transfer: TransferHarness, kind: int) -> None: + run, destination, _, _, _ = transfer + member = zipfile.ZipInfo("evil") + member.create_system = 3 + member.external_attr = (kind | 0o777) << 16 + with pytest.raises(ValueError): + run(archive_bytes([("good.json", b"ok"), (member, b"outside")])) + assert not destination.exists() + + +@pytest.mark.parametrize("names", [["same", "same"], ["Same", "same"]]) +def test_archive_name_collisions_never_publish(transfer: TransferHarness, names: list[str]) -> None: + run, destination, _, _, _ = transfer + expected_warning = pytest.warns(UserWarning, match="Duplicate name") if names[0] == names[1] else nullcontext() + with expected_warning: + payload = archive_bytes([(n, b"data") for n in names]) + with pytest.raises(ValueError): + run(payload) + assert not destination.exists() + + +def test_corrupt_member_does_not_publish_earlier_valid_member(transfer: TransferHarness) -> None: + run, destination, _, _, _ = transfer + payload = archive_bytes([("first.json", b"first"), ("second.json", b"second")]) + broken = bytearray(payload) + index = broken.index(b"PK\x01\x02") + broken[index + 16] ^= 1 + with pytest.raises(zipfile.BadZipFile): + run(bytes(broken)) + assert not destination.exists() or not list(destination.iterdir()) + + +@pytest.mark.parametrize("existing", ["file", "symlink", "parent-symlink"]) +def test_existing_evidence_and_symlink_parents_are_preserved( + transfer: TransferHarness, existing: str, tmp_path: Path +) -> None: + run, destination, _, _, _ = transfer + outside = tmp_path / "outside" + outside.mkdir() + sentinel = outside / "sentinel" + sentinel.write_bytes(b"unchanged") + if existing == "parent-symlink": + destination.parent.symlink_to(outside, target_is_directory=True) + else: + destination.mkdir(parents=True) + target = destination / "requirements-evidence.json" + target.symlink_to(outside / "missing") if existing == "symlink" else target.write_bytes(b"old") + with pytest.raises((ValueError, OSError)): + run() + assert sentinel.read_bytes() == b"unchanged" + if existing == "file": + assert (destination / "requirements-evidence.json").read_bytes() == b"old" + + +def test_member_count_and_expansion_are_bounded(transfer: TransferHarness) -> None: + run, destination, _, _, _ = transfer + with pytest.raises(ValueError): + run(archive_bytes([(f"report-{n}.json", b"x") for n in range(129)])) + with pytest.raises(ValueError): + run(archive_bytes([("large.json", b"x" * (16 * 1024 * 1024 + 1))])) + assert not destination.exists() + + +def test_raw_nul_and_encrypted_members_are_rejected(transfer: TransferHarness) -> None: + run, destination, _, _, _ = transfer + payload = archive_bytes([("unsafe_name", b"inert")]) + with pytest.raises(ValueError): + run(payload.replace(b"unsafe_name", b"unsafe\x00name")) + encrypted = bytearray(payload) + encrypted[6] |= 1 + encrypted[encrypted.index(b"PK\x01\x02") + 8] |= 1 + with pytest.raises(ValueError): + run(bytes(encrypted)) + assert not destination.exists() + + +def test_digest_mismatch_precedes_zip_interpretation(transfer: TransferHarness) -> None: + run, destination, _, _, module = transfer + payload = archive_bytes() + with ( + patch.object(module.zipfile, "ZipFile", side_effect=AssertionError("Unverified ZIP interpreted")), + pytest.raises(ValueError), + ): + run(payload, metadata_update={"digest": "sha256:" + "0" * 64}) + assert not destination.exists() + + +def test_independent_digest_mismatch_fails_before_download(transfer: TransferHarness) -> None: + run, destination, _, requests, _ = transfer + with pytest.raises(ValueError): + run(changes={"ARTIFACT_EXPECTED_SHA256": "f" * 64}) + assert len(requests) == 1 + assert not destination.exists() + + +def test_redirect_chain_never_recovers_authorization(transfer: TransferHarness) -> None: + run, _, _, requests, _ = transfer + payload = archive_bytes() + run( + payload, + responses=[ + Response(status=302, headers={"Location": "https://storage.example/a"}), + Response(status=307, headers={"Location": "https://api.github.com/redirected"}), + Response(payload), + ], + ) + assert all(r.get_header("Authorization") is None for r in requests[2:]) + + +def test_publication_collision_preserves_concurrent_file(transfer: TransferHarness) -> None: + run, destination, _, _, module = transfer + link = module.os.link + + def collide(source: str, target: str, **kwargs: Any) -> None: + assert (destination / "requirements-evidence.json").exists() is False + (destination / target).write_bytes(b"concurrent evidence") + link(source, target, **kwargs) + + with patch.object(module.os, "link", collide), pytest.raises(FileExistsError): + run() + assert (destination / "requirements-evidence.json").read_bytes() == b"concurrent evidence" + assert not (destination / "proof.xml").exists() + assert not list(destination.glob(".artifact-stage-*")) + + +def test_fresh_destinations_are_exclusive(transfer: TransferHarness) -> None: + run, _, runner, _, _ = transfer + destination = runner / "requirements-producer" + destination.mkdir() + with pytest.raises(ValueError): + run(changes={"ARTIFACT_DESTINATION": str(destination)}) + assert not list(destination.iterdir()) + + +def test_destination_escape_is_rejected_before_network(transfer: TransferHarness) -> None: + run, _, runner, requests, _ = transfer + with pytest.raises(ValueError): + run(changes={"ARTIFACT_DESTINATION": str(runner / "elsewhere")}) + assert requests == [] + + +def test_absolute_transfer_deadline_is_enforced(transfer: TransferHarness) -> None: + run, destination, _, _, module = transfer + times = iter([0, 0, 91]) + with patch.object(module.time, "monotonic", lambda: next(times)), pytest.raises(TimeoutError): + run() + assert not destination.exists() + + +def test_declared_total_expansion_limit_is_enforced(transfer: TransferHarness) -> None: + run, destination, _, _, module = transfer + with patch.object(module, "MAX_EXPANDED", 5), pytest.raises(ValueError): + run(archive_bytes([("first", b"123"), ("second", b"456")])) + assert not destination.exists() diff --git a/tests/unit/workflows/test_requirements_evidence_delivery_workflow.py b/tests/unit/workflows/test_requirements_evidence_delivery_workflow.py index 9530a3d5a..c291e9698 100644 --- a/tests/unit/workflows/test_requirements_evidence_delivery_workflow.py +++ b/tests/unit/workflows/test_requirements_evidence_delivery_workflow.py @@ -90,9 +90,15 @@ def _workflow_steps(workflow: dict[str, object]) -> list[tuple[str, int, dict[st return collected -def _step_location(workflow: dict[str, object], name: str) -> tuple[str, int, dict[str, object]]: +def _step_location( + workflow: dict[str, object], name: str, *, job_name: str | None = None +) -> tuple[str, int, dict[str, object]]: """Return the unique job, index, and step for a workflow step name.""" - matches = [location for location in _workflow_steps(workflow) if location[2].get("name") == name] + matches = [ + location + for location in _workflow_steps(workflow) + if location[2].get("name") == name and (job_name is None or location[0] == job_name) + ] assert len(matches) == 1, name return matches[0] @@ -101,10 +107,10 @@ def _step_by_name(workflow: dict[str, object], name: str) -> dict[str, object]: return _step_location(workflow, name)[2] -def _assert_step_order(workflow: dict[str, object], earlier: str, later: str) -> None: +def _assert_step_order(workflow: dict[str, object], earlier: str, later: str, *, job_name: str | None = None) -> None: """Assert two uniquely named steps run in that order within one job.""" - earlier_job, earlier_index, _ = _step_location(workflow, earlier) - later_job, later_index, _ = _step_location(workflow, later) + earlier_job, earlier_index, _ = _step_location(workflow, earlier, job_name=job_name) + later_job, later_index, _ = _step_location(workflow, later, job_name=job_name) assert earlier_job == later_job assert earlier_index < later_index @@ -455,7 +461,9 @@ def _assert_retention_contract(workflow: dict[str, object]) -> None: publish = _step_by_name(workflow, "Publish Requirements evidence summary") upload = _step_by_name(workflow, "Persist Requirements evidence before Code Review") upload_review = _step_by_name(workflow, "Upload Code Review evidence artifact") - producer_enforce = _step_by_name(workflow, "Enforce Requirements evidence producer verdict") + _, _, producer_enforce = _step_location( + workflow, "Enforce native or admitted Requirements verdict", job_name="requirements-evidence-producer" + ) review_enforce = _step_by_name(workflow, "Enforce requirements evidence verdict") assert publish["if"] == "always()" # type: ignore[index] assert "GITHUB_STEP_SUMMARY" in publish["run"] # type: ignore[index] @@ -471,15 +479,20 @@ def _assert_retention_contract(workflow: dict[str, object]) -> None: "artifacts/requirements-evidence/legacy-tdd-evidence.json", ] assert upload_review["with"]["path"] == "artifacts/requirements-evidence/code-review.json" # type: ignore[index] - assert producer_enforce["if"] == "steps.run-evidence.outcome == 'failure'" - assert producer_enforce["run"] == "exit 1" + assert producer_enforce["if"] == "always()" assert review_enforce["if"] == "steps.run-code-review.outcome == 'failure'" assert review_enforce["run"] == "exit 1" _assert_step_order( - workflow, "Publish Requirements evidence summary", "Enforce Requirements evidence producer verdict" + workflow, + "Publish Requirements evidence summary", + "Enforce native or admitted Requirements verdict", + job_name="requirements-evidence-producer", ) _assert_step_order( - workflow, "Persist Requirements evidence before Code Review", "Enforce Requirements evidence producer verdict" + workflow, + "Persist Requirements evidence before Code Review", + "Enforce native or admitted Requirements verdict", + job_name="requirements-evidence-producer", ) _assert_step_order(workflow, "Upload Code Review evidence artifact", "Enforce requirements evidence verdict") @@ -662,7 +675,13 @@ def _assert_fresh_consumer_order( workflow: dict[str, object], outputs: dict[str, str], permissions: dict[str, str] ) -> None: assert outputs["prior-red-run-id"] == "${{ steps.prior-red-run.outputs.run-id }}" - assert permissions == {"actions": "read", "contents": "read", "issues": "read", "checks": "read"} + assert permissions == { + "actions": "read", + "contents": "read", + "issues": "read", + "checks": "read", + "pull-requests": "read", + } _assert_step_order( workflow, "Restore Requirements evidence for Code Review", "Download retained red proof for validation" ) @@ -1259,10 +1278,11 @@ def _assert_review_artifact_binding(parsed: dict[str, object]) -> None: restore = _step_by_name(parsed, "Restore Requirements evidence for Code Review") verify_head = _step_by_name(parsed, "Verify exact head for Code Review") assert upload["id"] == "upload-requirements-evidence" - assert restore["with"] == { - "artifact-ids": "${{ needs.requirements-evidence-producer.outputs.artifact-id }}", - "path": "artifacts/requirements-evidence", - } + environment = cast(dict[str, str], restore["env"]) + assert environment["ARTIFACT_ID"] == "${{ needs.requirements-evidence-producer.outputs.artifact-id }}" + assert environment["ARTIFACT_NAME"] == "requirements-evidence" + assert environment["ARTIFACT_DESTINATION"] == "${{ github.workspace }}/artifacts/requirements-evidence" + assert "/usr/bin/python3 -I -S" in str(restore["run"]) assert verify_head["env"] == {"EXPECTED_HEAD": "${{ github.event.pull_request.head.sha || github.sha }}"} assert 'test "$(git rev-parse HEAD)" = "$EXPECTED_HEAD"' in verify_head["run"] # type: ignore[operator] @@ -1603,18 +1623,60 @@ def test_same_named_fork_does_not_enter_promotion_path() -> None: assert "pull_request.head.repo.full_name == github.event.repository.full_name" in condition -def _review_and_enforcement_steps() -> tuple[ - dict[str, object], dict[str, object], dict[str, object], dict[str, object] -]: - """Load the proof and review steps that independently govern final PR status.""" - workflow = REPO_ROOT / ".github" / "workflows" / "requirements-evidence.yml" - parsed = yaml.load(workflow.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) - return ( - _step_by_name(parsed, "Run Code Review with finalized Requirements context"), - _step_by_name(parsed, "Enforce Requirements evidence producer verdict"), - _step_by_name(parsed, "Enforce Requirements evidence producer success"), - _step_by_name(parsed, "Enforce requirements evidence verdict"), +def _assert_independent_review_guards(workflow: dict[str, object]) -> None: + """Admission cannot override either Code Review failure or producer failure.""" + producer_guard = _step_by_name(workflow, "Enforce Requirements evidence producer success") + assert producer_guard["if"] == "needs.requirements-evidence-producer.result != 'success'" + assert producer_guard["run"] == "exit 1" + for review_name, enforce_name, review_id in ( + ( + "Run Code Review with finalized Requirements context", + "Enforce requirements evidence verdict", + "run-code-review", + ), + ( + "Run Code Review with trusted final Requirements context", + "Enforce final Code Review verdict", + "run-final-code-review", + ), + ): + review = _step_by_name(workflow, review_name) + review_enforce = _step_by_name(workflow, enforce_name) + assert review["continue-on-error"] == "true" + assert review_enforce["if"] == f"steps.{review_id}.outcome == 'failure'" + assert review_enforce["run"] == "exit 1" + + +def _assert_lane_verdict(workflow: dict[str, object], job_name: str, native_step: str) -> None: + """Execute the lane's actual shell verdict for successful and denied outcomes.""" + _, enforce_index, enforce = _step_location( + workflow, "Enforce native or admitted Requirements verdict", job_name=job_name ) + assert enforce["if"] == "always()" + assert enforce["shell"] == "bash" + assert enforce["env"] == { + "NATIVE_OUTCOME": "${{ steps." + native_step + ".outcome }}", + "ADMISSION_OUTCOME": "${{ steps.late-cycle-admission.outcome }}", + } + for native_outcome, admission_outcome, expected_exit in ( + ("success", "skipped", 0), + ("failure", "success", 0), + ("failure", "failure", 1), + ("failure", "skipped", 1), + ("skipped", "skipped", 1), + ("", "", 1), + ): + result = subprocess.run( + ["bash", "-c", cast(str, enforce["run"])], + env={"NATIVE_OUTCOME": native_outcome, "ADMISSION_OUTCOME": admission_outcome}, + check=False, + capture_output=True, + text=True, + ) + assert result.returncode == expected_exit, (job_name, native_outcome, admission_outcome, result.stderr) + for owner, index, step in _workflow_steps(workflow): + if owner == job_name and str(step.get("uses", "")).startswith("actions/upload-artifact@"): + assert index < enforce_index @pytest.mark.parametrize( @@ -1623,18 +1685,18 @@ def _review_and_enforcement_steps() -> tuple[ ids=["requirements-failure", "code-review-failure"], ) def test_requirements_evidence_workflow_blocks_each_final_verdict(verdict: str) -> None: - """Requirements and Code Review failures remain independently terminal.""" - review, producer_enforce, producer_guard, review_enforce = _review_and_enforcement_steps() - + """Every lane needs native success or admission, and review failure still blocks.""" + workflow = REPO_ROOT / ".github" / "workflows" / "requirements-evidence.yml" + parsed = yaml.load(workflow.read_text(encoding="utf-8"), Loader=yaml.BaseLoader) if verdict == "requirements-failure": - assert producer_enforce["if"] == "steps.run-evidence.outcome == 'failure'" - assert producer_enforce["run"] == "exit 1" - assert producer_guard["if"] == "needs.requirements-evidence-producer.result != 'success'" - assert producer_guard["run"] == "exit 1" + for job_name, native_step in ( + ("requirements-evidence-producer", "run-evidence"), + ("requirements-evidence", "run-native-evidence"), + ("requirements-evidence-final", "run-final-native-evidence"), + ): + _assert_lane_verdict(parsed, job_name, native_step) else: - assert review["continue-on-error"] == "true" - assert review_enforce["if"] == "steps.run-code-review.outcome == 'failure'" - assert review_enforce["run"] == "exit 1" + _assert_independent_review_guards(parsed) def test_all_proof_consumers_independently_authenticate_live_stack_context() -> None: diff --git a/tests/unit/workflows/test_requirements_planning_permissions.py b/tests/unit/workflows/test_requirements_planning_permissions.py index b9b5c69b2..521531ae9 100644 --- a/tests/unit/workflows/test_requirements_planning_permissions.py +++ b/tests/unit/workflows/test_requirements_planning_permissions.py @@ -17,4 +17,10 @@ def test_each_proof_consumer_has_explicit_read_only_checks_permission(job_name: """Authenticated check-run reads require permission without check-writing authority.""" workflow = yaml.safe_load((REPO_ROOT / ".github/workflows/requirements-evidence.yml").read_text()) permissions = workflow["jobs"][job_name]["permissions"] - assert permissions == {"actions": "read", "contents": "read", "issues": "read", "checks": "read"} + assert permissions == { + "actions": "read", + "contents": "read", + "issues": "read", + "checks": "read", + "pull-requests": "read", + }