diff --git a/.github/workflows/devnet-application-qa.yml b/.github/workflows/devnet-application-qa.yml index 1028094..06b1f28 100644 --- a/.github/workflows/devnet-application-qa.yml +++ b/.github/workflows/devnet-application-qa.yml @@ -1,55 +1,55 @@ -name: Devnet Master QA — registered application GitHub first +name: Devnet registered application GitHub stage (fail closed) on: + workflow_dispatch: push: - branches: [qa/master-unified-v0.2.4-github-first] + branches: [qa/registered-app-executor-fix-v0.1.0] paths: - - 'qa/application/devnet-master-qa-registered-application-stages-v0.1.0-QA-BLOCKED.zip' - 'qa/application/jobs/**' - workflow_dispatch: + - 'qa/application/registered_gate.py' + - 'qa/application/candidate_checks.py' + - '.github/workflows/devnet-application-qa.yml' permissions: contents: read jobs: - execute-application-tests: + application-github: runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - contents: read + timeout-minutes: 30 steps: - uses: actions/checkout@v5 with: persist-credentials: false - - name: Verify and extract exact immutable QA extension - shell: bash - run: | - set -euo pipefail - f=qa/application/devnet-master-qa-registered-application-stages-v0.1.0-QA-BLOCKED.zip - echo "10bb63d065a264a9870c24a9b11dae045e654cef4e4e8b2c6e07a12ec4946411 $f" | sha256sum -c - - test ! -e qa/application/jobs/selftest - test ! -e qa/application/registered_gate.py - unzip -nq "$f" - uses: actions/setup-python@v6 with: python-version: '3.13' - - name: Exercise entire registered application gate + - name: Install pinned cryptographic test dependency + run: python -m pip install --disable-pip-version-check 'cryptography==50.0.2' + - name: Validate fail-closed gate + run: python3 -m unittest discover -s qa/application -p 'test_*.py' -v + - name: Execute registered candidate GitHub checklist shell: bash run: | set -euo pipefail - export DEVNET_PINNED_MASTER_ZIP="$PWD/qa/master/devnet-master-qa-unified-v0.3.8-QA-BLOCKED.zip" - python3 -m unittest discover -s qa/application -p 'test_*.py' -v - python3 qa/application/ci_all.py --mode run - python3 qa/application/ci_all.py --mode verify-local + shopt -s nullglob + manifests=(qa/application/jobs/*/manifest.json) + if (( ${#manifests[@]} != 1 )); then + echo 'BLOCKED: exactly one actual candidate must be registered' >&2 + exit 1 + fi + python3 qa/application/registered_gate.py run "${manifests[0]}" + python3 qa/application/registered_gate.py verify "${manifests[0]}" - uses: actions/upload-artifact@v4 + if: always() with: name: registered-application-github-evidence path: qa-stage-evidence/ - if-no-files-found: error + if-no-files-found: ignore retention-days: 90 - - name: Report fail-closed stage + - name: Preserve release block if: always() - run: echo 'GitHub application stage only. Real VM, GUI, host, install/rollback and full release remain BLOCKED.' >> "$GITHUB_STEP_SUMMARY" + run: echo 'GitHub-only stage; VM, host and operator acceptance NOT verified. RELEASE BLOCKED.' >> "$GITHUB_STEP_SUMMARY" - attest-application-tests: - needs: execute-application-tests + sign-candidate-evidence: + needs: application-github runs-on: ubuntu-24.04 permissions: contents: read @@ -59,30 +59,26 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - name: Verify and extract exact immutable QA extension - shell: bash - run: | - set -euo pipefail - f=qa/application/devnet-master-qa-registered-application-stages-v0.1.0-QA-BLOCKED.zip - echo "10bb63d065a264a9870c24a9b11dae045e654cef4e4e8b2c6e07a12ec4946411 $f" | sha256sum -c - - test ! -e qa/application/jobs/selftest - test ! -e qa/application/registered_gate.py - unzip -nq "$f" - uses: actions/download-artifact@v4 with: name: registered-application-github-evidence path: qa-stage-evidence - - name: Verify all log hashes before signing - run: python3 qa/application/ci_all.py --mode verify-local + - name: Recheck candidate and stage receipt before signing + shell: bash + run: | + set -euo pipefail + shopt -s nullglob + manifests=(qa/application/jobs/*/manifest.json) + test "${#manifests[@]}" -eq 1 + python3 qa/application/registered_gate.py verify "${manifests[0]}" - uses: actions/attest@v4 with: subject-path: qa/application/jobs/*/candidate.zip - uses: actions/attest@v4 with: subject-path: qa-stage-evidence/*/receipt.json - - verify-signed-application-tests: - needs: attest-application-tests + independent-signature-check: + needs: sign-candidate-evidence runs-on: ubuntu-24.04 permissions: contents: read @@ -91,24 +87,68 @@ jobs: - uses: actions/checkout@v5 with: persist-credentials: false - - name: Verify and extract exact immutable QA extension + - uses: actions/download-artifact@v4 + with: + name: registered-application-github-evidence + path: qa-stage-evidence + - name: Verify candidate-bound GitHub-issued signatures shell: bash + env: + GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - f=qa/application/devnet-master-qa-registered-application-stages-v0.1.0-QA-BLOCKED.zip - echo "10bb63d065a264a9870c24a9b11dae045e654cef4e4e8b2c6e07a12ec4946411 $f" | sha256sum -c - - test ! -e qa/application/jobs/selftest - test ! -e qa/application/registered_gate.py - unzip -nq "$f" + shopt -s nullglob + manifests=(qa/application/jobs/*/manifest.json) + test "${#manifests[@]}" -eq 1 + python3 qa/application/registered_gate.py verify "${manifests[0]}" + zips=(qa/application/jobs/*/candidate.zip) + receipts=(qa-stage-evidence/*/receipt.json) + test "${#zips[@]}" -eq 1 + test "${#receipts[@]}" -eq 1 + gh attestation verify "${zips[0]}" --repo "${GITHUB_REPOSITORY}" --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/devnet-application-qa.yml" --source-digest "$GITHUB_SHA" + gh attestation verify "${receipts[0]}" --repo "${GITHUB_REPOSITORY}" --signer-workflow "${GITHUB_REPOSITORY}/.github/workflows/devnet-application-qa.yml" --source-digest "$GITHUB_SHA" + echo 'SIGNED_GITHUB_STAGE_VERIFIED. REAL VM/HOST/RELEASE STILL BLOCKED.' >> "$GITHUB_STEP_SUMMARY" + package-private-vm-handoff: + # Only after independent GitHub signature verification. + # This transport artifact is NOT VM test evidence or release approval. + needs: independent-signature-check + runs-on: ubuntu-24.04 + permissions: + contents: read + attestations: read + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: '3.13' - uses: actions/download-artifact@v4 with: name: registered-application-github-evidence path: qa-stage-evidence - - name: Independently verify signed exact GitHub-stage evidence + - name: Prepare candidate-specific handoff (no VM execution) shell: bash - env: - GH_TOKEN: ${{ github.token }} run: | set -euo pipefail - python3 qa/application/ci_all.py --mode verify-signed - echo 'SIGNED_REGISTERED_APPLICATION_GITHUB_STAGE: VERIFIED_SCOPE_LIMITED' >> "$GITHUB_STEP_SUMMARY" + shopt -s nullglob + manifests=(qa/application/jobs/*/manifest.json) + receipts=(qa-stage-evidence/*/receipt.json) + test "${#manifests[@]}" -eq 1 + test "${#receipts[@]}" -eq 1 + work="$(mktemp -d)" + mkdir -p "$work/github" + cp -- "${receipts[0]}" "$work/github/receipt.json" + jobdir="$(dirname -- "${manifests[0]}")" + test -f "$jobdir/policy.json" -a -f "$jobdir/policy.sig" + python3 qa/application/handoff.py assemble \ + --job-dir "$jobdir" \ + --evidence-dir "$work" \ + --handoff-dir "$RUNNER_TEMP/private-vm-handoff" + echo 'GitHub handoff prepared; VM/host NOT_TESTED, release BLOCKED.' >> "$GITHUB_STEP_SUMMARY" + - uses: actions/upload-artifact@v4 + with: + name: devnet-private-vm-handoff + path: ${{ runner.temp }}/private-vm-handoff + retention-days: 30 + if-no-files-found: error diff --git a/.github/workflows/devnet-coordinator-regressions.yml b/.github/workflows/devnet-coordinator-regressions.yml new file mode 100644 index 0000000..69b6f06 --- /dev/null +++ b/.github/workflows/devnet-coordinator-regressions.yml @@ -0,0 +1,37 @@ +name: Devnet acceptance coordinator regression tests (not release approval) +on: + push: + branches: + - qa/registered-app-executor-fix-v0.1.0 + paths: + - 'qa/application/**' + - '.github/workflows/devnet-coordinator-regressions.yml' + workflow_dispatch: + pull_request: + branches: + - qa/master-unified-v0.2.4-github-first + paths: + - 'qa/application/**' + - '.github/workflows/devnet-coordinator-regressions.yml' +permissions: + contents: read +jobs: + coordinator-regressions: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-python@v6 + with: + python-version: '3.13' + - name: Install isolated signature-test dependency + run: python -m pip install --disable-pip-version-check 'cryptography==50.0.2' + - name: Compile all QA coordinator Python + run: python -m compileall -q qa/application + - name: Exercise ALL coordinator positive and negative tests + run: python -m unittest discover -s qa/application -p 'test_*.py' -v + - name: Preserve correct scope + if: always() + run: echo 'Coordinator regressions only. No application has passed GitHub→VM→host. Release remains BLOCKED.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/devnet-fullapp-v0685-github-first.yml b/.github/workflows/devnet-fullapp-v0685-github-first.yml new file mode 100644 index 0000000..6fe0d8f --- /dev/null +++ b/.github/workflows/devnet-fullapp-v0685-github-first.yml @@ -0,0 +1,292 @@ +name: Devnet full-application baseline v0.6.85 — GitHub FIRST (release blocked) +on: + push: + branches: [qa/registered-app-executor-fix-v0.1.0] + paths: + - '.github/workflows/devnet-fullapp-v0685-github-first.yml' + - 'qa/application/candidate_checks.py' + - 'qa/application/v0685_baseline_inspect.py' + - 'qa/application/v0685_installed_lifecycle.py' + - 'qa/application/guest_v0685_package.py' + - 'qa/application/guest_v0685_lifecycle.py' + - 'qa/application/v0685_stage_gate.py' + workflow_dispatch: +permissions: + contents: read +jobs: + exact-candidate-baseline: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Fetch preserved published full application (never rebuild) + shell: bash + run: | + set -euo pipefail + mkdir -p baseline-evidence + curl --fail --location --silent --show-error --retry 3 --connect-timeout 15 --max-time 180 \ + 'https://github.com/devnet82/vr-stack-control/releases/download/v0.6.85/vr-stack-control-v0.6.85.zip' \ + -o baseline-evidence/candidate.zip + echo 'db9af6ff47f3abcf07cf452ddd91e91cbd75a10296c0c6050bb5c612980d9cf3 baseline-evidence/candidate.zip' | sha256sum --check - + test "$(stat --format=%s baseline-evidence/candidate.zip)" -eq 53403 + - name: Run genuine archive integrity, safety and source-syntax checks + shell: bash + run: | + set -euo pipefail + python3 qa/application/candidate_checks.py integrity baseline-evidence/candidate.zip + python3 qa/application/candidate_checks.py safety baseline-evidence/candidate.zip + python3 qa/application/candidate_checks.py python_syntax baseline-evidence/candidate.zip + python3 qa/application/v0685_baseline_inspect.py \ + baseline-evidence/candidate.zip baseline-evidence/inspection.json + python3 -m json.tool baseline-evidence/inspection.json + - name: Install and uninstall EXACT ZIP in disposable unprivileged HOME + shell: bash + run: | + set -euo pipefail + python3 qa/application/v0685_installed_lifecycle.py \ + baseline-evidence/candidate.zip baseline-evidence/disposable-lifecycle + python3 -m json.tool baseline-evidence/disposable-lifecycle/lifecycle-receipt.json + - uses: actions/upload-artifact@v4 + if: always() + with: + name: v0685-original-github-input-and-structure + path: baseline-evidence/ + retention-days: 30 + if-no-files-found: warn + - name: Fail closed after input checks + if: always() + run: | + echo 'Original full-app ZIP integrity and Ubuntu disposable-home install/reinstall/uninstall checks only.' >> "$GITHUB_STEP_SUMMARY" + echo 'Real GitHub-hosted CachyOS install/upgrade/rollback/GUI and user VM/host checks NOT_TESTED. RELEASE BLOCKED.' >> "$GITHUB_STEP_SUMMARY" + native-cachyos-liveiso: + needs: exact-candidate-baseline + runs-on: ubuntu-24.04 + timeout-minutes: 30 + permissions: + contents: read + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: v0685-original-github-input-and-structure + path: original-input + - name: Verify exact v0.6.85 artifact and disposable KVM capabilities + shell: bash + run: | + set -euo pipefail + echo 'db9af6ff47f3abcf07cf452ddd91e91cbd75a10296c0c6050bb5c612980d9cf3 original-input/candidate.zip' | sha256sum -c - + echo '=== GitHub runner KVM device ===' + id + ls -l /dev/kvm || true + test -c /dev/kvm || { echo 'BLOCKED: GitHub hosted runner has NO /dev/kvm' >&2; exit 2; } + if ! test -r /dev/kvm -a -w /dev/kvm; then + # Grant only this disposable GitHub runner process access. + sudo chown "$(id -u):$(id -g)" /dev/kvm + sudo chmod 0600 /dev/kvm + fi + test -r /dev/kvm -a -w /dev/kvm + echo 'GITHUB_HOST_KVM_AVAILABLE (not yet a guest test)' + - name: Install isolated QEMU and FAT-disk tools on GitHub runner + shell: bash + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -qq -y --no-install-recommends qemu-system-x86 qemu-utils mtools dosfstools + - name: Prepare independently verified CachyOS ISO and pinned guest harness + shell: bash + run: | + set -euo pipefail + mkdir -p native-evidence /tmp/devnet-native-v0685 + base=https://iso.cachyos.org/desktop/260809 + name=cachyos-desktop-linux-260809.iso + curl -fLsS --retry 3 --connect-timeout 20 --max-time 1200 \ + "$base/$name.sha256" -o "/tmp/devnet-native-v0685/$name.sha256" + curl -fLsS --retry 3 --connect-timeout 20 --max-time 1200 \ + "$base/$name" -o "/tmp/devnet-native-v0685/$name" + (cd /tmp/devnet-native-v0685 && sha256sum -c "$name.sha256") | tee native-evidence/iso-sha256.txt + root="https://raw.githubusercontent.com/devnet82/devnet-cachyos-repair/d8e4447d19e77a77dc355dc07e72c0c5c3edf741/qa/portfolio-cachyos-vm-v0.1.0" + curl -fLsS "$root/qemu_control.py" -o /tmp/devnet-native-v0685/qemu_control.py + python3 -m py_compile /tmp/devnet-native-v0685/qemu_control.py + python3 -m py_compile qa/application/guest_v0685_package.py + truncate -s 128M /tmp/devnet-native-v0685/control.img + mkfs.vfat -F 32 -n DEVNETQA /tmp/devnet-native-v0685/control.img >/dev/null + img=/tmp/devnet-native-v0685/control.img + mcopy -i "$img" qa/application/guest_v0685_package.py ::/guest_v0685_package.py + python3 -m py_compile qa/application/guest_v0685_lifecycle.py + mcopy -i "$img" qa/application/guest_v0685_lifecycle.py ::/guest_v0685_lifecycle.py + mcopy -i "$img" original-input/candidate.zip ::/candidate.zip + cat > /tmp/devnet-native-v0685/run-guest.sh <<'GUEST' + #!/usr/bin/env bash + set -Eeuo pipefail + exec >/mnt/controller.log 2>&1 + trap 'rc=$?; echo VM_GUEST_CONTROLLER_EXIT=$rc; sync' EXIT + echo "DEVNET_NATIVE_CACHYOS_ORIGINAL_v0685_QA_START" + cat /etc/os-release + uname -r + python3 /mnt/guest_v0685_package.py /mnt/candidate.zip /mnt/qa-result.json + python3 /mnt/guest_v0685_lifecycle.py /mnt/candidate.zip \ + /mnt/lifecycle-result.json /mnt/lifecycle-tests.log + GUEST + bash -n /tmp/devnet-native-v0685/run-guest.sh + mcopy -i "$img" /tmp/devnet-native-v0685/run-guest.sh ::/install-guest.sh + mdir -i "$img" ::/ > native-evidence/control-manifest.txt + - name: Boot genuine CachyOS LIVE ISO in isolated QEMU and capture checks + shell: bash + run: | + set -euo pipefail + root=/tmp/devnet-native-v0685 + qemu-system-x86_64 -machine q35,accel=kvm -cpu host -m 4096 -smp 2 \ + -drive file="$root/cachyos-desktop-linux-260809.iso",media=cdrom,if=ide \ + -drive if=none,id=ctl,file="$root/control.img",format=raw \ + -device virtio-blk-pci,drive=ctl,serial=DEVNETCONTROL \ + -nic none -boot order=d -vga std -display none \ + -serial file:native-evidence/serial.log \ + -monitor unix:"$root/qemu-monitor.sock",server=on,wait=off \ + -daemonize -pidfile "$root/qemu.pid" + sleep 110 + python3 "$root/qemu_control.py" "$root/qemu-monitor.sock" framebuffer \ + --image native-evidence/guest-before-tests.ppm + python3 "$root/qemu_control.py" "$root/qemu-monitor.sock" begin-install + captured=0 + for attempt in $(seq 1 75); do + mcopy -o -i "$root/control.img" ::/controller.log native-evidence/controller.log >/dev/null 2>&1 || true + mcopy -o -i "$root/control.img" ::/lifecycle-result.json native-evidence/lifecycle-result.json >/dev/null 2>&1 || true + mcopy -o -i "$root/control.img" ::/lifecycle-tests.log native-evidence/lifecycle-tests.log >/dev/null 2>&1 || true + if mcopy -o -i "$root/control.img" ::/qa-result.json native-evidence/qa-result.json >/dev/null 2>&1 \ + && mcopy -o -i "$root/control.img" ::/lifecycle-result.json native-evidence/lifecycle-result.json >/dev/null 2>&1; then + if python3 -c 'import json; json.load(open("native-evidence/qa-result.json")); json.load(open("native-evidence/lifecycle-result.json"))' 2>/dev/null; then + captured=1 + break + fi + fi + sleep 8 + done + test "$captured" -eq 1 + python3 - <<'PY' + import json + r=json.load(open("native-evidence/qa-result.json")) + assert r["schema"]=="devnet-native-cachyos-v0685-package-v1" + required=["native_cachyos","control_disk_identity","exact_sha256", + "zip_integrity","full_application_layout","native_syntax", + "version_bin_vr-control","version_bin_vr-control-gui", + "version_bin_vr-stack-tray"] + for check in required: + print("CACHYOS_GUEST",check,r["checks"].get(check)) + assert r["checks"][check]["status"]=="PASS", check + assert r["release_approved"] is False + assert r["overall"]=="PARTIAL" # Installed OS/GUI remain unverified + lifecycle=json.load(open("native-evidence/lifecycle-result.json")) + required_lifecycle=["fresh_install","exact_install_hashes","reinstall", + "reinstalled_hashes","configuration_preserved", + "uninstall","uninstall_removes_app_files", + "uninstall_preserves_unrelated_data"] + for check in required_lifecycle: + print("CACHYOS_GUEST_REAL_INSTALL",check,lifecycle["checks"].get(check)) + assert lifecycle["checks"][check]["status"]=="PASS",check + assert lifecycle["overall"]=="PASS_LIMITED" + assert lifecycle["release_approved"] is False + print("LIVE_CACHYOS_PACKAGE_GUEST: PASS_LIMITED; MASTER_QA: BLOCKED") + PY + - name: Preserve authentic native guest evidence on every outcome + if: always() + uses: actions/upload-artifact@v4 + with: + name: fullapp-v0685-native-cachyos-liveiso-evidence + path: native-evidence/ + retention-days: 30 + if-no-files-found: warn + - name: Enforce precise stage scope + if: always() + run: | + echo 'A live CachyOS ISO guest is NOT an installed CachyOS KDE application acceptance environment.' >> "$GITHUB_STEP_SUMMARY" + echo 'Install/upgrade/rollback, genuine KDE GUI/tray and physical host hardware remain NOT_TESTED; release BLOCKED.' >> "$GITHUB_STEP_SUMMARY" + sign-v0685-native-scope: + needs: [exact-candidate-baseline, native-cachyos-liveiso] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + id-token: write + attestations: write + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: v0685-original-github-input-and-structure + path: original-input + - uses: actions/download-artifact@v4 + with: + name: fullapp-v0685-native-cachyos-liveiso-evidence + path: native-evidence + - name: Independently verify every in-scope real executed check + shell: bash + run: | + set -euo pipefail + python3 qa/application/v0685_stage_gate.py \ + original-input/candidate.zip \ + original-input/disposable-lifecycle/lifecycle-receipt.json \ + native-evidence/qa-result.json \ + native-evidence/lifecycle-result.json \ + native-evidence/verified-scope.json + - uses: actions/attest@v4 + with: + subject-path: original-input/candidate.zip + - uses: actions/attest@v4 + with: + subject-path: native-evidence/verified-scope.json + - uses: actions/upload-artifact@v4 + with: + name: v0685-signed-liveiso-scope-receipt + path: native-evidence/verified-scope.json + retention-days: 90 + if-no-files-found: error + - name: Preserve release gate + run: echo 'Signed evidence is ONLY for historical v0.6.85 Ubuntu and CachyOS LIVE ISO. Full Master QA BLOCKED.' >> "$GITHUB_STEP_SUMMARY" + independent-v0685-provenance-verification: + needs: sign-v0685-native-scope + runs-on: ubuntu-24.04 + permissions: + contents: read + attestations: read + steps: + - uses: actions/download-artifact@v4 + with: + name: v0685-original-github-input-and-structure + path: original-input + - uses: actions/download-artifact@v4 + with: + name: v0685-signed-liveiso-scope-receipt + path: signed-evidence + - name: Verify exact original application and source-bound test receipt signatures + shell: bash + env: + GH_TOKEN: ${{ github.token }} + run: | + set -euo pipefail + workflow="$GITHUB_REPOSITORY/.github/workflows/devnet-fullapp-v0685-github-first.yml" + gh attestation verify original-input/candidate.zip --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$workflow" --source-digest "$GITHUB_SHA" + gh attestation verify signed-evidence/verified-scope.json --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$workflow" --source-digest "$GITHUB_SHA" + python3 - <<'PY' + import hashlib,json,os,pathlib + d=json.loads(pathlib.Path("signed-evidence/verified-scope.json").read_text()) + z=pathlib.Path("original-input/candidate.zip") + assert hashlib.sha256(z.read_bytes()).hexdigest()==d["candidate_sha256"] + assert d["github_sha"]==os.environ["GITHUB_SHA"] + assert d["github_run_id"]==os.environ["GITHUB_RUN_ID"] + assert (d["ubuntu_disposable_install_pass"], + d["real_cachyos_liveiso_package_pass"], + d["real_cachyos_liveiso_lifecycle_pass"]) == (9,9,8) + assert d["decision"]=="PASS_LIMITED" + assert d["full_master_qa"]=="BLOCKED" + assert d["release_approved"] is False + print("SIGNED_GITHUB_NATIVE_LIVEISO: VERIFIED_SCOPE_LIMITED") + PY + echo 'SIGNED LIVE ISO scope verified independently; FULL MASTER QA BLOCKED.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/devnet-router-v010-native-cachyos.yml b/.github/workflows/devnet-router-v010-native-cachyos.yml new file mode 100644 index 0000000..11906f8 --- /dev/null +++ b/.github/workflows/devnet-router-v010-native-cachyos.yml @@ -0,0 +1,233 @@ +name: Devnet router v0.1.0 exact ZIP native CachyOS — GitHub FIRST + +on: + push: + branches: [qa/registered-app-executor-fix-v0.1.0] + paths: + - 'qa/router-stage-v0.1.0/candidate.zip' + - '.github/workflows/devnet-router-v010-native-cachyos.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify-exact-zip: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Verify exact unchanged router archive, safety, file identities + shell: bash + run: | + set -euo pipefail + candidate=qa/router-stage-v0.1.0/candidate.zip + test -f "$candidate" + echo '719f0d8a44d6431993654975d49555db19b7c5885c243f07fe3bcea6a09463cd qa/router-stage-v0.1.0/candidate.zip' | sha256sum -c - + python3 - <<'PY' + import hashlib,io,pathlib,stat,zipfile + p=pathlib.Path('qa/router-stage-v0.1.0/candidate.zip') + with zipfile.ZipFile(p) as z: + entries=z.infolist() + assert len(entries)==5 + expected={'devnet-router-stage-v0.1.0/'+x for x in + ('README.md','LOCAL_DEVELOPMENT_TEST_RESULTS.txt', + 'SHA256SUMS.txt','orchestrator.py','test_orchestrator.py')} + assert {x.filename for x in entries}==expected + assert z.testzip() is None + assert all(not x.is_dir() and stat.S_IFMT(x.external_attr >> 16) + in (0,stat.S_IFREG) for x in entries) + hashes={name: digest for digest,name in + (line.split() for line in + z.read('devnet-router-stage-v0.1.0/SHA256SUMS.txt').decode().splitlines())} + for name,expected_sha in hashes.items(): + data=z.read('devnet-router-stage-v0.1.0/'+name) + assert hashlib.sha256(data).hexdigest()==expected_sha,name + for name in ('orchestrator.py','test_orchestrator.py'): + compile(z.read('devnet-router-stage-v0.1.0/'+name),name,'exec') + print('EXACT_ROUTER_ZIP_SAFE: PASS; NATIVE_VM: NOT_TESTED') + PY + - uses: actions/upload-artifact@v4 + with: + name: router-v010-exact-original-zip + path: qa/router-stage-v0.1.0/candidate.zip + if-no-files-found: error + retention-days: 30 + + native-cachyos-unit-tests: + needs: verify-exact-zip + runs-on: ubuntu-24.04 + timeout-minutes: 30 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/download-artifact@v4 + with: + name: router-v010-exact-original-zip + path: input + - name: Confirm original candidate + virtualisation capabilities + shell: bash + run: | + set -euo pipefail + echo '719f0d8a44d6431993654975d49555db19b7c5885c243f07fe3bcea6a09463cd input/candidate.zip' | sha256sum -c - + test -c /dev/kvm + if ! test -r /dev/kvm -a -w /dev/kvm; then + sudo chown "$(id -u):$(id -g)" /dev/kvm + sudo chmod 0600 /dev/kvm + fi + test -r /dev/kvm -a -w /dev/kvm + - name: Prepare pinned genuine CachyOS guest (no network or host directory mounts) + shell: bash + run: | + set -euo pipefail + sudo apt-get update -qq + sudo apt-get install -qq -y --no-install-recommends qemu-system-x86 qemu-utils mtools dosfstools + root=/tmp/devnet-router-native + mkdir -p "$root" evidence + base=https://iso.cachyos.org/desktop/260809 + name=cachyos-desktop-linux-260809.iso + curl -fLsS --retry 3 --connect-timeout 20 --max-time 1200 "$base/$name.sha256" -o "$root/$name.sha256" + curl -fLsS --retry 3 --connect-timeout 20 --max-time 1200 "$base/$name" -o "$root/$name" + (cd "$root" && sha256sum -c "$name.sha256") | tee evidence/iso-checksum.log + pinned=https://raw.githubusercontent.com/devnet82/devnet-cachyos-repair/d8e4447d19e77a77dc355dc07e72c0c5c3edf741/qa/portfolio-cachyos-vm-v0.1.0 + curl -fLsS "$pinned/qemu_control.py" -o "$root/qemu_control.py" + python3 -m py_compile "$root/qemu_control.py" + truncate -s 128M "$root/control.img" + mkfs.vfat -F 32 -n DEVNETQA "$root/control.img" >/dev/null + mcopy -i "$root/control.img" input/candidate.zip ::/candidate.zip + cat > "$root/guest-test.sh" <<'GUEST' + #!/usr/bin/env bash + set -Eeuo pipefail + exec > /mnt/controller.log 2>&1 + trap 'rc=$?; echo ROUTER_GUEST_EXIT=$rc; sync' EXIT + grep -Eq '^ID=cachyos$' /etc/os-release + echo "REAL_CACHYOS_GUEST=$(uname -r)" + python3 - <<'PY' + import hashlib,pathlib,zipfile + p=pathlib.Path('/mnt/candidate.zip') + assert hashlib.sha256(p.read_bytes()).hexdigest()=='719f0d8a44d6431993654975d49555db19b7c5885c243f07fe3bcea6a09463cd' + with zipfile.ZipFile(p) as z: + assert z.testzip() is None + z.extractall('/tmp/router-tests') + print('REAL_CACHYOS_EXACT_ZIP_PASS') + PY + cd /tmp/router-tests/devnet-router-stage-v0.1.0 + python3 -m unittest -v test_orchestrator > /mnt/unit-tests.log 2>&1 + cat /mnt/unit-tests.log + grep -q 'Ran 23 tests' /mnt/unit-tests.log + grep -qx 'OK' /mnt/unit-tests.log + python3 - <<'PY' + import json,pathlib + result={'schema':'devnet-v010-native-cachyos-units-v1', + 'candidate_sha256':'719f0d8a44d6431993654975d49555db19b7c5885c243f07fe3bcea6a09463cd', + 'native_cachyos':True,'unit_tests_pass':23, + 'ollama_live_model_test':'NOT_TESTED', + 'application_creation_and_repair':'NOT_TESTED', + 'master_qa':'BLOCKED','release_approved':False} + pathlib.Path('/mnt/native-result.json').write_text(json.dumps(result,sort_keys=True)+'\n') + print('CACHYOS_NATIVE_ROUTER_23_23: PASS_LIMITED') + PY + GUEST + bash -n "$root/guest-test.sh" + mcopy -i "$root/control.img" "$root/guest-test.sh" ::/install-guest.sh + - name: Boot real CachyOS live ISO and execute full 23-test suite + shell: bash + run: | + set -euo pipefail + root=/tmp/devnet-router-native + qemu-system-x86_64 -machine q35,accel=kvm -cpu host -m 4096 -smp 2 \ + -drive file="$root/cachyos-desktop-linux-260809.iso",media=cdrom,if=ide \ + -drive if=none,id=ctl,file="$root/control.img",format=raw \ + -device virtio-blk-pci,drive=ctl,serial=DEVNETCONTROL \ + -nic none -boot order=d -vga std -display none \ + -serial file:evidence/serial.log \ + -monitor unix:"$root/qemu-monitor.sock",server=on,wait=off \ + -daemonize -pidfile "$root/qemu.pid" + sleep 110 + python3 "$root/qemu_control.py" "$root/qemu-monitor.sock" framebuffer --image evidence/guest-before.ppm + python3 "$root/qemu_control.py" "$root/qemu-monitor.sock" begin-install + captured=0 + for attempt in $(seq 1 75); do + mcopy -o -i "$root/control.img" ::/controller.log evidence/controller.log >/dev/null 2>&1 || true + mcopy -o -i "$root/control.img" ::/unit-tests.log evidence/unit-tests.log >/dev/null 2>&1 || true + if mcopy -o -i "$root/control.img" ::/native-result.json evidence/native-result.json >/dev/null 2>&1; then + if python3 -m json.tool evidence/native-result.json >/dev/null 2>&1; then + captured=1; break + fi + fi + sleep 8 + done + test "$captured" -eq 1 + python3 - <<'PY' + import json + r=json.load(open('evidence/native-result.json')) + assert r['schema']=='devnet-v010-native-cachyos-units-v1' + assert r['candidate_sha256']=='719f0d8a44d6431993654975d49555db19b7c5885c243f07fe3bcea6a09463cd' + assert r['native_cachyos'] is True + assert r['unit_tests_pass']==23 + assert r['master_qa']=='BLOCKED' and r['release_approved'] is False + print('REAL_GITHUB_CACHYOS_ROUTER_UNITS: 23/23 PASS_LIMITED; FULL MASTER QA BLOCKED') + PY + - uses: actions/upload-artifact@v4 + if: always() + with: + name: router-v010-genuine-cachyos-evidence + path: evidence/ + retention-days: 30 + if-no-files-found: warn + + independent-scope-gate: + needs: [verify-exact-zip, native-cachyos-unit-tests] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + attestations: write + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: router-v010-exact-original-zip + path: input + - uses: actions/download-artifact@v4 + with: + name: router-v010-genuine-cachyos-evidence + path: evidence + - name: Independently verify candidate and guest result scope + shell: bash + run: | + set -euo pipefail + python3 - <<'PY' + import hashlib,json,pathlib + zipfile=pathlib.Path('input/candidate.zip') + h=hashlib.sha256(zipfile.read_bytes()).hexdigest() + assert h=='719f0d8a44d6431993654975d49555db19b7c5885c243f07fe3bcea6a09463cd' + r=json.loads(pathlib.Path('evidence/native-result.json').read_text()) + assert r['native_cachyos'] and r['unit_tests_pass']==23 + assert r['candidate_sha256']==h + assert r['ollama_live_model_test']=='NOT_TESTED' + assert r['application_creation_and_repair']=='NOT_TESTED' + assert r['master_qa']=='BLOCKED' and r['release_approved'] is False + assert 'Ran 23 tests' in pathlib.Path('evidence/unit-tests.log').read_text() + pathlib.Path('scope-receipt.json').write_text(json.dumps({ + 'schema':'devnet-native-router-scope-v1', + 'original_sha256':h,'native_cachyos_tests_pass':23, + 'scope':'UNIT_TESTS_ONLY_WITH_MOCK_OLLAMA', + 'full_master_qa':'BLOCKED','release_approved':False},sort_keys=True)+'\n') + print('INDEPENDENT_NATIVE_CACHYOS_SCOPE_PASS_LIMITED') + PY + - uses: actions/attest@v4 + with: + subject-path: input/candidate.zip + - uses: actions/attest@v4 + with: + subject-path: scope-receipt.json + - uses: actions/upload-artifact@v4 + with: + name: router-v010-signed-limited-scope + path: scope-receipt.json + if-no-files-found: error + - run: echo 'Signed scope is native unit tests with mocked Ollama. NO host model/integration/app repair. Full Master QA BLOCKED.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/devnet-safe-ai-v011-native-cachyos.yml b/.github/workflows/devnet-safe-ai-v011-native-cachyos.yml new file mode 100644 index 0000000..8213ca1 --- /dev/null +++ b/.github/workflows/devnet-safe-ai-v011-native-cachyos.yml @@ -0,0 +1,226 @@ +name: Devnet Safe AI v0.1.1 exact archive — GitHub-first native CachyOS QA + +on: + push: + branches: [qa/registered-app-executor-fix-v0.1.0] + paths: + - 'qa/devnet-safe-ai-v0.1.1/**' + - '.github/workflows/devnet-safe-ai-v011-native-cachyos.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify-exact-private-safe-package: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Decode exact original candidate; integrity, scope and privacy preflight + shell: bash + run: | + set -Eeuo pipefail + mkdir -p input + base64 -d qa/devnet-safe-ai-v0.1.1/candidate.zip.b64 > input/candidate.zip + echo 'cc9d7a183963a923c5f1b9bd4aa30d4f11156bd8ef81e4a2bc3f5b2907a9aba1 input/candidate.zip' | sha256sum -c - + python3 - <<'PY' + import hashlib,io,pathlib,re,stat,zipfile + p=pathlib.Path('input/candidate.zip') + expected={'devnet-safe-ai-v0.1.1.py','test_devnet_safe_ai_v0.1.1.py','SHA256SUMS','DEVNET-SAFE-AI-v0.1.1-README.md'} + with zipfile.ZipFile(p) as z: + entries=z.infolist() + assert len(entries)==4 and {i.filename for i in entries}==expected + assert z.testzip() is None + assert all(not i.is_dir() and stat.S_IFMT(i.external_attr >> 16) in (0,stat.S_IFREG) for i in entries) + sums=z.read('SHA256SUMS').decode().splitlines() + assert len(sums)==3 + for line in sums: + digest,name=line.split() + assert name in expected-{'SHA256SUMS'} + assert hashlib.sha256(z.read(name)).hexdigest()==digest,name + for name in ('devnet-safe-ai-v0.1.1.py','test_devnet_safe_ai_v0.1.1.py'): + compile(z.read(name),name,'exec') + all_text='\n'.join(z.read(name).decode() for name in expected if name!='SHA256SUMS') + assert not re.search(r'/home/(?!agent\b)[a-zA-Z0-9_.-]+',all_text) + assert not re.search(r'-----BEGIN [^-]*PRIVATE KEY|github_pat_[A-Za-z0-9_]{15,}|gh[psu]_[A-Za-z0-9]{15,}',all_text) + print('EXACT_ARCHIVE_SHA256_PASS; PRIVACY_PREFLIGHT_PASS; GITHUB_VM_NOT_YET_TESTED') + PY + - uses: actions/upload-artifact@v4 + with: + name: devnet-safe-v011-exact-candidate + path: input/candidate.zip + if-no-files-found: error + retention-days: 30 + + real-cachyos-vm-30-regressions: + needs: verify-exact-private-safe-package + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - uses: actions/download-artifact@v4 + with: + name: devnet-safe-v011-exact-candidate + path: input + - name: KVM preflight and genuine CachyOS live guest preparation + shell: bash + run: | + set -Eeuo pipefail + echo 'cc9d7a183963a923c5f1b9bd4aa30d4f11156bd8ef81e4a2bc3f5b2907a9aba1 input/candidate.zip' | sha256sum -c - + test -c /dev/kvm + if ! test -r /dev/kvm -a -w /dev/kvm; then + sudo chown "$(id -u):$(id -g)" /dev/kvm + sudo chmod 0600 /dev/kvm + fi + test -r /dev/kvm -a -w /dev/kvm + sudo apt-get update -qq + sudo apt-get install -qq -y --no-install-recommends qemu-system-x86 qemu-utils mtools dosfstools + root=/tmp/devnet-safe-v011-native + mkdir -p "$root" evidence + base=https://iso.cachyos.org/desktop/260809 + name=cachyos-desktop-linux-260809.iso + curl -fLsS --retry 3 --connect-timeout 20 --max-time 1200 "$base/$name.sha256" -o "$root/$name.sha256" + curl -fLsS --retry 3 --connect-timeout 20 --max-time 1200 "$base/$name" -o "$root/$name" + (cd "$root" && sha256sum -c "$name.sha256") | tee evidence/iso-checksum.log + pinned=https://raw.githubusercontent.com/devnet82/devnet-cachyos-repair/d8e4447d19e77a77dc355dc07e72c0c5c3edf741/qa/portfolio-cachyos-vm-v0.1.0 + curl -fLsS "$pinned/qemu_control.py" -o "$root/qemu_control.py" + python3 -m py_compile "$root/qemu_control.py" + truncate -s 128M "$root/control.img" + mkfs.vfat -F 32 -n DEVNETQA "$root/control.img" >/dev/null + mcopy -i "$root/control.img" input/candidate.zip ::/candidate.zip + cat > "$root/guest-test.sh" <<'GUEST' + #!/usr/bin/env bash + set -Eeuo pipefail + exec > /mnt/controller.log 2>&1 + trap 'rc=$?; echo SAFEAI_GUEST_EXIT=$rc; sync' EXIT + grep -Eq '^ID=cachyos$' /etc/os-release + echo "REAL_CACHYOS_GUEST_KERNEL=$(uname -r)" + python3 - <<'PY' + import hashlib,pathlib,zipfile + p=pathlib.Path('/mnt/candidate.zip') + assert hashlib.sha256(p.read_bytes()).hexdigest()=='cc9d7a183963a923c5f1b9bd4aa30d4f11156bd8ef81e4a2bc3f5b2907a9aba1' + with zipfile.ZipFile(p) as z: + assert z.testzip() is None + z.extractall('/tmp/devnet-safe-ai-v011') + print('REAL_CACHYOS_EXACT_ZIP_PASS') + PY + cd /tmp/devnet-safe-ai-v011 + python3 test_devnet_safe_ai_v0.1.1.py > /mnt/unit-tests.log 2>&1 + cat /mnt/unit-tests.log + grep -q 'Ran 30 tests' /mnt/unit-tests.log + grep -qx 'OK' /mnt/unit-tests.log + python3 - <<'PY' + import json,pathlib + result={'schema':'devnet-safe-ai-v011-github-cachyos-unit-scope-v1', + 'candidate_sha256':'cc9d7a183963a923c5f1b9bd4aa30d4f11156bd8ef81e4a2bc3f5b2907a9aba1', + 'native_cachyos':True,'unit_tests_pass':30,'unit_tests_fail':0, + 'ollama_host_live':'NOT_TESTED','opencode_full_runtime':'NOT_TESTED', + 'vm_host_master_qa':'NOT_TESTED','install_upgrade_rollback':'NOT_TESTED', + 'master_qa':'BLOCKED','release_approved':False} + pathlib.Path('/mnt/native-result.json').write_text(json.dumps(result,sort_keys=True)+'\n') + print('REAL_GITHUB_NATIVE_CACHYOS_30_REGRESSIONS_PASS_LIMITED') + PY + GUEST + bash -n "$root/guest-test.sh" + mcopy -i "$root/control.img" "$root/guest-test.sh" ::/install-guest.sh + - name: Boot real CachyOS guest with isolated networking and execute fresh 30-test suite + shell: bash + run: | + set -Eeuo pipefail + root=/tmp/devnet-safe-v011-native + qemu-system-x86_64 -machine q35,accel=kvm -cpu host -m 4096 -smp 2 \ + -drive file="$root/cachyos-desktop-linux-260809.iso",media=cdrom,if=ide \ + -drive if=none,id=ctl,file="$root/control.img",format=raw \ + -device virtio-blk-pci,drive=ctl,serial=DEVNETCONTROL \ + -nic none -boot order=d -vga std -display none \ + -serial file:evidence/serial.log \ + -monitor unix:"$root/qemu-monitor.sock",server=on,wait=off \ + -daemonize -pidfile "$root/qemu.pid" + sleep 110 + python3 "$root/qemu_control.py" "$root/qemu-monitor.sock" framebuffer --image evidence/guest-before.ppm + python3 "$root/qemu_control.py" "$root/qemu-monitor.sock" begin-install + captured=0 + for attempt in $(seq 1 75); do + mcopy -o -i "$root/control.img" ::/controller.log evidence/controller.log >/dev/null 2>&1 || true + mcopy -o -i "$root/control.img" ::/unit-tests.log evidence/unit-tests.log >/dev/null 2>&1 || true + if mcopy -o -i "$root/control.img" ::/native-result.json evidence/native-result.json >/dev/null 2>&1; then + if python3 -m json.tool evidence/native-result.json >/dev/null 2>&1; then + captured=1; break + fi + fi + sleep 8 + done + test "$captured" -eq 1 + python3 - <<'PY' + import json + r=json.load(open('evidence/native-result.json')) + assert r['schema']=='devnet-safe-ai-v011-github-cachyos-unit-scope-v1' + assert r['candidate_sha256']=='cc9d7a183963a923c5f1b9bd4aa30d4f11156bd8ef81e4a2bc3f5b2907a9aba1' + assert r['native_cachyos'] is True and r['unit_tests_pass']==30 + assert r['master_qa']=='BLOCKED' and r['release_approved'] is False + assert 'Ran 30 tests' in open('evidence/unit-tests.log').read() + print('GITHUB_CACHYOS_30_OF_30_TESTS_PASS_LIMITED_FULL_MASTER_QA_BLOCKED') + PY + - uses: actions/upload-artifact@v4 + if: always() + with: + name: devnet-safe-v011-real-cachyos-evidence + path: evidence/ + retention-days: 30 + if-no-files-found: warn + + independent-evidence-and-scope-gate: + needs: [verify-exact-private-safe-package, real-cachyos-vm-30-regressions] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + attestations: write + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: devnet-safe-v011-exact-candidate + path: input + - uses: actions/download-artifact@v4 + with: + name: devnet-safe-v011-real-cachyos-evidence + path: evidence + - name: Verify independent guest evidence; fail closed on missing scope + shell: bash + run: | + set -Eeuo pipefail + python3 - <<'PY' + import hashlib,json,pathlib + p=pathlib.Path('input/candidate.zip') + h=hashlib.sha256(p.read_bytes()).hexdigest() + assert h=='cc9d7a183963a923c5f1b9bd4aa30d4f11156bd8ef81e4a2bc3f5b2907a9aba1' + r=json.loads(pathlib.Path('evidence/native-result.json').read_text()) + assert r['candidate_sha256']==h and r['native_cachyos'] is True + assert r['unit_tests_pass']==30 and r['unit_tests_fail']==0 + assert r['ollama_host_live']=='NOT_TESTED' and r['opencode_full_runtime']=='NOT_TESTED' + assert r['install_upgrade_rollback']=='NOT_TESTED' + assert r['master_qa']=='BLOCKED' and r['release_approved'] is False + log=pathlib.Path('evidence/unit-tests.log').read_text() + assert 'Ran 30 tests' in log and '\nOK\n' in log + assert 'REAL_CACHYOS_EXACT_ZIP_PASS' in pathlib.Path('evidence/controller.log').read_text() + pathlib.Path('scope-receipt.json').write_text(json.dumps({ + 'schema':'devnet-safe-v011-github-scope-v1','archive_sha256':h, + 'native_cachyos_unit_tests_pass':30,'full_master_qa':'BLOCKED', + 'runtime_gui_host_integration':'NOT_TESTED', 'release_approved':False},sort_keys=True)+'\n') + print('INDEPENDENT_SCOPE_GATE_PASS_LIMITED; NO_RELEASE') + PY + - uses: actions/attest@v4 + with: + subject-path: input/candidate.zip + - uses: actions/attest@v4 + with: + subject-path: scope-receipt.json + - uses: actions/upload-artifact@v4 + with: + name: devnet-safe-v011-signed-github-scope + path: scope-receipt.json + if-no-files-found: error + - run: echo '30/30 tests only in genuine GitHub-hosted CachyOS VM. Host install/upgrade/rollback, security runtime, and Master QA are NOT TESTED; release BLOCKED.' >> "$GITHUB_STEP_SUMMARY" diff --git a/.github/workflows/devnet-safe-ai-v012-native-cachyos.yml b/.github/workflows/devnet-safe-ai-v012-native-cachyos.yml new file mode 100644 index 0000000..63a12b5 --- /dev/null +++ b/.github/workflows/devnet-safe-ai-v012-native-cachyos.yml @@ -0,0 +1,231 @@ +name: Devnet Safe AI v0.1.2 exact archive — GitHub-first native CachyOS QA + +on: + push: + branches: [qa/registered-app-executor-fix-v0.1.0] + paths: + - 'qa/devnet-safe-ai-v0.1.2/**' + - '.github/workflows/devnet-safe-ai-v012-native-cachyos.yml' + pull_request: + branches: [qa/master-unified-v0.2.4-github-first] + paths: + - 'qa/devnet-safe-ai-v0.1.2/**' + - '.github/workflows/devnet-safe-ai-v012-native-cachyos.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + verify-exact-private-safe-package: + runs-on: ubuntu-24.04 + timeout-minutes: 10 + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Decode exact original candidate; integrity, scope and privacy preflight + shell: bash + run: | + set -Eeuo pipefail + mkdir -p input + base64 -d qa/devnet-safe-ai-v0.1.2/candidate.zip.b64 > input/candidate.zip + echo 'fd227dfc54e26e10198ebc0623088aea7884fa62840b7999b32d8a2d0618a8ae input/candidate.zip' | sha256sum -c - + python3 - <<'PY' + import hashlib,io,pathlib,re,stat,zipfile + p=pathlib.Path('input/candidate.zip') + expected={'devnet-safe-ai-v0.1.2.py','test_devnet_safe_ai_v0.1.2.py','SHA256SUMS','DEVNET-SAFE-AI-v0.1.2-README.md'} + with zipfile.ZipFile(p) as z: + entries=z.infolist() + assert len(entries)==4 and {i.filename for i in entries}==expected + assert z.testzip() is None + assert all(not i.is_dir() and stat.S_IFMT(i.external_attr >> 16) in (0,stat.S_IFREG) for i in entries) + sums=z.read('SHA256SUMS').decode().splitlines() + assert len(sums)==3 + for line in sums: + digest,name=line.split() + assert name in expected-{'SHA256SUMS'} + assert hashlib.sha256(z.read(name)).hexdigest()==digest,name + for name in ('devnet-safe-ai-v0.1.2.py','test_devnet_safe_ai_v0.1.2.py'): + compile(z.read(name),name,'exec') + all_text='\n'.join(z.read(name).decode() for name in expected if name!='SHA256SUMS') + assert not re.search(r'/home/(?!agent\b)[a-zA-Z0-9_.-]+',all_text) + assert not re.search(r'-----BEGIN [^-]*PRIVATE KEY|github_pat_[A-Za-z0-9_]{15,}|gh[psu]_[A-Za-z0-9]{15,}',all_text) + print('EXACT_ARCHIVE_SHA256_PASS; PRIVACY_PREFLIGHT_PASS; GITHUB_VM_NOT_YET_TESTED') + PY + - uses: actions/upload-artifact@v4 + with: + name: devnet-safe-v012-exact-candidate + path: input/candidate.zip + if-no-files-found: error + retention-days: 30 + + real-cachyos-vm-30-regressions: + needs: verify-exact-private-safe-package + runs-on: ubuntu-24.04 + timeout-minutes: 35 + steps: + - uses: actions/download-artifact@v4 + with: + name: devnet-safe-v012-exact-candidate + path: input + - name: KVM preflight and genuine CachyOS live guest preparation + shell: bash + run: | + set -Eeuo pipefail + echo 'fd227dfc54e26e10198ebc0623088aea7884fa62840b7999b32d8a2d0618a8ae input/candidate.zip' | sha256sum -c - + test -c /dev/kvm + if ! test -r /dev/kvm -a -w /dev/kvm; then + sudo chown "$(id -u):$(id -g)" /dev/kvm + sudo chmod 0600 /dev/kvm + fi + test -r /dev/kvm -a -w /dev/kvm + sudo apt-get update -qq + sudo apt-get install -qq -y --no-install-recommends qemu-system-x86 qemu-utils mtools dosfstools + root=/tmp/devnet-safe-v012-native + mkdir -p "$root" evidence + base=https://iso.cachyos.org/desktop/260809 + name=cachyos-desktop-linux-260809.iso + curl -fLsS --retry 3 --connect-timeout 20 --max-time 1200 "$base/$name.sha256" -o "$root/$name.sha256" + curl -fLsS --retry 3 --connect-timeout 20 --max-time 1200 "$base/$name" -o "$root/$name" + (cd "$root" && sha256sum -c "$name.sha256") | tee evidence/iso-checksum.log + pinned=https://raw.githubusercontent.com/devnet82/devnet-cachyos-repair/d8e4447d19e77a77dc355dc07e72c0c5c3edf741/qa/portfolio-cachyos-vm-v0.1.0 + curl -fLsS "$pinned/qemu_control.py" -o "$root/qemu_control.py" + python3 -m py_compile "$root/qemu_control.py" + truncate -s 128M "$root/control.img" + mkfs.vfat -F 32 -n DEVNETQA "$root/control.img" >/dev/null + mcopy -i "$root/control.img" input/candidate.zip ::/candidate.zip + cat > "$root/guest-test.sh" <<'GUEST' + #!/usr/bin/env bash + set -Eeuo pipefail + exec > /mnt/controller.log 2>&1 + trap 'rc=$?; echo SAFEAI_GUEST_EXIT=$rc; sync' EXIT + grep -Eq '^ID=cachyos$' /etc/os-release + echo "REAL_CACHYOS_GUEST_KERNEL=$(uname -r)" + python3 - <<'PY' + import hashlib,pathlib,zipfile + p=pathlib.Path('/mnt/candidate.zip') + assert hashlib.sha256(p.read_bytes()).hexdigest()=='fd227dfc54e26e10198ebc0623088aea7884fa62840b7999b32d8a2d0618a8ae' + with zipfile.ZipFile(p) as z: + assert z.testzip() is None + z.extractall('/tmp/devnet-safe-ai-v012') + print('REAL_CACHYOS_EXACT_ZIP_PASS') + PY + cd /tmp/devnet-safe-ai-v012 + python3 test_devnet_safe_ai_v0.1.2.py > /mnt/unit-tests.log 2>&1 + cat /mnt/unit-tests.log + grep -q 'Ran 30 tests' /mnt/unit-tests.log + grep -qx 'OK' /mnt/unit-tests.log + python3 - <<'PY' + import json,pathlib + result={'schema':'devnet-safe-ai-v012-github-cachyos-unit-scope-v1', + 'candidate_sha256':'fd227dfc54e26e10198ebc0623088aea7884fa62840b7999b32d8a2d0618a8ae', + 'native_cachyos':True,'unit_tests_pass':30,'unit_tests_fail':0, + 'ollama_host_live':'NOT_TESTED','opencode_full_runtime':'NOT_TESTED', + 'vm_host_master_qa':'NOT_TESTED','install_upgrade_rollback':'NOT_TESTED', + 'master_qa':'BLOCKED','release_approved':False} + pathlib.Path('/mnt/native-result.json').write_text(json.dumps(result,sort_keys=True)+'\n') + print('REAL_GITHUB_NATIVE_CACHYOS_30_REGRESSIONS_PASS_LIMITED') + PY + GUEST + bash -n "$root/guest-test.sh" + mcopy -i "$root/control.img" "$root/guest-test.sh" ::/install-guest.sh + - name: Boot real CachyOS guest with isolated networking and execute fresh 30-test suite + shell: bash + run: | + set -Eeuo pipefail + root=/tmp/devnet-safe-v012-native + qemu-system-x86_64 -machine q35,accel=kvm -cpu host -m 4096 -smp 2 \ + -drive file="$root/cachyos-desktop-linux-260809.iso",media=cdrom,if=ide \ + -drive if=none,id=ctl,file="$root/control.img",format=raw \ + -device virtio-blk-pci,drive=ctl,serial=DEVNETCONTROL \ + -nic none -boot order=d -vga std -display none \ + -serial file:evidence/serial.log \ + -monitor unix:"$root/qemu-monitor.sock",server=on,wait=off \ + -daemonize -pidfile "$root/qemu.pid" + sleep 110 + python3 "$root/qemu_control.py" "$root/qemu-monitor.sock" framebuffer --image evidence/guest-before.ppm + python3 "$root/qemu_control.py" "$root/qemu-monitor.sock" begin-install + captured=0 + for attempt in $(seq 1 75); do + mcopy -o -i "$root/control.img" ::/controller.log evidence/controller.log >/dev/null 2>&1 || true + mcopy -o -i "$root/control.img" ::/unit-tests.log evidence/unit-tests.log >/dev/null 2>&1 || true + if mcopy -o -i "$root/control.img" ::/native-result.json evidence/native-result.json >/dev/null 2>&1; then + if python3 -m json.tool evidence/native-result.json >/dev/null 2>&1; then + captured=1; break + fi + fi + sleep 8 + done + test "$captured" -eq 1 + python3 - <<'PY' + import json + r=json.load(open('evidence/native-result.json')) + assert r['schema']=='devnet-safe-ai-v012-github-cachyos-unit-scope-v1' + assert r['candidate_sha256']=='fd227dfc54e26e10198ebc0623088aea7884fa62840b7999b32d8a2d0618a8ae' + assert r['native_cachyos'] is True and r['unit_tests_pass']==30 + assert r['master_qa']=='BLOCKED' and r['release_approved'] is False + assert 'Ran 30 tests' in open('evidence/unit-tests.log').read() + print('GITHUB_CACHYOS_30_OF_30_TESTS_PASS_LIMITED_FULL_MASTER_QA_BLOCKED') + PY + - uses: actions/upload-artifact@v4 + if: always() + with: + name: devnet-safe-v012-real-cachyos-evidence + path: evidence/ + retention-days: 30 + if-no-files-found: warn + + independent-evidence-and-scope-gate: + needs: [verify-exact-private-safe-package, real-cachyos-vm-30-regressions] + runs-on: ubuntu-24.04 + timeout-minutes: 10 + permissions: + contents: read + attestations: write + id-token: write + steps: + - uses: actions/download-artifact@v4 + with: + name: devnet-safe-v012-exact-candidate + path: input + - uses: actions/download-artifact@v4 + with: + name: devnet-safe-v012-real-cachyos-evidence + path: evidence + - name: Verify independent guest evidence; fail closed on missing scope + shell: bash + run: | + set -Eeuo pipefail + python3 - <<'PY' + import hashlib,json,pathlib + p=pathlib.Path('input/candidate.zip') + h=hashlib.sha256(p.read_bytes()).hexdigest() + assert h=='fd227dfc54e26e10198ebc0623088aea7884fa62840b7999b32d8a2d0618a8ae' + r=json.loads(pathlib.Path('evidence/native-result.json').read_text()) + assert r['candidate_sha256']==h and r['native_cachyos'] is True + assert r['unit_tests_pass']==30 and r['unit_tests_fail']==0 + assert r['ollama_host_live']=='NOT_TESTED' and r['opencode_full_runtime']=='NOT_TESTED' + assert r['install_upgrade_rollback']=='NOT_TESTED' + assert r['master_qa']=='BLOCKED' and r['release_approved'] is False + log=pathlib.Path('evidence/unit-tests.log').read_text() + assert 'Ran 30 tests' in log and '\nOK\n' in log + assert 'REAL_CACHYOS_EXACT_ZIP_PASS' in pathlib.Path('evidence/controller.log').read_text() + pathlib.Path('scope-receipt.json').write_text(json.dumps({ + 'schema':'devnet-safe-v012-github-scope-v1','archive_sha256':h, + 'native_cachyos_unit_tests_pass':30,'full_master_qa':'BLOCKED', + 'runtime_gui_host_integration':'NOT_TESTED', 'release_approved':False},sort_keys=True)+'\n') + print('INDEPENDENT_SCOPE_GATE_PASS_LIMITED; NO_RELEASE') + PY + - uses: actions/attest@v4 + with: + subject-path: input/candidate.zip + - uses: actions/attest@v4 + with: + subject-path: scope-receipt.json + - uses: actions/upload-artifact@v4 + with: + name: devnet-safe-v012-signed-github-scope + path: scope-receipt.json + if-no-files-found: error + - run: echo '30/30 tests only in genuine GitHub-hosted CachyOS VM. Host install/upgrade/rollback, security runtime, and Master QA are NOT TESTED; release BLOCKED.' >> "$GITHUB_STEP_SUMMARY" diff --git a/qa/application/README.md b/qa/application/README.md new file mode 100644 index 0000000..bdd63b1 --- /dev/null +++ b/qa/application/README.md @@ -0,0 +1,163 @@ +# Devnet registered application acceptance — QA BLOCKED + +This is a **candidate-specific acceptance gate** for the existing Devnet Master QA. +It is not another router, a replacement for the fixed-scope controller v0.4.4, +or a licence for AI self-approval. The candidate-aware VM-stage engine has +passed its regression tests but has NOT been deployed or used to run a real app. + +## Confirmed installed-system fault (30 October 2026) +The existing `~/.local/lib/devnet-qa-automation/controller.py` checks exactly +13 fixed `vr-stack-v0.6.132` tests, returns `PASS_LIMITED` upon completion +and is called by `agent.py once`. Agent returns exit 0 on `PASS_LIMITED`. +This only proves the *bounded smoke-test scope*. It does not register other +applications, run exact-candidate GitHub CI, execute upgrade/rollback/GUI +tests, or independently close full Master QA. + +## New coordinator and scope +- `registered_gate.py`: runs the candidate's GitHub-stage tests; requires an + exact ZIP, full required lifecycle IDs and a hash-bound manifest. +- `release_coordinator.py`: rechecks ALL provenance every invocation, refuses + inherited results, requires independently signed operator checklist, + independently attested GitHub stage, and separately signed VM and host + receipts with per-check SHA256 logs. +- `handoff.py`: assembles and checks a SHA256-bound transport bundle containing + the exact ZIP, the unmodified approved checklist and signature, and the + independently GitHub-attested receipt. Neither handoff creation nor checking + can grant VM PASS or release approval. +- `test_handoff.py`: covers signed policy, two independent GitHub attestations, + altered packages, altered checklists, symlinks, missing files and false approval. +- `stage_executor.py`: executes only the protected, exact per-app VM/host driver + commands after checking the signed operator manifest, matching candidate bytes + and signed GitHub handoff. Captures actual subprocess logs, stops on failure, + prevents evidence reuse and intentionally cannot sign or approve itself. + VM/host protected drivers, isolated signing services and host broker are not + yet provisioned. +- `test_stage_executor.py`: verified regressions for real fixture subprocesses, + failure aborts, driver scope, fresh evidence and gaming pause; these are + fixture tests, NOT actual CachyOS GUI acceptance. +- `test_release_coordinator.py`: positive test and negative cases covering + missing signatures, changed candidate/checklist/revision, missing stages, + FAIL statuses, altered logs and broken chain. +- `.github/workflows/devnet-coordinator-regressions.yml`: tests this new + code on GitHub; testing the coordinator does NOT certify a real application. +- `.github/workflows/devnet-application-qa.yml`: candidate GitHub stage; + currently deliberately BLOCKED until a real candidate is registered. + +## Exact release-closure contract +1. Operator signs the **stable** `qa/application/jobs/ID/policy.json` + checklist using an offline private key and saves `policy.sig`. A changing + candidate `manifest.json` must contain the EXACT same approved checks but + binds the new source ZIP SHA256. The existing policy may be reused only + when that approved checklist is unchanged; every new candidate revision + still requires all applicable tests to be run again. Public key at + `/etc/devnet-qa/trust/operator.pub` must be root-owned/non-writable. +2. Register the exact application ZIP at `qa/application/jobs/ID/candidate.zip`. + Manifest binds the ZIP SHA256, complete applicable checks and stages. + Do not substitute old router/Master QA ZIPs for an application. +3. Execute real candidate-specific tests in GitHub FIRST. The scope-limited + `qa-stage-evidence/ID/receipt.json` must be independently GitHub-attested + by the designated workflow, then placed at + `EVIDENCE/github/receipt.json`. The coordinator validates the exact source + commit with `/usr/bin/gh attestation verify`. +4. Deploy the new stage executor with a root-controlled, job-specific VM + test driver in the CachyOS guest. The driver must install the **same** + candidate and run every registered VM check. Its `EVIDENCE/vm/receipt.json` + must be signed as `receipt.sig` with a private key inaccessible to OpenCode, + the regular user process and the AI. Root-owned public key: + `/etc/devnet-qa/trust/vm.pub`. +5. If any host checks are required, a separately authorised host executor + must run them with the gaming guard honoured, and sign + `EVIDENCE/host/receipt.json` with its *different* protected key. Root-owned + public key: `/etc/devnet-qa/trust/host.pub`. Omission of host checks + requires a genuinely applicable, operator-signed checklist scope. +6. VM/host receipt schema: `schema=devnet-trusted-stage-v1`, `stage`, + `job_id`, `candidate_sha256`, `manifest_sha256`, `github_sha`, + `previous_receipt_sha256`, `checks`, `release_approved=false`. + Every `checks[ID]` has `status=PASS`, `log=logs/ID.log` and the + SHA256 digest of the real file in `EVIDENCE/STAGE/logs/ID.log`. + VM receipt chains to the GitHub receipt; host receipt chains to VM. +7. Only a verified result of `READY_FOR_OPERATOR_ACCEPTANCE` permits + *requesting* the user's final acceptance. It **never** grants release + approval itself. Any changed ZIP/checklist/source revision invalidates + all prior receipts; run GitHub→VM→host again. + +Evaluate (read-only; requires actual signed evidence and trusted public keys): + +```sh +python3 qa/application/release_coordinator.py \ + --job-dir qa/application/jobs/ID \ + --evidence-dir /mnt/devnet-ai/qa/evidence/ID +``` + +## Current blockers — do NOT misreport as passed +- No current FULL application ZIP and complete operator-approved registration + exists on this QA branch. We found an unpublished draft release asset + `vr-stack-control-v0.6.132.zip` (SHA256 + `04118623d828de96526e1c185409c586bf9ed1b4b1318451e4301277ff69baf1`, + 4,675 bytes), but the 30 Sep QA engineering report identifies it as a + **version-sync hotfix**, NOT the full application. A provisional full-app + registration was withdrawn; the original draft ZIP was NOT touched. +- GitHub Actions cannot read that draft-release asset using its current + token: `Resource not accessible by integration (HTTP 403)`. + Do not publish the draft or weaken token restrictions just to make CI green. +- The full-app preview v0.6.135 ZIP was not uploaded to GitHub according + to its own archived STATUS.md; no exact full-app candidate is supplied. +- There is no protected, complete, operator-signed per-application checklist. +- `stage_executor.py` exists, but has NOT been installed on the VM. Real + candidate-specific install/GUI/tray/rollback driver and independent signer + have not been deployed or verified. +- No protected host acceptance executor or signer has been deployed. +- No full-scope receipt chain has been generated or validated. +- GitHub coordinator regression run 41141854109: **41/41 PASS** on commit + b8e8bab0e9734669cf8fc506d831402dd1cac549 (includes transport tampering + protection and real fixture subprocesses, not real application execution). +- GitHub application-stage run 41140630122: blocked with HTTP 403 when + attempting to read the inaccessible unpublished draft asset. +- GitHub application-stage run 41140960760: intentionally BLOCKED because + no complete application is registered. Neither is a full acceptance run. + +**Release remains BLOCKED.** The original v0.4.4 runner, router v0.2.4, +Master QA v0.3.8, original ZIPs, existing VM and gaming guard are preserved. + +## Implementation boundary + +The newly added `package-private-vm-handoff` GitHub job depends on the +independent GitHub attestation verification job. It cannot trigger until an +actual candidate completes GitHub checks. Its output is an unsigned transport +artifact and does **not** execute real CachyOS VM, host GUI or rollback checks. +The local broker, real application-specific VM test driver, protected signer +and automatic dispatch are still missing. The earlier full native CachyOS +GitHub VM workflow was specific to a small hotfix and is NOT a substitute for +full application GitHub-first testing. Real GitHub-hosted VM acceptance remains +NOT_TESTED for any full application candidate. + +A preserved `devnet-master-qa-registered-application-stages-v0.1.0-QA-BLOCKED.zip` +exists in the ChatGPT Library, but its original bytes could not be materialised +through the present connection. Do not represent it as inspected or assume +that its contents were installed or integrated. + +## Mandatory QA-runner reality gate — 31 October 2026 (additive, QA BLOCKED) + +The candidate Master QA extension is `qa/master/QA_RUNNER_REALITY_RULE_v0.1.0.md`. +This is a mandatory **planned acceptance requirement for all QA-runner modifications**, +not permission to skip the pre-existing protected checklist or a declaration that +the immutable Master QA v0.3.8 ZIP has been updated/deployed. + +**Incident:** The r2 Devnet Safe AI v0.1.2 runner rejected real CachyOS OpenCode +output `opencode v2.2.4` despite passing 30 unit regressions. Real dependency +output was not tested before handing over the runner. Mark r2 VM runtime BLOCKED. + +**New acceptance IDs when the runner/tooling changes:** +`runner_real_dependency_output`, `runner_negative_contracts`, +`runner_independent_evidence`. Run the exact runner with actual target VM +dependency output and capture binary/runner/candidate hashes and raw CLI +stdout/stderr. Reject wrong major versions, missing tools, nonzero exits, +malformed and reused output. Run 100% fresh job-specific tests after each +revision GitHub first, local CachyOS VM second, authorised host third. Every +unverified test stays FAIL/BLOCKED/NOT_TESTED, not inherited PASS. + +`qa/master/runner_reality_gate.py` is a scoped unsigned live-output probe and +re-execution checker, not a signing service and not the protected Master QA. +The new policy, probe and development tests are **staged on this QA branch only**. +Integration with the independent signer and protected Master QA baseline, +real host/VM tests, and final release acceptance remain BLOCKED. diff --git a/qa/application/candidate_checks.py b/qa/application/candidate_checks.py new file mode 100644 index 0000000..77dd8ab --- /dev/null +++ b/qa/application/candidate_checks.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +"""Static checks for the exact archived application ZIP; never execute ZIP code. + +GitHub first-stage only. These checks do not certify install, GUI, tray or rollback. +""" +import argparse +import pathlib +import stat +import subprocess +import sys +import tempfile +import zipfile + +MAX_ARCHIVE_BYTES = 50_000_000 +MAX_UNPACKED_BYTES = 150_000_000 +MAX_MEMBER_COUNT = 2000 + +def inspect(path): + if not path.is_file() or path.is_symlink(): + raise ValueError("missing candidate or unsafe symlink") + if path.stat().st_size > MAX_ARCHIVE_BYTES: + raise ValueError("candidate too large") + with zipfile.ZipFile(path) as archive: + members = archive.infolist() + if not members or len(members) > MAX_MEMBER_COUNT: + raise ValueError("invalid member count") + names = set() + total = 0 + for item in members: + name = item.filename + parts = pathlib.PurePosixPath(name) + if (not name or name.startswith("/") or "\\" in name or + any(part in (".", "..") for part in name.split("/")) or + str(parts) != name.rstrip("/") or name in names or + "\x00" in name): + raise ValueError("unsafe archive path or duplicate") + names.add(name) + kind = stat.S_IFMT(item.external_attr >> 16) + if kind not in (0, stat.S_IFDIR, stat.S_IFREG): + raise ValueError("links and special files prohibited") + total += item.file_size + if total > MAX_UNPACKED_BYTES: + raise ValueError("unpacked size limit exceeded") + if archive.testzip() is not None: + raise ValueError("ZIP member CRC error") + return members + +def run(mode, path): + members = inspect(path) + if mode in ("integrity", "safety"): + print(mode.upper(), "PASS", "members", len(members)) + return + syntax = 0 + with zipfile.ZipFile(path) as z: + for m in members: + if m.is_dir(): + continue + data = z.read(m) + if m.filename.endswith(".py"): + compile(data, m.filename, "exec") + syntax += 1 + elif m.filename.endswith((".sh", ".bash")): + # bash -n parses from standard input without executing application code. + result = subprocess.run(["/usr/bin/bash", "-n"], input=data, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, timeout=10) + if result.returncode: + raise ValueError("shell syntax error: " + m.filename) + syntax += 1 + if not syntax: + raise ValueError("candidate has no supported source files for syntax checks") + print("STATIC_SYNTAX_PASS", syntax) + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("mode", choices=["integrity", "safety", "python_syntax"]) + parser.add_argument("candidate", type=pathlib.Path) + args = parser.parse_args() + try: + run(args.mode, args.candidate) + except (ValueError, OSError, RuntimeError, SyntaxError, + zipfile.BadZipFile, subprocess.TimeoutExpired) as ex: + print("QA BLOCKED:", ex, file=sys.stderr) + raise SystemExit(2) diff --git a/qa/application/guest_v0685_lifecycle.py b/qa/application/guest_v0685_lifecycle.py new file mode 100644 index 0000000..7468ed6 --- /dev/null +++ b/qa/application/guest_v0685_lifecycle.py @@ -0,0 +1,157 @@ +#!/usr/bin/env python3 +"""Real v0.6.85 install/reinstall/uninstall in a disposable native CachyOS guest. + +Runs untrusted application installer only via unprivileged 'nobody' user in a +temporary HOME; the guest itself is disposable, without network or host mounts. +NOT a real installed-CachyOS/KDE GUI/VR/host/release-acceptance test. +""" +from __future__ import annotations +import hashlib +import json +import os +from pathlib import Path, PurePosixPath +import pwd +import shutil +import stat +import subprocess +import sys +import tempfile +import zipfile + +SHA="db9af6ff47f3abcf07cf452ddd91e91cbd75a10296c0c6050bb5c612980d9cf3" +EXES={"vr-control":"bin/vr-control","vr-control-gui":"bin/vr-control-gui", + "vr-doctor":"bin/vr-doctor","vr-stackd":"bin/vr-stackd", + "vr-stack-tray":"bin/vr-stack-tray"} + +def digest(p): + return hashlib.sha256(p.read_bytes()).hexdigest() + +def main(candidate, result_path, log_path): + results={"schema":"devnet-v0685-native-cachyos-lifecycle-v1", + "candidate_sha256":digest(candidate), + "checks":{}, "release_approved":False, + "installed_cachyos_vm":"NOT_TESTED", "kde_gui":"NOT_TESTED", + "tray_visibility":"NOT_TESTED", + "transactional_upgrade":"NOT_TESTED", + "transactional_rollback":"NOT_TESTED", + "physical_hardware":"NOT_TESTED"} + log=[] + def record(check, status, detail): + results["checks"][check]={"status":status,"detail":str(detail)[:400]} + log.append(f"{check} {status}: {detail}") + def verify(check, action): + try: + detail=action() + record(check,"PASS",detail) + return True + except Exception as ex: + record(check,"FAIL",repr(ex)) + return False + try: + if not any(x.strip()=="ID=cachyos" for x in Path("/etc/os-release").read_text().splitlines()): + raise RuntimeError("not a CachyOS guest") + if os.geteuid()!=0: + raise RuntimeError("guest setup must run as root; app runs as nobody") + p=subprocess.run(["lsblk","-dn","-o","SERIAL","/dev/vda"], + capture_output=True,text=True,timeout=10) + if p.returncode or p.stdout.strip()!="DEVNETCONTROL": + raise RuntimeError("wrong VM evidence disk; stop") + if results["candidate_sha256"]!=SHA: + raise RuntimeError("original application ZIP hash mismatch") + nobody=pwd.getpwnam("nobody") + with tempfile.TemporaryDirectory(prefix="devnet-fullapp-guest-",dir="/var/tmp") as d: + root=Path(d) + home=root/"home" + src=root/"source" + home.mkdir() + src.mkdir() + with zipfile.ZipFile(candidate) as z: + infos=z.infolist() + for item in infos: + name=item.filename + path=PurePosixPath(name) + kind=stat.S_IFMT(item.external_attr >> 16) + if (not name or path.is_absolute() or + ".." in path.parts or "\\" in name or + kind not in (0,stat.S_IFDIR,stat.S_IFREG)): + raise RuntimeError("unsafe archive item") + if z.testzip() is not None: + raise RuntimeError("bad ZIP CRC") + z.extractall(src) + app_dirs=list(src.glob("*/install.sh")) + if len(app_dirs)!=1: + raise RuntimeError("full app installer missing/ambiguous") + app=app_dirs[0].parent + for folder,dirs,files in os.walk(root): + os.chown(folder,nobody.pw_uid,nobody.pw_gid) + for entry in dirs+files: + path=Path(folder)/entry + if not path.is_symlink(): + os.chown(path,nobody.pw_uid,nobody.pw_gid) + config=home/".config"/"vr-stack"/"stack.conf" + config.parent.mkdir(parents=True) + # The parent .config was created AFTER the earlier ownership + # walk. Leaving it root-owned would incorrectly block a genuine + # unprivileged installer from creating ~/.config/systemd/user. + os.chown(config.parent.parent,nobody.pw_uid,nobody.pw_gid) + os.chown(config.parent,nobody.pw_uid,nobody.pw_gid) + if config.parent.parent.stat().st_uid != nobody.pw_uid: + raise RuntimeError("QA fixture config HOME ownership incorrect") + config.write_text("ORIGINAL_GUEST_CONFIG=preserve\n") + os.chown(config,nobody.pw_uid,nobody.pw_gid) + unrelated=home/"KEEP_UNRELATED" + unrelated.write_text("keep") + os.chown(unrelated,nobody.pw_uid,nobody.pw_gid) + env={"HOME":str(home),"PATH":"/usr/bin:/bin", + "XDG_CONFIG_HOME":str(home/".config"), + "XDG_DATA_HOME":str(home/".local/share"), + "LANG":"C.UTF-8"} + def command(script): + cmd=["/usr/bin/runuser","-u","nobody","--","/usr/bin/env","-i", + *[f"{k}={v}" for k,v in env.items()], + "/usr/bin/bash",str(app/script)] + p=subprocess.run(cmd,cwd=app,capture_output=True, + text=True,timeout=60,check=False) + log.append("EXECUTED "+script+" exit="+str(p.returncode)) + log.append((p.stdout+"\n"+p.stderr)[-5000:]) + if p.returncode: + raise RuntimeError(script+" exit "+str(p.returncode)) + return "actual unprivileged installer exit 0" + def hashes(): + for name,original in EXES.items(): + target=home/".local/bin"/name + if not target.is_file() or digest(target)!=digest(app/original): + raise RuntimeError("incorrect installed artifact: "+name) + return str(len(EXES))+" installed executable hashes match ZIP" + def preserved(): + if config.read_text()!="ORIGINAL_GUEST_CONFIG=preserve\n": + raise RuntimeError("pre-existing config changed") + if unrelated.read_text()!="keep": + raise RuntimeError("unrelated HOME data changed") + return "config and unrelated user file retained" + def removed(): + leftovers=[name for name in EXES if (home/".local/bin"/name).exists()] + if leftovers: + raise RuntimeError("installer left files: "+repr(leftovers)) + return "all registered app executables removed" + if verify("fresh_install",lambda:command("install.sh")): + verify("exact_install_hashes",hashes) + if verify("reinstall",lambda:command("install.sh")): + verify("reinstalled_hashes",hashes) + verify("configuration_preserved",preserved) + if verify("uninstall",lambda:command("uninstall.sh")): + verify("uninstall_removes_app_files",removed) + verify("uninstall_preserves_unrelated_data",preserved) + except Exception as ex: + record("harness","FAIL",repr(ex)) + finally: + statuses=[x["status"] for x in results["checks"].values()] + results["overall"]="FAIL" if not statuses or "FAIL" in statuses else "PASS_LIMITED" + result_path.write_text(json.dumps(results,indent=2,sort_keys=True)+"\n") + log_path.write_text("\n".join(log)+"\n") + return 2 if results["overall"]=="FAIL" else 0 + +if __name__=="__main__": + if len(sys.argv)!=4: + raise SystemExit("usage: guest_v0685_lifecycle.py candidate.zip result.json log.txt") + sys.exit(main(Path(sys.argv[1]),Path(sys.argv[2]),Path(sys.argv[3]))) diff --git a/qa/application/guest_v0685_package.py b/qa/application/guest_v0685_package.py new file mode 100644 index 0000000..d1eca50 --- /dev/null +++ b/qa/application/guest_v0685_package.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +"""Exact unchanged v0.6.85 full-application archive checks INSIDE a real CachyOS guest. + +NO artificial VERSION file. Original v0.6.85 release encodes version in its +GUI, control CLI and tray source. This stage verifies actual native guest, +identity of disposable disk, original ZIP hash, CRC, paths, script syntax, and +original version markers. It never grants installed-GUI/host/release approval. +""" +from __future__ import annotations +import hashlib +import json +from pathlib import Path, PurePosixPath +import re +import stat +import subprocess +import sys +import zipfile + +EXPECTED="db9af6ff47f3abcf07cf452ddd91e91cbd75a10296c0c6050bb5c612980d9cf3" +PREFIX="vr-stack-control-v0.6.85/" +REQUIRED={"install.sh","uninstall.sh","bin/vr-control","bin/vr-control-gui", + "bin/vr-stack-tray","bin/vr-stackd","bin/vr-doctor"} +VERSION_FILES={"bin/vr-control","bin/vr-control-gui","bin/vr-stack-tray"} + +def sha(path): + h=hashlib.sha256() + with path.open("rb") as s: + for b in iter(lambda:s.read(1024*1024),b""): + h.update(b) + return h.hexdigest() + +def analyze(candidate): + report={"schema":"devnet-native-cachyos-v0685-package-v1", + "candidate_sha256":sha(candidate), + "checks":{}, "release_approved":False, + "installed_cachyos_system":"NOT_TESTED", + "kde_gui":"NOT_TESTED","tray_visibility":"NOT_TESTED", + "transactional_upgrade":"NOT_TESTED", + "transactional_rollback":"NOT_TESTED","host_hardware":"NOT_TESTED"} + def check(name, ok, detail): + report["checks"][name]={"status":"PASS" if ok else "FAIL", + "detail":str(detail)[:400]} + if not ok: + raise ValueError(name+": "+str(detail)) + try: + osrel=Path("/etc/os-release").read_text() + check("native_cachyos",any(x.strip()=="ID=cachyos" for x in osrel.splitlines()), + "real CachyOS /etc/os-release") + blk=subprocess.run(["/usr/bin/lsblk","-dn","-o","SERIAL","/dev/vda"], + capture_output=True,text=True,timeout=10) + check("control_disk_identity",blk.returncode==0 and + blk.stdout.strip()=="DEVNETCONTROL", + "pinned disposable virtio control disk") + check("exact_sha256",report["candidate_sha256"]==EXPECTED, + report["candidate_sha256"]) + with zipfile.ZipFile(candidate) as z: + infos=z.infolist() + names=set() + total=0 + for entry in infos: + name=entry.filename + parts=PurePosixPath(name) + mode=stat.S_IFMT(entry.external_attr >> 16) + if (not name or name in names or not name.startswith(PREFIX) or + "\\" in name or parts.is_absolute() or + ".." in parts.parts or + mode not in (0,stat.S_IFDIR,stat.S_IFREG)): + raise ValueError("unsafe/duplicated archive member") + names.add(name) + total+=entry.file_size + if total>150_000_000 or len(names)>2000: + raise ValueError("ZIP limits exceeded") + check("zip_integrity",z.testzip() is None,"real CRC of every ZIP member") + check("full_application_layout", + REQUIRED.issubset({n[len(PREFIX):] for n in names}), + "required app executables and installer present") + count=0 + for member in infos: + if member.is_dir(): + continue + data=z.read(member) + head=data.split(b"\n",1)[0] + if head.startswith(b"#!/usr/bin/env python3") or member.filename.endswith(".py"): + compile(data,member.filename,"exec") + count+=1 + elif b"bash" in head and head.startswith(b"#!"): + p=subprocess.run(["/usr/bin/bash","-n"],input=data, + capture_output=True,timeout=12) + if p.returncode: + raise ValueError("invalid Bash syntax "+member.filename) + count+=1 + check("native_syntax",count>=7, + str(count)+" actual Python/Bash source files parsed") + for item in VERSION_FILES: + data=z.read(PREFIX+item) + check("version_"+item.replace("/","_"), + b"v0.6.85" in data, + "original v0.6.85 application source marker") + except Exception as ex: + report["harness_error"]=repr(ex)[:500] + statuses=[x["status"] for x in report["checks"].values()] + report["overall"]="PARTIAL" if statuses and all(x=="PASS" for x in statuses) else "FAIL" + return report + +if __name__=="__main__": + if len(sys.argv)!=3: + raise SystemExit("usage: guest_v0685_package.py /mnt/candidate.zip /mnt/qa-result.json") + try: + result=analyze(Path(sys.argv[1])) + Path(sys.argv[2]).write_text(json.dumps(result,sort_keys=True,indent=2)+"\n") + print(json.dumps({"overall":result["overall"],"checks":result["checks"]},sort_keys=True)) + raise SystemExit(0 if result["overall"]=="PARTIAL" else 2) + except (OSError,ValueError,zipfile.BadZipFile) as ex: + print("CACHYOS_GUEST_BLOCKED",repr(ex),file=sys.stderr) + raise SystemExit(2) diff --git a/qa/application/handoff.py b/qa/application/handoff.py new file mode 100644 index 0000000..c52ca78 --- /dev/null +++ b/qa/application/handoff.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Assemble and independently verify an exact-candidate CI -> VM handoff. + +A handoff is transport, NOT a release and not proof the application was run in +a VM. A private-VM broker must verify it before calling the stage executor. +No SSH, private VM access, installer execution or signature creation here. +""" +from __future__ import annotations +import argparse +import json +from pathlib import Path +import shutil +import sys + +import release_coordinator as gate + + +def assemble(job_dir: Path, evidence_dir: Path, target: Path): + manifest, manifest_hash, checks = gate.manifest_for(job_dir) + receipt, source = gate.github_receipt(job_dir, evidence_dir, manifest_hash, checks) + policy = job_dir / "policy.json" + policy_sig = job_dir / "policy.sig" + gate.require(policy.is_file() and not policy.is_symlink() + and policy_sig.is_file() and not policy_sig.is_symlink(), + "operator policy/signature missing") + p = gate.load(policy) + gate.require(isinstance(p, dict) and + set(p) == {"schema", "job_id", "checks"} and + p["schema"] == "devnet-qa-checklist-v1" and + p["job_id"] == manifest["job_id"] and + p["checks"] == manifest["checks"], + "operator-approved checklist mismatch") + gate.require(not target.exists(), "handoff exists: never reuse or overwrite") + target.mkdir(parents=True,mode=0o700) + job_out = target / manifest["job_id"] + job_out.mkdir(mode=0o700) + gh_out = target / "evidence" / "github" + gh_out.mkdir(mode=0o700,parents=True) + inputs = [(job_dir / "candidate.zip", job_out / "candidate.zip"), + (job_dir / "manifest.json", job_out / "manifest.json"), + (policy, job_out / "policy.json"), + (policy_sig, job_out / "policy.sig"), + (receipt, gh_out / "receipt.json")] + for src, dst in inputs: + gate.require(src.is_file() and not src.is_symlink(), "missing/symlink source") + shutil.copyfile(src,dst) + details = {"schema":"devnet-qa-handoff-v1", + "job_id":manifest["job_id"], + "candidate_sha256":manifest["sha256"], + "manifest_sha256":manifest_hash, + "policy_sha256":gate.sha256(policy), + "github_receipt_sha256":gate.sha256(receipt), + "github_sha":source, + "release_approved":False} + (target/"HANDOFF.json").write_text(json.dumps(details,sort_keys=True,indent=2)+"\n") + return details + + +def verify(target: Path, trust_dir: Path, repo: str, + signature_verifier=gate.verify_trusted_signature, + github_verifier=gate.verify_github_signature): + gate.require(target.is_dir() and not target.is_symlink(), "invalid handoff") + d=gate.load(target/"HANDOFF.json") + gate.require(isinstance(d,dict) and set(d) == + {"schema","job_id","candidate_sha256","manifest_sha256", + "policy_sha256","github_receipt_sha256","github_sha", + "release_approved"} and + d["schema"]=="devnet-qa-handoff-v1" and + d["release_approved"] is False, "handoff schema/approval violation") + job_id=d["job_id"] + gate.require(isinstance(job_id,str) and gate.ID.fullmatch(job_id), + "invalid handoff job id") + job_dir=target/job_id + evidence_dir=target/"evidence" + gate.require(job_dir.is_dir() and not job_dir.is_symlink(), + "missing/unsafe job directory") + for relative in ["candidate.zip","manifest.json","policy.json","policy.sig"]: + path=job_dir/relative + gate.require(path.is_file() and not path.is_symlink(), + "missing/unsafe handoff file "+relative) + receipt_path=evidence_dir/"github"/"receipt.json" + gate.require(receipt_path.is_file() and not receipt_path.is_symlink(), + "missing/unsafe GitHub receipt") + manifest, manifest_hash, checks=gate.manifest_for(job_dir) + gate.require(d["candidate_sha256"] == manifest["sha256"] and + d["manifest_sha256"] == manifest_hash and + d["policy_sha256"] == gate.sha256(job_dir/"policy.json") and + d["github_receipt_sha256"] == gate.sha256(receipt_path), + "handoff hashes do not match exact input bytes") + gate.verified_policy(job_dir,trust_dir,signature_verifier) + observed, source=gate.github_receipt(job_dir,evidence_dir,manifest_hash,checks) + gate.require(d["github_sha"] == source and observed == receipt_path, + "handoff revision/receipt mismatch") + # Verify BOTH GitHub-issued attestations; a JSON PASS is insufficient. + github_verifier(receipt_path,repo,source) + github_verifier(job_dir/"candidate.zip",repo,source) + return {"schema":"devnet-qa-verified-handoff-v1", + "job_id":job_id,"candidate_sha256":manifest["sha256"], + "github_sha":source,"github":"PASS_SIGNED", + "vm":"NOT_TESTED","host":"NOT_TESTED", + "release_approved":False} + + +def main(): + parser=argparse.ArgumentParser() + parser.add_argument("action",choices=["assemble","verify"]) + parser.add_argument("--job-dir",type=Path) + parser.add_argument("--evidence-dir",type=Path) + parser.add_argument("--handoff-dir",type=Path,required=True) + parser.add_argument("--trust-dir",type=Path,default=Path("/etc/devnet-qa/trust")) + parser.add_argument("--repo",default="devnet82/vr-stack-control") + args=parser.parse_args() + try: + if args.action=="assemble": + gate.require(args.job_dir is not None and + args.evidence_dir is not None, + "assemble requires job-dir and evidence-dir") + result=assemble(args.job_dir,args.evidence_dir,args.handoff_dir) + else: + result=verify(args.handoff_dir,args.trust_dir,args.repo) + except (gate.Blocked,OSError,ValueError,KeyError,TypeError, + json.JSONDecodeError) as exc: + print(json.dumps({"decision":"BLOCKED","reason":str(exc), + "release_approved":False})) + return 2 + print(json.dumps(result,sort_keys=True)) + return 0 + + +if __name__=="__main__": + sys.exit(main()) diff --git a/qa/application/registered_gate.py b/qa/application/registered_gate.py new file mode 100644 index 0000000..a6d2254 --- /dev/null +++ b/qa/application/registered_gate.py @@ -0,0 +1,131 @@ +#!/usr/bin/env python3 +"""Fail-closed candidate registration and GitHub-stage evidence collector. +Never claims VM, GUI, tray, host or release acceptance. +""" +import argparse, hashlib, json, os, pathlib, subprocess, sys, time + +ROOT = pathlib.Path(__file__).resolve().parents[2] +JOBS = ROOT / "qa/application/jobs" +OUT = ROOT / "qa-stage-evidence" +VALID = {"github", "vm", "host"} +REQUIRED = {"install", "upgrade", "rollback", "gui", "tray", "recovery", "security"} + +def digest(p): + h = hashlib.sha256() + with p.open("rb") as stream: + for block in iter(lambda: stream.read(1024*1024), b""): + h.update(block) + return h.hexdigest() + +def fail(s): + raise ValueError(s) + +def register(manifest): + if not isinstance(manifest, dict) or set(manifest) != {"job_id", "candidate", "sha256", "checks"}: + fail("invalid registration keys") + job = manifest["job_id"] + if not isinstance(job, str) or not job or not all(c.isalnum() or c in "-_" for c in job): + fail("invalid job id") + candidate = JOBS / job / "candidate.zip" + if manifest["candidate"] != f"qa/application/jobs/{job}/candidate.zip": + fail("candidate must use registered immutable path") + if not candidate.is_file() or digest(candidate) != manifest["sha256"]: + fail("missing candidate or SHA256 mismatch") + checks = manifest["checks"] + if not isinstance(checks, list) or not checks: + fail("empty checklist") + ids = set() + for check in checks: + if not isinstance(check, dict) or set(check) != {"id", "stage", "argv"}: + fail("invalid check schema") + cid, stage, argv = check["id"], check["stage"], check["argv"] + if not isinstance(cid, str) or not cid or not all(z.isalnum() or z in "-_" for z in cid) or cid in ids: + fail("duplicate/empty check ID") + ids.add(cid) + if stage not in VALID or not isinstance(argv, list): + fail("invalid stage/argv") + if stage == "github": + if not argv or not all(isinstance(x,str) and x for x in argv): + fail("invalid GitHub executable") + if pathlib.PurePath(argv[0]).is_absolute() or ".." in pathlib.PurePath(argv[0]).parts: + fail("unsafe command path") + elif argv: + fail("VM/host checks must be performed by trusted downstream executor") + if not any(x["stage"]=="github" for x in checks): + fail("no GitHub-stage test registered") + if not REQUIRED.issubset(ids): + fail("mandatory lifecycle checklist incomplete") + return job, candidate, checks + +def run_one(job, check): + # Exact command argv, no shell interpolation, no inherited secrets. + env = {"PATH": os.environ.get("PATH", "/usr/bin:/bin"), + "HOME": os.environ.get("RUNNER_TEMP", "/tmp"), + "LANG": "C.UTF-8"} + work = JOBS / job + start = time.monotonic() + try: + cp = subprocess.run(check["argv"], cwd=work, env=env, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, + timeout=180, check=False) + return {"id":check["id"], "status":"PASS" if cp.returncode==0 else "FAIL", + "exit_code":cp.returncode, "duration_ms":int((time.monotonic()-start)*1000), + "output_sha256":hashlib.sha256(cp.stdout).hexdigest(), + "output":cp.stdout.decode("utf-8","replace")[-20000:]} + except (OSError,subprocess.TimeoutExpired) as exc: + return {"id":check["id"],"status":"BLOCKED","error":type(exc).__name__} + +def run(manifest_path): + m=json.loads(manifest_path.read_text()) + job,candidate,checks=register(m) + if OUT.exists(): + fail("evidence directory already exists; no stale reuse") + results=[run_one(job,c) if c["stage"]=="github" else + {"id":c["id"],"status":"NOT_TESTED","stage":c["stage"]} + for c in checks] + receipt={"schema":1,"job_id":job,"candidate_sha256":digest(candidate), + "manifest_sha256":digest(manifest_path),"github_sha":os.environ.get("GITHUB_SHA"), + "checks":results,"full_acceptance":False,"release_approved":False} + p=OUT/job + p.mkdir(parents=True,exist_ok=False) + (p/"receipt.json").write_text(json.dumps(receipt,sort_keys=True,indent=2)+"\n") + if any(x["status"]!="PASS" for x in results if x.get("stage","github")=="github"): + fail("GitHub-stage check failure") + print("GITHUB_STAGE: PASS; VM/HOST/RELEASE: NOT APPROVED") + +def verify(manifest_path): + m=json.loads(manifest_path.read_text()) + job,candidate,checks=register(m) + p=OUT/job/"receipt.json" + d=json.loads(p.read_text()) + if set(d)!={"schema","job_id","candidate_sha256","manifest_sha256","github_sha","checks","full_acceptance","release_approved"} or d["schema"]!=1 or d["job_id"]!=job: + fail("invalid receipt schema/job") + if d["github_sha"]!=os.environ.get("GITHUB_SHA") or not isinstance(d["github_sha"],str) or len(d["github_sha"])!=40: + fail("wrong/missing GitHub commit identity") + if d["candidate_sha256"]!=digest(candidate) or d["manifest_sha256"]!=digest(manifest_path): + fail("receipt provenance mismatch") + if d["full_acceptance"] is not False or d["release_approved"] is not False: + fail("untrusted approval") + if [x["id"] for x in d["checks"]] != [x["id"] for x in checks]: + fail("incomplete checklist") + for result,check in zip(d["checks"],checks): + if check["stage"]=="github": + if result["status"]!="PASS" or result.get("exit_code")!=0 or not isinstance(result.get("output_sha256"),str): + fail("GitHub check not passed") + # Do not confuse a captured log excerpt with complete output evidence. + if "output" not in result or not isinstance(result["output"],str): + fail("missing GitHub output excerpt") + if check["stage"]!="github" and result["status"]!="NOT_TESTED": + fail("VM/host acceptance cannot be certified here") + print("GITHUB_RECEIPT: SCOPE_LIMITED_VERIFIED") + +if __name__=="__main__": + ap=argparse.ArgumentParser() + ap.add_argument("mode",choices=["run","verify"]) + ap.add_argument("manifest",type=pathlib.Path) + args=ap.parse_args() + try: + (run if args.mode=="run" else verify)(args.manifest) + except (ValueError,KeyError,OSError,json.JSONDecodeError) as exc: + print("QA BLOCKED:",exc,file=sys.stderr) + sys.exit(1) diff --git a/qa/application/release_coordinator.py b/qa/application/release_coordinator.py new file mode 100644 index 0000000..851dba3 --- /dev/null +++ b/qa/application/release_coordinator.py @@ -0,0 +1,271 @@ +#!/usr/bin/env python3 +"""Devnet Master QA: independently verify a registered candidate's stage chain. + +This is a fail-closed release-acceptance *gate*, not a privileged executor. +The existing v0.4.4 fixed-scope controller remains untouched. +No AI result, systemd exit code or 13-check PASS_LIMITED result grants acceptance. +""" +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys + +SCHEMA = "devnet-trusted-stage-v1" +MANDATORY = frozenset(("install", "upgrade", "rollback", "gui", "tray", + "recovery", "security")) +ID = re.compile(r"^[A-Za-z0-9_-]{1,100}$") +SHA256 = re.compile(r"^[a-f0-9]{64}$") +GIT_SHA = re.compile(r"^[a-f0-9]{40}$") +WORKFLOW = "devnet-application-qa.yml" +STAGES = ("github", "vm", "host") + + +class Blocked(Exception): + pass + + +def sha256(path: Path) -> str: + h = hashlib.sha256() + with path.open("rb") as stream: + for chunk in iter(lambda: stream.read(1024 * 1024), b""): + h.update(chunk) + return h.hexdigest() + + +def load(path: Path): + return json.loads(path.read_text(encoding="utf-8")) + + +def require(condition, reason): + if not condition: + raise Blocked(reason) + + +def manifest_for(job_dir: Path): + manifest_file = job_dir / "manifest.json" + manifest = load(manifest_file) + require(isinstance(manifest, dict), "invalid manifest type") + require(set(manifest) == {"job_id", "candidate", "sha256", "checks"}, + "manifest schema mismatch") + job_id = manifest["job_id"] + require(isinstance(job_id, str) and ID.fullmatch(job_id), "invalid job ID") + require(job_dir.name == job_id, "job directory mismatch") + require(manifest["candidate"] == + "qa/application/jobs/" + job_id + "/candidate.zip", + "candidate path is not exact") + candidate_sha = manifest["sha256"] + require(isinstance(candidate_sha, str) and SHA256.fullmatch(candidate_sha), + "invalid candidate SHA256") + candidate = job_dir / "candidate.zip" + require(candidate.is_file() and not candidate.is_symlink(), + "candidate ZIP missing or symlink") + require(sha256(candidate) == candidate_sha, "candidate ZIP bytes changed") + checks = manifest["checks"] + require(isinstance(checks, list) and checks, "empty checklist") + by_stage = {s: [] for s in STAGES} + all_ids = set() + for check in checks: + require(isinstance(check, dict) and set(check) == + {"id", "stage", "argv"}, "check schema mismatch") + cid, stage, argv = check["id"], check["stage"], check["argv"] + require(isinstance(cid, str) and ID.fullmatch(cid) and cid not in all_ids, + "invalid or duplicate check ID") + require(stage in STAGES and isinstance(argv, list), "invalid check stage") + if stage != "github": + require(argv == [], "VM and host commands must come from trusted executors") + else: + require(len(argv) > 0 and all(isinstance(a, str) and a for a in argv), + "GitHub test has no valid executable") + by_stage[stage].append(cid) + all_ids.add(cid) + require(MANDATORY.issubset(all_ids), "required lifecycle checks missing") + require(by_stage["github"], "no candidate-specific GitHub tests") + require(by_stage["vm"], "no actual VM acceptance checks registered") + return manifest, sha256(manifest_file), by_stage + + +def verified_policy(job_dir, trust_dir, signature_verifier): + """Check stable approved scope separately from the changing candidate revision.""" + policy_path = job_dir / "policy.json" + signature_verifier(policy_path, job_dir / "policy.sig", trust_dir / "operator.pub") + policy = load(policy_path) + require(isinstance(policy, dict) and + set(policy) == {"schema", "job_id", "checks"} and + policy.get("schema") == "devnet-qa-checklist-v1" and + policy.get("job_id") == job_dir.name, + "approved QA policy schema/job mismatch") + manifest = load(job_dir / "manifest.json") + require(isinstance(manifest, dict) and + policy["checks"] == manifest.get("checks"), + "candidate checklist changed without operator policy approval") + return sha256(policy_path) + + +def github_receipt(job_dir, evidence_dir, manifest_hash, by_stage): + path = evidence_dir / "github" / "receipt.json" + r = load(path) + require(isinstance(r, dict), "GitHub receipt invalid") + require(r.get("schema") == 1, "wrong GitHub receipt schema") + require(r.get("job_id") == job_dir.name, "wrong GitHub job") + require(r.get("candidate_sha256") == sha256(job_dir / "candidate.zip"), + "GitHub candidate mismatch") + require(r.get("manifest_sha256") == manifest_hash, "GitHub checklist mismatch") + require(r.get("full_acceptance") is False and + r.get("release_approved") is False, "GitHub receipt claims acceptance") + source = r.get("github_sha") + require(isinstance(source, str) and GIT_SHA.fullmatch(source), + "missing exact GitHub source commit") + results = r.get("checks") + require(isinstance(results, list) and len(results) == + sum(map(len, by_stage.values())), "incomplete GitHub checklist") + # Order and stages are fixed by the manifest, not by AI-produced records. + expected = [(c["id"], c["stage"]) for c in load(job_dir / "manifest.json")["checks"]] + for item, (cid, stage) in zip(results, expected): + require(isinstance(item, dict) and item.get("id") == cid, + "GitHub check identity mismatch") + if stage == "github": + require(item.get("status") == "PASS" and + item.get("exit_code") == 0 and + isinstance(item.get("output_sha256"), str) and + SHA256.fullmatch(item["output_sha256"]), + "GitHub test missing successful execution evidence") + else: + require(item.get("stage") == stage and + item.get("status") == "NOT_TESTED", + "GitHub attempted to certify later stages") + return path, source + + +def verify_github_signature(path: Path, repo: str, source: str): + require(bool(re.fullmatch(r"[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+", repo)), + "invalid pinned GitHub repository") + gh = Path("/usr/bin/gh") + require(gh.is_file() and not gh.is_symlink() and gh.stat().st_uid == 0 and + (gh.stat().st_mode & 0o022) == 0, "trusted GitHub verifier binary unavailable") + args = [str(gh), "attestation", "verify", str(path), "--repo", repo, + "--signer-workflow", repo + "/.github/workflows/" + WORKFLOW, + "--source-digest", source] + try: + p = subprocess.run(args, stdin=subprocess.DEVNULL, + stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + timeout=45, check=False) + except (OSError, subprocess.TimeoutExpired) as exc: + raise Blocked("GitHub independent attestation unavailable") from exc + require(p.returncode == 0, "GitHub signature/source verification failed") + + +def verify_trusted_signature(path: Path, signature: Path, key: Path): + # Only protected, root-provisioned signing identities can verify VM/host. + require(key.is_file() and not key.is_symlink() and + key.stat().st_uid == 0 and + (key.stat().st_mode & 0o022) == 0, + "trusted stage public key missing or unprotected") + try: + from cryptography.hazmat.primitives import serialization + from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PublicKey + public = serialization.load_pem_public_key(key.read_bytes()) + require(isinstance(public, Ed25519PublicKey), + "trusted stage key is not Ed25519") + public.verify(signature.read_bytes(), path.read_bytes()) + except (ImportError, OSError, ValueError, TypeError) as exc: + raise Blocked("trusted signature verification unavailable") from exc + except Exception as exc: + raise Blocked("trusted stage signature failed") from exc + + +def signed_stage(stage, job_dir, evidence_dir, trust_dir, source, manifest_hash, + check_ids, previous_receipt_sha, verify_signature=verify_trusted_signature): + path = evidence_dir / stage / "receipt.json" + signature = evidence_dir / stage / "receipt.sig" + key = trust_dir / (stage + ".pub") + require(path.is_file() and signature.is_file(), + stage.upper() + " signed execution receipt absent") + verify_signature(path, signature, key) + r = load(path) + require(isinstance(r, dict) and set(r) == + {"schema", "stage", "job_id", "candidate_sha256", "manifest_sha256", + "github_sha", "previous_receipt_sha256", "checks", "release_approved"}, + stage.upper() + " signed receipt schema mismatch") + require(r["schema"] == SCHEMA and r["stage"] == stage and + r["job_id"] == job_dir.name, "stage identity mismatch") + require(r["candidate_sha256"] == sha256(job_dir / "candidate.zip") and + r["manifest_sha256"] == manifest_hash and + r["github_sha"] == source, + stage.upper() + " wrong application/checklist/revision") + require(r["previous_receipt_sha256"] == previous_receipt_sha and + r["release_approved"] is False, "broken evidence chain/unsafe approval") + checks = r["checks"] + require(isinstance(checks, dict) and set(checks) == set(check_ids), + stage.upper() + " incomplete registered checklist") + for cid in check_ids: + item = checks[cid] + require(isinstance(item, dict) and set(item) == + {"status", "log", "log_sha256"}, + stage.upper() + " missing per-check execution evidence") + require(item["status"] == "PASS" and + item["log"] == "logs/" + cid + ".log" and + isinstance(item["log_sha256"], str) and + SHA256.fullmatch(item["log_sha256"]), + stage.upper() + " invalid check result/evidence path") + log = path.parent / "logs" / (cid + ".log") + require(log.is_file() and not log.is_symlink() and + sha256(log) == item["log_sha256"], + stage.upper() + " missing or tampered execution log") + return sha256(path) + + +def assess(job_dir: Path, evidence_dir: Path, trust_dir: Path, repo: str, + github_verifier=verify_github_signature, + stage_verifier=verify_trusted_signature): + # Every invocation independently rebuilds identity and evidence; never + # inherits a PASS or persists an AI-issued approval. + # Signed stable checklist persists across candidate revisions; exact + # candidate SHA/revision and all actual results MUST be retested afresh. + verified_policy(job_dir, trust_dir, stage_verifier) + m, manifest_hash, by_stage = manifest_for(job_dir) + gh_path, source = github_receipt(job_dir, evidence_dir, manifest_hash, by_stage) + github_verifier(gh_path, repo, source) + github_verifier(job_dir / "candidate.zip", repo, source) + previous = sha256(gh_path) + previous = signed_stage("vm", job_dir, evidence_dir, trust_dir, source, + manifest_hash, by_stage["vm"], previous, stage_verifier) + if by_stage["host"]: + previous = signed_stage("host", job_dir, evidence_dir, trust_dir, source, + manifest_hash, by_stage["host"], previous, stage_verifier) + return {"schema": "devnet-acceptance-gate-v1", + "job_id": m["job_id"], "candidate_sha256": m["sha256"], + "manifest_sha256": manifest_hash, "github_sha": source, + "github": "PASS_SIGNED", "vm": "PASS_SIGNED", + "host": "PASS_SIGNED" if by_stage["host"] else "NOT_APPLICABLE", + "decision": "READY_FOR_OPERATOR_ACCEPTANCE", + "release_approved": False} + + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--job-dir", type=Path, required=True) + p.add_argument("--evidence-dir", type=Path, required=True) + p.add_argument("--trust-dir", type=Path, + default=Path("/etc/devnet-qa/trust")) + p.add_argument("--repo", default="devnet82/vr-stack-control") + args = p.parse_args() + try: + result = assess(args.job_dir.resolve(), args.evidence_dir.resolve(), + args.trust_dir.resolve(), args.repo) + except (Blocked, OSError, KeyError, TypeError, ValueError, + json.JSONDecodeError) as exc: + print(json.dumps({"decision": "BLOCKED", + "reason": str(exc), "release_approved": False})) + return 2 + print(json.dumps(result, sort_keys=True)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/qa/application/stage_executor.py b/qa/application/stage_executor.py new file mode 100644 index 0000000..ad2b0ef --- /dev/null +++ b/qa/application/stage_executor.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +"""Registered Devnet QA stage executor (unprivileged, unsigned, fail closed). + +Trust assumptions: + * Complete operator checklist is signed by an offline private key. + * The root-owned, non-writable driver defines real app-specific commands. + * GitHub source/ZIP/receipt are independently attested before VM execution. + * This process cannot sign evidence or approve a release. + * A separate, privileged verifier must decide whether evidence is genuine. +""" +from __future__ import annotations +import argparse +import hashlib +import json +import os +from pathlib import Path +import re +import subprocess +import sys +import time + +import release_coordinator as gate + +MAX_OUTPUT = 2_000_000 +MAX_TIMEOUT = 300 + +def safe_driver(path: Path, ids): + gate.require(path.is_file() and not path.is_symlink() and + path.stat().st_uid == 0 and + not (path.stat().st_mode & 0o022), + "trusted root-controlled driver unavailable") + return validate_driver(gate.load(path), ids) + +def validate_driver(driver, ids): + """Check exact driver scope independently of filesystem ownership.""" + gate.require(isinstance(driver, dict) and set(driver) == + {"schema", "stage", "job_id", "checks"}, + "invalid driver schema") + checks = driver["checks"] + gate.require(driver["schema"] == "devnet-stage-driver-v1" and + isinstance(checks, dict) and set(checks) == set(ids), + "driver does not cover EXACT registered stage checklist") + for cid, test in checks.items(): + gate.require(isinstance(test, dict) and set(test) == + {"argv", "timeout"}, "invalid trusted test schema") + argv = test["argv"] + timeout = test["timeout"] + gate.require(isinstance(argv, list) and bool(argv) and + all(isinstance(x, str) and bool(x) and "\x00" not in x + for x in argv) and + isinstance(timeout, int) and not isinstance(timeout, bool) and + 1 <= timeout <= MAX_TIMEOUT, "invalid trusted command") + executable = Path(argv[0]) + gate.require(executable.is_absolute() and executable.is_file() and + not executable.is_symlink(), "not a fixed absolute executable") + return driver + +def game_guard(): + # The host broker is separately required to pause VM dispatch when gaming. + # For direct host-only tests this guard must be present and callable. + p = Path("/home/dave/.local/lib/devnet-qa-automation/agent.py") + gate.require(p.is_file() and not p.is_symlink(), "host game guard unavailable") + import importlib.util + spec = importlib.util.spec_from_file_location("devnet_gameguard", p) + gate.require(spec is not None and spec.loader is not None, + "host game guard cannot be loaded") + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + gate.require(not module.gaming_now(), "gaming pause active") + +def run_stage(job_dir: Path, evidence_dir: Path, driver_path: Path, trust_dir: Path, + repo: str, stage: str, *, + github_verifier=gate.verify_github_signature, + signature_verifier=gate.verify_trusted_signature, + pause=game_guard): + gate.require(stage in ("vm", "host"), "unsupported stage") + gate.verified_policy(job_dir, trust_dir, signature_verifier) + manifest, manifest_hash, by_stage = gate.manifest_for(job_dir) + gh_receipt, source = gate.github_receipt(job_dir, evidence_dir, manifest_hash, by_stage) + github_verifier(gh_receipt, repo, source) + github_verifier(job_dir/"candidate.zip", repo, source) + previous = gate.sha256(gh_receipt) + if stage == "host": + previous = gate.signed_stage("vm", job_dir, evidence_dir, trust_dir, + source, manifest_hash, by_stage["vm"], + previous, signature_verifier) + ids = by_stage[stage] + gate.require(ids, "empty stage; never invent non-applicability") + driver = safe_driver(driver_path, ids) + gate.require(driver["job_id"] == manifest["job_id"] and + driver["stage"] == stage, "driver pinned to a different job/stage") + candidate = job_dir/"candidate.zip" + gate.require(candidate.is_file() and not candidate.is_symlink() and + gate.sha256(candidate) == manifest["sha256"], + "exact candidate missing or changed") + # Never inherit a prior PASS, even if the previous build appears identical. + root = evidence_dir/stage + gate.require(not root.exists(), "existing stage evidence: start a fresh revision/run") + root.mkdir(parents=True, mode=0o700) + (root/"logs").mkdir(mode=0o700) + results = {} + all_pass = True + for cid in ids: + # On host, a live guard is mandatory before EVERY command. + if stage == "host": + pause() + command = driver["checks"][cid] + log_path = root/"logs"/(cid+".log") + start = time.monotonic() + with log_path.open("xb") as log: + os.chmod(log_path, 0o600) + try: + # No shell interpolation, no inherited credentials, no home writes. + cp = subprocess.run(command["argv"], cwd=job_dir, + env={"PATH":"/usr/bin:/bin", "LANG":"C.UTF-8", + "HOME":"/tmp", "DEVNET_QA_CANDIDATE":str(candidate)}, + stdin=subprocess.DEVNULL, stdout=log, + stderr=subprocess.STDOUT, + timeout=command["timeout"], check=False) + ok = cp.returncode == 0 + code = cp.returncode + except (subprocess.TimeoutExpired, OSError) as exc: + ok = False + code = -1 + log.write(("QA BLOCKED: "+type(exc).__name__+"\n").encode()) + log.flush() + os.fsync(log.fileno()) + if log_path.stat().st_size > MAX_OUTPUT: + ok = False + all_pass = all_pass and ok + results[cid] = {"status":"PASS" if ok else "FAIL", + "log":"logs/"+cid+".log", + "log_sha256":gate.sha256(log_path)} + if not ok: + # Stop after failure rather than running destructive follow-on tests. + break + if not all_pass: + (root/"blocked.json").write_text(json.dumps({ + "stage":stage, "job_id":manifest["job_id"], + "result":"BLOCKED", "checks":results, + "release_approved":False},sort_keys=True)+"\n") + raise gate.Blocked("stage failed: no signed PASS receipt created") + receipt = {"schema":gate.SCHEMA, "stage":stage, + "job_id":manifest["job_id"], + "candidate_sha256":manifest["sha256"], + "manifest_sha256":manifest_hash, + "github_sha":source, + "previous_receipt_sha256":previous, + "checks":results,"release_approved":False} + (root/"receipt.json").write_text(json.dumps(receipt,sort_keys=True,indent=2)+"\n") + # Deliberately NO receipt.sig: a distinct trusted executor/signer must + # independently inspect real logs and sign from a protected identity. + return {"stage":stage,"result":"EXECUTED_UNSIGNED", + "checks_passed":len(ids), "release_approved":False} + +def main(): + p = argparse.ArgumentParser() + p.add_argument("--job-dir", type=Path, required=True) + p.add_argument("--evidence-dir", type=Path, required=True) + p.add_argument("--driver", type=Path, required=True) + p.add_argument("--trust-dir", type=Path, default=Path("/etc/devnet-qa/trust")) + p.add_argument("--repo", default="devnet82/vr-stack-control") + p.add_argument("--stage", choices=["vm","host"], required=True) + a = p.parse_args() + try: + d = run_stage(a.job_dir.resolve(), a.evidence_dir.resolve(), + a.driver, a.trust_dir, a.repo, a.stage) + except (gate.Blocked, OSError, ValueError, KeyError, + TypeError, json.JSONDecodeError) as exc: + print(json.dumps({"stage":a.stage, "result":"BLOCKED", + "reason":str(exc), "release_approved":False})) + return 2 + print(json.dumps(d, sort_keys=True)) + return 0 + +if __name__ == "__main__": + sys.exit(main()) diff --git a/qa/application/test_handoff.py b/qa/application/test_handoff.py new file mode 100644 index 0000000..c5ca1be --- /dev/null +++ b/qa/application/test_handoff.py @@ -0,0 +1,149 @@ +import json +from pathlib import Path +import shutil +import tempfile +import unittest + +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +import handoff +import release_coordinator as gate + + +class HandoffTests(unittest.TestCase): + def setUp(self): + tmp=tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + base=Path(tmp.name) + self.job=base/"example_job" + self.job.mkdir() + self.evidence=base/"original_evidence" + (self.evidence/"github").mkdir(parents=True) + self.target=base/"transport" + self.trust=base/"trust" + self.trust.mkdir() + (self.job/"candidate.zip").write_bytes(b"candidate original bytes") + checks=[{"id":"github_archive","stage":"github","argv":["python3","-V"]}] + checks.extend({"id":i,"stage":"vm","argv":[]} for i in + ("install","upgrade","rollback","gui","tray","recovery","security")) + checks.append({"id":"host_hw","stage":"host","argv":[]}) + manifest={"job_id":"example_job", + "candidate":"qa/application/jobs/example_job/candidate.zip", + "sha256":gate.sha256(self.job/"candidate.zip"), + "checks":checks} + (self.job/"manifest.json").write_text(json.dumps(manifest)) + (self.job/"policy.json").write_text(json.dumps({ + "schema":"devnet-qa-checklist-v1","job_id":"example_job","checks":checks})) + self.private=Ed25519PrivateKey.generate() + (self.job/"policy.sig").write_bytes( + self.private.sign((self.job/"policy.json").read_bytes())) + (self.trust/"operator.pub").write_bytes( + self.private.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo)) + self.source="a"*40 + results=[{"id":c["id"],"status":"PASS","exit_code":0, + "output_sha256":"e"*64} if c["stage"]=="github" else + {"id":c["id"],"stage":c["stage"],"status":"NOT_TESTED"} + for c in checks] + receipt={"schema":1,"job_id":"example_job", + "candidate_sha256":manifest["sha256"], + "manifest_sha256":gate.sha256(self.job/"manifest.json"), + "github_sha":self.source,"checks":results, + "full_acceptance":False,"release_approved":False} + (self.evidence/"github"/"receipt.json").write_text(json.dumps(receipt)) + self.calls=[] + + def signatures(self,path,signature,key_path): + public=serialization.load_pem_public_key(key_path.read_bytes()) + public.verify(signature.read_bytes(),path.read_bytes()) + + def github_attest(self,path,repo,sha): + self.calls.append(path.name) + self.assertEqual(sha,self.source) + self.assertEqual(repo,"devnet82/vr-stack-control") + + def make(self): + return handoff.assemble(self.job,self.evidence,self.target) + + def check(self): + return handoff.verify(self.target,self.trust, + "devnet82/vr-stack-control", + signature_verifier=self.signatures, + github_verifier=self.github_attest) + + def test_valid_handoff_verifies_both_attestations_scope_limited(self): + self.make() + result=self.check() + self.assertEqual(result["github"],"PASS_SIGNED") + self.assertEqual(result["vm"],"NOT_TESTED") + self.assertEqual(result["host"],"NOT_TESTED") + self.assertIs(result["release_approved"],False) + self.assertEqual(set(self.calls),{"candidate.zip","receipt.json"}) + + def test_handoff_never_overwrites(self): + self.make() + with self.assertRaises(gate.Blocked): + self.make() + + def test_changed_candidate_is_blocked(self): + self.make() + (self.target/"example_job"/"candidate.zip").write_bytes(b"tampered") + with self.assertRaises(gate.Blocked): + self.check() + + def test_changed_manifest_is_blocked(self): + self.make() + (self.target/"example_job"/"manifest.json").write_text("{}") + with self.assertRaises((gate.Blocked,KeyError)): + self.check() + + def test_changed_policy_signature_is_blocked(self): + self.make() + (self.target/"example_job"/"policy.sig").write_bytes(b"fake") + with self.assertRaises(Exception): + self.check() + + def test_missing_attestation_is_blocked(self): + self.make() + def deny(*_): + raise gate.Blocked("no valid GitHub attestation") + with self.assertRaises(gate.Blocked): + handoff.verify(self.target,self.trust,"devnet82/vr-stack-control", + signature_verifier=self.signatures, + github_verifier=deny) + + def test_missing_receipt_is_blocked(self): + self.make() + (self.target/"evidence"/"github"/"receipt.json").unlink() + with self.assertRaises(gate.Blocked): + self.check() + + def test_handoff_fake_release_approval_is_blocked(self): + self.make() + p=self.target/"HANDOFF.json" + data=json.loads(p.read_text()) + data["release_approved"]=True + p.write_text(json.dumps(data)) + with self.assertRaises(gate.Blocked): + self.check() + + def test_symlink_candidate_is_blocked(self): + self.make() + p=self.target/"example_job"/"candidate.zip" + p.unlink() + p.symlink_to(self.job/"candidate.zip") + with self.assertRaises(gate.Blocked): + self.check() + + def test_mismatched_source_commit_is_blocked(self): + self.make() + p=self.target/"HANDOFF.json" + data=json.loads(p.read_text()) + data["github_sha"]="b"*40 + p.write_text(json.dumps(data)) + with self.assertRaises(gate.Blocked): + self.check() + +if __name__=="__main__": + unittest.main() diff --git a/qa/application/test_master_runner_reality.py b/qa/application/test_master_runner_reality.py new file mode 100644 index 0000000..d6a5190 --- /dev/null +++ b/qa/application/test_master_runner_reality.py @@ -0,0 +1,11 @@ +"""Include additive Master QA runner-reality regressions in existing coordinator CI. + +This runs DEVELOPMENT contract tests only. Actual VM/host real output remains +a separate mandatory signed stage; these fixtures are NOT native VM PASS. +""" +import sys +from pathlib import Path + +MASTER = Path(__file__).resolve().parents[1] / "master" +sys.path.insert(0, str(MASTER)) +from test_runner_reality_gate import RunnerContractTests # noqa: F401 diff --git a/qa/application/test_registered_gate.py b/qa/application/test_registered_gate.py new file mode 100644 index 0000000..feb08cf --- /dev/null +++ b/qa/application/test_registered_gate.py @@ -0,0 +1,26 @@ +import importlib.util, pathlib, tempfile, unittest +p=pathlib.Path(__file__).with_name("registered_gate.py") +s=importlib.util.spec_from_file_location("registered_gate",p) +g=importlib.util.module_from_spec(s) +s.loader.exec_module(g) + +class FailClosedTests(unittest.TestCase): + def test_missing_candidate(self): + with self.assertRaises(ValueError): + g.register({"job_id":"absent","candidate":"qa/application/jobs/absent/candidate.zip", + "sha256":"0"*64,"checks":[]}) + def test_invalid_job(self): + with self.assertRaises(ValueError): + g.register({"job_id":"../escape","candidate":"qa/application/jobs/../escape/candidate.zip", + "sha256":"0"*64,"checks":[]}) + def test_no_manifest(self): + with tempfile.TemporaryDirectory() as d: + with self.assertRaises((ValueError,KeyError)): + g.register({}) + def test_required_lifecycle(self): + self.assertEqual(len(g.REQUIRED),7) + def test_valid_stages(self): + self.assertEqual(g.VALID,{"github","vm","host"}) + +if __name__=="__main__": + unittest.main() diff --git a/qa/application/test_release_coordinator.py b/qa/application/test_release_coordinator.py new file mode 100644 index 0000000..75f6743 --- /dev/null +++ b/qa/application/test_release_coordinator.py @@ -0,0 +1,221 @@ +import hashlib +import importlib.util +import json +from pathlib import Path +import tempfile +import unittest + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +source = Path(__file__).with_name("release_coordinator.py") +spec = importlib.util.spec_from_file_location("release_coordinator", source) +gate = importlib.util.module_from_spec(spec) +spec.loader.exec_module(gate) + + +class StageChain(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + root = Path(self.tmp.name) + self.job = root / "demo" + self.job.mkdir() + self.evidence = root / "evidence" + self.trust = root / "trust" + self.trust.mkdir() + (self.job / "candidate.zip").write_bytes(b"fixed-registered-app-candidate") + checks = [{"id": "package_integrity", "stage": "github", "argv": ["python3", "-V"]}] + checks += [{"id": x, "stage": "vm", "argv": []} for x in + ("install", "upgrade", "rollback", "gui", "tray", "recovery", "security")] + checks += [{"id": "vr_host", "stage": "host", "argv": []}] + self.manifest = {"job_id": "demo", + "candidate": "qa/application/jobs/demo/candidate.zip", + "sha256": gate.sha256(self.job / "candidate.zip"), + "checks": checks} + self.save_manifest() + self.source = "a" * 40 + self.privates = {s: Ed25519PrivateKey.generate() for s in ("operator", "vm", "host")} + for stage, key in self.privates.items(): + (self.trust / (stage + ".pub")).write_bytes( + key.public_key().public_bytes( + encoding=serialization.Encoding.PEM, + format=serialization.PublicFormat.SubjectPublicKeyInfo)) + self.sign_policy() + self.make_github() + self.make_signed_stages() + + def save_manifest(self): + (self.job / "manifest.json").write_text(json.dumps(self.manifest, sort_keys=True)) + + def sign_policy(self): + # Operator approves the checklist once; each candidate ZIP revision + # must still obtain fresh GitHub, VM and host receipts. + policy = {"schema": "devnet-qa-checklist-v1", "job_id": "demo", + "checks": self.manifest["checks"]} + path = self.job / "policy.json" + path.write_text(json.dumps(policy, sort_keys=True)) + (self.job / "policy.sig").write_bytes( + self.privates["operator"].sign(path.read_bytes())) + + def write_receipt(self, stage, body): + p = self.evidence / stage + p.mkdir(exist_ok=True, parents=True) + raw = json.dumps(body, sort_keys=True).encode() + (p / "receipt.json").write_bytes(raw) + if stage in self.privates: + (p / "receipt.sig").write_bytes(self.privates[stage].sign(raw)) + + def make_github(self): + results = [] + for check in self.manifest["checks"]: + if check["stage"] == "github": + results.append({"id": check["id"], "status": "PASS", + "exit_code": 0, "output_sha256": "e" * 64}) + else: + results.append({"id": check["id"], "stage": check["stage"], + "status": "NOT_TESTED"}) + body = {"schema": 1, "job_id": "demo", + "candidate_sha256": self.manifest["sha256"], + "manifest_sha256": gate.sha256(self.job / "manifest.json"), + "github_sha": self.source, "checks": results, + "full_acceptance": False, "release_approved": False} + self.write_receipt("github", body) + + def make_signed_stages(self): + prev = gate.sha256(self.evidence / "github" / "receipt.json") + for stage in ("vm", "host"): + checks = {} + logdir = self.evidence / stage / "logs" + logdir.mkdir(parents=True, exist_ok=True) + for check in self.manifest["checks"]: + if check["stage"] != stage: + continue + cid = check["id"] + log = logdir / (cid + ".log") + log.write_text("actual executor placeholder evidence " + cid) + checks[cid] = {"status": "PASS", "log": "logs/" + cid + ".log", + "log_sha256": gate.sha256(log)} + body = {"schema": gate.SCHEMA, "stage": stage, "job_id": "demo", + "candidate_sha256": self.manifest["sha256"], + "manifest_sha256": gate.sha256(self.job / "manifest.json"), + "github_sha": self.source, + "previous_receipt_sha256": prev, "checks": checks, + "release_approved": False} + self.write_receipt(stage, body) + prev = gate.sha256(self.evidence / stage / "receipt.json") + + def signature_verifier(self, receipt, signature, pubkey): + public = serialization.load_pem_public_key(pubkey.read_bytes()) + public.verify(signature.read_bytes(), receipt.read_bytes()) + + def assess(self): + return gate.assess(self.job, self.evidence, self.trust, + "devnet82/vr-stack-control", + github_verifier=lambda path, repo, sha: ( + self.assertEqual(sha, self.source), + self.assertEqual(repo, "devnet82/vr-stack-control")), + stage_verifier=self.signature_verifier) + + def blocked(self): + with self.assertRaises((gate.Blocked, ValueError, InvalidSignature)): + self.assess() + + def test_exact_signed_chain_reaches_operator_only(self): + r = self.assess() + self.assertEqual(r["decision"], "READY_FOR_OPERATOR_ACCEPTANCE") + self.assertIs(r["release_approved"], False) + self.assertEqual(r["host"], "PASS_SIGNED") + + def test_changed_candidate_cannot_inherit_evidence(self): + (self.job / "candidate.zip").write_bytes(b"changed") + self.blocked() + + def test_changed_checklist_invalidates_every_stage(self): + self.manifest["checks"].append({"id": "new_regression", "stage": "vm", "argv": []}) + self.save_manifest() + self.blocked() + + def test_missing_github_is_blocked(self): + (self.evidence / "github" / "receipt.json").unlink() + with self.assertRaises((OSError, gate.Blocked)): + self.assess() + + def test_missing_vm_is_blocked(self): + (self.evidence / "vm" / "receipt.sig").unlink() + self.blocked() + + def test_tampered_vm_evidence_is_blocked(self): + p = self.evidence / "vm" / "receipt.json" + p.write_bytes(p.read_bytes() + b" ") + with self.assertRaises(Exception): + self.assess() + + def test_wrong_vm_revision_is_blocked_even_with_valid_signature(self): + p = self.evidence / "vm" / "receipt.json" + d = json.loads(p.read_text()) + d["github_sha"] = "b" * 40 + self.write_receipt("vm", d) + self.blocked() + + def test_vm_fail_does_not_advance(self): + p = self.evidence / "vm" / "receipt.json" + d = json.loads(p.read_text()) + d["checks"]["gui"]["status"] = "FAIL" + self.write_receipt("vm", d) + self.blocked() + + def test_host_cannot_inherit_different_vm_receipt(self): + p = self.evidence / "host" / "receipt.json" + d = json.loads(p.read_text()) + d["previous_receipt_sha256"] = "0" * 64 + self.write_receipt("host", d) + self.blocked() + + def test_no_host_receipt_does_not_grant_acceptance(self): + (self.evidence / "host" / "receipt.json").unlink() + self.blocked() + + def test_wrong_github_execution_exit_is_blocked(self): + p = self.evidence / "github" / "receipt.json" + d = json.loads(p.read_text()) + d["checks"][0]["exit_code"] = 1 + self.write_receipt("github", d) + self.blocked() + + def test_13_smokes_cannot_replace_full_checklist(self): + self.manifest["checks"] = self.manifest["checks"][:1] + self.save_manifest() + self.blocked() + + def test_unsigned_host_pass_cannot_advance(self): + (self.evidence / "host" / "receipt.sig").write_bytes(b"fake-signature") + with self.assertRaises(Exception): + self.assess() + + def test_unsigned_scope_change_is_blocked(self): + self.manifest["checks"] = self.manifest["checks"][:-1] + self.save_manifest() + with self.assertRaises(Exception): + self.assess() + + def test_unsigned_candidate_revision_never_inherits_pass(self): + # The operator-approved checklist stays signed; revision changes. + # Previous receipts for the old artifact must be rejected. + self.assertEqual(self.assess()["decision"], "READY_FOR_OPERATOR_ACCEPTANCE") + self.manifest["sha256"] = "f" * 64 + self.save_manifest() + self.blocked() + + def test_tampered_vm_check_log_is_blocked(self): + (self.evidence / "vm" / "logs" / "gui.log").write_text("tampered") + self.blocked() + + def test_missing_installed_candidate(self): + (self.job / "candidate.zip").unlink() + self.blocked() + + +if __name__ == "__main__": + unittest.main() diff --git a/qa/application/test_stage_executor.py b/qa/application/test_stage_executor.py new file mode 100644 index 0000000..bde8674 --- /dev/null +++ b/qa/application/test_stage_executor.py @@ -0,0 +1,138 @@ +import json +from pathlib import Path +import sys +import tempfile +import unittest +from unittest import mock + +import stage_executor as stage +import release_coordinator as gate + +class RestrictedStageExecution(unittest.TestCase): + def setUp(self): + tmp = tempfile.TemporaryDirectory() + self.addCleanup(tmp.cleanup) + root = Path(tmp.name) + self.job = root/"demo" + self.job.mkdir() + self.ev = root/"evidence" + (self.ev/"github").mkdir(parents=True) + self.trust = root/"trust" + self.trust.mkdir() + self.driver_path = root/"protected-driver.json" + self.candidate = self.job/"candidate.zip" + self.candidate.write_bytes(b"fixture-candidate") + ids = ["install", "upgrade", "rollback", "gui", "tray", "recovery", "security"] + self.vm_ids = ids + checks = [{"id":"github_integrity","stage":"github", + "argv":["python3", "check.py"]}] + checks += [{"id":cid,"stage":"vm","argv":[]} for cid in ids] + checks += [{"id":"host_gpu","stage":"host","argv":[]}] + self.manifest = {"job_id":"demo", + "candidate":"qa/application/jobs/demo/candidate.zip", + "sha256":gate.sha256(self.candidate), "checks":checks} + (self.job/"manifest.json").write_text(json.dumps(self.manifest)) + (self.job/"policy.json").write_text(json.dumps({ + "schema":"devnet-qa-checklist-v1","job_id":"demo","checks":checks})) + (self.job/"policy.sig").write_text("fixture-untrusted") + self.sha = "a" * 40 + receipt = {"schema":1,"job_id":"demo", + "candidate_sha256":self.manifest["sha256"], + "manifest_sha256":gate.sha256(self.job/"manifest.json"), + "github_sha":self.sha, "release_approved":False, + "full_acceptance":False,"checks":[]} + for check in checks: + if check["stage"]=="github": + receipt["checks"].append({"id":check["id"], + "status":"PASS", "exit_code":0, + "output_sha256":"0"*64}) + else: + receipt["checks"].append({"id":check["id"], + "stage":check["stage"], + "status":"NOT_TESTED"}) + (self.ev/"github"/"receipt.json").write_text(json.dumps(receipt)) + self.driver = {"schema":"devnet-stage-driver-v1","stage":"vm", + "job_id":"demo", "checks":{ + cid:{"argv":[sys.executable,"-c", + "print('FIXTURE REAL SUBPROCESS EXECUTED')"], + "timeout":10} for cid in ids}} + self.driver_path.write_text(json.dumps(self.driver)) + self.params = {"job_dir":self.job,"evidence_dir":self.ev, + "driver_path":self.driver_path,"trust_dir":self.trust, + "repo":"devnet82/vr-stack-control","stage":"vm", + "github_verifier":lambda *args: None, + "signature_verifier":lambda *args: None} + + def run_vm(self): + with mock.patch.object(stage,"safe_driver",return_value=self.driver): + return stage.run_stage(**self.params) + + def test_real_subprocesses_run_and_receipt_stays_unsigned(self): + result = self.run_vm() + self.assertEqual(result["checks_passed"],7) + self.assertEqual(result["result"],"EXECUTED_UNSIGNED") + self.assertFalse((self.ev/"vm"/"receipt.sig").exists()) + receipt = json.loads((self.ev/"vm"/"receipt.json").read_text()) + self.assertEqual(set(receipt["checks"]),set(self.vm_ids)) + self.assertTrue(all(v["status"]=="PASS" for v in receipt["checks"].values())) + self.assertIn("FIXTURE REAL SUBPROCESS EXECUTED", + (self.ev/"vm"/"logs"/"gui.log").read_text()) + self.assertIs(receipt["release_approved"],False) + + def test_check_failure_never_creates_pass_receipt(self): + self.driver["checks"]["install"]["argv"] = [ + sys.executable, "-c", "raise SystemExit(2)"] + with self.assertRaises(gate.Blocked): + self.run_vm() + self.assertTrue((self.ev/"vm"/"blocked.json").exists()) + self.assertFalse((self.ev/"vm"/"receipt.json").exists()) + self.assertFalse((self.ev/"vm"/"logs"/"upgrade.log").exists()) + + def test_changed_candidate_must_restart_github(self): + self.candidate.write_bytes(b"changed") + with self.assertRaises(gate.Blocked): + self.run_vm() + + def test_existing_evidence_cannot_be_reused(self): + self.run_vm() + with self.assertRaises(gate.Blocked): + self.run_vm() + + def test_no_missing_github_handoff_allowed(self): + (self.ev/"github"/"receipt.json").unlink() + with self.assertRaises(OSError): + self.run_vm() + + def test_driver_must_be_root_controlled(self): + with self.assertRaises(gate.Blocked): + stage.safe_driver(self.driver_path, self.vm_ids) + + def test_different_driver_scope_is_blocked(self): + self.driver["checks"].pop("gui") + with self.assertRaises(gate.Blocked): + stage.validate_driver(self.driver, self.vm_ids) + + def test_exact_driver_scope_validation(self): + # GitHub setup-python can expose sys.executable as a symlink; driver + # policy intentionally requires a direct root-managed executable. + for test in self.driver["checks"].values(): + test["argv"] = ["/usr/bin/printf", "TEST_OK"] + self.assertEqual(stage.validate_driver(self.driver,self.vm_ids),self.driver) + + def test_host_pauses_before_first_real_command(self): + self.params["stage"]="host" + driver={"schema":"devnet-stage-driver-v1","stage":"host", + "job_id":"demo","checks":{"host_gpu":{ + "argv":[sys.executable,"-c","print('must never run')"], + "timeout":10}}} + def pause(): + raise gate.Blocked("gaming") + with mock.patch.object(stage,"safe_driver",return_value=driver): + with mock.patch.object(gate,"signed_stage",return_value="a"*64): + with self.assertRaises(gate.Blocked): + stage.run_stage(**self.params,pause=pause) + self.assertFalse((self.ev/"host"/"logs"/"host_gpu.log").exists()) + self.assertFalse((self.ev/"host"/"receipt.json").exists()) + +if __name__=="__main__": + unittest.main() diff --git a/qa/application/v0685_baseline_inspect.py b/qa/application/v0685_baseline_inspect.py new file mode 100644 index 0000000..e6de988 --- /dev/null +++ b/qa/application/v0685_baseline_inspect.py @@ -0,0 +1,49 @@ +#!/usr/bin/env python3 +"""Read-only inventory of the ORIGINAL published full-application release ZIP. + +All findings are about package bytes, NOT install, GUI, rollback or release QA. +""" +import argparse +import hashlib +import json +from pathlib import Path, PurePosixPath +import zipfile + +APP_FILES = {"install.sh", "uninstall.sh", "bin/vr-control", + "bin/vr-control-gui", "bin/vr-stack-tray", "bin/vr-stackd"} + +def inspect(path: Path): + with zipfile.ZipFile(path) as z: + names = [i.filename for i in z.infolist() if not i.is_dir()] + leading = {name.split("/", 1)[0] for name in names if "/" in name} + prefix = (next(iter(leading)) + "/") if len(leading) == 1 and all( + name.startswith(next(iter(leading)) + "/") for name in names) else "" + normalized = [n[len(prefix):] if prefix else n for n in names] + missing = sorted(APP_FILES - set(normalized)) + if missing: + raise ValueError("Not a verified full-app package; required files absent: " + repr(missing)) + scripts = sorted(x for x in normalized if x.startswith("tests/")) + result = {"schema":"devnet-original-zip-inventory-v1", + "zip_sha256": hashlib.sha256(path.read_bytes()).hexdigest(), + "entries":len(names),"root_prefix":prefix, + "required_app_files":sorted(APP_FILES), + "packaged_test_paths":scripts, + "packaged_test_count":len(scripts), + "full_app_layout":"PASS", + "real_installed_system":"NOT_TESTED", + "gui":"NOT_TESTED","tray":"NOT_TESTED", + "upgrade":"NOT_TESTED","rollback":"NOT_TESTED", + "release_approved":False} + return result + +def main(): + p = argparse.ArgumentParser() + p.add_argument("candidate",type=Path) + p.add_argument("receipt",type=Path) + a = p.parse_args() + result = inspect(a.candidate) + a.receipt.write_text(json.dumps(result,indent=2,sort_keys=True)+"\n") + print("FULL_APP_LAYOUT: PASS; INSTALLED_SYSTEM: NOT_TESTED; RELEASE: BLOCKED") + +if __name__ == "__main__": + main() diff --git a/qa/application/v0685_installed_lifecycle.py b/qa/application/v0685_installed_lifecycle.py new file mode 100644 index 0000000..d0f53b1 --- /dev/null +++ b/qa/application/v0685_installed_lifecycle.py @@ -0,0 +1,121 @@ +#!/usr/bin/env python3 +"""Execute original VR Stack Control v0.6.85 installer in disposable CI HOME. + +This is an Ubuntu GitHub runner smoke/lifecycle stage only, NEVER a CachyOS +installed-VM, GUI, tray, hardware, rollback or user acceptance certification. +No sudo, no release writes, no modification to host user's actual HOME. +""" +import hashlib +import json +import os +from pathlib import Path +import subprocess +import sys +import tempfile +import zipfile + + +def sha(p): + return hashlib.sha256(p.read_bytes()).hexdigest() + +def main(candidate, evidence): + evidence.mkdir(parents=True, exist_ok=True) + report = {"schema":"devnet-v0685-disposable-ubuntu-lifecycle-v1", + "candidate_sha256": sha(candidate), + "checks":{}, "release_approved":False, + "installed_cachyos_vm":"NOT_TESTED", + "real_gui":"NOT_TESTED", + "real_tray":"NOT_TESTED", + "upgrade_to_new_revision":"NOT_TESTED", + "transactional_rollback":"NOT_TESTED"} + with tempfile.TemporaryDirectory(prefix="devnet-v0685-", dir="/tmp") as tmp: + root=Path(tmp) + home=root/"home" + home.mkdir(mode=0o700) + src=root/"source" + src.mkdir() + with zipfile.ZipFile(candidate) as z: + z.extractall(src) + packages=list(src.glob("*/install.sh")) + if len(packages)!=1: + raise ValueError("exactly one full-app install.sh required") + app=packages[0].parent + sentinel=home/"outside-app-preserve.txt" + sentinel.write_text("DO_NOT_TOUCH") + config=home/".config/vr-stack/stack.conf" + config.parent.mkdir(parents=True) + config.write_text("DEVNET_PRESERVE_SENTINEL=yes\n") + env={**os.environ, + "HOME":str(home), + "XDG_CONFIG_HOME":str(home/".config"), + "XDG_DATA_HOME":str(home/".local/share"), + "XDG_RUNTIME_DIR":str(root/"runtime"), + "PATH":os.environ.get("PATH","/usr/bin:/bin")} + (root/"runtime").mkdir(mode=0o700) + expected={ + "vr-control":"bin/vr-control", + "vr-control-gui":"bin/vr-control-gui", + "vr-doctor":"bin/vr-doctor", + "vr-stackd":"bin/vr-stackd", + "vr-stack-tray":"bin/vr-stack-tray"} + def test(id, callback): + try: + detail=callback() + report["checks"][id]={"status":"PASS","detail":str(detail)[:300]} + print(id,"PASS",flush=True) + except Exception as e: + report["checks"][id]={"status":"FAIL","detail":repr(e)[:350]} + print(id,"FAIL",str(e),flush=True) + raise + def command(name): + p=subprocess.run(["/usr/bin/bash",name], + cwd=app,env=env,stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE,stderr=subprocess.STDOUT, + text=True,timeout=45,check=False) + (evidence/(name+".log")).write_text(p.stdout) + if p.returncode: + raise RuntimeError(f"{name} returned {p.returncode}; review log artifact") + return f"{name} returned 0" + def installed(): + for name,original in expected.items(): + observed=home/".local/bin"/name + if not observed.is_file(): + raise ValueError("missing installed "+name) + if sha(observed)!=sha(app/original): + raise ValueError("installed file does not match ZIP "+name) + return f"{len(expected)} exact installed executable hashes verified" + try: + test("fresh_install",lambda:command("install.sh")) + test("exact_install_hashes",installed) + test("reinstall",lambda:command("install.sh")) + test("reinstalled_file_hashes",installed) + def config_preserved(): + if config.read_text()!="DEVNET_PRESERVE_SENTINEL=yes\n": + raise ValueError("pre-existing user configuration changed") + return "original config bytes unchanged" + test("preserve_existing_config",config_preserved) + test("uninstall",lambda:command("uninstall.sh")) + def uninstalled(): + present=[name for name in expected if (home/".local/bin"/name).exists()] + if present: + raise ValueError("installed app files remain after uninstall: "+str(present)) + return "app executables removed" + test("uninstall_removes_app_files",uninstalled) + test("uninstall_preserves_user_config",config_preserved) + def outside_preserved(): + if sentinel.read_text()!="DO_NOT_TOUCH": + raise ValueError("unrelated user data changed") + return "unrelated data unchanged" + test("outside_user_data_preserved",outside_preserved) + except Exception: + report["result"]="FAIL" + else: + report["result"]="PASS_LIMITED" + finally: + (evidence/"lifecycle-receipt.json").write_text(json.dumps(report,indent=2,sort_keys=True)+"\n") + return 0 if report["result"]=="PASS_LIMITED" else 2 + +if __name__=="__main__": + if len(sys.argv)!=3: + raise SystemExit("usage: v0685_installed_lifecycle.py CANDIDATE_ZIP EVIDENCE_DIR") + sys.exit(main(Path(sys.argv[1]).resolve(),Path(sys.argv[2]).resolve())) diff --git a/qa/application/v0685_stage_gate.py b/qa/application/v0685_stage_gate.py new file mode 100644 index 0000000..6a018dc --- /dev/null +++ b/qa/application/v0685_stage_gate.py @@ -0,0 +1,105 @@ +#!/usr/bin/env python3 +"""Independent fail-closed scope-limited v0.6.85 GitHub + native guest evidence check. + +Never turns a genuine CachyOS LIVE ISO run into installed-KDE/host acceptance. +""" +import hashlib +import json +import os +from pathlib import Path +import re +import sys + +EXPECTED="db9af6ff47f3abcf07cf452ddd91e91cbd75a10296c0c6050bb5c612980d9cf3" +UBUNTU={"fresh_install","exact_install_hashes","reinstall", + "reinstalled_file_hashes","preserve_existing_config","uninstall", + "uninstall_removes_app_files","uninstall_preserves_user_config", + "outside_user_data_preserved"} +GUEST_PACKAGE={"native_cachyos","control_disk_identity","exact_sha256", + "zip_integrity","full_application_layout","native_syntax", + "version_bin_vr-control","version_bin_vr-control-gui", + "version_bin_vr-stack-tray"} +GUEST_LIFECYCLE={"fresh_install","exact_install_hashes","reinstall", + "reinstalled_hashes","configuration_preserved","uninstall", + "uninstall_removes_app_files","uninstall_preserves_unrelated_data"} + +class Blocked(Exception): + pass + +def sha(path): + h=hashlib.sha256() + with path.open("rb") as f: + for b in iter(lambda:f.read(1048576),b""): + h.update(b) + return h.hexdigest() + +def must(cond,msg): + if not cond: raise Blocked(msg) + +def verify_stage(doc,ids,overall,scope): + must(isinstance(doc,dict) and doc.get("release_approved") is False,scope+" unsafe approval") + must(doc.get("overall",doc.get("result"))==overall,scope+" incomplete outcome") + checks=doc.get("checks") + must(isinstance(checks,dict) and set(checks)==ids,scope+" incomplete check registry") + for key in sorted(ids): + item=checks[key] + must(isinstance(item,dict) and item.get("status")=="PASS", + scope+" check "+key+" not PASS") + return len(ids) + +def evaluate(candidate,ubuntu,guest,guest_lifecycle,source_sha,run_id): + must(bool(re.fullmatch("[a-f0-9]{40}",source_sha)),"invalid exact GitHub commit") + must(str(run_id).isdigit() and int(run_id)>0,"invalid GitHub run") + must(candidate.is_file() and not candidate.is_symlink() + and sha(candidate)==EXPECTED,"wrong candidate ZIP bytes") + docs=[] + for path in (ubuntu,guest,guest_lifecycle): + must(path.is_file() and not path.is_symlink(),"required original evidence missing") + docs.append(json.loads(path.read_text())) + u,g,l=docs + for idx,doc in enumerate(docs): + must(doc.get("candidate_sha256")==EXPECTED, + "check evidence candidate SHA mismatch: "+str(idx)) + n1=verify_stage(u,UBUNTU,"PASS_LIMITED","ubuntu-disposable") + n2=verify_stage(g,GUEST_PACKAGE,"PARTIAL","real-cachyos-liveiso-package") + n3=verify_stage(l,GUEST_LIFECYCLE,"PASS_LIMITED","real-cachyos-liveiso-lifecycle") + must(g.get("schema")=="devnet-native-cachyos-v0685-package-v1", + "not the correct native guest") + must(l.get("schema")=="devnet-v0685-native-cachyos-lifecycle-v1", + "not the correct native lifecycle") + must(u.get("schema")=="devnet-v0685-disposable-ubuntu-lifecycle-v1", + "not the correct Ubuntu lifecycle") + must(g.get("installed_cachyos_system")=="NOT_TESTED" + and l.get("installed_cachyos_vm")=="NOT_TESTED" + and u.get("real_gui")=="NOT_TESTED", + "missing explicit untested limits") + return {"schema":"devnet-v0685-github-scope-receipt-v1", + "github_sha":source_sha,"github_run_id":str(run_id), + "candidate_sha256":EXPECTED, + "original_candidate":"vr-stack-control-v0.6.85.zip", + "candidate_type":"original-full-app-historical-baseline", + "ubuntu_disposable_install_pass":n1, + "real_cachyos_liveiso_package_pass":n2, + "real_cachyos_liveiso_lifecycle_pass":n3, + "source_evidence_sha256":{str(i):sha(p) for i,p in enumerate( + (ubuntu,guest,guest_lifecycle))}, + "decision":"PASS_LIMITED", + "installed_cachyos_kde_gui":"NOT_TESTED", + "tray_pointer_and_visibility":"NOT_TESTED", + "upgrade_rollback_revision":"NOT_TESTED", + "physical_vr_host":"NOT_TESTED", + "full_master_qa":"BLOCKED", + "release_approved":False} + +if __name__=="__main__": + if len(sys.argv)!=6: + raise SystemExit("usage: v0685_stage_gate.py candidate.zip ubuntu.json guest.json guest_lifecycle.json output.json") + try: + paths=list(map(Path,sys.argv[1:])) + receipt=evaluate(*paths[:4],os.environ["GITHUB_SHA"],os.environ["GITHUB_RUN_ID"]) + must(not paths[4].exists(),"cannot overwrite previously accepted evidence") + paths[4].write_text(json.dumps(receipt,indent=2,sort_keys=True)+"\n") + print("EXACT_GITHUB_AND_LIVE_CACHYOS_STAGE: PASS_LIMITED; MASTER_QA: BLOCKED") + except (OSError,KeyError,ValueError,TypeError,Blocked,json.JSONDecodeError) as ex: + print("EVIDENCE_BLOCKED:",repr(ex),file=sys.stderr) + raise SystemExit(2) diff --git a/qa/devnet-safe-ai-v0.1.1/candidate.zip.b64 b/qa/devnet-safe-ai-v0.1.1/candidate.zip.b64 new file mode 100644 index 0000000..8ab6061 --- /dev/null +++ b/qa/devnet-safe-ai-v0.1.1/candidate.zip.b64 @@ -0,0 +1 @@ +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 diff --git a/qa/devnet-safe-ai-v0.1.2/candidate.zip.b64 b/qa/devnet-safe-ai-v0.1.2/candidate.zip.b64 new file mode 100644 index 0000000..5158704 --- /dev/null +++ b/qa/devnet-safe-ai-v0.1.2/candidate.zip.b64 @@ -0,0 +1 @@ +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 diff --git a/qa/master/QA_RUNNER_REALITY_RULE_v0.1.0.md b/qa/master/QA_RUNNER_REALITY_RULE_v0.1.0.md new file mode 100644 index 0000000..48425ff --- /dev/null +++ b/qa/master/QA_RUNNER_REALITY_RULE_v0.1.0.md @@ -0,0 +1,20 @@ +# Devnet Master QA — additive runner reality gate v0.1.0 +**Status: QA candidate, NOT release approval.** 2026-10-31. + +This extends—not replaces—the preserved Devnet Master QA v0.3.8 baseline, its GitHub-first order, signed operator checklist, job-specific requirements, deterministic evidence gate, gaming controls, and the rule to execute 100% of applicable checks FRESH on EVERY revision. No prior PASS may be inherited. The QA runner, installer, updater and evidence collector count as software and require full applicable testing too. + +## Mandatory runner acceptance + +1. **Real-tool compatibility.** Execute the EXACT new/modified runner against actual dependency CLI/API output inside the genuine target OS/VM BEFORE relying on it for application acceptance. Record command argv, resolved binary path, binary SHA256, exit code, raw stdout/stderr, version, environment and timestamp. Bind the evidence to exact candidate ZIP SHA256, runner SHA256, job ID and revision. Mocks, simulated output and parser-only unit tests may supplement but NEVER replace live execution. +2. **Version and output matrix.** Test each parsed tool output against the tool's genuine current native formatting and boundaries. The real CachyOS output `opencode v2.2.4` must pass the OpenCode major-2 check, as must `2.2.4` and `v2.2.4`. Wrong major, malformed/foreign output, nonzero exit, missing binary, unexpected prefix, altered command, timeout, or incompatible tool version must fail closed. Test whitespace, warnings, stderr and suffixes where they occur in real output. Do not loosen validation to accept arbitrary text. +3. **Independent runner evidence.** A separate deterministic verifier must check the real executable identity, raw outcome, exact revision/artefact SHA256, complete scope, freshness, and tampering/negative cases. A runner's own PASS is not approval. Any missing, altered, stale, untrusted, inherited or simulated result must remain FAIL/BLOCKED/NOT_TESTED. +4. **ALL tests again on EVERY change.** Every changed runner/parser/dependency/configuration requires all applicable fresh GitHub-hosted tests FIRST; local CachyOS QA VM SECOND; authorised host tests THIRD. Rerun even previously passing checks. Cover install, upgrade, rollback, real runtime, sandbox/privacy/permissions, genuine GUI/tray when applicable, file writes, gaming guard and recovery, 9B/30B live switching and idle unloading when applicable. Never convert a limited unit test result into full acceptance. +5. **Prerequisites are blockers.** Missing `opencode`/`ollama`, package-mirror errors, sudo permission issues, unsupported versions, or unavailable GUI are blockers and cannot be bypassed with fake executables or inherited evidence. Fix the prerequisite safely and rerun the full stage. +6. **Signed checklist protection.** Add the check IDs `runner_real_dependency_output`, `runner_negative_contracts`, `runner_independent_evidence` to every signed job checklist for which runner validation is applicable. No altering or reducing the existing operator-approved checklist without renewed authorisation/signature. The independently protected Master QA stage/signing service remains authoritative. `release_approved=false` until all job-specific checks, signatures and final user acceptance are complete. +7. **Change control.** Preserve original baseline bytes and real evidence. Version this extension as a new QA candidate; perform GitHub-first tests before activating it locally. Never claim the existing immutable baseline/deployed runner was rewritten merely because this draft policy exists. + +## Incident record / permanent regression + +On 31 October 2026, Devnet Safe AI v0.1.2 local Master QA r2 rejected valid native OpenCode output `opencode v2.2.4` because the runner only recognised bare numeric versions. Its 30 unit regressions passed but the real VM stage was blocked. This was a defect in the QA runner, not evidence that the application was broken. Runner r3 changed the parser; its live VM/host gates remain unverified until executed. + +The associated executable `runner_reality_gate.py` captures real OpenCode CLI evidence and checks it again, bound to runner/candidate hashes. Its local and GitHub unit tests remain **development-only** until the true VM and independently signed Master QA acceptance are proven. No automatic release approval. diff --git a/qa/master/runner_reality_gate.py b/qa/master/runner_reality_gate.py new file mode 100644 index 0000000..4de10d0 --- /dev/null +++ b/qa/master/runner_reality_gate.py @@ -0,0 +1,109 @@ +#!/usr/bin/env python3 +"""Additive Devnet Master QA real CLI contract check; unsigned, never approves release.""" +import argparse +from datetime import datetime, timedelta, timezone +import hashlib +import json +from pathlib import Path +import re +import shutil +import subprocess +import sys + +SCHEMA="devnet-runner-dependency-reality-v1" +VERSION=re.compile(r"\A(?:opencode[ \t]+)?v?2\.(\d+)\.(\d+)(?:[-+][a-zA-Z0-9.-]+)?\Z",re.I) + +def parse_opencode(raw): + m=VERSION.fullmatch(raw.strip()) + if not m: + raise ValueError("unsupported OpenCode v2 output") + return "2."+m.group(1)+"."+m.group(2) + +def digest(p): + h=hashlib.sha256() + with Path(p).open("rb") as fp: + for chunk in iter(lambda:fp.read(1024*1024),b""): + h.update(chunk) + return h.hexdigest() + +def live_target(): + exe=shutil.which("opencode") + if not exe: + raise RuntimeError("BLOCKED: real OpenCode missing") + exe=Path(exe).resolve(strict=True) + if not exe.is_file(): + raise RuntimeError("BLOCKED: OpenCode executable unavailable") + cp=subprocess.run([str(exe),"--version"],stdin=subprocess.DEVNULL, + capture_output=True,text=True,timeout=20,check=False) + if cp.returncode: + raise RuntimeError("BLOCKED: OpenCode --version exit "+str(cp.returncode)) + return exe,cp,parse_opencode(cp.stdout) + +def identity(p): + p=Path(p).resolve(strict=True) + if not p.is_file(): + raise RuntimeError("BLOCKED: runner/candidate not a file") + return digest(p) + +def capture(runner,candidate,job,revision): + if not re.fullmatch(r"[A-Za-z0-9_-]{1,64}",job) or not re.fullmatch(r"[A-Za-z0-9._-]{1,80}",revision): + raise ValueError("invalid job/revision identity") + exe,cp,version=live_target() + return dict(schema=SCHEMA,kind="LIVE_EXECUTION",argv=[str(exe),"--version"], + binary_sha256=digest(exe),runner_sha256=identity(runner), + candidate_sha256=identity(candidate),job_id=job,revision=revision, + stdout=cp.stdout,stderr=cp.stderr,exit_code=cp.returncode, + parsed_version=version,timestamp=datetime.now(timezone.utc).isoformat(), + release_approved=False,scope="CLI_ONLY") + +def verify(r,runner,candidate,job,revision): + mandatory={"schema","kind","argv","binary_sha256","runner_sha256","candidate_sha256", + "job_id","revision","stdout","stderr","exit_code","parsed_version", + "timestamp","release_approved","scope"} + if not isinstance(r,dict) or set(r)!=mandatory: + raise ValueError("incomplete evidence") + if r["schema"]!=SCHEMA or r["kind"]!="LIVE_EXECUTION" or r["scope"]!="CLI_ONLY" or r["release_approved"] is not False or r["exit_code"]!=0: + raise ValueError("untrusted evidence or approval") + when=datetime.fromisoformat(r["timestamp"]) + if when.tzinfo is None or not timedelta(seconds=-30)<=datetime.now(timezone.utc)-when<=timedelta(minutes=15): + raise ValueError("stale/future evidence") + if (r["runner_sha256"]!=identity(runner) or r["candidate_sha256"]!=identity(candidate) + or r["job_id"]!=job or r["revision"]!=revision): + raise ValueError("different candidate or runner revision") + exe,cp,version=live_target() + if r["argv"]!=[str(exe),"--version"] or r["binary_sha256"]!=digest(exe) or r["parsed_version"]!=version: + raise ValueError("executable/version mismatch") + if r["stdout"]!=cp.stdout or parse_opencode(r["stdout"])!=version: + raise ValueError("captured stdout does not match re-executed native CLI") + return True + +def main(): + ap=argparse.ArgumentParser() + ap.add_argument("mode",choices=["capture","verify"]) + ap.add_argument("evidence",type=Path) + ap.add_argument("--runner",type=Path,required=True) + ap.add_argument("--candidate",type=Path,required=True) + ap.add_argument("--job",required=True) + ap.add_argument("--revision",required=True) + a=ap.parse_args() + try: + if a.mode=="capture": + if a.evidence.exists(): + raise ValueError("existing evidence cannot be reused") + r=capture(a.runner,a.candidate,a.job,a.revision) + a.evidence.parent.mkdir(parents=True,exist_ok=True) + with a.evidence.open("x") as out: + json.dump(r,out,indent=2,sort_keys=True) + out.write("\n") + a.evidence.chmod(0o600) + else: + verify(json.loads(a.evidence.read_text()),a.runner,a.candidate,a.job,a.revision) + print("PASS_LIMITED: REAL_CLI; MASTER QA/RELEASE STILL BLOCKED") + return 0 + except (OSError,RuntimeError,ValueError,KeyError,TypeError,IndexError, + subprocess.TimeoutExpired,json.JSONDecodeError) as e: + print("BLOCKED:",e,file=sys.stderr) + return 2 + +if __name__=="__main__": + raise SystemExit(main()) diff --git a/qa/master/test_runner_reality_gate.py b/qa/master/test_runner_reality_gate.py new file mode 100644 index 0000000..fad30b5 --- /dev/null +++ b/qa/master/test_runner_reality_gate.py @@ -0,0 +1,47 @@ +"""Development regressions only; real target VM must execute runner_reality_gate separately.""" +import unittest +from unittest import mock +import runner_reality_gate as g + +class RunnerContractTests(unittest.TestCase): + def test_exact_cachyos_v224_output(self): + self.assertEqual(g.parse_opencode("opencode v2.2.4\n"),"2.2.4") + def test_bare_version(self): + self.assertEqual(g.parse_opencode("2.2.4"),"2.2.4") + def test_v_version(self): + self.assertEqual(g.parse_opencode("v2.2.4"),"2.2.4") + def test_suffix(self): + self.assertEqual(g.parse_opencode("opencode v2.2.4-rc.1"),"2.2.4") + def test_whitespace(self): + self.assertEqual(g.parse_opencode(" opencode v2.2.4 \n"),"2.2.4") + def test_invalid_major_and_output_fail_closed(self): + for s in ("opencode v1.2.4","v3.0.0","2.2","opencode error", + "opencode v2.2.4\nbad","ollama v2.2.4","v2.2.4; rm -rf /",""): + with self.subTest(s=s),self.assertRaises(ValueError): + g.parse_opencode(s) + def test_missing_real_binary_blocks(self): + with mock.patch.object(g.shutil,"which",return_value=None): + with self.assertRaisesRegex(RuntimeError,"real OpenCode missing"): + g.live_target() + def test_nonzero_exit_blocks(self): + class Result: + returncode=2 + stdout="opencode v2.2.4" + stderr="failed" + with mock.patch.object(g.shutil,"which",return_value="/usr/bin/python3"): + with mock.patch.object(g.subprocess,"run",return_value=Result()): + with self.assertRaisesRegex(RuntimeError,"exit 2"): + g.live_target() + def test_incomplete_receipt_blocks(self): + with self.assertRaisesRegex(ValueError,"incomplete evidence"): + g.verify({},__file__,__file__,"x","r1") + def test_forged_approval_blocks(self): + e={k:None for k in ("schema","kind","argv","binary_sha256","runner_sha256", + "candidate_sha256","job_id","revision","stdout","stderr","exit_code", + "parsed_version","timestamp","release_approved","scope")} + e.update(schema=g.SCHEMA,kind="LIVE_EXECUTION",scope="CLI_ONLY", + release_approved=True,exit_code=0) + with self.assertRaisesRegex(ValueError,"untrusted evidence"): + g.verify(e,__file__,__file__,"x","r1") +if __name__=="__main__": + unittest.main() diff --git a/qa/router-stage-v0.1.0/candidate.zip b/qa/router-stage-v0.1.0/candidate.zip new file mode 100644 index 0000000..b4645aa Binary files /dev/null and b/qa/router-stage-v0.1.0/candidate.zip differ