diff --git a/.github/workflows/hourly-pr-maintenance.yml b/.github/workflows/hourly-pr-maintenance.yml index 4bd0e3d3..ef4f1a0c 100644 --- a/.github/workflows/hourly-pr-maintenance.yml +++ b/.github/workflows/hourly-pr-maintenance.yml @@ -45,7 +45,7 @@ jobs: trigger_reviews: true review_dispatch_limit: "-1" branch_update_limit: "-1" - enable_auto_merge: true - merge_mode: direct_or_auto + enable_auto_merge: false + merge_mode: disabled update_branches: true secrets: inherit diff --git a/.github/workflows/hourly-product-development.yml b/.github/workflows/hourly-product-development.yml index 4ac87337..9b12404e 100644 --- a/.github/workflows/hourly-product-development.yml +++ b/.github/workflows/hourly-product-development.yml @@ -57,8 +57,8 @@ jobs: open_prs="$( gh api "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" \ --paginate \ - --slurp \ - --jq 'map(length) | add // 0' + --jq 'length' | + awk '{total += $1} END {print total + 0}' )" if [ "$open_prs" -eq 0 ]; then echo "develop=true" >>"$GITHUB_OUTPUT" @@ -99,10 +99,10 @@ jobs: set -euo pipefail baseline="${RUNNER_TEMP}/hourly-pristine" git clone --quiet --local --no-hardlinks . "$baseline" - git -C "$baseline" rev-parse HEAD >"${RUNNER_TEMP}/hourly-development-base-sha" - sudo chown -R root:root "$baseline" "${RUNNER_TEMP}/hourly-development-base-sha" + git -C "$baseline" rev-parse HEAD >"${RUNNER_TEMP}/base-sha" + sudo chown -R root:root "$baseline" "${RUNNER_TEMP}/base-sha" sudo chmod -R a-w "$baseline" - sudo chmod 0444 "${RUNNER_TEMP}/hourly-development-base-sha" + sudo chmod 0444 "${RUNNER_TEMP}/base-sha" - name: Install the pinned OpenCode CLI if: steps.queue.outputs.develop == 'true' @@ -116,8 +116,7 @@ jobs: curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \ --output "$archive" \ "https://github.com/anomalyco/opencode/releases/download/v${OPENCODE_VERSION}/opencode-linux-x64.tar.gz" - printf '%s %s -' "$OPENCODE_SHA256" "$archive" | sha256sum --check - + printf '%s %s\n' "$OPENCODE_SHA256" "$archive" | sha256sum --check - if ! tar --list --gzip --file "$archive" | grep -qx 'opencode'; then echo "::error::The reviewed OpenCode archive did not contain the expected executable." exit 1 @@ -232,8 +231,7 @@ jobs: set -euo pipefail result_file="${RUNNER_TEMP}/opencode-result.ndjson" if [ ! -s "$result_file" ]; then - printf '%s -' '{"type":"error","message":"OpenCode produced no final result"}' >"$result_file" + printf '%s\n' '{"type":"error","message":"OpenCode produced no final result"}' >"$result_file" exit 1 fi @@ -245,7 +243,7 @@ jobs: python "${RUNNER_TEMP}/hourly-pristine/scripts/ci/hourly_product_guard.py" capture \ --workspace "$GITHUB_WORKSPACE" \ --baseline "${RUNNER_TEMP}/hourly-pristine" \ - --base-sha-file "${RUNNER_TEMP}/hourly-development-base-sha" \ + --base-sha-file "${RUNNER_TEMP}/base-sha" \ --patch-file "${RUNNER_TEMP}/egressweave.patch" \ --stat-file "${RUNNER_TEMP}/egressweave.stat" @@ -258,6 +256,7 @@ jobs: ${{ runner.temp }}/egressweave.patch ${{ runner.temp }}/egressweave.stat ${{ runner.temp }}/opencode-result.ndjson + ${{ runner.temp }}/base-sha if-no-files-found: error retention-days: 3 @@ -271,10 +270,6 @@ jobs: actions: read contents: read pull-requests: read - outputs: - publish: ${{ steps.package.outputs.publish }} - base_sha: ${{ steps.package.outputs.base_sha }} - patch_sha256: ${{ steps.package.outputs.patch_sha256 }} steps: - name: Harden runner uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 @@ -305,8 +300,8 @@ jobs: open_prs="$( gh api "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" \ --paginate \ - --slurp \ - --jq 'map(length) | add // 0' + --jq 'length' | + awk '{total += $1} END {print total + 0}' )" if [ "$open_prs" -ne 0 ] || [ "$current_sha" != "$EXPECTED_BASE_SHA" ]; then echo "verify=false" >>"$GITHUB_OUTPUT" @@ -315,6 +310,23 @@ jobs: fi echo "verify=true" >>"$GITHUB_OUTPUT" + - name: Require the exact handoff base before applying the patch + if: steps.gate.outputs.verify == 'true' + env: + EXPECTED_BASE_SHA: ${{ needs.develop.outputs.base_sha }} + run: | + set -euo pipefail + handoff_base_sha_file="${RUNNER_TEMP}/hourly-product-change/base-sha" + current_sha="$(git rev-parse HEAD)" + handoff_base_sha="$(cat "$handoff_base_sha_file")" + if [[ ! "$EXPECTED_BASE_SHA" =~ ^[0-9a-f]{40}$ ]] || \ + [[ ! "$handoff_base_sha" =~ ^[0-9a-f]{40}$ ]] || \ + [ "$current_sha" != "$EXPECTED_BASE_SHA" ] || \ + [ "$handoff_base_sha" != "$EXPECTED_BASE_SHA" ]; then + echo "::error::The patch handoff base does not match the exact checkout." + exit 1 + fi + - name: Build a trusted verifier image before applying the patch if: steps.gate.outputs.verify == 'true' id: verifier_image @@ -351,6 +363,8 @@ jobs: - name: Validate, apply, and seal the untrusted patch if: steps.gate.outputs.verify == 'true' + env: + EXPECTED_BASE_SHA: ${{ needs.develop.outputs.base_sha }} run: | set -euo pipefail patch_file="${RUNNER_TEMP}/hourly-product-change/egressweave.patch" @@ -359,11 +373,16 @@ jobs: --workspace "$GITHUB_WORKSPACE" \ --patch-file "$patch_file" \ --result-file "$result_file" + result_base_sha="$(jq -r ".base_sha" "$result_file")" + if [ "$result_base_sha" != "$EXPECTED_BASE_SHA" ]; then + echo "::error::The verified result base SHA does not match the exact handoff base." + exit 1 + fi sealed_dir="/opt/egressweave-reverify" sudo install -d -m 0555 -o root -g root "$sealed_dir" sudo install -m 0444 -o root -g root "$patch_file" "$sealed_dir/egressweave.patch" - jq -r '.base_sha' "$result_file" | sudo tee "$sealed_dir/base-sha" >/dev/null + printf '%s\n' "$EXPECTED_BASE_SHA" | sudo tee "$sealed_dir/base-sha" >/dev/null jq -r '.patch_sha256' "$result_file" | sudo tee "$sealed_dir/patch-sha256" >/dev/null sudo chown root:root "$sealed_dir/base-sha" "$sealed_dir/patch-sha256" sudo chmod 0444 "$sealed_dir/base-sha" "$sealed_dir/patch-sha256" @@ -400,17 +419,21 @@ jobs: sh -euc ' mkdir -p "$HOME" cp -R --no-preserve=ownership,mode,timestamps \ - /source/src /source/tests /source/docs \ - /source/README.md /source/CHANGELOG.md /source/pyproject.toml \ + /source/src /source/tests /source/docs /source/.github /source/scripts \ /work/ + for root_file in /source/* /source/.[!.]* /source/..?*; do + [ -f "$root_file" ] && [ ! -L "$root_file" ] || continue + cp --no-preserve=ownership,mode,timestamps "$root_file" /work/ + done ruff check . pytest -q - python -m compileall -q src tests + python -m compileall -q src tests scripts ' - - name: Record the independently verified immutable patch + - name: Recheck the independently verified immutable patch if: steps.gate.outputs.verify == 'true' - id: package + env: + EXPECTED_BASE_SHA: ${{ needs.develop.outputs.base_sha }} run: | set -euo pipefail sealed_dir="/opt/egressweave-reverify" @@ -420,164 +443,21 @@ jobs: echo "::error::The sealed patch changed during independent verification." exit 1 fi - echo "publish=true" >>"$GITHUB_OUTPUT" - echo "base_sha=$(cat "$sealed_dir/base-sha")" >>"$GITHUB_OUTPUT" - echo "patch_sha256=$expected" >>"$GITHUB_OUTPUT" - - publish: - name: Publish the independently verified change - needs: reverify - if: needs.reverify.outputs.publish == 'true' - runs-on: ubuntu-24.04 - timeout-minutes: 15 - permissions: - actions: read - contents: read - id-token: write - pull-requests: read - steps: - - name: Harden runner - uses: step-security/harden-runner@bf7454d06d71f1098171f2acdf0cd4708d7b5920 - with: - egress-policy: audit - - - name: Check out a fresh protected branch - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 - with: - ref: main - fetch-depth: 1 - persist-credentials: false - - - name: Download the independently verified change - uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c - with: - name: hourly-product-change-${{ github.run_id }}-${{ github.run_attempt }} - path: ${{ runner.temp }}/hourly-product-change - - - name: Recheck repository state and patch identity - id: publish_gate - env: - GH_TOKEN: ${{ github.token }} - EXPECTED_BASE_SHA: ${{ needs.reverify.outputs.base_sha }} - EXPECTED_PATCH_SHA256: ${{ needs.reverify.outputs.patch_sha256 }} - run: | - set -euo pipefail - patch_file="${RUNNER_TEMP}/hourly-product-change/egressweave.patch" - current_sha="$(git rev-parse HEAD)" - open_prs="$( - gh api "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" \ - --paginate \ - --slurp \ - --jq 'map(length) | add // 0' - )" - observed_patch_sha256="$(sha256sum "$patch_file" | awk '{print $1}')" - if [ "$open_prs" -ne 0 ] || [ "$current_sha" != "$EXPECTED_BASE_SHA" ]; then - echo "publish=false" >>"$GITHUB_OUTPUT" - echo "::notice::Discarding the verified patch because repository state changed before publication." - exit 0 - fi - if [ "$observed_patch_sha256" != "$EXPECTED_PATCH_SHA256" ]; then - echo "::error::The patch artifact changed after independent reverification." - exit 1 - fi - result_file="${RUNNER_TEMP}/publish-result.json" - python scripts/ci/hourly_product_guard.py apply \ - --workspace "$GITHUB_WORKSPACE" \ - --patch-file "$patch_file" \ - --result-file "$result_file" - if [ "$(jq -r '.base_sha' "$result_file")" != "$EXPECTED_BASE_SHA" ] || \ - [ "$(jq -r '.patch_sha256' "$result_file")" != "$EXPECTED_PATCH_SHA256" ]; then - echo "::error::Publisher guard identity did not match independent reverification." + base_sha="$(cat "$sealed_dir/base-sha")" + if [[ ! "$base_sha" =~ ^[0-9a-f]{40}$ ]] || \ + [ "$base_sha" != "$EXPECTED_BASE_SHA" ]; then + echo "::error::The sealed base SHA does not match the exact handoff base." exit 1 fi - echo "publish=true" >>"$GITHUB_OUTPUT" - - - name: Select a write identity that triggers downstream checks - if: steps.publish_gate.outputs.publish == 'true' - id: write_token - env: - PREFERRED_TOKEN: ${{ secrets.PR_REVIEW_MERGE_TOKEN || secrets.OPENCODE_APPROVE_TOKEN || '' }} - OIDC_AUDIENCE: opencode-github-action - OPENCODE_API_BASE_URL: https://api.opencode.ai - run: | - set -euo pipefail - if [ -n "${PREFERRED_TOKEN:-}" ]; then - echo "::add-mask::$PREFERRED_TOKEN" - echo "token=$PREFERRED_TOKEN" >>"$GITHUB_OUTPUT" - echo "source=organization-secret" >>"$GITHUB_OUTPUT" - exit 0 - fi - if [ -z "${ACTIONS_ID_TOKEN_REQUEST_TOKEN:-}" ] || [ -z "${ACTIONS_ID_TOKEN_REQUEST_URL:-}" ]; then - echo "::error::No external write identity is available; refusing a GITHUB_TOKEN-authored PR that would suppress downstream workflow events." - exit 1 - fi - request_url="${ACTIONS_ID_TOKEN_REQUEST_URL}" - separator="&" - case "$request_url" in - *\?*) ;; - *) separator="?" ;; - esac - oidc_response="$( - curl -fsS \ - -H "Authorization: Bearer ${ACTIONS_ID_TOKEN_REQUEST_TOKEN}" \ - "${request_url}${separator}audience=${OIDC_AUDIENCE}" - )" - oidc_token="$(jq -r '.value // empty' <<<"$oidc_response")" - if [ -z "$oidc_token" ]; then - echo "::error::OIDC token response was empty." - exit 1 - fi - token_response="$( - curl -fsS \ - -X POST \ - -H "Authorization: Bearer ${oidc_token}" \ - "${OPENCODE_API_BASE_URL}/exchange_github_app_token" - )" - app_token="$(jq -r '.token // empty' <<<"$token_response")" - if [ -z "$app_token" ]; then - echo "::error::OpenCode app token exchange returned no repository write token." - exit 1 - fi - echo "::add-mask::$app_token" - echo "token=$app_token" >>"$GITHUB_OUTPUT" - echo "source=opencode-app-oidc" >>"$GITHUB_OUTPUT" - - - name: Push a branch, open a pull request, and queue guarded auto-merge - if: steps.publish_gate.outputs.publish == 'true' - env: - GH_TOKEN: ${{ steps.write_token.outputs.token }} - TOKEN_SOURCE: ${{ steps.write_token.outputs.source }} - run: | - set -euo pipefail - result_file="${RUNNER_TEMP}/hourly-product-change/opencode-result.ndjson" - stat_file="${RUNNER_TEMP}/hourly-product-change/egressweave.stat" - title="feat: close the next bounded product gap" - narrative_sha256="$(sha256sum "$result_file" | awk '{print $1}')" - tests="$(printf '%s\n' '- `ruff check .`' '- `pytest -q`' '- `python -m compileall -q src tests`' '- `git diff --check`')" - branch="agent/hourly-product-gap-${GITHUB_RUN_ID}-${GITHUB_RUN_ATTEMPT}" - - git config user.name "github-actions[bot]" - git config user.email "41898282+github-actions[bot]@users.noreply.github.com" - git switch -c "$branch" - git add -A -- src/egressweave tests docs README.md CHANGELOG.md - if git diff --cached --quiet; then - echo "::error::The independently verified patch produced no committable change." - exit 1 - fi - git commit -m "$title" - git remote set-url origin "https://x-access-token:${GH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" - git push --set-upstream origin "$branch" - - body_file="${RUNNER_TEMP}/hourly-product-change/pr-body.md" - { - printf '## Autonomous product improvement\n\nThis pull request contains one bounded, independently reverified change selected while the repository had zero open pull requests. The model narrative is retained only in the short-lived workflow artifact so untrusted generated prose is not injected into the review context. Narrative SHA-256: `%s`.\n\n' "$narrative_sha256" - printf '## Verification\n\n%s\n\n' "$tests" - printf '## Diff boundary\n\n```text\n' - cat "$stat_file" - printf '```\n\n' - printf 'Generated by the hourly bounded maintainer. Write identity: `%s`. Full CI, security scans, independent reviews, and branch protections remain authoritative.\n' "$TOKEN_SOURCE" - } >"$body_file" - - pr_url="$(gh pr create --base main --head "$branch" --title "$title" --body-file "$body_file")" - gh pr merge "$pr_url" --auto --squash + - name: Upload the independently verified handoff + if: steps.gate.outputs.verify == 'true' + uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a + with: + name: hourly-verified-product-change-${{ github.run_id }}-${{ github.run_attempt }} + path: | + /opt/egressweave-reverify/egressweave.patch + /opt/egressweave-reverify/base-sha + /opt/egressweave-reverify/patch-sha256 + if-no-files-found: error + retention-days: 3 diff --git a/CHANGELOG.md b/CHANGELOG.md index c739fe2d..743ee9a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -60,6 +60,11 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). without changing the centrally managed review-agent credential contract. ### Security +- Reject non-exact integer subclasses in connection-pool count fields before + finite capacity is retained. Exact built-in integers and reviewed ASCII + decimal strings remain supported and normalize to built-in integers; callers + using custom integer subclasses must convert them deliberately before trusted + policy construction. - Require the request timeout policy to use the exact `EgressTimeoutPolicy` type during trusted construction. Timeout-policy subclasses are rejected before transport dispatch can dynamically invoke an overridden `as_httpcore_timeout()`, @@ -68,6 +73,10 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). type during trusted construction. Connection-pool policy subclasses are rejected before subclass-controlled attributes can diverge from reviewed finite pool capacity and fingerprinting. +- Remove the repository-write publisher from the autonomous product scheduler + and disable hourly scheduler auto-merge. Verified model output now ends at a + short-lived handoff; any pull-request merge remains current-head reviewed and + operator-controlled under normal protection. - Canonicalize the public manifest writer's optional `forbidden_root` before any output-parent creation or output-path access. Missing, non-directory, symlinked, unresolvable, or otherwise noncanonical roots now fail with one diff --git a/README.md b/README.md index dc01870a..a17edfde 100644 --- a/README.md +++ b/README.md @@ -324,20 +324,20 @@ Two hourly, credential-separated workflows keep the pull-request queue and the product roadmap moving without bypassing normal governance: - at minute `07`, the repository calls the organization-owned review-fix and - merge schedulers to inspect feedback, recheck current-head evidence, update - eligible branches, and merge only when every central gate permits it; + merge schedulers to inspect feedback, recheck current-head evidence, and + update eligible branches; final merges remain operator-controlled; - at minute `37`, a bounded OpenCode maintainer backed by `NVIDIA_NIM_API_KEY` runs only when there are zero open pull requests and implements one test-driven improvement. -The product workflow uses three fresh runners. The model job has read-only -GitHub permissions, no direct network access, and can emit only a guard-checked -patch. A second credential-free job builds trusted dependencies before applying -the patch and executes modified source only inside an offline, non-root, -capability-free, read-only verifier container. A third publisher rechecks the -sealed patch but never executes modified package code before obtaining an -external write identity. CI, security scans, independent reviews, branch -protection, and guarded auto-merge remain authoritative. See +The product workflow uses two fresh runners. The model job has read-only GitHub +permissions, no direct network access, and can emit only a guard-checked patch. +A second credential-free job builds trusted dependencies before applying the +patch and executes modified source only inside an offline, non-root, +capability-free, read-only verifier container. It emits only a short-lived +digest-bound handoff; no repository-local job obtains write authority or +publishes the patch. CI, security scans, independent reviews, branch +protection, and the operator-controlled merge boundary remain authoritative. See [`docs/hourly-autonomous-maintenance.md`](docs/hourly-autonomous-maintenance.md) for the complete control and configuration contract. diff --git a/docs/hourly-autonomous-maintenance.md b/docs/hourly-autonomous-maintenance.md index a4090956..46c98e76 100644 --- a/docs/hourly-autonomous-maintenance.md +++ b/docs/hourly-autonomous-maintenance.md @@ -1,16 +1,17 @@ # Hourly autonomous maintenance EgressWeave uses two deliberately separate hourly workflows. Pull-request -governance stays independent from product-development model execution, and -untrusted model-controlled source never shares a job with repository write -credentials. +governance stays independent from product-development model execution. The +product scheduler can produce and independently verify a bounded patch, but it +has no repository-write, ref, release, package, attestation, or publication +identity. ## Cadence | Minute | Workflow | Responsibility | |---:|---|---| | `07` | `Hourly PR Maintenance` | Inspect every open pull request, dispatch bounded review-feedback repairs, re-read live reviews and checks, update eligible branches, and merge only when the central policy permits it. | -| `37` | `Hourly Autonomous Product Development` | Run only when the repository has zero open pull requests, produce one bounded buyer-visible improvement, independently reverify it, and publish it as a normal pull request. | +| `37` | `Hourly Autonomous Product Development` | Run only when the repository has zero open pull requests, produce one bounded buyer-visible improvement, independently reverify it, and retain a short-lived digest-bound patch handoff for external review. | GitHub may delay scheduled runs while hosted-runner capacity is constrained. Workflow concurrency prevents overlapping hourly runs, while repository CI @@ -25,25 +26,32 @@ workflows from `ContextualWisdomLab/.github` at an immutable commit: dispatch the centrally controlled review autofix workflow. 2. `pr-review-merge-scheduler.yml` re-reads the live pull request, reviews, unresolved threads, required checks, branch state, and head SHA before it - updates, queues, or merges anything. + updates anything. This repository disables scheduler merges; an operator + must perform the final normal protected merge after rechecking that evidence. The central workflow resolves its co-located scheduler implementation from the -called workflow's own immutable repository and SHA. The EgressWeave workflow -does not duplicate governance logic or execute scheduler code from a mutable -branch. +called workflow's own immutable repository and SHA. The EgressWeave product +scheduler does not repurpose or alter that inherited review-agent credential +contract. ## Zero-PR product-development loop -`.github/workflows/hourly-product-development.yml` uses three fresh Ubuntu 24.04 +`.github/workflows/hourly-product-development.yml` uses two fresh Ubuntu 24.04 runners. The model job can only emit a bounded patch and does not execute -model-modified repository code; the reverifier can execute that patch only -inside an offline least-privilege container; and the publisher can write to -GitHub but never executes modified package code. +model-modified repository code. The reverifier executes that patch only inside +an offline least-privilege container and emits a short-lived handoff containing +the exact protected-main base SHA, patch SHA-256, and patch bytes. -Every zero-open-PR decision—the initial development gate, the independent -reverification gate, and the publication gate—uses GitHub CLI pagination and -sums every REST response page. A pull request beyond the first 100 results -therefore still blocks development, reverification, and publication. +The scheduler does not create a branch, pull request, or auto-merge request. It +does not obtain a repository-write token, exchange OIDC for a GitHub App token, +move a ref, reapply a patch under a write identity, publish a package, or create +a release. + +Both zero-open-PR decisions—the initial development gate and the independent +reverification gate—use GitHub CLI pagination and sum every REST response page. +A pull request beyond the first 100 results therefore still blocks model +execution and reverification. The second gate also requires the protected-main +head to equal the exact base SHA captured before model execution. ### 1. Read-only development and patch capture @@ -59,10 +67,10 @@ model is `nvidia/nemotron-3-super-120b-a12b`. The model execution boundary provides: -- block-mode runner egress restricted to the reviewed package sources, GitHub, - and `integrate.api.nvidia.com:443`; -- deny-by-default OpenCode permissions, with edits limited to the normal bounded - source, test, documentation, README, and CHANGELOG paths; +- block-mode runner egress restricted to reviewed package sources, GitHub, and + `integrate.api.nvidia.com:443`; +- deny-by-default OpenCode permissions, with edits limited to the bounded source, + test, documentation, README, and CHANGELOG paths; - an isolated empty `HOME` and XDG configuration/data/cache roots, plus `OPENCODE_DISABLE_PROJECT_CONFIG=true`, so repository or runner OpenCode configuration, auto-discovered agents, commands, and plugins cannot augment @@ -72,7 +80,7 @@ The model execution boundary provides: workflow edits; - no Ruff, pytest, compileall, Python-module, code-generation, or other model-modified repository execution while the model credential is present; - only exact read-only Git diff/status shell commands are permitted; + only exact read-only Git diff and status commands are permitted; - disabled OpenCode auto-update, remote model-list refresh, default plugins, and LSP downloads; - an exact credential-disclosure scan that reports only affected paths and never @@ -89,14 +97,15 @@ After model execution, only the protected baseline copy of `scripts/ci/hourly_product_guard.py` runs on the host. It uses an alternate Git index and NUL-safe path handling to reject deletions, renames, mode changes, executables, links, binaries, unsafe paths, oversized files, and oversized -diffs. The job uploads only the resulting patch, diff stat, and a short-lived -model summary. The patch is authoritative; generated prose is never injected -into the pull-request review context. +diffs. The job uploads the resulting patch, diff stat, model result, and the +captured `base-sha` only for the next credential-free job. That first artifact is +untrusted until independent reverification succeeds. ### 2. Credential-free isolated reverification A fresh runner has no secrets, no OIDC permission, and no repository-write -permission. Before applying the patch, it builds a verifier image from the +permission. Before applying the patch, it rechecks all open pull-request pages +and the exact protected-main base SHA. It then builds a verifier image from the protected branch and installs the trusted dependency and test toolchain. The Python base image is resolved to an immutable repository digest, and the built verifier is addressed by its immutable image ID. @@ -112,21 +121,34 @@ Modified source and tests then execute only in a container configured with: - a read-only source mount and no Docker socket, secrets, or host write mount. Inside that boundary, Ruff, pytest, and compileall run against the patched -source. A successful job emits only the protected base SHA and SHA-256 digest -of the independently verified patch. - -### 3. Credential-isolated publication - -A third fresh runner checks the zero-PR condition, protected-branch SHA, patch -SHA-256, and guard result again. It applies the patch for publication but does -not install or execute the modified package or tests. Only after those checks -does it obtain a write identity, preferring an organization maintenance secret -and otherwise using the centrally operated OpenCode GitHub App OIDC exchange. - -The publisher creates an `agent/hourly-product-gap-*` branch and pull request -and requests squash auto-merge. It never writes directly to `main`. Normal CI, -security scans, independent review, unresolved-thread checks, branch -protection, and the hourly PR loop remain authoritative. +source. The job rehashes the sealed patch and validates the 40-character base +SHA before uploading exactly three owner-readable files: + +```text +egressweave.patch +base-sha +patch-sha256 +``` + +The artifact name includes the workflow run and attempt, and retention is three +days. Successful reverification proves only that this exact patch passed the +configured checks against this exact base in the isolated job. It is not a pull +request, approval, merge authorization, provenance statement, or release. + +### 3. External promotion boundary + +No repository-local product-development job promotes the verified handoff. A +future external credential-separated promotion mechanism may consume it only +after independent review of that mechanism and its immutable source. Before any +repository write, that mechanism must independently acquire the exact artifact, +verify the base SHA and patch SHA-256, reconstruct and verify the exact tree, +recheck the live protected-main head and complete pull-request state, and obtain +all required independent approvals and security gates. + +No such promotion mechanism is claimed by this repository. When it is absent, +the verified artifact expires without publication. Operators must not manually +reinterpret a successful reverification job as permission to push, open a pull +request, enable auto-merge, or bypass branch protection. ## Model change boundary @@ -151,22 +173,20 @@ The scheduled product-development workflow requires: - `NVIDIA_NIM_API_KEY`, mapped only to OpenCode's `NVIDIA_API_KEY` environment variable for the NVIDIA NIM endpoint; -- either `PR_REVIEW_MERGE_TOKEN`, `OPENCODE_APPROVE_TOKEN`, or a working - organization OpenCode App OIDC exchange for a write identity that triggers - downstream pull-request events; - the standard Docker installation available on GitHub-hosted Ubuntu runners. -The workflow fails closed when the model credential, immutable verifier image, -container isolation, or external write identity is unavailable. It never falls -back to a repository `GITHUB_TOKEN`-authored pull request or a direct `main` -write. +The workflow fails closed when the model credential, protected base identity, +immutable verifier image, container isolation, or patch identity is unavailable. +It has no fallback repository-write identity and does not reuse review-agent, +release, package, attestation, or ref credentials. ## Manual operation -Both workflows support `workflow_dispatch`. Manual runs use the same checks, -concurrency, permissions, patch boundary, container isolation, full REST -pagination, and publication gates as scheduled runs. A manual run cannot bypass -the zero-open-PR condition or any repository policy. +Both workflows support `workflow_dispatch`. Manual product-development runs use +the same read-only permissions, exact-base checks, patch boundary, container +isolation, full REST pagination, and non-publication boundary as scheduled runs. +A manual run cannot bypass the zero-open-PR condition or turn the verified +handoff into a repository write. ## Agent implementation references diff --git a/docs/release-evidence-preparation.md b/docs/release-evidence-preparation.md new file mode 100644 index 00000000..81da606f --- /dev/null +++ b/docs/release-evidence-preparation.md @@ -0,0 +1,173 @@ +# Release evidence preparation runbook + +## Purpose and trust boundary + +`scripts/ci/prepare_release_evidence.py` prepares the exact credential-free input +set consumed by the shipped sealed-evidence verifier. It operates only on inert, +already-built wheel and source-distribution archives. It does not import either +distribution, resolve dependencies, use the network, sign an artifact, publish a +package, create or move a tag, alter a protected ref, or acquire repository-write, +OIDC, attestation, package-index, or release credentials. + +The preparation control is intentionally separate from the organization-owned +credentialed attestation workflow. A valid handoff proves that one exact local +six-file set is internally consistent and bound to an explicit repository and +source commit. It does **not** prove that the distributions were honestly built +from that source and does not claim a SLSA Build level. That stronger claim +requires independently reviewed hosted-build provenance and credential-separated +attestation verification. + +## Initial directory contract + +Use a fresh real directory reached through a canonical absolute path with no +symbolic-link component. Before preparation, the directory must contain exactly +two regular direct-child files for one matching stable version: + +```text +egressweave-X.Y.Z-py3-none-any.whl +egressweave-X.Y.Z.tar.gz +``` + +Any additional file, nested directory, symbolic link, malformed archive name, +duplicate distribution kind, or wheel/source version mismatch fails before an +evidence output is created. The reviewed dependency manifest and hash-locked +runtime requirements must also be existing canonical regular files. + +Each selected wheel and source distribution is preflighted as a current regular +file with a finite compressed-byte bound before the deterministic generator is +loaded or any ZIP or tar archive parser runs. The preparer records that exact +device, inode, and size identity, opens the pathname with no-follow semantics, +and requires the opened descriptor and current pathname to match the accepted +identity. It then copies the bounded descriptor bytes into a fresh owner-only +parser-only snapshot with the same canonical filename. The parser receives only +that private snapshot and never the caller-controlled evidence pathname, so a +post-preflight path replacement cannot redirect parser work to an alternate or +oversized archive. + +The preparer rechecks the accepted descriptor and pathname after the bounded +copy. Archive-member cardinality, path, metadata, and semantic validation remain +separate controls. The original distributions are independently descriptor-bound, +hashed, and revalidated again while checksums and the final sealed evidence set +are produced. A writer that mutates the same accepted inode can still make the +candidate fail at a later digest or identity check; the control does not claim +immutable local storage and never converts such a race into trusted evidence. + +The handoff-manifest parent must already exist as a real canonical directory. The +handoff path must name one regular file and remain outside the evidence directory. +The preparer never creates convenience directory aliases and never follows an +output-path symbolic link. + +## Credential-free command + +Run this only after the exact protected-main source commit has passed all quality, +security, review, approval, package-acceptance, and reproducibility gates: + +```bash +PYTHONPATH=src python scripts/ci/prepare_release_evidence.py \ + --evidence-dir "$RUNNER_TEMP/release-evidence" \ + --handoff-manifest "$RUNNER_TEMP/release-evidence-manifest.json" \ + --repository ContextualWisdomLab/EgressWeave \ + --source-sha "$GITHUB_SHA" \ + --dependency-manifest scripts/ci/release_runtime_dependencies.json \ + --runtime-lock requirements-ci.txt +``` + +The command must run in a job whose token has no write permission and whose +checkout is detached at the exact accepted source SHA with persisted credentials +disabled. The job must not expose signing, publication, release, tag, model, or +attestation credentials. + +## Generated contract + +The preparer computes both deterministic CycloneDX 1.7 JSON documents from the +private identity-bound parser snapshots, constructs canonical strict-JSON source +identity, computes sorted lowercase SHA-256 entries over the original accepted +distributions and generated payloads, and then exclusively creates owner-only +generated files. The private parser snapshots are deleted with their temporary +directory before any generated evidence is published. After successful +preparation, the evidence directory contains exactly: + +```text +egressweave-X.Y.Z-py3-none-any.whl +egressweave-X.Y.Z-py3-none-any.whl.cdx.json +egressweave-X.Y.Z.tar.gz +egressweave-X.Y.Z.tar.gz.cdx.json +SOURCE_IDENTITY.json +SHA256SUMS +``` + +`SOURCE_IDENTITY.json` uses the versioned canonical profile documented in +`sealed-release-evidence.md`. `SHA256SUMS` covers all five other payloads and is +ordered by filename, not by digest. The SBOM root components bind to the exact +archive filenames and SHA-256 values and use deterministic content-derived UUID +version 5 serial numbers. + +The preparer then invokes the shipped verifier to: + +1. validate cardinality, names, sizes, strict JSON, source identity, checksums, + CycloneDX profile, artifact/SBOM bindings, and descriptor/path identity; +2. create a new owner-only deterministic handoff manifest outside the set; +3. independently rebuild the complete manifest semantics from a second bounded + evidence pass; +4. reread the closed handoff through bounded descriptor/path checks; and +5. report success only when both post-publication snapshots exactly match. + +## Failure and retry semantics + +Every failure is non-success. A failed run may leave newly created but untrusted +partial evidence because local filesystems cannot atomically publish six separate +paths as one transaction. Never repair, overwrite, or reuse that candidate in +place. Delete the complete disposable directory and failed handoff, rebuild the +wheel and source distribution from the unchanged exact accepted source in a clean +credential-free job, and run the full preparation again with a fresh output path. + +Do not treat a queued check, review latency, an incomplete automated review, or a +pending external approval as accepted release evidence. Do not pass a failed or +partially generated set to any job holding write, OIDC, attestation, publication, +tag, or release authority. + +## Credentialed consumer requirements + +A later organization-owned reusable workflow may consume only an immutable copy +of the six payloads plus the separately stored handoff. Before requesting an +attestation, that workflow must recheck the exact repository, source SHA, +source-identity digest, checksum-file digest, payload cardinality, and every +payload digest. It must not rebuild archives, resolve dependencies, import the +wheel, execute repository scripts, or accept a branch name in place of the exact +source commit while privileged credentials are present. + +Workflow source must be immutable and independently reviewed. Artifact transfer +must be digest-bound, and publication must refuse stale protected-main heads, +mutable tags, alternate artifact sets, or handoff/source disagreement. The +repository-side preparer deliberately contains no fallback that weakens these +organization controls. + +## Standards alignment and precise claims + +- The JSON encoders reject non-finite values and emit deterministic RFC 8259 JSON. +- SBOMs use the CycloneDX 1.7 JSON schema and bind their root components to exact + distribution bytes. +- Repository and source identity are checksum-covered assertions, not provenance. +- No SLSA Build level is claimed. Future claims must be stated as `SLSA Build Lx + (v1.2)` only after every normative requirement is mapped to independently + verifiable evidence. +- The clean rebuild and evidence-preservation procedure supports NIST SSDF + practices for protecting release integrity and retaining evidence useful to + suppliers, purchasers, and assessors. + +## References + +Bray, T. (2017). *The JavaScript Object Notation (JSON) data interchange format* +(RFC 8259). Internet Engineering Task Force. https://doi.org/10.17487/RFC8259 + +OWASP Foundation. (n.d.). *CycloneDX v1.7 JSON reference*. Retrieved August 6, +2026, from https://cyclonedx.org/docs/1.7/json/ + +Souppaya, M., Scarfone, K., & Dodson, D. (2022). *Secure software development +framework (SSDF) version 1.1: Recommendations for mitigating the risk of software +vulnerabilities* (NIST SP 800-218). National Institute of Standards and +Technology. https://doi.org/10.6028/NIST.SP.800-218 + +Supply-chain Levels for Software Artifacts. (n.d.). *Build provenance: SLSA +specification v1.2*. Retrieved August 6, 2026, from +https://slsa.dev/spec/v1.2/build-provenance diff --git a/docs/release.md b/docs/release.md index 909a785d..20c4d716 100644 --- a/docs/release.md +++ b/docs/release.md @@ -68,6 +68,22 @@ a repository-secret fallback. 100% statement/branch coverage, docstring checks, and independent review gates pass. +### Credential-free release-evidence preparation + +After local and pull-request package acceptance, the separately documented +[release-evidence preparation control](release-evidence-preparation.md) may be +run only from a read-only, credential-free checkout detached at the exact +accepted source SHA. It requires exactly one wheel and matching source +distribution, applies compressed-byte bounds before archive parsing, and creates +the deterministic six-file evidence set plus a separately stored handoff for +independent re-verification. + +This branch-local preparer is not integrated into the credential-bearing release +workflow and does not modify or weaken that workflow's tag, OIDC, publication, +release, or approval boundaries. Its output is a credential-free consistency +handoff, not hosted build provenance, publication authorization, or a SLSA Build +level claim. + ## Publish 1. Open **Actions → release → Run workflow**. diff --git a/docs/research/connection-pool-resource-limits.md b/docs/research/connection-pool-resource-limits.md index 88471aa5..b494f3a5 100644 --- a/docs/research/connection-pool-resource-limits.md +++ b/docs/research/connection-pool-resource-limits.md @@ -18,13 +18,16 @@ an exact `EgressConnectionPoolPolicy` with different documented field values instead. This boundary does not claim EgressWeave sandboxes arbitrary Python code already executing inside the embedding process. -`max_connections` must be a positive integer or ASCII decimal string. -`max_keepalive_connections` may be zero to retain no idle connections but must -not exceed total capacity. `keepalive_expiry_seconds` must be a finite +Count fields accept only exact built-in integers or ASCII decimal strings; +integer subclasses are rejected before a capacity value is retained. +`max_connections` must be positive. `max_keepalive_connections` may be zero to +retain no idle connections but must not exceed total capacity. Accepted decimal +text is converted to the same exact built-in integer representation before the +relational capacity check. `keepalive_expiry_seconds` must be a finite non-negative real number and may be zero for immediate expiry. Booleans, fractional counts, signed or non-ASCII count text, negative values, non-finite expiry values, unrelated objects, and contradictory capacities fail during -trusted policy construction. +trusted policy construction. This primitive-value check does not make EgressWeave a Python sandbox. ## Standards basis @@ -54,18 +57,22 @@ and portable across standalone and modular integrations. 1. Both public `EgressPolicy` constructors accept the same immutable pool policy. 2. Trusted construction accepts only the exact `EgressConnectionPoolPolicy` type; subclasses are rejected before transport pool values are read. -3. Total connection capacity is always positive and finite. -4. Idle capacity is finite, may be zero, and cannot exceed total capacity. -5. Idle expiry is finite and non-negative; `None` cannot disable reclamation. -6. Synchronous and asynchronous HTTPCore pools receive the exact normalized +3. `max_connections` and `max_keepalive_connections` retain exact built-in + integer values; non-exact integer subclasses do not cross trusted + construction. +4. Total connection capacity is always positive and finite. +5. Idle capacity is finite, may be zero, and cannot exceed total capacity. +6. Idle expiry is finite and non-negative; `None` cannot disable reclamation. +7. Synchronous and asynchronous HTTPCore pools receive the exact normalized values from the policy. -7. No transport imports HTTPX's private `DEFAULT_LIMITS` object. -8. The normalized pool policy participates in deterministic policy and decision +8. No transport imports HTTPX's private `DEFAULT_LIMITS` object. +9. The normalized pool policy participates in deterministic policy and decision fingerprints without recording live connection state. -9. Defaults, valid environment-style count text, invalid configuration, - relational invariants, exact policy-type enforcement, sync/async delegation, - public API exposure, and fingerprint drift are covered by offline regression - tests with complete production statement and branch coverage. +10. Defaults, valid environment-style count text, invalid configuration, + primitive count-value sealing, relational invariants, exact policy-type + enforcement, sync/async delegation, public API exposure, and fingerprint + drift are covered by offline regression tests with complete production + statement and branch coverage. ## Operational guidance @@ -80,8 +87,10 @@ assuming it is universally safer. Applications that previously subclassed `EgressConnectionPoolPolicy` must migrate to an exact instance and configure the supported finite fields directly. -The exact-type check runs during trusted startup, before a pool or request can -consume those values. +Applications that used a custom integer subclass for either connection-count +field must convert it deliberately to a built-in `int` or reviewed ASCII decimal +text before policy construction. These exact-type checks run during trusted +startup, before a pool or request can consume the values. ## References diff --git a/scripts/ci/prepare_release_evidence.py b/scripts/ci/prepare_release_evidence.py new file mode 100644 index 00000000..ea2f7535 --- /dev/null +++ b/scripts/ci/prepare_release_evidence.py @@ -0,0 +1,489 @@ +"""Prepare one credential-free six-file release evidence set. + +The script treats built distributions as untrusted inert archives. It generates +paired deterministic CycloneDX 1.7 documents, seals exact repository and source +identity, writes sorted checksums, and asks the shipped verifier to create and +independently recheck a handoff manifest outside the evidence directory. It has +no network, signing, publication, release, tag, ref, model, or credential logic. +""" + +from __future__ import annotations + +import argparse +import hashlib +import importlib.util +import json +import os +import stat +import tempfile +from pathlib import Path +from types import ModuleType +from typing import Any + +from egressweave import release_evidence + +ATTESTABLE_GENERATOR_PATH = Path(__file__).with_name( + "generate_attestable_release_sbom.py" +) +MAX_DISTRIBUTION_BYTES = release_evidence.MAX_ARTIFACT_BYTES +COPY_BLOCK_BYTES = 1_048_576 +DistributionIdentity = tuple[int, int, int] + +__all__ = ["main", "prepare_release_evidence"] + + +def _parse_arguments() -> argparse.Namespace: + """Parse exact evidence, reviewed dependency, identity, and handoff inputs.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--evidence-dir", type=Path, required=True) + parser.add_argument("--handoff-manifest", type=Path, required=True) + parser.add_argument("--repository", required=True) + parser.add_argument("--source-sha", required=True) + parser.add_argument("--dependency-manifest", type=Path, required=True) + parser.add_argument("--runtime-lock", type=Path, required=True) + return parser.parse_args() + + +def _require_source_identity(repository: str, source_sha: str) -> None: + """Require the exact repository and one lowercase Git object identifier.""" + if ( + repository != release_evidence.EXPECTED_REPOSITORY + or release_evidence.SOURCE_SHA_PATTERN.fullmatch(source_sha) is None + ): + raise SystemExit("release repository or source identity is invalid") + + +def _require_canonical_directory(path: Path, *, label: str) -> Path: + """Return one existing real directory reached without symbolic links.""" + if not path.is_dir() or path.is_symlink(): + raise SystemExit(f"{label} is missing or unsafe") + try: + lexical_path = Path(os.path.abspath(path)) + resolved_path = path.resolve(strict=True) + except (OSError, RuntimeError) as error: + raise SystemExit(f"{label} is missing or unsafe") from error + if lexical_path != resolved_path: + raise SystemExit(f"{label} must not traverse symbolic links") + return resolved_path + + +def _require_canonical_file(path: Path, *, label: str) -> Path: + """Return one existing regular repository input reached without links.""" + if not path.is_file() or path.is_symlink(): + raise SystemExit(f"{label} is missing or unsafe") + try: + lexical_path = Path(os.path.abspath(path)) + resolved_path = path.resolve(strict=True) + path_state = path.lstat() + except (OSError, RuntimeError) as error: + raise SystemExit(f"{label} is missing or unsafe") from error + if lexical_path != resolved_path or not stat.S_ISREG(path_state.st_mode): + raise SystemExit(f"{label} is missing or unsafe") + return resolved_path + + +def _require_handoff_outside_evidence( + handoff_path: Path, + evidence_root: Path, +) -> Path: + """Return one named output file whose real parent remains outside evidence.""" + if handoff_path.name in {"", ".", ".."}: + raise SystemExit("handoff manifest path must name one regular file") + parent = _require_canonical_directory( + handoff_path.parent, + label="handoff manifest parent", + ) + resolved_output = parent / handoff_path.name + if resolved_output == evidence_root or resolved_output.is_relative_to(evidence_root): + raise SystemExit("handoff manifest must remain outside the sealed evidence set") + return resolved_output + + +def _select_distributions(evidence_root: Path) -> tuple[Path, Path]: + """Select exactly one canonical wheel and one matching source distribution.""" + try: + entries = sorted(evidence_root.iterdir(), key=lambda path: path.name) + except OSError as error: + raise SystemExit("release evidence input directory is unreadable") from error + if any(path.is_symlink() or not path.is_file() for path in entries): + raise SystemExit("release evidence inputs must be regular direct-child files") + + wheels = [path for path in entries if release_evidence.WHEEL_PATTERN.fullmatch(path.name)] + sdists = [path for path in entries if release_evidence.SDIST_PATTERN.fullmatch(path.name)] + if len(entries) != 2 or len(wheels) != 1 or len(sdists) != 1: + raise SystemExit( + "release evidence inputs require exactly one wheel and source distribution" + ) + + wheel_match = release_evidence.WHEEL_PATTERN.fullmatch(wheels[0].name) + sdist_match = release_evidence.SDIST_PATTERN.fullmatch(sdists[0].name) + if wheel_match is None or sdist_match is None: + raise SystemExit( + "release evidence inputs require exactly one wheel and source distribution" + ) + if wheel_match.group("version") != sdist_match.group("version"): + raise SystemExit("release wheel and source distribution versions do not match") + return wheels[0], sdists[0] + + +def _distribution_identity(metadata: os.stat_result) -> DistributionIdentity: + """Return the device, inode, and finite byte size that identify one archive.""" + return metadata.st_dev, metadata.st_ino, metadata.st_size + + +def _require_distribution_metadata( + metadata: os.stat_result, + *, + label: str, +) -> DistributionIdentity: + """Return one regular finite distribution identity or fail through stable errors.""" + if not stat.S_ISREG(metadata.st_mode): + raise SystemExit(f"{label} is unreadable or unsafe") + if metadata.st_size > MAX_DISTRIBUTION_BYTES: + raise SystemExit(f"{label} exceeds the safety bound") + return _distribution_identity(metadata) + + +def _require_distribution_preflight( + path: Path, + *, + label: str, +) -> DistributionIdentity: + """Bind current regular-file identity before loading any archive parser.""" + try: + path_state = path.lstat() + except OSError as error: + raise SystemExit(f"{label} is unreadable or unsafe") from error + return _require_distribution_metadata(path_state, label=label) + + +def _snapshot_distribution( + path: Path, + snapshot_root: Path, + accepted_identity: DistributionIdentity, + *, + label: str, +) -> Path: + """Copy one accepted descriptor into a private parser-only immutable snapshot. + + The accepted path identity is checked against both the no-follow descriptor + and the current pathname before and after the bounded copy. Archive parsers + receive only the private snapshot, never the mutable caller-controlled path. + """ + read_flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0) + write_flags = ( + os.O_WRONLY + | os.O_CREAT + | os.O_EXCL + | getattr(os, "O_BINARY", 0) + | getattr(os, "O_NOFOLLOW", 0) + ) + source_descriptor: int | None = None + snapshot_descriptor: int | None = None + snapshot_path = snapshot_root / path.name + try: + source_descriptor = os.open(path, read_flags) + opened_identity = _require_distribution_metadata( + os.fstat(source_descriptor), + label=label, + ) + current_identity = _require_distribution_metadata(path.lstat(), label=label) + if opened_identity != accepted_identity or current_identity != accepted_identity: + raise SystemExit(f"{label} is unreadable or unsafe") + + snapshot_descriptor = os.open(snapshot_path, write_flags, 0o600) + os.fchmod(snapshot_descriptor, 0o600) + copied_bytes = 0 + while True: + block = os.read(source_descriptor, COPY_BLOCK_BYTES) + if not block: + break + copied_bytes += len(block) + if copied_bytes > MAX_DISTRIBUTION_BYTES: + raise SystemExit(f"{label} exceeds the safety bound") + remaining = memoryview(block) + while remaining: + written = os.write(snapshot_descriptor, remaining) + if written <= 0: + raise OSError("short snapshot write") + remaining = remaining[written:] + os.fsync(snapshot_descriptor) + + final_opened_identity = _require_distribution_metadata( + os.fstat(source_descriptor), + label=label, + ) + final_path_identity = _require_distribution_metadata(path.lstat(), label=label) + snapshot_state = os.fstat(snapshot_descriptor) + if ( + final_opened_identity != accepted_identity + or final_path_identity != accepted_identity + or not stat.S_ISREG(snapshot_state.st_mode) + or stat.S_IMODE(snapshot_state.st_mode) != 0o600 + or snapshot_state.st_size != copied_bytes + ): + raise SystemExit(f"{label} is unreadable or unsafe") + return snapshot_path + except FileExistsError: + raise SystemExit(f"{label} parser snapshot already exists") from None + except OSError as error: + raise SystemExit(f"{label} is unreadable or unsafe") from error + finally: + if snapshot_descriptor is not None: + os.close(snapshot_descriptor) + if source_descriptor is not None: + os.close(source_descriptor) + + +def _load_attestable_generator() -> ModuleType: + """Load the repository-only deterministic generator without importing archives.""" + specification = importlib.util.spec_from_file_location( + "egressweave_generate_attestable_release_sbom_for_preparation", + ATTESTABLE_GENERATOR_PATH, + ) + if specification is None or specification.loader is None: + raise SystemExit("attestable release SBOM generator could not be loaded") + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def _strict_pretty_json_bytes(document: dict[str, Any]) -> bytes: + """Return deterministic indented strict-JSON bytes with one final newline.""" + try: + return ( + json.dumps( + document, + indent=2, + sort_keys=True, + ensure_ascii=True, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + except (RecursionError, TypeError, ValueError): + raise SystemExit("generated release evidence is not strict JSON") from None + + +def _source_identity_bytes(repository: str, source_sha: str) -> bytes: + """Return canonical compact source-identity bytes for the sealed set.""" + document = { + "format": release_evidence.SOURCE_IDENTITY_FORMAT, + "formatVersion": release_evidence.SOURCE_IDENTITY_VERSION, + "repository": repository, + "sourceSha": source_sha, + } + return ( + json.dumps( + document, + sort_keys=True, + separators=(",", ":"), + ensure_ascii=True, + allow_nan=False, + ) + + "\n" + ).encode("utf-8") + + +def _sha256_file(path: Path, *, label: str) -> str: + """Hash one bounded descriptor-bound regular distribution.""" + digest = hashlib.sha256() + total_bytes = 0 + try: + with path.open("rb") as stream: + path_state = path.lstat() + opened_state = os.fstat(stream.fileno()) + if ( + not stat.S_ISREG(path_state.st_mode) + or not stat.S_ISREG(opened_state.st_mode) + or (path_state.st_dev, path_state.st_ino) + != (opened_state.st_dev, opened_state.st_ino) + ): + raise SystemExit(f"{label} is unreadable or unsafe") + for block in iter(lambda: stream.read(1_048_576), b""): + total_bytes += len(block) + if total_bytes > MAX_DISTRIBUTION_BYTES: + raise SystemExit(f"{label} exceeds the safety bound") + digest.update(block) + except OSError as error: + raise SystemExit(f"{label} is unreadable") from error + return digest.hexdigest() + + +def _write_private_file(path: Path, payload: bytes, *, label: str) -> None: + """Exclusively create one owner-only regular file and durably write all bytes.""" + flags = os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_NOFOLLOW", 0) + descriptor: int | None = None + try: + descriptor = os.open(path, flags, 0o600) + os.fchmod(descriptor, 0o600) + remaining = memoryview(payload) + while remaining: + written = os.write(descriptor, remaining) + if written <= 0: + raise OSError("short write") + remaining = remaining[written:] + os.fsync(descriptor) + opened_state = os.fstat(descriptor) + path_state = path.lstat() + if ( + not stat.S_ISREG(opened_state.st_mode) + or not stat.S_ISREG(path_state.st_mode) + or (opened_state.st_dev, opened_state.st_ino) + != (path_state.st_dev, path_state.st_ino) + or stat.S_IMODE(opened_state.st_mode) != 0o600 + ): + raise OSError("unsafe output identity") + except FileExistsError: + raise SystemExit(f"{label} already exists") from None + except OSError as error: + raise SystemExit(f"{label} cannot be created safely") from error + finally: + if descriptor is not None: + os.close(descriptor) + + +def _checksum_bytes(payloads: dict[str, bytes | Path]) -> bytes: + """Return sorted canonical SHA-256 lines for five exact payloads.""" + lines: list[str] = [] + for filename in sorted(payloads): + payload = payloads[filename] + digest = ( + _sha256_file(payload, label=f"release distribution {filename}") + if isinstance(payload, Path) + else hashlib.sha256(payload).hexdigest() + ) + lines.append(f"{digest} {filename}\n") + return "".join(lines).encode("ascii") + + +def prepare_release_evidence( + evidence_dir: Path, + handoff_path: Path, + *, + repository: str, + source_sha: str, + dependency_manifest_path: Path, + runtime_lock_path: Path, +) -> dict[str, Any]: + """Create and independently verify one credential-free release handoff. + + The input directory must initially contain only one canonical wheel and one + matching source distribution. Each accepted archive is copied from its + no-follow identity-bound descriptor into a private parser-only snapshot + before the generator loads. Every generated file is new, owner-only, and + deterministic. The returned mapping is the exact manifest already rebuilt + and verified after the separately stored handoff has been durably published. + """ + _require_source_identity(repository, source_sha) + evidence_root = _require_canonical_directory( + evidence_dir, + label="release evidence input directory", + ) + resolved_handoff = _require_handoff_outside_evidence(handoff_path, evidence_root) + dependency_manifest = _require_canonical_file( + dependency_manifest_path, + label="reviewed runtime dependency manifest", + ) + runtime_lock = _require_canonical_file( + runtime_lock_path, + label="hash-locked runtime requirements", + ) + wheel_path, sdist_path = _select_distributions(evidence_root) + wheel_label = f"release distribution {wheel_path.name}" + sdist_label = f"release distribution {sdist_path.name}" + wheel_identity = _require_distribution_preflight(wheel_path, label=wheel_label) + sdist_identity = _require_distribution_preflight(sdist_path, label=sdist_label) + + with tempfile.TemporaryDirectory(prefix="egressweave-release-evidence-") as temporary: + snapshot_root = Path(temporary) + wheel_snapshot = _snapshot_distribution( + wheel_path, + snapshot_root, + wheel_identity, + label=wheel_label, + ) + sdist_snapshot = _snapshot_distribution( + sdist_path, + snapshot_root, + sdist_identity, + label=sdist_label, + ) + generator = _load_attestable_generator() + wheel_sbom = _strict_pretty_json_bytes( + generator.build_attestable_sbom( + wheel_snapshot, + dependency_manifest, + runtime_lock, + ) + ) + sdist_sbom = _strict_pretty_json_bytes( + generator.build_attestable_sbom( + sdist_snapshot, + dependency_manifest, + runtime_lock, + ) + ) + + source_identity = _source_identity_bytes(repository, source_sha) + generated_payloads = { + f"{wheel_path.name}.cdx.json": wheel_sbom, + f"{sdist_path.name}.cdx.json": sdist_sbom, + release_evidence.SOURCE_IDENTITY_FILENAME: source_identity, + } + checksum_payloads: dict[str, bytes | Path] = { + wheel_path.name: wheel_path, + sdist_path.name: sdist_path, + **generated_payloads, + } + checksums = _checksum_bytes(checksum_payloads) + + for filename, payload in generated_payloads.items(): + _write_private_file( + evidence_root / filename, + payload, + label=f"release evidence {filename}", + ) + _write_private_file( + evidence_root / "SHA256SUMS", + checksums, + label="release evidence SHA256SUMS", + ) + + prepared_manifest = release_evidence.build_evidence_manifest( + evidence_root, + repository=repository, + source_sha=source_sha, + ) + release_evidence.write_evidence_manifest( + prepared_manifest, + resolved_handoff, + forbidden_root=evidence_root, + ) + release_evidence.reverify_published_evidence_manifest( + evidence_root, + resolved_handoff, + repository=repository, + source_sha=source_sha, + expected_manifest=prepared_manifest, + ) + return prepared_manifest + + +def main() -> int: + """Prepare one exact evidence set and return zero only after re-verification.""" + arguments = _parse_arguments() + prepare_release_evidence( + arguments.evidence_dir, + arguments.handoff_manifest, + repository=arguments.repository, + source_sha=arguments.source_sha, + dependency_manifest_path=arguments.dependency_manifest, + runtime_lock_path=arguments.runtime_lock, + ) + print(f"prepared sealed release evidence: {arguments.evidence_dir}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/egressweave/connection_pool_policy.py b/src/egressweave/connection_pool_policy.py index f372dc79..93574602 100644 --- a/src/egressweave/connection_pool_policy.py +++ b/src/egressweave/connection_pool_policy.py @@ -22,7 +22,7 @@ def _normalize_connection_count( """Return one exact ASCII-compatible connection-count limit.""" if isinstance(value, bool): raise TypeError(f"{field_name} must be an integer or ASCII decimal string") - if isinstance(value, int): + if type(value) is int: normalized = value elif isinstance(value, str): if not value or not value.isascii() or not value.isdecimal(): diff --git a/src/egressweave/release_evidence.py b/src/egressweave/release_evidence.py index 4a0b579d..d60ea5d1 100644 --- a/src/egressweave/release_evidence.py +++ b/src/egressweave/release_evidence.py @@ -54,6 +54,7 @@ __all__ = [ "build_evidence_manifest", "main", + "reverify_published_evidence_manifest", "write_evidence_manifest", ] @@ -745,6 +746,30 @@ def _require_post_publication_state( raise SystemExit("evidence manifest output changed after publication") +def reverify_published_evidence_manifest( + evidence_dir: Path, + output_path: Path, + *, + repository: str, + source_sha: str, + expected_manifest: dict[str, Any], +) -> None: + """Reverify one sealed evidence set and its exact published manifest bytes. + + ``expected_manifest`` is encoded through the same strict deterministic public + evidence contract used by the writer. The verifier then rebuilds the sealed + set and rereads the closed output through bounded descriptor/path checks. + """ + expected_payload = _encode_evidence_manifest(expected_manifest) + _require_post_publication_state( + evidence_dir, + output_path, + repository=repository, + source_sha=source_sha, + expected_payload=expected_payload, + ) + + def main() -> int: """Verify sealed evidence and write one deterministic credential handoff manifest.""" arguments = _parse_arguments() @@ -760,18 +785,17 @@ def main() -> int: repository=arguments.repository, source_sha=arguments.source_sha, ) - expected_payload = _encode_evidence_manifest(manifest) write_evidence_manifest( manifest, output_path, forbidden_root=resolved_evidence_dir, ) - _require_post_publication_state( + reverify_published_evidence_manifest( evidence_dir, output_path, repository=arguments.repository, source_sha=arguments.source_sha, - expected_payload=expected_payload, + expected_manifest=manifest, ) print(f"verified sealed release evidence: {output_path}") return 0 diff --git a/src/egressweave/validation.py b/src/egressweave/validation.py index 4e5820fa..9542ab9e 100644 --- a/src/egressweave/validation.py +++ b/src/egressweave/validation.py @@ -396,10 +396,12 @@ def _revalidate_pinned_egress_url( validated: ValidatedEgressURL, policy: EgressPolicy ) -> ValidatedEgressURL: """Re-check caller-supplied validation state before transport use.""" + if type(validated) is not ValidatedEgressURL: + raise EgressNotAllowedError(EGRESS_NOT_ALLOWED) from None + integrity_signature = getattr(validated, "_integrity_signature", None) if ( - not isinstance(validated, ValidatedEgressURL) - or not isinstance(integrity_signature, bytes) + not isinstance(integrity_signature, bytes) or not isinstance(validated.normalized_url, str) or not isinstance(validated.hostname, str) or not isinstance(validated.port, int) diff --git a/tests/test_connection_pool_count_value_documentation.py b/tests/test_connection_pool_count_value_documentation.py new file mode 100644 index 00000000..fa38af49 --- /dev/null +++ b/tests/test_connection_pool_count_value_documentation.py @@ -0,0 +1,27 @@ +"""Documentation contracts for exact connection-pool count value types.""" + +from __future__ import annotations + +from pathlib import Path + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +POOL_GUIDE_PATH = ( + REPOSITORY_ROOT / "docs" / "research" / "connection-pool-resource-limits.md" +) +CHANGELOG_PATH = REPOSITORY_ROOT / "CHANGELOG.md" + + +def test_connection_pool_guide_documents_exact_builtin_count_values() -> None: + """Keep operator guidance aligned with the primitive-value integrity boundary.""" + guide = POOL_GUIDE_PATH.read_text(encoding="utf-8") + + assert "Count fields accept only exact built-in integers" in guide + assert "integer subclasses are rejected" in guide + assert "does not make EgressWeave a Python sandbox" in guide + + +def test_changelog_records_connection_count_value_sealing() -> None: + """Record the pre-1.0 primitive-value tightening in release history.""" + changelog = CHANGELOG_PATH.read_text(encoding="utf-8") + + assert "Reject non-exact integer subclasses in connection-pool count fields" in changelog diff --git a/tests/test_connection_pool_count_value_types.py b/tests/test_connection_pool_count_value_types.py new file mode 100644 index 00000000..e1a5a456 --- /dev/null +++ b/tests/test_connection_pool_count_value_types.py @@ -0,0 +1,41 @@ +"""Security contracts for exact connection-pool count value types.""" + +from __future__ import annotations + +import pytest + +from egressweave import EgressConnectionPoolPolicy + + +class _ConnectionCountSubclass(int): + """Represent an unreviewed integer subclass crossing trusted configuration.""" + + +@pytest.mark.parametrize( + "field_name", + ["max_connections", "max_keepalive_connections"], +) +def test_connection_pool_policy_rejects_integer_subclasses(field_name: str) -> None: + """Reject non-exact integers before retaining finite pool-capacity values.""" + with pytest.raises(TypeError, match=field_name): + EgressConnectionPoolPolicy( + **{field_name: _ConnectionCountSubclass(1)} # type: ignore[arg-type] + ) + + +def test_connection_pool_policy_keeps_reviewed_count_input_forms() -> None: + """Continue accepting exact integers and reviewed ASCII decimal strings.""" + exact_integer = EgressConnectionPoolPolicy( + max_connections=8, + max_keepalive_connections=2, + ) + decimal_string = EgressConnectionPoolPolicy( + max_connections="8", + max_keepalive_connections="2", + ) + + assert type(exact_integer.max_connections) is int + assert type(exact_integer.max_keepalive_connections) is int + assert decimal_string == exact_integer + assert type(decimal_string.max_connections) is int + assert type(decimal_string.max_keepalive_connections) is int diff --git a/tests/test_hourly_opencode_nvidia_contract.py b/tests/test_hourly_opencode_nvidia_contract.py index dc1410e8..d29fea10 100644 --- a/tests/test_hourly_opencode_nvidia_contract.py +++ b/tests/test_hourly_opencode_nvidia_contract.py @@ -84,17 +84,75 @@ def test_credentialed_model_runner_never_executes_model_modified_code() -> None: def test_open_pull_request_gates_count_every_paginated_page() -> None: - """Refuse autonomous publication when an open PR exists beyond page one.""" + """Refuse development or reverification for an open PR beyond page one.""" workflow = " ".join( _read(PRODUCT_WORKFLOW_PATH).replace("\\\n", "").split() ) complete_query = ( 'gh api "repos/${GITHUB_REPOSITORY}/pulls?state=open&per_page=100" ' - "--paginate --slurp --jq 'map(length) | add // 0'" + "--paginate --jq 'length' | " + "awk '{total += $1} END {print total + 0}'" ) - assert workflow.count(complete_query) == 3 - assert "--jq 'length'" not in workflow + assert workflow.count(complete_query) == 2 + assert "--slurp" not in workflow + + +def test_product_scheduler_never_publishes_a_model_modified_tree() -> None: + """End the scheduler at a digest-bound credential-free patch handoff.""" + workflow = _read(PRODUCT_WORKFLOW_PATH) + forbidden_fragments = ( + "\n publish:", + "id-token: write", + "PR_REVIEW_MERGE_TOKEN", + "OPENCODE_APPROVE_TOKEN", + "exchange_github_app_token", + "git remote set-url", + "git push ", + "gh pr create", + "gh pr merge", + "contents: write", + ) + + assert all(fragment not in workflow for fragment in forbidden_fragments) + assert ": write" not in workflow + initial_handoff = workflow.split( + "Upload the bounded change for credential-free reverification", 1 + )[1].split("\n\n reverify:", 1)[0] + assert "${{ runner.temp }}/base-sha" in initial_handoff + assert "Require the exact handoff base before applying the patch" in workflow + assert 'handoff_base_sha="$(cat "$handoff_base_sha_file")"' in workflow + assert '[ "$current_sha" != "$EXPECTED_BASE_SHA" ] ||' in workflow + assert '[ "$handoff_base_sha" != "$EXPECTED_BASE_SHA" ]; then' in workflow + assert "The patch handoff base does not match the exact checkout" in workflow + assert 'result_base_sha="$(jq -r ".base_sha" "$result_file")"' in workflow + assert '[ "$result_base_sha" != "$EXPECTED_BASE_SHA" ]; then' in workflow + assert "Upload the independently verified handoff" in workflow + recheck = workflow.split( + "Recheck the independently verified immutable patch", + 1, + )[1].split("Upload the independently verified handoff", 1)[0] + assert "EXPECTED_BASE_SHA: ${{ needs.develop.outputs.base_sha }}" in recheck + assert '[[ ! "$base_sha" =~ ^[0-9a-f]{40}$ ]]' in recheck + assert '[ "$base_sha" != "$EXPECTED_BASE_SHA" ]' in recheck + assert "does not match the exact handoff base" in recheck + assert "hourly-verified-product-change-${{ github.run_id }}" in workflow + assert "/opt/egressweave-reverify/egressweave.patch" in workflow + assert "/opt/egressweave-reverify/base-sha" in workflow + assert "/opt/egressweave-reverify/patch-sha256" in workflow + handoff = workflow.split("Upload the independently verified handoff", 1)[1] + assert "if-no-files-found: error" in handoff + assert "retention-days: 3" in handoff + + +def test_ai_generated_pull_requests_require_a_guarded_manual_merge() -> None: + """Prevent autonomous product changes from being merged without operator review.""" + maintenance_workflow = _read( + REPOSITORY_ROOT / ".github" / "workflows" / "hourly-pr-maintenance.yml" + ) + + assert "enable_auto_merge: false" in maintenance_workflow + assert "merge_mode: disabled" in maintenance_workflow def test_review_scheduler_keeps_its_existing_identity_contract() -> None: @@ -120,6 +178,15 @@ def test_operator_documentation_records_the_pinned_agent_and_secret_mapping() -> assert "OpenAI Codex Action" not in documentation +def test_operator_documentation_forbids_repository_local_patch_publication() -> None: + """Document that verified patches require an external promotion boundary.""" + documentation = " ".join(_read(MAINTENANCE_DOCUMENTATION_PATH).split()) + + assert "does not create a branch, pull request, or auto-merge request" in documentation + assert "external credential-separated promotion mechanism" in documentation + assert "reconstruct and verify the exact tree" in documentation + + def test_buyer_readme_identifies_the_opencode_nvidia_maintainer() -> None: """Keep the public execution identity aligned with the audited workflow.""" readme = _read(README_PATH) @@ -128,3 +195,56 @@ def test_buyer_readme_identifies_the_opencode_nvidia_maintainer() -> None: assert "bounded OpenCode maintainer" in readme assert "`NVIDIA_NIM_API_KEY`" in readme assert "COPILOT_GITHUB_TOKEN" not in readme + + +def test_product_workflow_keeps_printf_escapes_on_indented_yaml_lines() -> None: + """Keep shell format escapes on one YAML line so workflow parsing succeeds.""" + workflow = _read(PRODUCT_WORKFLOW_PATH) + workflow_lines = workflow.splitlines() + checksum_line = ( + " printf '%s %s\\n' " + '"$OPENCODE_SHA256" "$archive" | sha256sum --check -' + ) + fallback_line = ( + " printf '%s\\n' " + "'{\"type\":\"error\",\"message\":\"OpenCode produced no final result\"}' " + '>"$result_file"' + ) + + assert checksum_line in workflow_lines + assert fallback_line in workflow_lines + assert workflow.endswith("\n") + + +def test_offline_verifier_materializes_the_complete_repository_contract() -> None: + """Make every repository-owned test input available before offline checks run.""" + workflow = _read(PRODUCT_WORKFLOW_PATH) + verifier = workflow.split( + "Test only inside the offline least-privilege verifier container", + 1, + )[1].split("Recheck the independently verified immutable patch", 1)[0] + + for required_directory in ( + "/source/src", + "/source/tests", + "/source/docs", + "/source/.github", + "/source/scripts", + ): + assert required_directory in verifier + + root_loop = "for root_file in /source/* /source/.[!.]* /source/..?*; do" + regular_file_guard = ( + '[ -f "$root_file" ] && [ ! -L "$root_file" ] || continue' + ) + root_copy = ( + 'cp --no-preserve=ownership,mode,timestamps "$root_file" /work/' + ) + compileall = "python -m compileall -q src tests scripts" + assert root_loop in verifier + assert regular_file_guard in verifier + assert root_copy in verifier + assert compileall in verifier + assert verifier.index(root_loop) < verifier.index("ruff check .") + assert verifier.index(root_copy) < verifier.index("pytest -q") + assert verifier.index(root_copy) < verifier.index(compileall) diff --git a/tests/test_pinned_result_integrity.py b/tests/test_pinned_result_integrity.py index fc263d1b..557e4aee 100644 --- a/tests/test_pinned_result_integrity.py +++ b/tests/test_pinned_result_integrity.py @@ -1,5 +1,8 @@ """Regression tests for forged or tampered validation results.""" +from collections.abc import Callable + +import httpx import pytest from egressweave import ( @@ -7,11 +10,26 @@ EgressPolicy, ValidatedEgressURL, build_pinned_https_async_client, + build_pinned_https_client, validate_egress_url_details, ) from egressweave import validation as v POLICY = EgressPolicy.from_hosts("api.openai.com") +PinnedBuilder = Callable[..., httpx.Client | httpx.AsyncClient] +PINNED_BUILDERS = ( + pytest.param(build_pinned_https_client, id="sync"), + pytest.param(build_pinned_https_async_client, id="async"), +) + + +class _HostileValidatedResult(ValidatedEgressURL): + """Expose pre-type-check integrity-signature access through a test descriptor.""" + + @property + def _integrity_signature(self) -> bytes: + """Fail if validation reads subclass-controlled integrity state.""" + raise AssertionError("untrusted validated-result signature read") def _forge_untrusted_result() -> ValidatedEgressURL: @@ -45,11 +63,23 @@ def test_validated_result_constructor_is_factory_only() -> None: ) -def test_build_pinned_client_rejects_untrusted_result() -> None: +@pytest.mark.parametrize("builder", PINNED_BUILDERS) +def test_build_pinned_client_rejects_untrusted_result(builder: PinnedBuilder) -> None: + with pytest.raises(EgressNotAllowedError): + builder(_forge_untrusted_result(), policy=POLICY) + + +@pytest.mark.parametrize("builder", PINNED_BUILDERS) +def test_build_pinned_client_rejects_subclass_before_attribute_access( + builder: PinnedBuilder, +) -> None: + hostile_result = object.__new__(_HostileValidatedResult) + with pytest.raises(EgressNotAllowedError): - build_pinned_https_async_client(_forge_untrusted_result(), policy=POLICY) + builder(hostile_result, policy=POLICY) +@pytest.mark.parametrize("builder", PINNED_BUILDERS) @pytest.mark.parametrize( "field_name, replacement", [ @@ -76,10 +106,13 @@ def test_build_pinned_client_rejects_untrusted_result() -> None: ], ) def test_build_pinned_client_rejects_tampered_trusted_result( - monkeypatch, field_name: str, replacement: object + monkeypatch, + builder: PinnedBuilder, + field_name: str, + replacement: object, ) -> None: validated = _validated_result(monkeypatch) object.__setattr__(validated, field_name, replacement) with pytest.raises(EgressNotAllowedError): - build_pinned_https_async_client(validated, policy=POLICY) + builder(validated, policy=POLICY) diff --git a/tests/test_prepare_release_evidence.py b/tests/test_prepare_release_evidence.py new file mode 100644 index 00000000..c5b61da0 --- /dev/null +++ b/tests/test_prepare_release_evidence.py @@ -0,0 +1,294 @@ +"""Tests for credential-free preparation of the sealed release evidence set.""" + +from __future__ import annotations + +import importlib.util +import json +import shutil +import stat +from pathlib import Path + +import pytest +from test_release_sbom import LOCK_PATH, MANIFEST_PATH, _write_sdist, _write_wheel + +from egressweave import release_evidence + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +PREPARER_PATH = REPOSITORY_ROOT / "scripts" / "ci" / "prepare_release_evidence.py" +REPOSITORY = "ContextualWisdomLab/EgressWeave" +SOURCE_SHA = "0123456789abcdef0123456789abcdef01234567" +WHEEL_NAME = "egressweave-0.3.0-py3-none-any.whl" +SDIST_NAME = "egressweave-0.3.0.tar.gz" + + +def _load_preparer(): + """Load the repository-only preparation script from its exact path.""" + specification = importlib.util.spec_from_file_location( + "egressweave_prepare_release_evidence", + PREPARER_PATH, + ) + assert specification is not None and specification.loader is not None + module = importlib.util.module_from_spec(specification) + specification.loader.exec_module(module) + return module + + +def _write_distributions(directory: Path) -> tuple[Path, Path]: + """Create one canonical wheel and source distribution fixture.""" + directory.mkdir() + wheel_path = directory / WHEEL_NAME + sdist_path = directory / SDIST_NAME + _write_wheel(wheel_path) + _write_sdist(sdist_path) + return wheel_path, sdist_path + + +def _prepare(preparer, evidence_dir: Path, handoff_path: Path): + """Run the public preparation function with reviewed repository inputs.""" + return preparer.prepare_release_evidence( + evidence_dir, + handoff_path, + repository=REPOSITORY, + source_sha=SOURCE_SHA, + dependency_manifest_path=MANIFEST_PATH, + runtime_lock_path=LOCK_PATH, + ) + + +def test_prepare_release_evidence_emits_one_verified_six_file_set( + tmp_path: Path, +) -> None: + """Generate the exact sealed payloads and one separately stored handoff.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + handoff_path = tmp_path / "handoff.json" + + prepared_manifest = _prepare(preparer, evidence_dir, handoff_path) + + expected_names = { + WHEEL_NAME, + f"{WHEEL_NAME}.cdx.json", + SDIST_NAME, + f"{SDIST_NAME}.cdx.json", + "SOURCE_IDENTITY.json", + "SHA256SUMS", + } + assert {path.name for path in evidence_dir.iterdir()} == expected_names + checksum_lines = (evidence_dir / "SHA256SUMS").read_text(encoding="ascii").splitlines() + checksum_names = [line.split(" ", 1)[1] for line in checksum_lines] + assert checksum_names == sorted(checksum_names) + assert len(checksum_lines) == 5 + assert set(checksum_names) == expected_names - {"SHA256SUMS"} + assert (evidence_dir / "SOURCE_IDENTITY.json").read_bytes() == ( + b'{"format":"egressweave.release-source-identity","formatVersion":1,' + b'"repository":"ContextualWisdomLab/EgressWeave",' + b'"sourceSha":"0123456789abcdef0123456789abcdef01234567"}\n' + ) + + independently_verified = release_evidence.build_evidence_manifest( + evidence_dir, + repository=REPOSITORY, + source_sha=SOURCE_SHA, + ) + assert prepared_manifest == independently_verified + assert json.loads(handoff_path.read_text(encoding="utf-8")) == independently_verified + for generated_path in [ + evidence_dir / f"{WHEEL_NAME}.cdx.json", + evidence_dir / f"{SDIST_NAME}.cdx.json", + evidence_dir / "SOURCE_IDENTITY.json", + evidence_dir / "SHA256SUMS", + handoff_path, + ]: + assert stat.S_IMODE(generated_path.stat().st_mode) == 0o600 + + +def test_prepare_release_evidence_is_repeatable_for_identical_archives( + tmp_path: Path, +) -> None: + """Produce byte-identical evidence when every exact input byte is reused.""" + preparer = _load_preparer() + first_dir = tmp_path / "first" + first_wheel, first_sdist = _write_distributions(first_dir) + second_dir = tmp_path / "second" + second_dir.mkdir() + shutil.copyfile(first_wheel, second_dir / first_wheel.name) + shutil.copyfile(first_sdist, second_dir / first_sdist.name) + + _prepare(preparer, first_dir, tmp_path / "first-handoff.json") + _prepare(preparer, second_dir, tmp_path / "second-handoff.json") + + for filename in ( + f"{WHEEL_NAME}.cdx.json", + f"{SDIST_NAME}.cdx.json", + "SOURCE_IDENTITY.json", + "SHA256SUMS", + ): + assert (first_dir / filename).read_bytes() == (second_dir / filename).read_bytes() + assert (tmp_path / "first-handoff.json").read_bytes() == ( + tmp_path / "second-handoff.json" + ).read_bytes() + + +def test_prepare_release_evidence_rejects_unexpected_input_before_writing( + tmp_path: Path, +) -> None: + """Refuse stale or unrelated files before any evidence output is created.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + (evidence_dir / "stale.txt").write_text("stale", encoding="utf-8") + handoff_path = tmp_path / "handoff.json" + + with pytest.raises(SystemExit, match="exactly one wheel and source distribution"): + _prepare(preparer, evidence_dir, handoff_path) + + assert {path.name for path in evidence_dir.iterdir()} == { + WHEEL_NAME, + SDIST_NAME, + "stale.txt", + } + assert not handoff_path.exists() + + +def test_prepare_release_evidence_rejects_oversized_archive_before_generator( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Reject compressed input bytes before loading any archive parser.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + wheel_path, _ = _write_distributions(evidence_dir) + wheel_path.write_bytes(b"") + with wheel_path.open("r+b") as stream: + stream.truncate(preparer.MAX_DISTRIBUTION_BYTES + 1) + handoff_path = tmp_path / "handoff.json" + + def fail_if_loaded(): + raise AssertionError("the generator ran before the distribution size preflight") + + monkeypatch.setattr(preparer, "_load_attestable_generator", fail_if_loaded) + + with pytest.raises(SystemExit, match="release distribution .* exceeds the safety bound"): + _prepare(preparer, evidence_dir, handoff_path) + + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not handoff_path.exists() + + +def test_archive_replacement_after_preflight_never_reaches_parser( + tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """Bind parser input to the exact regular archive accepted by preflight.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + wheel_path, _ = _write_distributions(evidence_dir) + replacement = tmp_path / "replacement.whl" + replacement.write_bytes(b"") + with replacement.open("r+b") as stream: + stream.truncate(preparer.MAX_DISTRIBUTION_BYTES + 1) + handoff_path = tmp_path / "handoff.json" + original_preflight = preparer._require_distribution_preflight + replaced = False + parser_inputs: list[Path] = [] + + def replace_after_preflight(path: Path, *, label: str): + nonlocal replaced + accepted_identity = original_preflight(path, label=label) + if path == wheel_path and not replaced: + wheel_path.unlink() + replacement.replace(wheel_path) + replaced = True + return accepted_identity + + class RecordingGenerator: + """Fail if a pathname replacement is ever delegated to an archive parser.""" + + def build_attestable_sbom(self, artifact_path: Path, *args): + parser_inputs.append(artifact_path) + raise AssertionError("the parser received a post-preflight replacement") + + monkeypatch.setattr( + preparer, + "_require_distribution_preflight", + replace_after_preflight, + ) + monkeypatch.setattr( + preparer, + "_load_attestable_generator", + lambda: RecordingGenerator(), + ) + + with pytest.raises( + SystemExit, + match="release distribution .* (?:exceeds the safety bound|is unreadable or unsafe)", + ): + _prepare(preparer, evidence_dir, handoff_path) + + assert replaced + assert parser_inputs == [] + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not handoff_path.exists() + + +def test_prepare_release_evidence_rejects_symlinked_distribution_before_writing( + tmp_path: Path, +) -> None: + """Reject a linked archive instead of following it into the sealed set.""" + preparer = _load_preparer() + source_dir = tmp_path / "source" + wheel_path, sdist_path = _write_distributions(source_dir) + evidence_dir = tmp_path / "evidence" + evidence_dir.mkdir() + try: + (evidence_dir / wheel_path.name).symlink_to(wheel_path) + except OSError: + pytest.skip("symbolic links are unavailable on this platform") + shutil.copyfile(sdist_path, evidence_dir / sdist_path.name) + handoff_path = tmp_path / "handoff.json" + + with pytest.raises(SystemExit, match="regular direct-child files"): + _prepare(preparer, evidence_dir, handoff_path) + + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not handoff_path.exists() + + +def test_prepare_release_evidence_rejects_handoff_inside_the_sealed_set( + tmp_path: Path, +) -> None: + """Keep the generated handoff from mutating the evidence it summarizes.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + handoff_path = evidence_dir / "handoff.json" + + with pytest.raises(SystemExit, match="handoff manifest must remain outside"): + _prepare(preparer, evidence_dir, handoff_path) + + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not handoff_path.exists() + + +def test_prepare_release_evidence_rejects_invalid_source_identity_before_writing( + tmp_path: Path, +) -> None: + """Validate exact repository and source authority before creating evidence.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + handoff_path = tmp_path / "handoff.json" + + with pytest.raises(SystemExit, match="repository or source identity is invalid"): + preparer.prepare_release_evidence( + evidence_dir, + handoff_path, + repository="other/repository", + source_sha="main", + dependency_manifest_path=MANIFEST_PATH, + runtime_lock_path=LOCK_PATH, + ) + + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not handoff_path.exists() diff --git a/tests/test_prepare_release_evidence_review_regressions.py b/tests/test_prepare_release_evidence_review_regressions.py new file mode 100644 index 00000000..5d017388 --- /dev/null +++ b/tests/test_prepare_release_evidence_review_regressions.py @@ -0,0 +1,156 @@ +"""Review-driven regressions for the sealed release-evidence preparer.""" + +from __future__ import annotations + +from pathlib import Path + +import pytest +from test_prepare_release_evidence import ( + LOCK_PATH, + MANIFEST_PATH, + REPOSITORY, + SDIST_NAME, + SOURCE_SHA, + WHEEL_NAME, + _load_preparer, + _write_distributions, +) + +from egressweave import release_evidence + +REPOSITORY_ROOT = Path(__file__).resolve().parents[1] +PREPARATION_GUIDE = REPOSITORY_ROOT / "docs" / "release-evidence-preparation.md" +PREPARER_PATH = REPOSITORY_ROOT / "scripts" / "ci" / "prepare_release_evidence.py" + + +def _prepare_with_inputs( + preparer, + evidence_dir: Path, + handoff_path: Path, + *, + dependency_manifest_path: Path = MANIFEST_PATH, + runtime_lock_path: Path = LOCK_PATH, +): + """Run the preparer through its public entry point with explicit input paths.""" + return preparer.prepare_release_evidence( + evidence_dir, + handoff_path, + repository=REPOSITORY, + source_sha=SOURCE_SHA, + dependency_manifest_path=dependency_manifest_path, + runtime_lock_path=runtime_lock_path, + ) + + +def test_operator_guide_names_existing_reviewed_dependency_inputs() -> None: + """Keep the copy-paste preparation command bound to repository-owned inputs.""" + guide = PREPARATION_GUIDE.read_text(encoding="utf-8") + + assert "scripts/ci/release_runtime_dependencies.json" in guide + assert "requirements-ci.txt" in guide + assert "scripts/ci/runtime-dependency-manifest.json" not in guide + assert "requirements-runtime.txt" not in guide + + +def test_preparer_uses_public_post_publication_reverification_api() -> None: + """Keep the standalone preparer off private package implementation symbols.""" + source = PREPARER_PATH.read_text(encoding="utf-8") + + assert hasattr(release_evidence, "reverify_published_evidence_manifest") + assert "release_evidence._require_post_publication_state" not in source + assert "release_evidence.reverify_published_evidence_manifest" in source + + +def test_handoff_path_without_filename_fails_before_evidence_mutation( + tmp_path: Path, +) -> None: + """Reject a directory-like handoff target before generating sealed evidence.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + + with pytest.raises(SystemExit, match="handoff manifest path must name one regular file"): + _prepare_with_inputs(preparer, evidence_dir, Path(".")) + + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + + +def test_distribution_versions_must_match_before_generated_outputs( + tmp_path: Path, +) -> None: + """Reject wheel/sdist filename version drift before generating evidence.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _, sdist_path = _write_distributions(evidence_dir) + mismatched_sdist = evidence_dir / "egressweave-0.4.0.tar.gz" + sdist_path.rename(mismatched_sdist) + + with pytest.raises(SystemExit, match="versions do not match"): + _prepare_with_inputs(preparer, evidence_dir, tmp_path / "handoff.json") + + assert {path.name for path in evidence_dir.iterdir()} == { + WHEEL_NAME, + mismatched_sdist.name, + } + assert not (tmp_path / "handoff.json").exists() + + +def test_private_writer_refuses_preexisting_generated_file_without_overwrite( + tmp_path: Path, +) -> None: + """Preserve a pre-existing generated path rather than replacing it.""" + preparer = _load_preparer() + generated = tmp_path / "generated.json" + generated.write_bytes(b"sentinel") + + with pytest.raises(SystemExit, match="already exists"): + preparer._write_private_file(generated, b"replacement", label="generated evidence") + + assert generated.read_bytes() == b"sentinel" + + +@pytest.mark.parametrize("input_kind", ["dependency manifest", "runtime lock"]) +def test_reviewed_dependency_inputs_reject_symlinks( + tmp_path: Path, + input_kind: str, +) -> None: + """Reject symlinked reviewed dependency inputs before generated output.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + linked_input = tmp_path / ("dependency.json" if input_kind == "dependency manifest" else "runtime.txt") + target = MANIFEST_PATH if input_kind == "dependency manifest" else LOCK_PATH + try: + linked_input.symlink_to(target) + except OSError: + pytest.skip("symbolic links are unavailable on this platform") + + dependency_manifest = linked_input if input_kind == "dependency manifest" else MANIFEST_PATH + runtime_lock = linked_input if input_kind == "runtime lock" else LOCK_PATH + with pytest.raises(SystemExit, match="missing or unsafe"): + _prepare_with_inputs( + preparer, + evidence_dir, + tmp_path / "handoff.json", + dependency_manifest_path=dependency_manifest, + runtime_lock_path=runtime_lock, + ) + + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not (tmp_path / "handoff.json").exists() + + +def test_missing_handoff_parent_fails_before_generated_outputs( + tmp_path: Path, +) -> None: + """Require an existing canonical handoff parent before evidence mutation.""" + preparer = _load_preparer() + evidence_dir = tmp_path / "evidence" + _write_distributions(evidence_dir) + handoff_path = tmp_path / "missing-parent" / "handoff.json" + + with pytest.raises(SystemExit, match="handoff manifest parent is missing or unsafe"): + _prepare_with_inputs(preparer, evidence_dir, handoff_path) + + assert {path.name for path in evidence_dir.iterdir()} == {WHEEL_NAME, SDIST_NAME} + assert not handoff_path.exists() diff --git a/tests/test_timeout_policy_type_documentation.py b/tests/test_timeout_policy_type_documentation.py index 4da53b1b..d0c336f9 100644 --- a/tests/test_timeout_policy_type_documentation.py +++ b/tests/test_timeout_policy_type_documentation.py @@ -10,7 +10,7 @@ def _read(path: Path) -> str: - """Return one repository text file as UTF-8.""" + """Return one repository text file as normalized UTF-8 prose.""" return " ".join(path.read_text(encoding="utf-8").split()) @@ -37,3 +37,13 @@ def test_changelog_records_timeout_policy_type_hardening() -> None: "subclass", ): assert fragment in changelog + + +def test_changelog_keeps_security_heading_and_entries_at_markdown_root() -> None: + """Prevent whitespace drift from turning release-history structure into code.""" + changelog = CHANGELOG_PATH.read_text(encoding="utf-8") + + assert "\n### Security\n" in changelog + assert "\n- Require the request timeout policy" in changelog + assert "\n ### Security\n" not in changelog + assert "\n - Require the request timeout policy" not in changelog