diff --git a/.agents/skills/ci-consumer-adoption/SKILL.md b/.agents/skills/ci-consumer-adoption/SKILL.md index 6dcae7d..30e6a62 100644 --- a/.agents/skills/ci-consumer-adoption/SKILL.md +++ b/.agents/skills/ci-consumer-adoption/SKILL.md @@ -24,7 +24,7 @@ still bills, and a repository that is routed to hardware its jobs cannot use. Do not choose a tier by reading prose. Resolve it: ```bash -python3 scripts/resolve_profile.py --visibility private --plan enterprise-cloud \ +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch resolve_profile.py -- --visibility private --plan enterprise-cloud \ --code-security --secret-protection --code-quality ``` diff --git a/.agents/skills/nddev-repo-flow/SKILL.md b/.agents/skills/nddev-repo-flow/SKILL.md index b60dae2..2bf6396 100644 --- a/.agents/skills/nddev-repo-flow/SKILL.md +++ b/.agents/skills/nddev-repo-flow/SKILL.md @@ -36,20 +36,22 @@ it explains, so a reversal is deliberate. 2. Update `catalog/capabilities.yml`, and `catalog/tools.yml` if you added or bumped an action. `used_by` and tool registration are both derived from the tree by `check_tool_registry.py`, so an omission fails rather than rots. -3. `python3 scripts/generate_docs.py`. +3. Run `generate_docs.py` through the repository's isolated Python launcher. 4. Add a caller example under `examples/`. `check_examples.py` fails a reusable with no example — it is the only executable statement of a caller contract. 5. Sync prose: `README.md`, the tier docs, the matching example. -6. `python3 scripts/sync_skills.py` if you touched a skill. +6. Run `sync_skills.py` through the isolated launcher if you touched a skill. 7. `CHANGELOG.md` under `[Unreleased]`. 8. Validate, then PR. ## Validate ```bash -uv pip install --system --require-hashes -r requirements-ci.txt -python3 scripts/validate_all.py --tier core # what ci-gate blocks on -python3 scripts/validate_all.py # all three tiers +python3.13 -I -B -m venv --copies .venv +uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt +.venv/bin/python -I -B scripts/check_python_syntax.py +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- --tier core +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- actionlint GH_TOKEN=$(gh auth token) uvx zizmor@1.26.1 --persona regular --min-severity low .github/workflows ``` diff --git a/.claude/skills/ci-consumer-adoption/.generated-from-agents-skills b/.claude/skills/ci-consumer-adoption/.generated-from-agents-skills index a6bcb12..258ec07 100644 --- a/.claude/skills/ci-consumer-adoption/.generated-from-agents-skills +++ b/.claude/skills/ci-consumer-adoption/.generated-from-agents-skills @@ -1,2 +1,2 @@ source=.agents/skills/ci-consumer-adoption/SKILL.md -sha256=349d73b940434016b11ee2ec99ee7edf011896d0f5b259282f2722252743341c +sha256=e92e2f643b1646e2bcdca8e9d1c11550a02732c5192944b20b28428078318173 diff --git a/.claude/skills/ci-consumer-adoption/SKILL.md b/.claude/skills/ci-consumer-adoption/SKILL.md index 6dcae7d..30e6a62 100644 --- a/.claude/skills/ci-consumer-adoption/SKILL.md +++ b/.claude/skills/ci-consumer-adoption/SKILL.md @@ -24,7 +24,7 @@ still bills, and a repository that is routed to hardware its jobs cannot use. Do not choose a tier by reading prose. Resolve it: ```bash -python3 scripts/resolve_profile.py --visibility private --plan enterprise-cloud \ +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch resolve_profile.py -- --visibility private --plan enterprise-cloud \ --code-security --secret-protection --code-quality ``` diff --git a/.claude/skills/nddev-repo-flow/.generated-from-agents-skills b/.claude/skills/nddev-repo-flow/.generated-from-agents-skills index 62a1113..b756c62 100644 --- a/.claude/skills/nddev-repo-flow/.generated-from-agents-skills +++ b/.claude/skills/nddev-repo-flow/.generated-from-agents-skills @@ -1,2 +1,2 @@ source=.agents/skills/nddev-repo-flow/SKILL.md -sha256=f97f89810a01582fb14f3f8978fdb0ae856d14c5760e547e8e45528e5fe4fe56 +sha256=c32d871d397a4f1dabff335d9d7bdbf54f92d4c7b4f6306daff89b3772e8f5a3 diff --git a/.claude/skills/nddev-repo-flow/SKILL.md b/.claude/skills/nddev-repo-flow/SKILL.md index b60dae2..2bf6396 100644 --- a/.claude/skills/nddev-repo-flow/SKILL.md +++ b/.claude/skills/nddev-repo-flow/SKILL.md @@ -36,20 +36,22 @@ it explains, so a reversal is deliberate. 2. Update `catalog/capabilities.yml`, and `catalog/tools.yml` if you added or bumped an action. `used_by` and tool registration are both derived from the tree by `check_tool_registry.py`, so an omission fails rather than rots. -3. `python3 scripts/generate_docs.py`. +3. Run `generate_docs.py` through the repository's isolated Python launcher. 4. Add a caller example under `examples/`. `check_examples.py` fails a reusable with no example — it is the only executable statement of a caller contract. 5. Sync prose: `README.md`, the tier docs, the matching example. -6. `python3 scripts/sync_skills.py` if you touched a skill. +6. Run `sync_skills.py` through the isolated launcher if you touched a skill. 7. `CHANGELOG.md` under `[Unreleased]`. 8. Validate, then PR. ## Validate ```bash -uv pip install --system --require-hashes -r requirements-ci.txt -python3 scripts/validate_all.py --tier core # what ci-gate blocks on -python3 scripts/validate_all.py # all three tiers +python3.13 -I -B -m venv --copies .venv +uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt +.venv/bin/python -I -B scripts/check_python_syntax.py +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- --tier core +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- actionlint GH_TOKEN=$(gh auth token) uvx zizmor@1.26.1 --persona regular --min-severity low .github/workflows ``` diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 75fa8c0..2d5e763 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -53,17 +53,25 @@ jobs: with: persist-credentials: false - name: Set up Python + id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" + update-environment: false - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: 0.11.30 - name: Install validator dependencies - run: uv pip install --system --require-hashes -r requirements-ci.txt + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} + run: | + "$PYTHON_PATH" -I -B -m venv --copies .venv + uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt - name: Run static validators - run: python3 scripts/validate_all.py --tier core + run: | + .venv/bin/python -I -B scripts/check_python_syntax.py + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- --tier core # Blocking, but scoped: a product fact is checked for expiry only when the # changed capability declares it, and a runtime-coverage waiver only when its @@ -95,15 +103,21 @@ jobs: fetch-depth: 0 persist-credentials: false - name: Set up Python + id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" + update-environment: false - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: 0.11.30 - name: Install validator dependencies - run: uv pip install --system --require-hashes -r requirements-ci.txt + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} + run: | + "$PYTHON_PATH" -I -B -m venv --copies .venv + uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt - name: Resolve the change base id: base env: @@ -131,11 +145,12 @@ jobs: BASE_REF: ${{ steps.base.outputs.ref }} run: | set -euo pipefail + .venv/bin/python -I -B scripts/check_python_syntax.py if [ -z "$BASE_REF" ]; then echo "no resolvable base; running the full sweep instead of scoping to nothing" - python3 scripts/validate_all.py --tier scheduled + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- --tier scheduled else - python3 scripts/validate_all.py --tier touched --changed-from "$BASE_REF" + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- --tier touched --changed-from "$BASE_REF" fi actionlint: @@ -202,57 +217,67 @@ jobs: with: terraform_wrapper: false + - name: Set up Python + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + update-environment: false + - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: 0.11.30 - python-version: '3.13' - activate-environment: true enable-cache: false - name: Install the probe's dependency and both Python fixtures + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} run: | set -euo pipefail + "$PYTHON_PATH" -I -B -m venv --copies .venv # The probe reads workflow YAML through the repository's strict # loader, so it needs PyYAML — from the hash-locked file CI already # uses, not an ad-hoc install. - uv pip install --require-hashes -r requirements-ci.txt + uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt # Both fixture packages must be importable in one environment: the # probe runs the same step twice and only the directory changes. - uv pip install -e tests/fixtures/python -e tests/fixtures/negative/python pytest + uv pip install --python .venv/bin/python \ + -e tests/fixtures/python -e tests/fixtures/negative/python pytest - name: terraform-ci must reject unformatted HCL run: | - python3 -I scripts/negative_gate_probe.py \ + .venv/bin/python -I -B scripts/check_python_syntax.py + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch negative_gate_probe.py -- \ --workflow .github/workflows/terraform-ci.yml --job terraform \ --step 'Format check' \ --bad tests/fixtures/negative/terraform --good tests/fixtures/terraform - name: go-ci must report a failing test run: | - python3 -I scripts/negative_gate_probe.py \ + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch negative_gate_probe.py -- \ --workflow .github/workflows/go-ci.yml --job go \ --step 'Run tests' \ --bad tests/fixtures/negative/go --good tests/fixtures/go - name: sql-ci must reject SQL the dialect refuses run: | - python3 -I scripts/negative_gate_probe.py \ + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch negative_gate_probe.py -- \ --workflow .github/workflows/sql-ci.yml --job sql \ --step 'Lint' \ --bad tests/fixtures/negative/sql --good tests/fixtures/sql - name: python-ci must report a failing test run: | - python3 -I scripts/negative_gate_probe.py \ + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch negative_gate_probe.py -- \ --workflow .github/workflows/python-ci.yml --job python \ --step 'Run tests' \ --bad tests/fixtures/negative/python --good tests/fixtures/python \ - --input test_command='python -m pytest -q' + --python-input test_command='-m pytest -q' - name: docs-ci must reject a broken relative link run: | - python3 -I scripts/negative_gate_probe.py \ + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch negative_gate_probe.py -- \ --workflow .github/workflows/docs-ci.yml --job docs \ --step 'Check relative Markdown links' \ --bad . --good . \ @@ -266,7 +291,7 @@ jobs: env: GITHUB_TOKEN: ${{ github.token }} run: | - python3 -I scripts/negative_gate_probe.py \ + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch negative_gate_probe.py -- \ --workflow .github/workflows/zizmor-no-sarif.yml --job zizmor \ --step 'Run zizmor (plain, enforced)' \ --bad . --good . \ @@ -289,25 +314,34 @@ jobs: with: persist-credentials: false + - name: Set up Python + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + update-environment: false + - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 with: version: 0.11.30 - python-version: '3.13' - activate-environment: true enable-cache: false - name: Install the probe's dependency + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} run: | set -euo pipefail - uv pip install --require-hashes -r requirements-ci.txt + "$PYTHON_PATH" -I -B -m venv --copies .venv + uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt # hadolint varies by input rather than by directory, and its binary is # installed by an earlier step of the workflow itself, so --before runs # that download instead of assuming a hadolint is already present. - name: hadolint-ci must reject an unpinned base and package run: | - python3 -I scripts/negative_gate_probe.py \ + .venv/bin/python -I -B scripts/check_python_syntax.py + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch negative_gate_probe.py -- \ --workflow .github/workflows/hadolint-ci.yml --job hadolint \ --before 'Download hadolint (pinned + checksum-verified)' \ --step 'Run hadolint' \ diff --git a/.github/workflows/maintenance.yml b/.github/workflows/maintenance.yml index 08f1555..145d1c7 100644 --- a/.github/workflows/maintenance.yml +++ b/.github/workflows/maintenance.yml @@ -59,9 +59,11 @@ jobs: persist-credentials: false - name: Set up Python + id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" + update-environment: false - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 @@ -69,15 +71,20 @@ jobs: version: 0.11.30 - name: Install validator dependencies - run: uv pip install --system --require-hashes -r requirements-ci.txt + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} + run: | + "$PYTHON_PATH" -I -B -m venv --copies .venv + uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt - name: Run the advisory sweep id: sweep run: | set -uo pipefail + .venv/bin/python -I -B scripts/check_python_syntax.py # Deliberately not `set -e`: a finding is the expected outcome and # must be reported, not abort the job before it can be filed. - python3 scripts/validate_all.py --tier scheduled > sweep.txt 2>&1 + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- --tier scheduled > sweep.txt 2>&1 status=$? cat sweep.txt printf 'status=%s\n' "$status" >> "$GITHUB_OUTPUT" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index eaf9e3b..53da3dd 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -64,9 +64,11 @@ jobs: with: persist-credentials: false - name: Set up Python + id: python uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 with: python-version: "3.13" + update-environment: false - name: Set up uv uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 @@ -80,14 +82,20 @@ jobs: enable-cache: false - name: Install validator dependencies - run: uv pip install --system --require-hashes -r requirements-ci.txt + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} + run: | + "$PYTHON_PATH" -I -B -m venv --copies .venv + uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt # A release is the one place where the full sweep is the right gate: an # immutable artifact must not ship carrying an expired external fact or a # runtime-coverage waiver that is already past due. Pull requests get the # scoped tiers; a release gets everything. - name: Full validation sweep - run: python3 -I scripts/validate_all.py + run: | + .venv/bin/python -I -B scripts/check_python_syntax.py + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- - name: Resolve and validate version id: v diff --git a/.github/workflows/runtime-fixtures-event-write.yml b/.github/workflows/runtime-fixtures-event-write.yml index 6fe7965..84e1bc0 100644 --- a/.github/workflows/runtime-fixtures-event-write.yml +++ b/.github/workflows/runtime-fixtures-event-write.yml @@ -147,6 +147,17 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Set up isolated Python contract runtime + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + update-environment: false + - name: Provision isolated Python contract runtime + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} + run: >- + "$PYTHON_PATH" -I -B -m venv --copies .venv - name: Render benchmark evidence env: RESULTS: ${{ toJSON(needs) }} @@ -155,7 +166,7 @@ jobs: GUARDS: '{"fixture-benchmark":["cleanup-benchmark"]}' run: | set -euo pipefail - python3 -I scripts/render_runtime_evidence.py + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch render_runtime_evidence.py -- prepare-pr-hygiene: if: ${{ github.event_name == 'pull_request' }} @@ -283,6 +294,17 @@ jobs: uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: persist-credentials: false + - name: Set up isolated Python contract runtime + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + update-environment: false + - name: Provision isolated Python contract runtime + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} + run: >- + "$PYTHON_PATH" -I -B -m venv --copies .venv - name: Render PR-hygiene evidence env: RESULTS: ${{ toJSON(needs) }} @@ -291,4 +313,4 @@ jobs: GUARDS: '{"fixture-pr-hygiene":["cleanup-pr-hygiene"]}' run: | set -euo pipefail - python3 -I scripts/render_runtime_evidence.py + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch render_runtime_evidence.py -- diff --git a/.github/workflows/runtime-fixtures-languages.yml b/.github/workflows/runtime-fixtures-languages.yml index 2f293bd..ce587e2 100644 --- a/.github/workflows/runtime-fixtures-languages.yml +++ b/.github/workflows/runtime-fixtures-languages.yml @@ -448,6 +448,19 @@ jobs: with: persist-credentials: false + - name: Set up isolated Python contract runtime + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + update-environment: false + + - name: Provision isolated Python contract runtime + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} + run: >- + "$PYTHON_PATH" -I -B -m venv --copies .venv + - name: Render evidence for the runtime-coverage ledger env: RESULTS: ${{ toJSON(needs) }} @@ -456,4 +469,4 @@ jobs: {"fixture-go-ci":"go-ci.yml","fixture-python-ci":"python-ci.yml","fixture-node-ci":"node-ci.yml","fixture-rust-ci":"rust-ci.yml","fixture-cpp-ci":"cpp-ci.yml","fixture-terraform-ci":"terraform-ci.yml","fixture-sql-ci":"sql-ci.yml","fixture-docs-quality":"docs-quality.yml","fixture-hadolint":"hadolint-ci.yml","fixture-web-ci":"web-ci.yml","fixture-container-ci":"container-ci.yml","fixture-cross-platform-smoke":"cross-platform-smoke.yml","fixture-java-ci":"java-ci.yml","fixture-dotnet-ci":"dotnet-ci.yml","fixture-swift-ci":"swift-ci.yml","fixture-mutation-testing":"mutation-testing.yml","fixture-r-ci":"r-ci.yml","fixture-benchmark-compare":"benchmark-compare.yml","fixture-go-ci-os":"go-ci.yml","fixture-python-ci-os":"python-ci.yml","fixture-rust-ci-os":"rust-ci.yml","fixture-dotnet-ci-os":"dotnet-ci.yml","fixture-java-ci-os":"java-ci.yml","fixture-node-ci-os":"node-ci.yml","fixture-terraform-ci-os":"terraform-ci.yml","fixture-sql-ci-os":"sql-ci.yml","fixture-web-ci-os":"web-ci.yml"} run: | set -euo pipefail - python3 -I scripts/render_runtime_evidence.py + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch render_runtime_evidence.py -- diff --git a/.github/workflows/runtime-fixtures.yml b/.github/workflows/runtime-fixtures.yml index 4be0571..1e061ac 100644 --- a/.github/workflows/runtime-fixtures.yml +++ b/.github/workflows/runtime-fixtures.yml @@ -99,17 +99,20 @@ jobs: # scope. That gap is now closed by setup_uv, and this fixture is what # proves it. setup_uv: true - install_command: "uv pip install --system --require-hashes -r requirements-ci.txt" + install_command: >- + "$pythonLocation/bin/python" -I -B -m venv --copies .venv && + uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt # Deliberately NOT `validate_all --tier core`. That command reads # runtime-coverage.yml, whose digests this very run exists to refresh, so # the fixture could only pass once it had already succeeded — a circular # dependency that made the lane look broken every time a proven workflow # changed. These validators are just as real and depend on the tree alone. command: >- - python3 scripts/check_workflow_contracts.py && - python3 scripts/check_permissions.py && - python3 scripts/check_pinned_actions.py && - python3 scripts/generate_docs.py --check + export PATH="$PWD/.venv/bin:$PATH" && + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch check_workflow_contracts.py -- && + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch check_permissions.py -- && + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch check_pinned_actions.py -- && + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch generate_docs.py -- --check # required-gate — the change router. On a branch push `github.event.before` is # the all-zero oid, so this exercises the conservative all-true path: the one @@ -288,6 +291,19 @@ jobs: with: persist-credentials: false + - name: Set up isolated Python contract runtime + id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + update-environment: false + + - name: Provision isolated Python contract runtime + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} + run: >- + "$PYTHON_PATH" -I -B -m venv --copies .venv + - name: Render evidence for the runtime-coverage ledger env: RESULTS: ${{ toJSON(needs) }} @@ -296,4 +312,4 @@ jobs: {"fixture-actionlint":"actionlint.yml","fixture-zizmor-no-sarif":"zizmor-no-sarif.yml","fixture-secret-scan":"secret-scan.yml","fixture-secret-scan-binary":"secret-scan.yml","fixture-private-static":"private-static.yml","fixture-monorepo-router":"monorepo-changed-paths.yml","fixture-osv-scan":"osv-scan.yml","fixture-semgrep":"semgrep-ci.yml","fixture-coverage-gate":"coverage-gate.yml","fixture-grype-scan":"grype-scan.yml","fixture-iac-scan":"iac-scan.yml","fixture-public-codeql":"public-codeql.yml","fixture-zizmor-sarif":"zizmor-sarif.yml","fixture-rust-supply-chain":"rust-supply-chain.yml","fixture-docs-ci":"docs-ci.yml","fixture-gate-report":"gate.yml"} run: | set -euo pipefail - python3 -I scripts/render_runtime_evidence.py + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch render_runtime_evidence.py -- diff --git a/AGENTS.md b/AGENTS.md index 63bac30..cf53b93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,13 +22,15 @@ contracts below are strict. ## Commands ```bash -uv pip install --system --require-hashes -r requirements-ci.txt # PyYAML only -python3 scripts/validate_all.py --tier core # what ci-gate blocks on -python3 scripts/validate_all.py # everything +python3.13 -I -B -m venv --copies .venv +uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt # PyYAML only +.venv/bin/python -I -B scripts/check_python_syntax.py +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- --tier core +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- actionlint GH_TOKEN=$(gh auth token) uvx zizmor@1.26.1 --persona regular --min-severity low .github/workflows -python3 scripts/generate_docs.py # after any catalog change -python3 scripts/resolve_profile.py --visibility private --plan team --code-security +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch generate_docs.py -- +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch resolve_profile.py -- --visibility private --plan team --code-security ``` Use `uv`, never `pip`/`pipx`/`npm`, and never a mutable version (`@latest`). Run @@ -67,8 +69,8 @@ Touch this → also do this: gates are covered, `zizmor-no-sarif` among them. A gate that never fails is not a gate, and a probe that never passes is not a test. A gate that never fails is not a gate, and a probe that never passes is not a test. -- **a catalog file** → `python3 scripts/generate_docs.py`. -- **a skill** → `python3 scripts/sync_skills.py`. +- **a catalog file** → run `generate_docs.py` through the isolated launcher above. +- **a skill** → run `sync_skills.py` through the isolated launcher above. - **a product fact** → re-read its `source_urls` and correct it. Bumping the date alone is precisely what the freshness gate exists to prevent. Stagger the new expiry: 38 of 41 facts once shared one date and would have failed together. @@ -101,7 +103,9 @@ the named script — its fixtures say what the contract is. Two rules no validator can catch for you: - **Never interpolate `${{ inputs.* }}` or `${{ github.event.* }}` inside - `run:`** — pass through `env:`. Embedded Python runs as `python3 -I`. + `run:`** — pass through `env:`. Embedded Python runs as `python3 -I`; + repository tools run through the launcher defined in + `catalog/python-execution.yml`. - **Never make a push/schedule-only workflow a required status check.** OSSF Scorecard is the trap: it cannot run on a pull-request head, so it protects nothing while blocking every merge. The requirement can live in *classic* diff --git a/CHANGELOG.md b/CHANGELOG.md index 25f1ddb..34d282e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ ## [Unreleased] +- Add one hermetic Python execution boundary for every repository validator and + generator. A machine-readable policy now pins the interpreter and PyYAML, + inventories imports/resources/subprocess edges, strips ambient `PYTHON*` + state at every generation, rejects shadow or unregistered tools, and keeps + cold startup proof separate from semantic validation. Dependency imports are + accepted only from a coherent active venv whose distribution/version matches + the hash-pinned requirements policy; dependency subjects transition to the + repository venv explicitly and enforce exact per-environment CPython patch + identity across local and hosted layouts. Workflow, documentation and AST + process edges now share typed invocation/environment semantics, including + exact repository-interpreter provisioning and one-way `execve` receipts. A + verified `importlib` package boundary now owns every sibling helper without + cwd, `PYTHONPATH`, editable-install, or `sys.path` dependence + (#129). Dependency-bearing negative probes bind commands to that exact + interpreter and refuse setup/import errors as evidence, so a failing control + can never masquerade as proof that bad input was rejected. + - Make `public-scorecard.yml` fail closed outside a public default-branch push/schedule, assign a deterministic SARIF category, and expose the upload identifier. The repository's persistent Scorecard caller now exercises the diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 0fe01d5..1be262a 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -97,13 +97,15 @@ actionlint zizmor --persona pedantic --min-severity low .github/workflows # Complete repository contract, catalog, example, and generated-doc checks -python3 scripts/validate_all.py +.venv/bin/python -I -B scripts/check_python_syntax.py +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- ``` Install validator dependencies with the hash-locked file: ```bash -uv pip install --system --require-hashes -r requirements-ci.txt +python3.13 -I -B -m venv --copies .venv +uv pip install --python .venv/bin/python --require-hashes -r requirements-ci.txt ``` Install the tools locally with: diff --git a/README.md b/README.md index 43f1a1f..eef45eb 100644 --- a/README.md +++ b/README.md @@ -73,6 +73,9 @@ Attestations are gated on Enterprise Cloud and this estate has it. The machine-readable source of truth is [`catalog/capabilities.yml`](catalog/capabilities.yml). Generated mirrors live in [`docs/generated/`](docs/generated/) and are checked by `scripts/generate_docs.py --check`. +Repository-owned Python validation runs through one hermetic launcher; its +machine policy and threat boundary are documented in +[`docs/19-python-execution.md`](docs/19-python-execution.md). ### Extended packs (July 2026) diff --git a/catalog/README.md b/catalog/README.md index 7dcc65e..bb9c69d 100644 --- a/catalog/README.md +++ b/catalog/README.md @@ -18,6 +18,7 @@ references). | `deprecations.yml` | `deprecations:` | Retiring, deprecated, and removed capabilities and the migration path. | | `workflow-routing.yml` | `groups:` | Estate-neutral supported OS and machine requirements for every reusable workflow. | | `evidence-orchestration.yml` | `lanes:` | Evidence selection by level, platform, OS, architecture, profile, risk, change, release, and host environment. | +| `python-execution.yml` | JSON-subset mapping | Exact Python surface/import/process inventory and hermetic launcher/environment policy. | | `runtime-coverage.yml` | `entries:` | Digest-bound observed runtime proof and typed debt for every reusable workflow. | | `schema/capability.schema.yaml` | JSON Schema | Machine-readable shape for `capabilities.yml`; `scripts/validate_catalog.py` is the enforcing validator. | | `schema/workflow-routing.schema.yaml` | JSON Schema | Machine-readable shape for OS/capability routing; `scripts/check_runner_routing.py` is the enforcing validator. | @@ -40,9 +41,10 @@ stack packs are materialized workflows, not placeholders. - `docs/generated/evidence-orchestration.md` - `docs/generated/runtime-coverage.md` -Run `python3 scripts/generate_docs.py --check` before release; `ci.yml` runs it -through `scripts/validate_all.py` so generated docs cannot drift from the -catalog or workflow tree. +Run `generate_docs.py --check` through the launcher documented in +`docs/19-python-execution.md` before release; `ci.yml` runs it through +`validate_all.py` so generated docs cannot drift from the catalog or workflow +tree. ## `capabilities.yml` schema diff --git a/catalog/python-execution.yml b/catalog/python-execution.yml new file mode 100644 index 0000000..aa967e2 --- /dev/null +++ b/catalog/python-execution.yml @@ -0,0 +1,227 @@ +{ + "schema_version": 1, + "python": { + "major_minor": "3.13", + "subject_count": 42, + "launcher": "scripts/check_python_execution_contract.py", + "launcher_prefix": [".venv/bin/python", "-I", "-B", "scripts/check_python_execution_contract.py", "--launch"], + "syntax_gate_prefix": [".venv/bin/python", "-I", "-B", "scripts/check_python_syntax.py"], + "dependency_interpreter": ".venv/bin/python", + "package": { + "name": "ci_workflows_tools", + "root": "scripts", + "init": "scripts/__init__.py", + "loading": "verified-file-spec", + "sys_path_mutation": false, + "editable_install": false + }, + "dynamic_sibling_imports": { + "check_python_execution_contract.py": ["_strict_yaml"] + }, + "bootstrap": { + "transition_receipt": "NDDEV_PYTHON_BOOTSTRAP", + "receipt_version": "repository-venv-v1", + "maximum_transitions": 1 + }, + "argument_separator": "--", + "invocation_documents": { + ".agents/skills/ci-consumer-adoption/SKILL.md": {"role": "repository-launcher", "language": "markdown"}, + ".agents/skills/nddev-repo-flow/SKILL.md": {"role": "repository-launcher", "language": "markdown"}, + ".claude/skills/ci-consumer-adoption/SKILL.md": {"role": "repository-launcher", "language": "markdown"}, + ".claude/skills/nddev-repo-flow/SKILL.md": {"role": "repository-launcher", "language": "markdown"}, + "AGENTS.md": {"role": "repository-launcher", "language": "markdown"}, + "CONTRIBUTING.md": {"role": "repository-launcher", "language": "markdown"}, + "README.md": {"role": "consumer-command", "language": "markdown"}, + "docs/02-private-free.md": {"role": "adoption-guide", "language": "markdown"}, + "docs/05-runners.md": {"role": "adoption-guide", "language": "markdown"}, + "docs/19-python-execution.md": {"role": "repository-launcher", "language": "markdown"}, + "examples/evidence-orchestration.md": {"role": "adoption-guide", "language": "markdown"}, + "examples/private-free/security-selfhosted.yml": {"role": "consumer-command", "language": "yaml"}, + "examples/private-free/security.yml": {"role": "consumer-command", "language": "yaml"}, + "scripts/resolve_profile.py": {"role": "repository-launcher", "language": "python"}, + "scripts/validate_all.py": {"role": "repository-launcher", "language": "python"} + }, + "source_classes": { + "production_workflows": ".github/workflows/*.yml", + "negative_corpora": "tests/fixtures/negative/**", + "negative_contract": "rejection-only" + }, + "embedded_prefix": ["python3", "-I"], + "dependency_lock": "requirements-ci.txt", + "venv": { + "path": ".venv", + "implementation": "CPython", + "implementation_name": "cpython", + "release_level": "final", + "patch_policy": "per-environment-exact", + "include_system_site_packages": false, + "home_interpreter_names": ["python3.13", "python3", "python", "python3.13.exe", "python.exe"] + }, + "external_dependencies": { + "yaml": { + "distribution": "PyYAML", + "version": "6.0.3", + "module_path": "yaml/__init__.py" + } + } + }, + "environment": { + "base_allowlist": [ + "COMSPEC", "HOME", "LANG", "LC_ALL", "PATH", "PATHEXT", + "SSL_CERT_DIR", "SSL_CERT_FILE", "SYSTEMROOT", "SystemRoot", + "TEMP", "TMP", "TMPDIR", "USERPROFILE", "WINDIR" + ], + "canonical_values": {"LANG": "C", "LC_ALL": "C"}, + "interpreter_prefix": "PYTHON", + "stripped_names_evidence": "NDDEV_PYTHON_ENV_STRIPPED", + "inherited_names_evidence": "NDDEV_PYTHON_ENV_INHERITED", + "bootstrap_boundary": { + "surface": "check_python_execution_contract.py", + "function": "clean_environment", + "transition": "_transition_environment" + }, + "hostile_probe_variables": [ + "PYTHONBREAKPOINT", "PYTHONCOERCECLOCALE", "PYTHONHOME", + "PYTHONIOENCODING", "PYTHONPATH", "PYTHONUTF8", "PYTHONWARNINGS", + "pythonpath" + ] + }, + "required_resources": [ + ".github/workflows", "catalog", "docs", "examples", + "requirements-ci.txt", "scripts/__init__.py", "scripts/validate_all.py" + ], + "surface_environment": { + "negative_gate_probe.py": ["GH_TOKEN", "GITHUB_TOKEN"], + "render_runtime_evidence.py": ["GUARDS", "PROVES", "RESULTS", "RUN_URL"], + "verify_scorecard_runtime.py": ["GH_HOST", "GH_TOKEN"] + }, + "process_profiles": { + "exec-replace": {"api": "execve", "executable": "repository-venv", "argv": "exact", "cwd": "preserve", "environment": "replace-clean"}, + "external-tool": {"api": "subprocess", "executable": "path-search", "argv": "sequence", "cwd": "explicit", "environment": "replace-clean"}, + "external-tool-fixture": {"api": "subprocess", "executable": "path-search", "argv": "sequence", "cwd": "explicit-or-preserve", "environment": "replace-clean"}, + "hostile-python-probe": {"api": "subprocess", "executable": "current-interpreter", "argv": "exact", "cwd": "explicit", "environment": "replace-owned-hostile"}, + "isolated-python-fixture": {"api": "subprocess", "executable": "current-interpreter", "argv": "exact", "cwd": "explicit-or-preserve", "environment": "replace-clean"}, + "launcher-clean": {"api": "subprocess", "executable": "selected-interpreter", "argv": "exact", "cwd": "explicit-or-preserve", "environment": "replace-clean"}, + "network-gh": {"api": "subprocess", "executable": "path-search", "argv": "sequence", "cwd": "explicit", "environment": "replace-clean-inherit-named"}, + "shell-fixture": {"api": "subprocess", "executable": "path-search", "argv": "sequence", "cwd": "explicit-or-preserve", "environment": "replace-clean"} + }, + "surface_groups": { + "library": ["_runners.py", "_workflow_yaml.py"], + "read_only_validator": [ + "_strict_yaml.py", "check_actionlint_contract.py", + "check_benchmark_contract.py", "check_docs_links.py", "check_examples.py", + "check_gate_contract.py", "check_harden_runner_contract.py", + "check_merge_group.py", "check_monorepo_routing.py", + "check_permissions.py", "check_pinned_actions.py", + "check_pr_hygiene_contract.py", "check_privileged_ref_guard.py", + "check_public_docs.py", "check_release_graph.py", + "check_python_syntax.py", + "check_release_promotion_gate.py", "check_release_supply_chain.py", + "check_rulesets.py", "check_runner_routing.py", + "check_runtime_requirements.py", "check_scorecard_evidence_contract.py", + "check_secret_scan_contract.py", + "check_side_effect_fixture_contract.py", "check_skills.py", + "check_tool_pinning.py", "check_tool_registry.py", + "check_workflow_contracts.py", "compile_evidence_plan.py", + "render_runtime_evidence.py", "resolve_profile.py", "validate_catalog.py", + "validate_product_facts.py", "validate_profiles.py", + "validate_runtime_coverage.py", "verify_scorecard_runtime.py" + ], + "read_only_generator_check": ["generate_docs.py"], + "mutating_generator_import_help_only": ["sync_skills.py"], + "aggregate": ["validate_all.py"], + "fixture_harness": ["negative_gate_probe.py"] + }, + "sibling_imports": { + "__init__.py": [], + "_workflow_yaml.py": ["_strict_yaml"], + "check_actionlint_contract.py": ["_workflow_yaml", "check_python_execution_contract"], + "check_benchmark_contract.py": ["_workflow_yaml"], + "check_examples.py": ["_runners", "_workflow_yaml"], + "check_gate_contract.py": ["_strict_yaml", "_workflow_yaml", "check_python_execution_contract"], + "check_harden_runner_contract.py": ["_workflow_yaml"], + "check_merge_group.py": ["_workflow_yaml"], + "check_monorepo_routing.py": ["_workflow_yaml", "check_python_execution_contract"], + "check_permissions.py": ["_workflow_yaml"], + "check_pinned_actions.py": ["_workflow_yaml"], + "check_pr_hygiene_contract.py": ["_workflow_yaml", "check_python_execution_contract"], + "check_privileged_ref_guard.py": ["_workflow_yaml", "check_python_execution_contract"], + "check_public_docs.py": ["_workflow_yaml"], + "check_release_graph.py": ["_strict_yaml", "_workflow_yaml"], + "check_release_promotion_gate.py": ["_workflow_yaml", "check_python_execution_contract"], + "check_release_supply_chain.py": ["_workflow_yaml", "check_python_execution_contract"], + "check_runner_routing.py": ["_strict_yaml", "_workflow_yaml", "check_runtime_requirements"], + "check_runtime_requirements.py": ["_strict_yaml", "_workflow_yaml"], + "check_scorecard_evidence_contract.py": ["_strict_yaml", "_workflow_yaml", "check_harden_runner_contract", "check_python_execution_contract"], + "check_secret_scan_contract.py": ["_strict_yaml", "_workflow_yaml", "check_python_execution_contract"], + "check_side_effect_fixture_contract.py": ["_strict_yaml", "_workflow_yaml", "check_python_execution_contract"], + "check_skills.py": ["_strict_yaml"], + "check_tool_pinning.py": ["_workflow_yaml"], + "check_tool_registry.py": ["_strict_yaml", "_workflow_yaml"], + "check_workflow_contracts.py": ["_runners", "_workflow_yaml"], + "compile_evidence_plan.py": ["_strict_yaml", "_workflow_yaml"], + "generate_docs.py": ["_strict_yaml", "_workflow_yaml", "resolve_profile"], + "negative_gate_probe.py": ["_strict_yaml", "check_python_execution_contract"], + "resolve_profile.py": ["_strict_yaml"], + "validate_all.py": [ + "_strict_yaml", "check_actionlint_contract", "check_benchmark_contract", + "check_docs_links", "check_examples", "check_gate_contract", + "check_harden_runner_contract", "check_merge_group", + "check_monorepo_routing", "check_permissions", "check_pinned_actions", + "check_pr_hygiene_contract", "check_privileged_ref_guard", + "check_public_docs", "check_python_execution_contract", + "check_python_syntax", + "check_release_graph", "check_release_promotion_gate", + "check_release_supply_chain", "check_rulesets", "check_runner_routing", + "check_runtime_requirements", "check_scorecard_evidence_contract", + "check_secret_scan_contract", + "check_side_effect_fixture_contract", "check_skills", "check_tool_pinning", + "check_tool_registry", "check_workflow_contracts", "compile_evidence_plan", + "generate_docs", "render_runtime_evidence", "resolve_profile", + "validate_catalog", "validate_product_facts", "validate_profiles", + "validate_runtime_coverage" + ], + "validate_catalog.py": ["_strict_yaml", "_workflow_yaml"], + "validate_product_facts.py": ["_strict_yaml"], + "validate_profiles.py": ["_strict_yaml"], + "validate_runtime_coverage.py": ["_strict_yaml", "_workflow_yaml"], + "verify_scorecard_runtime.py": ["_strict_yaml", "_workflow_yaml", "check_python_execution_contract", "check_scorecard_evidence_contract"] + }, + "direct_external_imports": { + "_strict_yaml.py": ["yaml"], + "validate_catalog.py": ["yaml"], + "validate_product_facts.py": ["yaml"], + "validate_runtime_coverage.py": ["yaml"], + "verify_scorecard_runtime.py": ["yaml"] + }, + "process_edges": { + "check_actionlint_contract.py": {"_run_guard": {"count": 1, "profile": "isolated-python-fixture"}}, + "check_gate_contract.py": {"_run": {"count": 1, "profile": "isolated-python-fixture"}}, + "check_monorepo_routing.py": {"_run": {"count": 1, "profile": "external-tool-fixture"}}, + "check_pr_hygiene_contract.py": {"_exercise_validator": {"count": 1, "profile": "shell-fixture"}}, + "check_privileged_ref_guard.py": {"_run_guard": {"count": 1, "profile": "shell-fixture"}}, + "check_python_execution_contract.py": { + "_ensure_repository_interpreter": {"count": 1, "profile": "exec-replace"}, + "_launch": {"count": 1, "profile": "launcher-clean"}, + "_probe_child": {"count": 1, "profile": "launcher-clean"}, + "_probe_generations": {"count": 2, "profile": "hostile-python-probe"}, + "_probe_sanitize": {"count": 1, "profile": "launcher-clean"}, + "_run_import": {"count": 1, "profile": "launcher-clean"} + }, + "check_release_promotion_gate.py": {"_run": {"count": 1, "profile": "isolated-python-fixture"}}, + "check_release_supply_chain.py": { + "_check_gnu_tar_archive": {"count": 3, "profile": "external-tool-fixture"}, + "_find_gnu_tar": {"count": 1, "profile": "external-tool-fixture"}, + "_run": {"count": 1, "profile": "external-tool-fixture"} + }, + "check_scorecard_evidence_contract.py": {"_run_guard": {"count": 1, "profile": "isolated-python-fixture"}}, + "check_secret_scan_contract.py": {"_run": {"count": 1, "profile": "isolated-python-fixture"}}, + "check_side_effect_fixture_contract.py": {"_run_with_fake_gh": {"count": 1, "profile": "shell-fixture"}}, + "negative_gate_probe.py": {"run_in": {"count": 1, "profile": "shell-fixture"}}, + "validate_all.py": {"changed_paths": {"count": 2, "profile": "external-tool"}}, + "verify_scorecard_runtime.py": { + "api": {"count": 1, "profile": "network-gh"}, + "api_bytes": {"count": 1, "profile": "network-gh"} + } + } +} diff --git a/catalog/tools.yml b/catalog/tools.yml index 4efc9e9..dbc75db 100644 --- a/catalog/tools.yml +++ b/catalog/tools.yml @@ -204,6 +204,9 @@ tools: - .github/workflows/mutation-testing.yml - .github/workflows/private-static.yml - .github/workflows/release.yml + - .github/workflows/runtime-fixtures.yml + - .github/workflows/runtime-fixtures-event-write.yml + - .github/workflows/runtime-fixtures-languages.yml last_verified: "2026-07-04" - id: upload-artifact diff --git a/docs/02-private-free.md b/docs/02-private-free.md index 0794c80..7a70047 100644 --- a/docs/02-private-free.md +++ b/docs/02-private-free.md @@ -60,8 +60,8 @@ The resolver defaults private/internal repositories to the first, fail-closed mode. The hosted choice requires an explicit flag: ```bash -python3 scripts/resolve_profile.py --visibility private --plan free -python3 scripts/resolve_profile.py --visibility private --plan free \ +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch resolve_profile.py -- --visibility private --plan free +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch resolve_profile.py -- --visibility private --plan free \ --private-compute github-hosted ``` diff --git a/docs/05-runners.md b/docs/05-runners.md index 749cd4b..02ba439 100644 --- a/docs/05-runners.md +++ b/docs/05-runners.md @@ -225,7 +225,7 @@ caller. Do not create an Actions "routing job": that job has already entered a runner queue and therefore cannot enforce pre-queue routing. ```bash -python3 scripts/check_runner_routing.py \ +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch check_runner_routing.py -- \ --workflow .github/workflows/secret-scan.yml \ --platform linux --class integration --visibility private ``` diff --git a/docs/19-python-execution.md b/docs/19-python-execution.md new file mode 100644 index 0000000..ed20872 --- /dev/null +++ b/docs/19-python-execution.md @@ -0,0 +1,128 @@ +# Hermetic repository Python execution + +Repository-owned Python tools have one execution boundary: + +```bash +.venv/bin/python -I -B scripts/check_python_execution_contract.py --launch validate_all.py -- +``` + +Create the repository environment from the selected CPython 3.13 interpreter +with `python3.13 -I -B -m venv --copies .venv`, then install the hash-locked +requirements explicitly into `.venv/bin/python`. Activation is neither needed +nor trusted. + +Before invoking the dependency-aware launcher, run the stdlib-only cold syntax +gate with `.venv/bin/python -I -B scripts/check_python_syntax.py`. It AST-parses +and byte-compiles every Python surface and independently proves that balanced, +quoted and multiline parser fixtures pass while malformed delimiters and +truncated constructions fail. This separate boundary remains runnable when a +semantic validator itself cannot be imported. + +`catalog/python-execution.yml` is the machine-readable authority for the pinned +Python minor, exact surface inventory, sibling and external dependency graph, +required repository resources, subprocess edges, and child-environment policy. +The launcher rejects unknown or traversing tool names and does not add the +checkout or `scripts/` to `sys.path`. Instead, `scripts/__init__.py` is the exact +repository-tool package manifest. The stdlib-only bootstrap registers that +package through an `importlib` file spec whose sole submodule search location is +the verified, non-symlink repository `scripts/` directory; every sibling import +is package-qualified and every subject executes by its qualified module name. +Missing, stale, duplicate, shadowed, or wrong-origin package/helper identities +fail before subject execution. PyYAML remains an external dependency and is +accepted only from its separately verified hash-pinned venv distribution. +Invocation text is parsed into one typed `executable / flags / launcher / verb / +subject / arguments` record after deterministic POSIX continuation folding. +Workflow YAML is loaded structurally: only `jobs.*.steps[*].run` is executable +command input, and it must be a non-empty scalar. Nested `defaults.run` +mappings and reusable input definitions are configuration, while a null, +mapping, sequence, malformed scalar, or truncated command at a step boundary +fails closed. The same Invocation parser and renderer then validate decoded +literal, folded, quoted and multiline commands. Documentation is registered as +one of three source roles: repository-launcher references must demonstrate the +launcher, adoption guides may combine repository planning commands with +consumer examples, and consumer-command examples must not depend on this +library's repository-only launcher. The registry also fixes each source +language. Markdown contributes executable commands only from explicitly +language-tagged shell fences, Python only from registered AST string fixtures, +and workflow YAML only from structurally parsed step `run` scalars. Prose, +comments, mapping keys, and negative corpora are not re-tokenized as shell. +Every extracted command retains source path, line, role, and language; +unclassified sources, duplicate surfaces, and role drift fail closed. +The compatibility contract is CPython `3.13`, not one cross-host patch number: +uv may resolve that minor request to a newer supported patch. Each individual +environment is nevertheless exact and fail-closed: runtime `version_info`, +CPython implementation version, import cache tag, `pyvenv.cfg` version, base +installation, and executable must all describe the same patch. A +dependency-bearing subject is physically launched with `.venv/bin/python`; +the ambient `python3`, `PATH`, and `VIRTUAL_ENV` cannot select its interpreter. +Self workflows obtain the exact interpreter path from `actions/setup-python`, +disable that action's PATH mutation, create a copy-based `.venv`, and always +invoke the repository path. A stdlib-only launcher started under another +interpreter may make exactly one early `execve` transition to +`.venv/bin/python`; the transition emits a typed receipt and a missing, +foreign, symlinked, or repeated target fails before dependency import. +PyYAML is trusted only from the active Python 3.13 virtual environment: the +interpreter, prefix, base interpreter, `pyvenv.cfg`, non-system site-packages, +distribution metadata and import origin must form one coherent non-escaping +identity. `VIRTUAL_ENV` text is neither read nor trusted. +The venv is identified by Python's own runtime facts: `sys.prefix` and +`sys.exec_prefix` resolve to the repository-owned `.venv`, while +`sys.base_prefix`, `sys.base_exec_prefix`, and the optional +`sys._base_executable` anchor the base installation. The `pyvenv.cfg` `home` +directory must contain an interpreter resolving to that same base executable; +it is deliberately not required to equal the executable's parent because +framework and package-manager layouts need not have that shape. Exact patch +version, disabled system/user sites, trusted `purelib`/`platlib`, distribution +metadata, and module origin are checked independently. Harmless parent-path +aliases are resolved; a symlink that escapes a trusted root is rejected. + +Every child process receives an allowlisted environment rather than an ambient +copy. Interpreter-control variables (`PYTHON*`, case-insensitively) are removed +at every process edge and their names are carried as structured stripping +evidence. The same transition records the exact allowlisted variable names it +inherited; values are never written to evidence. Locale is canonicalized to +`C`. A validator may inherit an additional variable only through its explicit +launcher profile; Python-control variables can never be added there. +Each AST-observed process edge selects a total machine-readable profile for its +API, executable origin, argv form, cwd behavior, and environment replacement. +`os.execve` is the one replacement edge: its third positional mapping is the +complete child environment, and the registry rejects a missing, duplicated, or +untyped edge. +Dependency-bearing negative probes bind their workflow input to the exact +validated repository interpreter with `--python-input NAME=ARGS`. The harness +requires pytest to resolve inside that same venv before either fixture runs; +missing/import/setup failures are reported as `NOT_PROVEN`, while evidence +requires the bad fixture to fail for its assertion and the clean control to +complete successfully. Ambient `python`, PATH lookup, and caller-supplied +interpreter text cannot satisfy this contract. +GitHub API verifier edges use the `network-gh` profile with an explicit +repository cwd and only the named `GH_HOST`/`GH_TOKEN` inheritance; this matches +both byte and JSON API calls rather than treating their explicit cwd as ambient +preservation. + +The blocking cold-process gate proves Python 3.13 isolated mode, no bytecode +writes, hostile working-directory and shadow-module resistance, pinned PyYAML +ownership, exact imports/resources, no import-time writes, registered explicit +subprocess environments, multi-generation taint removal, and preservation of a +child's first exit code and diagnostic. It imports mutating generators but does +not execute them. Business semantics remain in their dedicated validators and +`validate_all.py`; cold startup success is never substituted for those checks. + +Embedded one-off Python inside a workflow is a separate contract and runs as +`python3 -I`. It is not a registered repository tool and cannot use the launcher +to bypass the surface inventory. + +Primary contracts: [Python 3.13 command-line and environment +semantics](https://docs.python.org/3.13/using/cmdline.html), [Python 3.13 +subprocess environment replacement](https://docs.python.org/3.13/library/subprocess.html), +[Python 3.13 virtual environments](https://docs.python.org/3.13/library/venv.html), +[Python 3.13 `sys` prefixes](https://docs.python.org/3.13/library/sys.html), and +[Python 3.13 `runpy`](https://docs.python.org/3.13/library/runpy.html). The +[uv Python-version contract](https://docs.astral.sh/uv/concepts/python-versions/) +defines a minor-only request as a compatible line whose preferred patch can be +upgraded; this repository therefore checks exact in-environment coherence +instead of hard-coding whichever patch one runner resolved today. +The pinned [`actions/setup-python` output +contract](https://github.com/actions/setup-python/blob/main/docs/advanced-usage.md#outputs-and-environment-variables) +provides the absolute bootstrap interpreter; its PATH update is disabled so +that only the recorded output can create the repository environment. diff --git a/examples/evidence-orchestration.md b/examples/evidence-orchestration.md index 8add230..239dde5 100644 --- a/examples/evidence-orchestration.md +++ b/examples/evidence-orchestration.md @@ -19,11 +19,29 @@ jobs: ref: path: .ci-workflows persist-credentials: false + - id: python + uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0 + with: + python-version: '3.13' + update-environment: false + - uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0 + with: + version: 0.11.30 + - name: Provision the compiler environment + env: + PYTHON_PATH: ${{ steps.python.outputs.python-path }} + run: | + "$PYTHON_PATH" -I -B -m venv --copies .ci-workflows/.venv + uv pip install --python .ci-workflows/.venv/bin/python \ + --require-hashes -r .ci-workflows/requirements-ci.txt - id: plan - run: >- - python3 .ci-workflows/scripts/compile_evidence_plan.py - --level pr-required --platform github-actions --os ubuntu --arch x64 - --profile public --risks code,security --changes workflows + run: | + cd .ci-workflows + .venv/bin/python -I -B scripts/check_python_syntax.py + .venv/bin/python -I -B scripts/check_python_execution_contract.py \ + --launch compile_evidence_plan.py -- \ + --level pr-required --platform github-actions --os ubuntu --arch x64 \ + --profile public --risks code,security --changes workflows actionlint: needs: evidence-plan diff --git a/scripts/__init__.py b/scripts/__init__.py new file mode 100644 index 0000000..7ba4d13 --- /dev/null +++ b/scripts/__init__.py @@ -0,0 +1,3 @@ +"""Repository-owned validator package; loaded only by the isolated launcher.""" + +PACKAGE_CONTRACT = "ci-workflows-tools-v1" diff --git a/scripts/_strict_yaml.py b/scripts/_strict_yaml.py index 6b32a48..89b361e 100644 --- a/scripts/_strict_yaml.py +++ b/scripts/_strict_yaml.py @@ -156,6 +156,4 @@ def main() -> int: if __name__ == "__main__": import sys - - sys.path.insert(0, str(Path(__file__).resolve().parent)) raise SystemExit(main()) diff --git a/scripts/_workflow_yaml.py b/scripts/_workflow_yaml.py index c1e979c..3ec8b65 100644 --- a/scripts/_workflow_yaml.py +++ b/scripts/_workflow_yaml.py @@ -9,7 +9,7 @@ from pathlib import Path from typing import Any -from _strict_yaml import strict_load +from ci_workflows_tools._strict_yaml import strict_load REPO_ROOT = Path(__file__).resolve().parent.parent WORKFLOWS_DIR = REPO_ROOT / ".github" / "workflows" diff --git a/scripts/check_actionlint_contract.py b/scripts/check_actionlint_contract.py index 526f8a6..2995472 100644 --- a/scripts/check_actionlint_contract.py +++ b/scripts/check_actionlint_contract.py @@ -11,13 +11,13 @@ from __future__ import annotations -import os import re import subprocess import sys from typing import Any -from _workflow_yaml import WORKFLOWS_DIR, get_on, load_yaml +from ci_workflows_tools._workflow_yaml import WORKFLOWS_DIR, get_on, load_yaml +from ci_workflows_tools.check_python_execution_contract import clean_environment ACTIONLINT = WORKFLOWS_DIR / "actionlint.yml" GUARD_ENV_KEYS = {"ACTIONLINT_RUNNER_OS", "ACTIONLINT_RUNNER_ARCH"} @@ -64,13 +64,10 @@ def _embedded_python(step: dict[str, Any] | None) -> str: def _run_guard(program: str, runner_os: str, runner_arch: str) -> int: - env = os.environ.copy() - env.update( - { - "ACTIONLINT_RUNNER_OS": runner_os, - "ACTIONLINT_RUNNER_ARCH": runner_arch, - } - ) + env = clean_environment({ + "ACTIONLINT_RUNNER_OS": runner_os, + "ACTIONLINT_RUNNER_ARCH": runner_arch, + }) return subprocess.run( [sys.executable, "-I", "-"], input=program, diff --git a/scripts/check_benchmark_contract.py b/scripts/check_benchmark_contract.py index d9712ff..94ea8ea 100644 --- a/scripts/check_benchmark_contract.py +++ b/scripts/check_benchmark_contract.py @@ -14,7 +14,7 @@ import sys from typing import Any -from _workflow_yaml import WORKFLOWS_DIR, get_on, load_yaml +from ci_workflows_tools._workflow_yaml import WORKFLOWS_DIR, get_on, load_yaml BENCHMARK = WORKFLOWS_DIR / "benchmark.yml" BENCHMARK_COMPARE = WORKFLOWS_DIR / "benchmark-compare.yml" diff --git a/scripts/check_examples.py b/scripts/check_examples.py index 9b41113..4e6afa7 100644 --- a/scripts/check_examples.py +++ b/scripts/check_examples.py @@ -11,7 +11,7 @@ from pathlib import Path from typing import Any -from _workflow_yaml import get_on, load_yaml +from ci_workflows_tools._workflow_yaml import get_on, load_yaml REPO_ROOT = Path(__file__).resolve().parent.parent EXAMPLES_DIR = REPO_ROOT / "examples" @@ -24,7 +24,7 @@ # Runner semantics live in one place so this check and # check_workflow_contracts.py cannot drift apart about what "hosted" and # "standard" mean. See _runners.py for why the distinction matters. -from _runners import HOSTED_RUNNER_PREFIXES, LARGER_RUNNER_SUFFIXES +from ci_workflows_tools._runners import HOSTED_RUNNER_PREFIXES, LARGER_RUNNER_SUFFIXES # Only an example that says in its own filename that it targets a private # repository may name a non-hosted runner. Public repositories get free unmetered # hosted minutes, and pointing one at self-hosted hardware turns a forked pull @@ -81,7 +81,9 @@ def _coverage() -> list[str]: the permissions it needs, the inputs it requires, and the runner the caller must name for itself. """ - from _workflow_yaml import SELF_WORKFLOWS, WORKFLOWS_DIR, get_on, load_yaml + from ci_workflows_tools._workflow_yaml import ( + SELF_WORKFLOWS, WORKFLOWS_DIR, get_on, load_yaml, + ) reusables = set() for path in sorted(WORKFLOWS_DIR.glob("*.yml")): diff --git a/scripts/check_gate_contract.py b/scripts/check_gate_contract.py index 9703c68..823a51e 100644 --- a/scripts/check_gate_contract.py +++ b/scripts/check_gate_contract.py @@ -31,7 +31,8 @@ import tempfile from pathlib import Path -from _workflow_yaml import REPO_ROOT, load_yaml +from ci_workflows_tools._workflow_yaml import REPO_ROOT, load_yaml +from ci_workflows_tools.check_python_execution_contract import clean_environment GATE = REPO_ROOT / ".github" / "workflows" / "gate.yml" CATALOG = REPO_ROOT / "catalog" / "capabilities.yml" @@ -60,14 +61,14 @@ def _embedded_program() -> str: def _run(program: Path, needs, required: str, allow: str = "") -> int: payload = "" if needs is None else json.dumps(needs) completed = subprocess.run( - ["python3", "-I", str(program)], - env={ + [sys.executable, "-I", str(program)], + env=clean_environment({ "PATH": "/usr/bin:/bin", "NEEDS_JSON": payload, "REQUIRED_JOBS": required, "ALLOW_SKIPPED": allow, "CHECK_NAME": "fixture", - }, + }), capture_output=True, text=True, timeout=30, @@ -150,7 +151,7 @@ def check() -> list[str]: # The catalog must not advertise it as a merge gate either. if CATALOG.is_file(): - from _strict_yaml import strict_load + from ci_workflows_tools._strict_yaml import strict_load for cap in (strict_load(CATALOG) or {}).get("capabilities") or []: if str(cap.get("id")) != "gate": @@ -185,5 +186,4 @@ def main() -> int: if __name__ == "__main__": - sys.path.insert(0, str(Path(__file__).resolve().parent)) raise SystemExit(main()) diff --git a/scripts/check_harden_runner_contract.py b/scripts/check_harden_runner_contract.py index a5257f3..4204ce7 100644 --- a/scripts/check_harden_runner_contract.py +++ b/scripts/check_harden_runner_contract.py @@ -12,7 +12,7 @@ import sys from typing import Any -from _workflow_yaml import load_yaml, workflow_files +from ci_workflows_tools._workflow_yaml import load_yaml, workflow_files HARDEN_RUNNER = ( "step-security/harden-runner@" diff --git a/scripts/check_merge_group.py b/scripts/check_merge_group.py index 0d17807..4108248 100644 --- a/scripts/check_merge_group.py +++ b/scripts/check_merge_group.py @@ -12,7 +12,7 @@ from pathlib import Path from typing import Any -from _workflow_yaml import get_on, load_yaml +from ci_workflows_tools._workflow_yaml import get_on, load_yaml REPO_ROOT = Path(__file__).resolve().parent.parent RULESETS_DIR = REPO_ROOT / ".github" / "rulesets" diff --git a/scripts/check_monorepo_routing.py b/scripts/check_monorepo_routing.py index 63eb470..8503388 100644 --- a/scripts/check_monorepo_routing.py +++ b/scripts/check_monorepo_routing.py @@ -20,7 +20,8 @@ from pathlib import Path from typing import Any -from _workflow_yaml import WORKFLOWS_DIR, load_yaml +from ci_workflows_tools._workflow_yaml import WORKFLOWS_DIR, load_yaml +from ci_workflows_tools.check_python_execution_contract import clean_environment ROUTER = WORKFLOWS_DIR / "monorepo-changed-paths.yml" ZERO_BEFORE = "0" * 40 @@ -70,9 +71,7 @@ def _run( command: list[str], *, cwd: Path, env: dict[str, str] | None = None, input_text: str | None = None, ) -> subprocess.CompletedProcess[str]: - process_env = os.environ.copy() - if env: - process_env.update(env) + process_env = clean_environment(env) return subprocess.run( command, cwd=cwd, diff --git a/scripts/check_permissions.py b/scripts/check_permissions.py index 6c33771..97b0fca 100644 --- a/scripts/check_permissions.py +++ b/scripts/check_permissions.py @@ -9,7 +9,7 @@ import sys from typing import Any -from _workflow_yaml import load_yaml, workflow_files +from ci_workflows_tools._workflow_yaml import load_yaml, workflow_files def check() -> list[str]: diff --git a/scripts/check_pinned_actions.py b/scripts/check_pinned_actions.py index e1553c7..9b8646e 100644 --- a/scripts/check_pinned_actions.py +++ b/scripts/check_pinned_actions.py @@ -7,7 +7,7 @@ import re import sys -from _workflow_yaml import workflow_files +from ci_workflows_tools._workflow_yaml import workflow_files # `uses: owner/repo[/path]@<40-hex> # vX.Y.Z` USES_RE = re.compile(r"^\s*(?:-\s*)?uses:\s*(?P\S+)(?P.*)$") diff --git a/scripts/check_pr_hygiene_contract.py b/scripts/check_pr_hygiene_contract.py index 89aa61f..a5a4427 100644 --- a/scripts/check_pr_hygiene_contract.py +++ b/scripts/check_pr_hygiene_contract.py @@ -10,7 +10,8 @@ from pathlib import Path from typing import Any, Callable -from _workflow_yaml import WORKFLOWS_DIR, get_on, load_yaml +from ci_workflows_tools._workflow_yaml import WORKFLOWS_DIR, get_on, load_yaml +from ci_workflows_tools.check_python_execution_contract import clean_environment WORKFLOW = WORKFLOWS_DIR / "pr-hygiene.yml" DEFAULT_NAME = "commitlint (default configuration)" @@ -102,12 +103,11 @@ def _exercise_validator(run: str, supplied: str, make_file: bool) -> subprocess. target.parent.mkdir(parents=True, exist_ok=True) target.write_text("export default {};\n", encoding="utf-8") output = workspace / "output" - env = { - **os.environ, + env = clean_environment({ "COMMITLINT_CONFIG": supplied, "GITHUB_OUTPUT": str(output), "WORKSPACE": str(workspace), - } + }) result = subprocess.run( ["bash", "-euo", "pipefail", "-c", run], env=env, text=True, capture_output=True, check=False, diff --git a/scripts/check_privileged_ref_guard.py b/scripts/check_privileged_ref_guard.py index dbc4ea6..39e5ff8 100644 --- a/scripts/check_privileged_ref_guard.py +++ b/scripts/check_privileged_ref_guard.py @@ -25,7 +25,8 @@ import subprocess import sys -from _workflow_yaml import get_on, load_yaml, workflow_files +from ci_workflows_tools._workflow_yaml import get_on, load_yaml, workflow_files +from ci_workflows_tools.check_python_execution_contract import clean_environment GUARD_STEP_NAME = "Reject caller-supplied ref on a privileged event" @@ -65,7 +66,9 @@ def _run_guard(body: str, event: str, ref: str) -> int: """Execute the extracted guard body the way the runner would.""" completed = subprocess.run( ["bash", "-c", body], - env={"PATH": "/usr/bin:/bin", "CALLER_EVENT": event, "CHECKOUT_REF": ref}, + env=clean_environment({ + "PATH": "/usr/bin:/bin", "CALLER_EVENT": event, "CHECKOUT_REF": ref, + }), capture_output=True, text=True, timeout=30, diff --git a/scripts/check_public_docs.py b/scripts/check_public_docs.py index 7e41178..ae6456c 100644 --- a/scripts/check_public_docs.py +++ b/scripts/check_public_docs.py @@ -35,7 +35,7 @@ import sys from pathlib import Path -from _workflow_yaml import REPO_ROOT +from ci_workflows_tools._workflow_yaml import REPO_ROOT # Everything a consumer reads. The catalog is excluded: product-facts.yml is the # sanctioned home for dated external figures and has its own freshness gate. @@ -127,5 +127,4 @@ def main() -> int: if __name__ == "__main__": - sys.path.insert(0, str(Path(__file__).resolve().parent)) raise SystemExit(main()) diff --git a/scripts/check_python_execution_contract.py b/scripts/check_python_execution_contract.py new file mode 100644 index 0000000..95d49a9 --- /dev/null +++ b/scripts/check_python_execution_contract.py @@ -0,0 +1,2412 @@ +#!/usr/bin/env python3 +"""Own isolated Python launch, environment taint, imports, and process edges.""" +from __future__ import annotations + +import ast +import builtins +import importlib +import importlib.metadata +import importlib.util +import json +import os +import py_compile +import re +import runpy +import shlex +import site +import subprocess +import sys +import sysconfig +import tempfile +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from symtable import SymbolTable, symtable +from types import ModuleType +from typing import Any, Mapping, Sequence + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPTS = REPO_ROOT / "scripts" +PACKAGE_NAME = "ci_workflows_tools" +PACKAGE_INIT = "__init__.py" +POLICY_PATH = REPO_ROOT / "catalog" / "python-execution.yml" +META_GATE = "check_python_execution_contract.py" +SPECIAL_GLOBALS = { + "__builtins__", "__cached__", "__file__", "__loader__", "__name__", + "__package__", "__spec__", +} +MUTATING_CALLS = { + "copy", "copy2", "copyfile", "copytree", "mkdir", "move", "open", + "remove", "rename", "replace", "rmtree", "run", "check_call", + "check_output", "unlink", "urlopen", "write_bytes", "write_text", +} +PROCESS_CALLS = {"run", "Popen", "check_call", "check_output"} +OS_PROCESS_PREFIXES = ("exec", "spawn") +OS_PROCESS_NAMES = {"system", "popen"} + + +@dataclass(frozen=True) +class Invocation: + executable: str + flags: tuple[str, ...] + launcher: str + verb: str + subject: str + arguments: tuple[str, ...] + + +class SourceRole(str, Enum): + WORKFLOW = "workflow" + REPOSITORY_LAUNCHER = "repository-launcher" + ADOPTION_GUIDE = "adoption-guide" + CONSUMER_COMMAND = "consumer-command" + SHELL_FIXTURE = "shell-fixture" + + +class SourceLanguage(str, Enum): + YAML = "yaml" + MARKDOWN = "markdown" + PYTHON = "python" + SHELL = "shell" + + +@dataclass(frozen=True) +class InvocationSource: + path: Path + text: str + role: SourceRole + language: SourceLanguage + + +@dataclass(frozen=True) +class ExtractedCommand: + source_path: Path + line: int + location: str + role: SourceRole + language: SourceLanguage + grammar: SourceLanguage + text: str + + +def _repository_package_spec(root: Path) -> tuple[Any | None, list[str]]: + """Build one exact package spec and report typed origin failures.""" + problems: list[str] = [] + package_init = root / PACKAGE_INIT + if root.is_symlink() or package_init.is_symlink() or not package_init.is_file(): + return None, ["repository tool package root is missing or unsafe"] + try: + text = package_init.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + return None, [f"repository tool package manifest is unreadable: {exc}"] + if text != ( + '"""Repository-owned validator package; loaded only by the isolated launcher."""\n' + "\nPACKAGE_CONTRACT = \"ci-workflows-tools-v1\"\n" + ): + problems.append("repository tool package manifest is stale or duplicated") + spec = importlib.util.spec_from_file_location( + PACKAGE_NAME, package_init, submodule_search_locations=[str(root)], + ) + if spec is None or spec.loader is None \ + or spec.origin != str(package_init) \ + or tuple(spec.submodule_search_locations or ()) != (str(root),): + problems.append("cannot construct the repository tool package spec") + return spec, problems + + +def _loaded_package_problems(module: ModuleType, root: Path) -> list[str]: + spec = getattr(module, "__spec__", None) + locations = tuple(getattr(spec, "submodule_search_locations", ()) or ()) + if getattr(spec, "origin", None) != str(root / PACKAGE_INIT) \ + or locations != (str(root),): + return ["repository tool package is duplicated or has wrong origin"] + if getattr(module, "PACKAGE_CONTRACT", None) != "ci-workflows-tools-v1": + return ["repository tool package contract is missing or stale"] + return [] + + +def _register_repository_package() -> None: + """Register the exact repository package without changing sys.path.""" + existing = sys.modules.get(PACKAGE_NAME) + if existing is not None: + problems = _loaded_package_problems(existing, SCRIPTS) + if problems: + raise RuntimeError(problems[0]) + return + spec, problems = _repository_package_spec(SCRIPTS) + if problems or spec is None: + raise RuntimeError(problems[0] if problems else "package spec is unavailable") + module = importlib.util.module_from_spec(spec) + sys.modules[PACKAGE_NAME] = module + try: + spec.loader.exec_module(module) + except BaseException: + sys.modules.pop(PACKAGE_NAME, None) + raise + problems = _loaded_package_problems(module, SCRIPTS) + if problems: + sys.modules.pop(PACKAGE_NAME, None) + raise RuntimeError(problems[0]) + + +def _helper_origin_problems(root: Path, module: str, origin: str | None) -> list[str]: + expected = root / f"{module}.py" + if not expected.is_file() or expected.is_symlink(): + return [f"repository helper {module!r} is missing or unsafe"] + if origin is None or Path(origin).resolve() != expected.resolve(): + return [f"repository helper {module!r} has wrong or shadowed origin"] + return [] + + +def _canonical_invocation(subject: str, arguments: Sequence[str] = ()) -> Invocation: + prefix = load_policy()["python"]["launcher_prefix"] + return Invocation(prefix[0], tuple(prefix[1:3]), prefix[3], prefix[4], subject, + tuple(arguments)) + + +def _render_invocation(invocation: Invocation) -> str: + return shlex.join([ + invocation.executable, *invocation.flags, invocation.launcher, + invocation.verb, invocation.subject, "--", *invocation.arguments, + ]) + + +def _dependency_python_argv( + executable: Path, expected: Path, prefix: Path, module_origin: Path, + arguments: str, +) -> list[str]: + """Validate one captured interpreter/dependency shape and return exact argv.""" + if executable.is_symlink() or executable.resolve() != expected.resolve(): + raise ValueError("dependency command is not running on the repository interpreter") + try: + tokens = shlex.split(arguments, posix=True) + except ValueError as exc: + raise ValueError(f"dependency command arguments are malformed: {exc}") from exc + if not tokens or any(token in {"&&", "||", ";", "|", "<", ">"} for token in tokens): + raise ValueError("dependency command arguments must be a non-empty argv sequence") + if module_origin.is_symlink() or not _inside(module_origin.resolve(), prefix.resolve()): + raise ValueError("required dependency is missing or outside the repository venv") + return [str(executable), *tokens] + + +def dependency_python_command(arguments: str, required_module: str) -> str: + """Bind fixture arguments to the already-verified repository interpreter.""" + if not re.fullmatch(r"[A-Za-z_][A-Za-z0-9_.]*", required_module): + raise ValueError("dependency module name is invalid") + spec = importlib.util.find_spec(required_module) + if spec is None or spec.origin is None: + raise ValueError(f"required module {required_module!r} is unavailable") + argv = _dependency_python_argv( + Path(sys.executable), _repository_interpreter(load_policy()), + Path(sys.prefix), Path(spec.origin), arguments, + ) + return shlex.join(argv) + + +def _logical_shell_commands(text: str) -> list[tuple[int, str]]: + """Join only POSIX backslash continuations while retaining start lines.""" + commands: list[tuple[int, str]] = [] + parts: list[str] = [] + start = 0 + for number, raw in enumerate(text.splitlines(), start=1): + stripped = raw.strip() + if not parts and (not stripped or stripped.startswith("#")): + continue + if not parts: + start = number + continued = stripped.endswith("\\") + parts.append(stripped[:-1].rstrip() if continued else stripped) + if not continued: + commands.append((start, " ".join(part for part in parts if part))) + parts = [] + if parts: + raise ValueError(f"line {start}: truncated POSIX continuation") + return commands + + +def _parse_invocation(command: str) -> Invocation | None: + marker = "scripts/check_python_execution_contract.py" + if marker not in command and "scripts/" not in command and "python" not in command: + return None + try: + tokens = shlex.split(command, posix=True) + except ValueError as exc: + raise ValueError(f"malformed shell command: {exc}") from exc + for operator in ("&&", "||", ";"): + if operator in tokens: + tokens = tokens[:tokens.index(operator)] + try: + index = tokens.index(marker) + except ValueError: + nested = [token for token in tokens if marker in token] + if len(nested) == 1 and nested[0] != command: + return _parse_invocation(nested[0]) + return None + if index < 3 or len(tokens) <= index + 3: + return Invocation(tokens[0] if tokens else "", tuple(tokens[1:index]), marker, + "", "", ()) + tail = tokens[index + 1:] + if tail.count("--") != 1: + return Invocation(tokens[0], tuple(tokens[1:index]), marker, + "", + tail[1] if len(tail) > 1 else "", ()) + separator = tail.index("--") + subject = tail[1] if len(tail) > 1 else "" + return Invocation(tokens[0], tuple(tokens[1:index]), marker, tail[0], subject, + tuple(tail[separator + 1:])) + + +def _workflow_commands(source: InvocationSource) -> tuple[list[ExtractedCommand], list[str]]: + """Return only executable step.run scalars from one strict YAML workflow.""" + try: + strict_yaml = importlib.import_module(f"{PACKAGE_NAME}._strict_yaml") + document = strict_yaml.strict_loads(source.text, str(source.path)) + except Exception as exc: + return [], [f"{source.path.name}: workflow YAML is invalid: {exc}"] + if not isinstance(document, dict): + return [], [f"{source.path.name}: workflow root must be a mapping"] + jobs = document.get("jobs") + if not isinstance(jobs, dict): + return [], [f"{source.path.name}: workflow jobs must be a mapping"] + commands: list[ExtractedCommand] = [] + problems: list[str] = [] + run_lines = iter( + number for number, raw in enumerate(source.text.splitlines(), 1) + if raw.lstrip().startswith("run:") + ) + for job_name, job in jobs.items(): + if not isinstance(job, dict): + continue + steps = job.get("steps", []) + if not isinstance(steps, list): + problems.append(f"{source.path.name}: jobs.{job_name}.steps must be a list") + continue + for index, step in enumerate(steps): + if not isinstance(step, dict) or "run" not in step: + continue + location = f"jobs.{job_name}.steps[{index}].run" + command = step["run"] + if not isinstance(command, str) or not command.strip(): + problems.append( + f"{source.path.name}: {location} must be a non-empty command scalar" + ) + continue + line = next(run_lines, 1) + commands.append(ExtractedCommand( + source.path, line, location, source.role, source.language, + SourceLanguage.SHELL, command, + )) + return commands, problems + + +def _markdown_commands(source: InvocationSource) -> tuple[list[ExtractedCommand], list[str]]: + commands: list[ExtractedCommand] = [] + problems: list[str] = [] + fence_language: str | None = None + fence_start = 0 + lines: list[str] = [] + executable = {"bash", "sh", "shell", "zsh"} + for number, raw in enumerate(source.text.splitlines(), 1): + match = re.match(r"^\s*```([^\s`]*)\s*$", raw) + if match: + if fence_language is None: + fence_language = match.group(1).lower() + fence_start = number + 1 + lines = [] + else: + if fence_language in executable: + try: + logical = _logical_shell_commands("\n".join(lines)) + except ValueError as exc: + problems.append(f"{source.path.name}: fence@{fence_start}: {exc}") + logical = [] + for offset, command in logical: + if _is_repository_command_candidate(command): + line = fence_start + offset - 1 + commands.append(ExtractedCommand( + source.path, line, f"fence@{line}", source.role, + source.language, SourceLanguage.SHELL, command, + )) + fence_language = None + lines = [] + continue + if fence_language is not None: + lines.append(raw) + if fence_language is not None: + problems.append(f"{source.path.name}: unterminated Markdown fence at line {fence_start - 1}") + return commands, problems + + +def _python_commands(source: InvocationSource) -> tuple[list[ExtractedCommand], list[str]]: + try: + tree = ast.parse(source.text, filename=str(source.path), feature_version=(3, 13)) + except SyntaxError as exc: + return [], [f"{source.path.name}: Python source is invalid: {exc}"] + commands: list[ExtractedCommand] = [] + for node in ast.walk(tree): + if not isinstance(node, ast.Constant) or not isinstance(node.value, str): + continue + try: + logical = _logical_shell_commands(node.value) + except ValueError as exc: + if _is_repository_command_candidate(node.value): + return [], [f"{source.path.name}: string@{node.lineno}: {exc}"] + continue + for offset, command in logical: + if _is_repository_command_candidate(command): + line = node.lineno + offset - 1 + commands.append(ExtractedCommand( + source.path, line, f"string@{line}", source.role, + source.language, SourceLanguage.SHELL, command, + )) + return commands, [] + + +def _is_repository_command_candidate(command: str) -> bool: + return bool(re.search( + r"scripts/(?:check_python_(?:execution_contract|syntax)|[A-Za-z0-9_]+)\.py\b", + command, + )) + + +def _source_commands(source: InvocationSource) -> tuple[list[ExtractedCommand], list[str]]: + if source.language is SourceLanguage.YAML: + return _workflow_commands(source) + if source.language is SourceLanguage.MARKDOWN: + return _markdown_commands(source) + if source.language is SourceLanguage.PYTHON: + return _python_commands(source) + if source.language is not SourceLanguage.SHELL: + return [], [f"{source.path.name}: unclassified executable source language"] + try: + commands = _logical_shell_commands(source.text) + except ValueError as exc: + return [], [f"{source.path.name}: {exc}"] + return [ + ExtractedCommand(source.path, number, f"line {number}", source.role, + source.language, SourceLanguage.SHELL, command) + for number, command in commands + ], [] + + +def _no_duplicates(pairs: list[tuple[str, Any]]) -> dict[str, Any]: + result: dict[str, Any] = {} + for key, value in pairs: + if key in result: + raise ValueError(f"duplicate policy key {key!r}") + result[key] = value + return result + + +def load_policy() -> dict[str, Any]: + """Load the JSON-subset YAML policy without needing a site dependency.""" + try: + raw = POLICY_PATH.read_text(encoding="utf-8") + policy = json.loads(raw, object_pairs_hook=_no_duplicates) + except (OSError, UnicodeError, json.JSONDecodeError, ValueError) as exc: + raise RuntimeError(f"cannot load canonical Python execution policy: {exc}") from exc + if not isinstance(policy, dict): + raise RuntimeError("canonical Python execution policy is not a mapping") + return policy + + +def _policy_surfaces(policy: Mapping[str, Any]) -> set[str]: + groups = policy.get("surface_groups") + if not isinstance(groups, dict): + return set() + return { + item + for values in groups.values() + if isinstance(values, list) + for item in values + if isinstance(item, str) + } + + +def _registered_tool(tool: str, surfaces: set[str]) -> bool: + return tool in surfaces and Path(tool).name == tool and tool.endswith(".py") + + +def _python_key(name: str) -> bool: + return name.upper().startswith("PYTHON") + + +def _evidence_names(raw: str, label: str) -> set[str]: + try: + parsed = json.loads(raw) + except json.JSONDecodeError as exc: + raise ValueError(f"{label} evidence is malformed") from exc + if not isinstance(parsed, list) or not all(isinstance(item, str) for item in parsed): + raise ValueError(f"{label} evidence must be a string array") + return set(parsed) + + +def _transition_environment( + source: Mapping[str, str], + overrides: Mapping[str, str] | None = None, + *, + inherit: Sequence[str] = (), +) -> dict[str, str]: + """Apply the one pure, observable environment transition.""" + policy = load_policy() + contract = policy["environment"] + allowlist = set(contract["base_allowlist"]) + allowed_inherit = { + name + for values in policy.get("surface_environment", {}).values() + for name in values + } + requested_inherit = set(inherit) + unknown = sorted(requested_inherit - allowed_inherit) + if unknown: + raise ValueError(f"unregistered inherited variables: {unknown}") + requested = allowlist | requested_inherit + forbidden = sorted(name for name in requested if _python_key(name)) + if forbidden: + raise ValueError(f"interpreter-control variables cannot be inherited: {forbidden}") + env = { + name: str(source[name]) + for name in sorted(requested) + if name in source and not _python_key(name) + } + canonical = {str(k): str(v) for k, v in contract["canonical_values"].items()} + for name, value in (overrides or {}).items(): + if _python_key(name): + raise ValueError(f"interpreter-control override is forbidden: {name}") + if name in canonical and str(value) != canonical[name]: + raise ValueError(f"canonical environment override is forbidden: {name}") + env[str(name)] = str(value) + env.update(canonical) + stripped_name = contract["stripped_names_evidence"] + inherited_name = contract["inherited_names_evidence"] + prior_stripped = _evidence_names(source.get(stripped_name, "[]"), "stripped") + prior_inherited = _evidence_names(source.get(inherited_name, "[]"), "inherited") + stripped = {name for name in source if _python_key(name)} + generated = set(canonical) | set(overrides or {}) + inherited = { + name for name in requested + if name in source and not _python_key(name) and name not in generated + } + env[stripped_name] = json.dumps( + sorted(prior_stripped | stripped), separators=(",", ":"), + ) + env[inherited_name] = json.dumps( + sorted(prior_inherited | inherited), separators=(",", ":"), + ) + return env + + +def clean_environment( + overrides: Mapping[str, str] | None = None, + *, + inherit: Sequence[str] = (), +) -> dict[str, str]: + """The sole ambient bootstrap boundary for every child process.""" + return _transition_environment(os.environ, overrides, inherit=inherit) + + +def hostile_probe_environment(root: Path) -> dict[str, str]: + """Inject taint at the one registered outer edge, never at a normal edge.""" + policy = load_policy() + names = policy["environment"]["hostile_probe_variables"] + values = { + "PYTHONBREAKPOINT": "hostile.breakpoint", + "PYTHONCOERCECLOCALE": "warn", + "PYTHONHOME": str(root / "missing-python-home"), + "PYTHONIOENCODING": "ascii:strict", + "PYTHONPATH": str(root / "shadow"), + "PYTHONUTF8": "0", + "PYTHONWARNINGS": "error", + "pythonpath": str(root / "mixed-case-shadow"), + } + if sorted(values) != sorted(names): + raise RuntimeError("hostile probe variables drifted from policy") + env = clean_environment() + # This edge owns the exact taint set. Ambient stripping is proven by the + # outer launcher; the nested probe must not inherit machine-specific names + # in its expected receipt. + env[policy["environment"]["stripped_names_evidence"]] = "[]" + env[policy["environment"]["inherited_names_evidence"]] = "[]" + env.update(values) + env["LANG"] = "invalid_LOCALE.invalid" + env["LC_ALL"] = "invalid_LOCALE.invalid" + return env + + +def _files() -> dict[str, Path]: + return {path.name: path for path in sorted(SCRIPTS.glob("*.py"))} + + +def _import_roots(tree: ast.AST) -> set[str]: + roots: set[str] = set() + for node in ast.walk(tree): + if isinstance(node, ast.Import): + roots.update(alias.name.split(".", 1)[0] for alias in node.names) + elif isinstance(node, ast.ImportFrom) and node.module: + if node.module.startswith(f"{PACKAGE_NAME}."): + roots.add(node.module.split(".", 1)[1].split(".", 1)[0]) + elif node.module == PACKAGE_NAME: + roots.update(alias.name.split(".", 1)[0] for alias in node.names) + else: + roots.add(node.module.split(".", 1)[0]) + return roots + + +def _unqualified_sibling_imports(tree: ast.AST, siblings: set[str]) -> list[str]: + """Reject sibling imports that bypass the verified repository package.""" + problems: list[str] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Import): + for alias in node.names: + root = alias.name.split(".", 1)[0] + if root in siblings: + problems.append(f"line {node.lineno}: unqualified sibling import {root!r}") + elif isinstance(node, ast.ImportFrom) and node.module: + root = node.module.split(".", 1)[0] + if root in siblings: + problems.append(f"line {node.lineno}: unqualified sibling import {root!r}") + return problems + + +def _needs_yaml(tool: str, policy: Mapping[str, Any]) -> bool: + graph = policy["sibling_imports"] + external = policy["direct_external_imports"] + pending = [tool] + seen: set[str] = set() + while pending: + current = pending.pop() + if current in seen: + continue + seen.add(current) + if "yaml" in external.get(current, []): + return True + pending.extend(f"{name}.py" for name in graph.get(current, [])) + return False + + +def _dependency_root() -> tuple[Path | None, list[str]]: + policy = load_policy() + dependency = policy["python"]["external_dependencies"]["yaml"] + problems: list[str] = [] + lock_path = (REPO_ROOT / policy["python"]["dependency_lock"]).absolute() + if lock_path != lock_path.resolve() or not _inside(lock_path.resolve(), REPO_ROOT) \ + or not lock_path.is_file(): + return None, [f"dependency lock is not a regular repository file: {lock_path}"] + try: + lock_text = lock_path.read_text(encoding="utf-8") + except (OSError, UnicodeError) as exc: + return None, [f"cannot read hash-pinned dependency lock: {exc}"] + problems += _locked_dependency_problems(lock_text, dependency) + try: + distribution = importlib.metadata.distribution(dependency["distribution"]) + spec = importlib.util.find_spec("yaml") + except importlib.metadata.PackageNotFoundError: + return None, ["pinned PyYAML distribution is unavailable"] + problems += _distribution_identity_problems( + distribution.metadata.get("Name", ""), distribution.version, dependency, + ) + if spec is None or spec.origin is None: + return None, problems + ["yaml module origin is unavailable"] + identity, identity_problems = _active_venv_identity() + problems += identity_problems + if identity is None: + return None, problems + roots = {Path(identity[name]).resolve() for name in ("purelib", "platlib")} + distribution_root = Path(distribution.locate_file("")).absolute() + metadata_entries = [ + entry for entry in (distribution.files or []) + if entry.name == "METADATA" and entry.parent.name.endswith(".dist-info") + ] + if len(metadata_entries) != 1: + problems.append("installed distribution has no unique dist-info/METADATA record") + distribution_path = distribution_root / "" + else: + distribution_path = Path( + distribution.locate_file(metadata_entries[0]) + ).absolute().parent + origin_raw = Path(spec.origin).absolute() + root = distribution_root.resolve() + problems += _dependency_path_problems( + roots, distribution_root, distribution_path, origin_raw, + dependency["module_path"], + ) + return root, problems + + +def _normalized_distribution(value: str) -> str: + return re.sub(r"[-_.]+", "-", value).lower() + + +def _distribution_identity_problems( + name: str, version: str, dependency: Mapping[str, str], +) -> list[str]: + problems: list[str] = [] + if _normalized_distribution(name) != _normalized_distribution(dependency["distribution"]): + problems.append("installed distribution name differs from dependency policy") + if version != dependency["version"]: + problems.append( + f"{dependency['distribution']} version must be {dependency['version']}, got {version}" + ) + return problems + + +def _locked_dependency_problems( + text: str, dependency: Mapping[str, str], +) -> list[str]: + problems: list[str] = [] + normalized = _normalized_distribution(dependency["distribution"]) + requirement = re.compile( + rf"(?im)^{re.escape(normalized)}=={re.escape(dependency['version'])}\s*\\$" + ) + if len(requirement.findall(text)) != 1: + problems.append("dependency lock lacks one exact pinned distribution/version record") + hashes = re.findall(r"(?m)^\s+--hash=sha256:([0-9a-f]{64})(?:\s*\\)?$", text) + if not hashes or len(hashes) != len(set(hashes)): + problems.append("dependency lock hashes are missing, malformed, or duplicated") + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#") or stripped.startswith("--hash=") \ + or requirement.fullmatch(stripped): + continue + problems.append(f"dependency lock contains an unexpected record: {stripped!r}") + return problems + + +def _inside(path: Path, root: Path) -> bool: + return path == root or root in path.parents + + +def _dependency_path_problems( + site_roots: set[Path], distribution_root: Path, distribution_path: Path, + origin: Path, module_path: str, +) -> list[str]: + problems: list[str] = [] + real_root = distribution_root.resolve() + real_distribution = distribution_path.resolve() + real_origin = origin.resolve() + if real_root not in site_roots: + problems.append(f"distribution root is outside the active venv: {distribution_root}") + if not _inside(real_distribution, real_root): + problems.append(f"distribution metadata escapes the active venv: {distribution_path}") + if not _inside(real_origin, real_root) \ + or real_origin != real_root / module_path: + problems.append(f"dependency import origin is untrusted: {origin}") + return problems + + +def _read_pyvenv(path: Path) -> tuple[dict[str, str], list[str]]: + problems: list[str] = [] + if not path.is_file() or path.is_symlink(): + return {}, [f"active venv has no regular non-symlink pyvenv.cfg: {path}"] + values: dict[str, str] = {} + try: + lines = path.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError) as exc: + return {}, [f"cannot read active pyvenv.cfg: {exc}"] + for number, line in enumerate(lines, start=1): + if not line.strip(): + continue + if "=" not in line: + problems.append(f"pyvenv.cfg:{number}: malformed record") + continue + key, value = (part.strip() for part in line.split("=", 1)) + if not key or key in values: + problems.append(f"pyvenv.cfg:{number}: empty or duplicate key") + continue + values[key] = value + return values, problems + + +def _base_anchor( + identity: Mapping[str, Any], config: Mapping[str, str], + venv_policy: Mapping[str, Any], +) -> tuple[Path | None, list[str]]: + """Resolve optional runtime and config facts to one base executable.""" + problems: list[str] = [] + home = config.get("home") + home_path = Path(home).absolute() if home else None + home_candidates = [] if home_path is None or not home_path.is_dir() else [ + home_path / name for name in venv_policy["home_interpreter_names"] + if (home_path / name).is_file() + ] + declared_paths = [ + Path(value).absolute() for value in ( + identity.get("base_executable", ""), config.get("executable", ""), + ) if value + ] + if any(not path.is_file() for path in declared_paths): + problems.append("declared base executable is missing or non-regular") + declared_targets = {path.resolve() for path in declared_paths if path.is_file()} + home_targets = {candidate.resolve() for candidate in home_candidates} + candidates = declared_targets & home_targets if declared_targets else home_targets + if len(candidates) != 1: + problems.append("pyvenv home does not identify one coherent base executable") + return None, problems + if len(declared_targets) > 1: + problems.append("runtime and pyvenv base executable identities differ") + return next(iter(candidates)), problems + + +def _venv_identity_problems( + identity: Mapping[str, Any], config: Mapping[str, str], *, + expected_prefix: Path | None = None, +) -> list[str]: + problems: list[str] = [] + policy = load_policy() + venv_policy = policy["python"]["venv"] + prefix = Path(identity["prefix"]).absolute() + base_prefix = Path(identity["base_prefix"]).absolute() + exec_prefix = Path(identity["exec_prefix"]).absolute() + base_exec_prefix = Path(identity["base_exec_prefix"]).absolute() + executable = Path(identity["executable"]).absolute() + real_prefix = prefix.resolve() + real_base_prefix = base_prefix.resolve() + version_info = tuple(identity["version_info"]) + implementation_version = tuple(identity["implementation_version"]) + expected_line = tuple(int(part) for part in policy["python"]["major_minor"].split(".")) + if version_info[:2] != expected_line or version_info[3:] != ( + venv_policy["release_level"], 0, + ): + problems.append("runtime Python identity is outside the canonical compatibility line") + if identity["implementation_name"] != venv_policy["implementation_name"] \ + or implementation_version != version_info: + problems.append("runtime and CPython implementation identities differ") + if identity["cache_tag"] != f"cpython-{expected_line[0]}{expected_line[1]}": + problems.append("runtime import cache tag differs from the CPython compatibility line") + if real_prefix == real_base_prefix: + problems.append("interpreter is not inside a virtual environment") + if expected_prefix is not None and ( + not expected_prefix.is_dir() or expected_prefix.is_symlink() + or real_prefix != expected_prefix.resolve() + or not _inside(real_prefix, REPO_ROOT.resolve()) + ): + problems.append("active virtual environment is not the repository-owned .venv") + if exec_prefix != prefix or base_exec_prefix.resolve() != base_prefix.resolve(): + problems.append("interpreter prefix and exec-prefix identities are incoherent") + real_base_executable, anchor_problems = _base_anchor(identity, config, venv_policy) + problems += anchor_problems + if real_base_executable is not None: + if not _inside(real_base_executable, real_base_prefix): + problems.append("declared base executable is outside the base installation") + executable_parents = {prefix / "bin", prefix / "Scripts"} + if executable.parent not in executable_parents or not executable.is_file() \ + or executable.is_symlink(): + problems.append("venv interpreter is not a regular executable in the venv") + # A copy-based venv intentionally has a distinct executable inode. Its + # base provenance is established by the coherent runtime/config/home + # anchors above, while this executable is constrained to the venv root. + expected_system_site = str(venv_policy["include_system_site_packages"]).lower() + if config.get("include-system-site-packages", "").lower() != expected_system_site: + problems.append("pyvenv must disable system site-packages") + configured_version = config.get("version_info", config.get("version", "")) + if configured_version != identity["version"]: + problems.append("pyvenv version differs from the running interpreter") + if config.get("implementation", venv_policy["implementation"]) != \ + venv_policy["implementation"]: + problems.append("pyvenv implementation must be CPython") + if identity["enable_user_site"] is not False: + problems.append("active interpreter must disable user site-packages") + for key in ("purelib", "platlib"): + path = Path(identity[key]).absolute() + if not _inside(path.resolve(), real_prefix) \ + or path.name != "site-packages": + problems.append(f"{key} escapes the active-venv site-packages root") + return problems + + +def _active_venv_identity() -> tuple[dict[str, Any] | None, list[str]]: + prefix = Path(sys.prefix).absolute() + config, problems = _read_pyvenv(prefix / "pyvenv.cfg") + identity = { + "prefix": str(prefix), + "base_prefix": sys.base_prefix, + "exec_prefix": sys.exec_prefix, + "base_exec_prefix": sys.base_exec_prefix, + "executable": sys.executable, + "base_executable": getattr(sys, "_base_executable", ""), + "version": ".".join(str(part) for part in sys.version_info[:3]), + "version_info": tuple(sys.version_info), + "implementation_name": sys.implementation.name, + "implementation_version": tuple(sys.implementation.version), + "cache_tag": sys.implementation.cache_tag, + "purelib": sysconfig.get_path("purelib"), + "platlib": sysconfig.get_path("platlib"), + "stdlib": sysconfig.get_path("stdlib"), + "platstdlib": sysconfig.get_path("platstdlib"), + "scripts": sysconfig.get_path("scripts"), + "data": sysconfig.get_path("data"), + "enable_user_site": site.ENABLE_USER_SITE, + } + problems += _venv_identity_problems( + identity, config, + expected_prefix=REPO_ROOT / load_policy()["python"]["venv"]["path"], + ) + return identity, problems + + +def _repository_interpreter(policy: Mapping[str, Any]) -> Path: + """Return the single executable named by the repository policy.""" + raw = policy["python"]["dependency_interpreter"] + path = REPO_ROOT / raw + expected = REPO_ROOT / policy["python"]["venv"]["path"] / "bin" / "python" + if path != expected: + raise RuntimeError("repository interpreter policy is not canonical") + return path + + +def _bootstrap_receipt(policy: Mapping[str, Any]) -> tuple[str, str]: + contract = policy["python"]["bootstrap"] + return contract["transition_receipt"], contract["receipt_version"] + + +def _bootstrap_decision(current: Path, expected: Path, receipt: str | None, + receipt_value: str) -> str: + """Classify the one-way interpreter edge without executing it.""" + if receipt not in (None, receipt_value): + return "malformed-receipt" + if current.resolve() == expected.resolve(): + return "arrived" if receipt == receipt_value else "current" + if receipt == receipt_value: + return "loop-or-wrong-target" + return "transition" + + +def _ensure_repository_interpreter() -> None: + """Perform at most one stdlib-only transition before identity/import checks.""" + policy = load_policy() + expected = _repository_interpreter(policy) + receipt_name, receipt_value = _bootstrap_receipt(policy) + received = os.environ.get(receipt_name) + current = Path(sys.executable).absolute() + if received not in (None, receipt_value): + print("python-launcher: malformed bootstrap receipt", file=sys.stderr) + raise SystemExit(2) + if not expected.is_file() or expected.is_symlink() or not os.access(expected, os.X_OK): + print("python-launcher: repository interpreter is unavailable or unsafe", file=sys.stderr) + raise SystemExit(2) + decision = _bootstrap_decision(current, expected, received, receipt_value) + if decision in {"current", "arrived"}: + if decision == "arrived": + print( + "python-bootstrap-transition: " + + json.dumps( + {"from": "external", "to": str(expected), "version": receipt_value}, + sort_keys=True, separators=(",", ":"), + ), + file=sys.stderr, + ) + return + if decision == "loop-or-wrong-target": + print("python-launcher: bootstrap transition loop or wrong target", file=sys.stderr) + raise SystemExit(2) + env = clean_environment({receipt_name: receipt_value}) + argv = [str(expected), "-I", "-B", str(Path(__file__).resolve()), *sys.argv[1:]] + os.execve(expected, argv, env) + + +def _venv_receipt() -> dict[str, Any]: + """Return non-secret runtime identity evidence from the validated process.""" + identity, problems = _active_venv_identity() + if problems or identity is None: + raise RuntimeError("cannot emit an unvalidated venv identity receipt") + dependency = load_policy()["python"]["external_dependencies"]["yaml"] + distribution = importlib.metadata.distribution(dependency["distribution"]) + spec = importlib.util.find_spec("yaml") + if spec is None or spec.origin is None: + raise RuntimeError("cannot emit a receipt without the yaml import origin") + config, config_problems = _read_pyvenv(Path(identity["prefix"]) / "pyvenv.cfg") + if config_problems: + raise RuntimeError("cannot emit a receipt without pyvenv identity") + base_executable, anchor_problems = _base_anchor( + identity, config, load_policy()["python"]["venv"], + ) + if anchor_problems or base_executable is None: + raise RuntimeError("cannot emit a receipt without one base executable") + def path_identity(raw: str) -> dict[str, str]: + path = Path(raw) + return {"raw": raw, "resolved": str(path.resolve())} + + return { + "base_executable": str(base_executable), + "cache_tag": identity["cache_tag"], + "dependency": dependency["distribution"], + "dependency_origin": str(Path(spec.origin).resolve()), + "dependency_version": distribution.version, + "implementation": identity["implementation_name"], + "implementation_version": list(identity["implementation_version"]), + "paths": { + name: path_identity(str(identity[name])) + for name in ( + "executable", "prefix", "exec_prefix", "base_prefix", + "base_exec_prefix", "base_executable", "purelib", "platlib", + "stdlib", "platstdlib", "scripts", "data", + ) if identity[name] + }, + "pyvenv": dict(sorted(config.items())), + "sys_version": sys.version, + "version_info": list(identity["version_info"]), + } + + +def _runtime_problems(policy: Mapping[str, Any] | None = None) -> list[str]: + problems: list[str] = [] + active_policy = policy or load_policy() + expected = tuple( + int(part) for part in active_policy["python"]["major_minor"].split(".") + ) + if sys.version_info[:2] != expected: + problems.append( + f"requires Python {expected[0]}.{expected[1]}, " + f"got {sys.version_info.major}.{sys.version_info.minor}" + ) + for name in ("isolated", "ignore_environment", "no_user_site", "safe_path"): + if getattr(sys.flags, name, 0) != 1: + problems.append(f"launcher requires sys.flags.{name}=1") + if getattr(sys.flags, "dont_write_bytecode", 0) != 1: + problems.append("launcher requires sys.flags.dont_write_bytecode=1") + return problems + + +def _resource_problems(root: Path, policy: Mapping[str, Any]) -> list[str]: + return [ + f"missing required repository resource {relative}" + for relative in policy["required_resources"] + if not (root / relative).exists() + ] + + +def _bootstrap( + tool: str, dependency_raw: str, args: list[str], *, import_only: bool, +) -> int: + policy = load_policy() + problems = _runtime_problems(policy) + surfaces = _policy_surfaces(policy) + files = _files() + expected = surfaces | {META_GATE, PACKAGE_INIT} + if len(surfaces) != policy["python"]["subject_count"] or set(files) != expected: + problems.append( + f"inventory drift: count={len(surfaces)} " + f"missing={sorted(expected - set(files))} extra={sorted(set(files) - expected)}" + ) + if not _registered_tool(tool, surfaces): + problems.append(f"unregistered or unsafe Python tool {tool!r}") + needs_yaml = tool in surfaces and _needs_yaml(tool, policy) + if needs_yaml != (dependency_raw == "@active"): + problems.append("dependency claim differs from the registered import graph") + dependency_root: Path | None = None + if needs_yaml: + dependency_root, dependency_problems = _dependency_root() + problems += dependency_problems + _register_repository_package() + for module, path in ((path.stem, path) for path in files.values() + if path.name != PACKAGE_INIT): + spec = importlib.util.find_spec(f"{PACKAGE_NAME}.{module}") + problems += _helper_origin_problems( + SCRIPTS, module, spec.origin if spec is not None else None, + ) + problems += _resource_problems(REPO_ROOT, policy) + if problems: + for problem in problems: + print(f"python-bootstrap: {problem}", file=sys.stderr) + return 2 + if import_only: + importlib.import_module(f"{PACKAGE_NAME}.{Path(tool).stem}") + return 0 + if needs_yaml: + print( + "python-execution-receipt: " + + json.dumps(_venv_receipt(), sort_keys=True, separators=(",", ":")), + file=sys.stderr, + ) + sys.argv = [str(files[tool]), *args] + runpy.run_module( + f"{PACKAGE_NAME}.{Path(tool).stem}", run_name="__main__", alter_sys=False, + ) + return 0 + + +def _surface_inherit(tool: str) -> tuple[str, ...]: + values = load_policy().get("surface_environment", {}).get(tool, []) + return tuple(values) if isinstance(values, list) else () + + +def _subject_interpreter( + tool: str, policy: Mapping[str, Any], *, root: Path = REPO_ROOT, + current: Path | None = None, +) -> tuple[Path | None, str, list[str]]: + """Select the physical interpreter before the subject process is built.""" + if not _needs_yaml(tool, policy): + return current or Path(sys.executable), "-", [] + executable = root / policy["python"]["dependency_interpreter"] + expected_parent = root / policy["python"]["venv"]["path"] / "bin" + if executable.parent != expected_parent or not executable.is_file() \ + or executable.is_symlink() \ + or not os.access(executable, os.X_OK): + return None, "@active", ["repository dependency interpreter is unavailable"] + return executable, "@active", [] + + +def _launch(tool: str, args: list[str]) -> int: + policy = load_policy() + surfaces = _policy_surfaces(policy) + if not _registered_tool(tool, surfaces): + print(f"python-launcher: unregistered or unsafe Python tool {tool!r}", file=sys.stderr) + return 2 + executable, dependency_claim, problems = _subject_interpreter(tool, policy) + if problems or executable is None: + for problem in problems or ["repository dependency interpreter is unavailable"]: + print(f"python-launcher: {problem}", file=sys.stderr) + return 2 + command = [ + str(executable), "-I", "-B", str(Path(__file__).resolve()), + "--bootstrap", tool, dependency_claim, "--", *args, + ] + env = clean_environment(inherit=_surface_inherit(tool)) + completed = subprocess.run(command, env=env, check=False) + return completed.returncode + + +def _probe_payload(role: str) -> dict[str, Any]: + policy = load_policy() + evidence_name = policy["environment"]["stripped_names_evidence"] + inherited_name = policy["environment"]["inherited_names_evidence"] + return { + "role": role, + "isolated": sys.flags.isolated, + "ignore_environment": sys.flags.ignore_environment, + "safe_path": sys.flags.safe_path, + "no_user_site": sys.flags.no_user_site, + "dont_write_bytecode": sys.flags.dont_write_bytecode, + "python_keys": sorted(name for name in os.environ if _python_key(name)), + "stripped": json.loads(os.environ.get(evidence_name, "[]")), + "inherited": json.loads(os.environ.get(inherited_name, "[]")), + "environment_keys": sorted(os.environ), + "locale": {"LANG": os.environ.get("LANG"), "LC_ALL": os.environ.get("LC_ALL")}, + } + + +def _probe_child() -> int: + env = clean_environment() + completed = subprocess.run( + [sys.executable, "-I", "-B", str(Path(__file__).resolve()), "--probe-leaf"], + env=env, text=True, encoding="utf-8", stdout=subprocess.PIPE, + stderr=subprocess.PIPE, check=False, + ) + if completed.returncode != 0: + sys.stderr.write(completed.stderr) + return completed.returncode + payload = _probe_payload("middle") + payload["leaf"] = json.loads(completed.stdout) + print(json.dumps(payload, sort_keys=True, separators=(",", ":"))) + return 0 + + +def _probe_sanitize() -> int: + """Cross the owned ambient boundary before any observed subject starts.""" + completed = subprocess.run( + [sys.executable, "-I", "-B", str(Path(__file__).resolve()), "--probe-middle"], + env=clean_environment(), text=True, encoding="utf-8", + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, + ) + sys.stdout.write(completed.stdout) + sys.stderr.write(completed.stderr) + return completed.returncode + + +def _probe_generations(root: Path) -> list[str]: + problems: list[str] = [] + command = [sys.executable, "-I", "-B", str(Path(__file__).resolve()), "--probe-sanitize"] + completed = subprocess.run( + command, cwd=root, env=hostile_probe_environment(root), text=True, + encoding="utf-8", stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, + ) + failure = subprocess.run( + [sys.executable, "-I", "-B", str(Path(__file__).resolve()), "--probe-fail"], + cwd=root, env=clean_environment(), text=True, encoding="utf-8", + stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, + ) + if completed.returncode != 0: + return [f"multi-generation hostile probe failed: {completed.stderr.strip()}"] + try: + middle = json.loads(completed.stdout) + except json.JSONDecodeError as exc: + return [f"multi-generation hostile probe emitted invalid JSON: {exc}"] + expected = sorted(load_policy()["environment"]["hostile_probe_variables"]) + environment_contract = load_policy()["environment"] + base_allowlist = set(environment_contract["base_allowlist"]) + canonical_names = set(environment_contract["canonical_values"]) + for label, payload in (("middle", middle), ("leaf", middle.get("leaf", {}))): + if not isinstance(payload, dict): + problems.append(f"{label} probe payload is missing") + continue + if payload.get("python_keys"): + problems.append(f"{label} inherited interpreter-control variables") + if payload.get("stripped") != expected: + problems.append(f"{label} stripped-name evidence differs from policy") + expected_inherited = sorted( + (base_allowlist - canonical_names) & set(payload.get("environment_keys", [])) + ) + if payload.get("inherited") != expected_inherited: + problems.append(f"{label} inherited-name evidence differs from transition") + if payload.get("locale") != {"LANG": "C", "LC_ALL": "C"}: + problems.append(f"{label} locale is not canonical") + for flag in ( + "isolated", "ignore_environment", "safe_path", "no_user_site", + "dont_write_bytecode", + ): + if payload.get(flag) != 1: + problems.append(f"{label} omitted interpreter flag {flag}") + if failure.returncode != 37 or failure.stderr != "probe-child-diagnostic\n": + problems.append("nested child exit/diagnostic was not preserved exactly") + return problems + + +def _undefined_names(source: str, filename: str) -> set[str]: + table = symtable(source, filename, "exec") + module_defined = { + symbol.get_name() for symbol in table.get_symbols() + if symbol.is_assigned() or symbol.is_imported() or symbol.is_namespace() + } + allowed = set(dir(builtins)) | SPECIAL_GLOBALS + unresolved: set[str] = set() + + def visit(scope: SymbolTable) -> None: + for symbol in scope.get_symbols(): + name = symbol.get_name() + if not symbol.is_referenced() or name in allowed: + continue + if scope.get_type() == "module" and name not in module_defined: + unresolved.add(name) + elif scope.get_type() != "module" and symbol.is_global() \ + and name not in module_defined: + unresolved.add(name) + for child in scope.get_children(): + visit(child) + + visit(table) + return unresolved + + +def _import_side_effects(tree: ast.Module) -> list[str]: + problems: list[str] = [] + for node in tree.body: + candidates: list[ast.AST] = [] + if isinstance(node, ast.Expr): + candidates.append(node.value) + elif isinstance(node, (ast.Assign, ast.AnnAssign)) and node.value is not None: + candidates.append(node.value) + for candidate in candidates: + for child in ast.walk(candidate): + if isinstance(child, ast.Call) and isinstance(child.func, ast.Attribute): + name = child.func.attr + elif isinstance(child, ast.Call) and isinstance(child.func, ast.Name): + name = child.func.id + else: + continue + if name in MUTATING_CALLS: + problems.append(f"line {child.lineno}: import-time call to {name!r}") + return problems + + +def _function_name(node: ast.AST, parents: Mapping[ast.AST, ast.AST]) -> str: + current = node + while current in parents: + current = parents[current] + if isinstance(current, (ast.FunctionDef, ast.AsyncFunctionDef)): + return current.name + return "" + + +def _process_edges(tree: ast.Module) -> tuple[dict[str, int], list[str]]: + parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} + counts: dict[str, int] = {} + problems: list[str] = [] + for node in tree.body: + if isinstance(node, ast.Import): + for alias in node.names: + if alias.name in {"os", "subprocess"} and alias.asname is not None: + problems.append( + f"line {node.lineno}: process module {alias.name!r} may not be aliased" + ) + elif isinstance(node, ast.ImportFrom) and node.module in {"os", "subprocess"}: + problems.append( + f"line {node.lineno}: process functions must not be imported directly" + ) + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + owner = node.func.value.id if isinstance(node.func.value, ast.Name) else "" + name = node.func.attr + is_process = owner == "subprocess" and name in PROCESS_CALLS + is_os_process = owner == "os" and ( + name in OS_PROCESS_NAMES or name.startswith(OS_PROCESS_PREFIXES) + ) + if not is_process and not is_os_process: + continue + function = _function_name(node, parents) + counts[function] = counts.get(function, 0) + 1 + keywords = {item.arg: item.value for item in node.keywords if item.arg} + positional_env = owner == "os" and name in {"execve", "execvpe"} \ + and len(node.args) >= 3 + if not positional_env and ( + "env" not in keywords or isinstance(keywords.get("env"), ast.Constant) + and keywords["env"].value is None + ): + problems.append(f"{function}:{node.lineno}: process edge has implicit env") + env_value = node.args[2] if positional_env else keywords.get("env") + if isinstance(env_value, ast.Attribute) and isinstance(env_value.value, ast.Name) \ + and env_value.value.id == "os" and env_value.attr == "environ": + problems.append(f"{function}:{node.lineno}: process edge forwards os.environ") + return counts, problems + + +def _process_semantic_problems( + tree: ast.Module, filename: str, policy: Mapping[str, Any], +) -> list[str]: + """Bind every AST process call to the complete semantics of its profile.""" + problems: list[str] = [] + parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} + registered = policy["process_edges"].get(filename, {}) + profiles = policy["process_profiles"] + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Attribute): + continue + owner = node.func.value.id if isinstance(node.func.value, ast.Name) else "" + name = node.func.attr + if owner == "subprocess" and name in PROCESS_CALLS: + observed_api = "subprocess" + elif owner == "os" and (name in OS_PROCESS_NAMES or name.startswith(OS_PROCESS_PREFIXES)): + observed_api = "execve" if name == "execve" else "os-process" + else: + continue + function = _function_name(node, parents) + edge = registered.get(function) + if not isinstance(edge, dict) or edge.get("profile") not in profiles: + continue # The inventory-drift error owns missing registrations. + profile = profiles[edge["profile"]] + if profile["api"] != observed_api: + problems.append( + f"{function}:{node.lineno}: process API differs from profile" + ) + keywords = {item.arg: item.value for item in node.keywords if item.arg} + has_cwd = "cwd" in keywords and not ( + isinstance(keywords["cwd"], ast.Constant) and keywords["cwd"].value is None + ) + if profile["cwd"] == "explicit" and not has_cwd: + problems.append(f"{function}:{node.lineno}: profile requires explicit cwd") + if profile["cwd"] == "preserve" and has_cwd: + problems.append(f"{function}:{node.lineno}: profile requires preserved cwd") + if observed_api == "execve": + if len(node.args) != 3 or node.keywords: + problems.append(f"{function}:{node.lineno}: execve shape is not exact") + elif not all(isinstance(item, ast.Name) for item in node.args) \ + or [item.id for item in node.args] != ["expected", "argv", "env"]: + problems.append( + f"{function}:{node.lineno}: execve path/argv/env binding is not canonical" + ) + else: + env_name = node.args[2].id + scope = node + while scope in parents and not isinstance( + scope, (ast.FunctionDef, ast.AsyncFunctionDef) + ): + scope = parents[scope] + assignments = [ + item for item in ast.walk(scope) + if isinstance(item, ast.Assign) + and any(isinstance(target, ast.Name) and target.id == env_name + for target in item.targets) + and isinstance(item.value, ast.Call) + and isinstance(item.value.func, ast.Name) + and item.value.func.id == "clean_environment" + ] + if len(assignments) != 1 or assignments[0].lineno >= node.lineno: + problems.append( + f"{function}:{node.lineno}: execve env lacks one prior clean transition" + ) + return problems + + +def _is_os_environ(node: ast.AST) -> bool: + return isinstance(node, ast.Attribute) and isinstance(node.value, ast.Name) \ + and node.value.id == "os" and node.attr == "environ" + + +def _environment_boundary_problems( + tree: ast.Module, filename: str, policy: Mapping[str, Any], +) -> list[str]: + """Recognize the one policy-owned ambient-to-clean semantic transition.""" + problems: list[str] = [] + parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} + boundary = policy["environment"]["bootstrap_boundary"] + observed: list[tuple[str, str]] = [] + for node in ast.walk(tree): + if isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) \ + and node.func.attr == "copy" and _is_os_environ(node.func.value): + problems.append(f"line {node.lineno}: ambient environment copy is forbidden") + if isinstance(node, ast.Call) and isinstance(node.func, ast.Name) \ + and node.func.id == "dict" and node.args and _is_os_environ(node.args[0]): + problems.append(f"line {node.lineno}: ambient environment dict merge is forbidden") + if isinstance(node, ast.Dict): + for key, value in zip(node.keys, node.values): + if key is None and _is_os_environ(value): + problems.append(f"line {node.lineno}: ambient environment spread is forbidden") + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name) \ + or node.func.id != boundary["transition"] \ + or not node.args or not _is_os_environ(node.args[0]): + continue + observed.append((_function_name(node, parents), node.func.id)) + expected = [(boundary["function"], boundary["transition"])] \ + if filename == boundary["surface"] else [] + if observed != expected: + problems.append( + f"ambient bootstrap boundary drifted: observed={observed} expected={expected}" + ) + return problems + + +def _inheritance_call_problems( + tree: ast.Module, filename: str, policy: Mapping[str, Any], +) -> list[str]: + problems: list[str] = [] + allowed = set(policy.get("surface_environment", {}).get(filename, [])) + boundary_surface = policy["environment"]["bootstrap_boundary"]["surface"] + parents = {child: parent for parent in ast.walk(tree) for child in ast.iter_child_nodes(parent)} + for node in ast.walk(tree): + if not isinstance(node, ast.Call) or not isinstance(node.func, ast.Name) \ + or node.func.id != "clean_environment": + continue + keyword = next((item.value for item in node.keywords if item.arg == "inherit"), None) + if keyword is None: + continue + if isinstance(keyword, (ast.Tuple, ast.List)) and all( + isinstance(item, ast.Constant) and isinstance(item.value, str) + for item in keyword.elts + ): + requested = {item.value for item in keyword.elts} + if requested - allowed: + problems.append( + f"line {node.lineno}: unregistered inherited variables " + f"{sorted(requested - allowed)}" + ) + continue + function = _function_name(node, parents) + canonical_dynamic = filename == boundary_surface and function == "_launch" \ + and isinstance(keyword, ast.Call) and isinstance(keyword.func, ast.Name) \ + and keyword.func.id == "_surface_inherit" + if not canonical_dynamic: + problems.append( + f"line {node.lineno}: inherited variables must be literal or policy-resolved" + ) + return problems + + +def _static_problems(policy: Mapping[str, Any], files: Mapping[str, Path]) -> list[str]: + problems: list[str] = [] + if policy["python"].get("package") != { + "name": PACKAGE_NAME, + "root": "scripts", + "init": "scripts/__init__.py", + "loading": "verified-file-spec", + "sys_path_mutation": False, + "editable_install": False, + }: + problems.append("Python repository-tool package policy is non-canonical") + if policy["python"].get("dynamic_sibling_imports") != { + META_GATE: ["_strict_yaml"], + } or f'import_module(f"{{PACKAGE_NAME}}._strict_yaml")' not in Path( + __file__ + ).read_text(encoding="utf-8"): + problems.append("Python dynamic sibling-import policy is non-canonical") + venv_policy = policy["python"].get("venv", {}) + expected_home_names = [ + f"python{policy['python']['major_minor']}", "python3", "python", + f"python{policy['python']['major_minor']}.exe", "python.exe", + ] + if venv_policy != { + "path": ".venv", + "implementation": "CPython", + "implementation_name": "cpython", + "release_level": "final", + "patch_policy": "per-environment-exact", + "include_system_site_packages": False, + "home_interpreter_names": expected_home_names, + }: + problems.append("Python active-venv identity policy is non-canonical") + expected_bootstrap = { + "transition_receipt": "NDDEV_PYTHON_BOOTSTRAP", + "receipt_version": "repository-venv-v1", + "maximum_transitions": 1, + } + if policy["python"].get("bootstrap") != expected_bootstrap: + problems.append("Python bootstrap transition policy is non-canonical") + expected_prefix = [ + policy["python"]["dependency_interpreter"], "-I", "-B", + "scripts/check_python_execution_contract.py", "--launch", + ] + if policy["python"].get("launcher_prefix") != expected_prefix: + problems.append("Python launcher prefix differs from repository interpreter policy") + expected_syntax_prefix = [ + policy["python"]["dependency_interpreter"], "-I", "-B", + "scripts/check_python_syntax.py", + ] + if policy["python"].get("syntax_gate_prefix") != expected_syntax_prefix: + problems.append("Python cold syntax prefix differs from repository interpreter policy") + documents = policy["python"].get("invocation_documents") + if not isinstance(documents, dict) or list(documents) != sorted(documents): + problems.append("Python invocation-document inventory is not canonical") + elif any( + not isinstance(item, str) or Path(item).is_absolute() or ".." in Path(item).parts + or not isinstance(registration, dict) + or set(registration) != {"role", "language"} + or registration["role"] not in { + "adoption-guide", "consumer-command", "repository-launcher", + } + or registration["language"] not in {"markdown", "python", "yaml"} + for item, registration in documents.items() + ): + problems.append("Python invocation-document inventory contains an unsafe path") + expected_source_classes = { + "production_workflows": ".github/workflows/*.yml", + "negative_corpora": "tests/fixtures/negative/**", + "negative_contract": "rejection-only", + } + if policy["python"].get("source_classes") != expected_source_classes: + problems.append("Python executable source-class policy is non-canonical") + surfaces = _policy_surfaces(policy) + expected_files = surfaces | {META_GATE, PACKAGE_INIT} + if len(surfaces) != policy["python"]["subject_count"] or set(files) != expected_files: + problems.append( + f"Python inventory drift: count={len(surfaces)} " + f"missing={sorted(expected_files - set(files))} " + f"extra={sorted(set(files) - expected_files)}" + ) + grouped = [ + item for values in policy.get("surface_groups", {}).values() for item in values + ] + if len(grouped) != len(set(grouped)): + problems.append("Python policy assigns a surface to multiple groups") + inherited = policy.get("surface_environment", {}) + if not isinstance(inherited, dict) or set(inherited) - surfaces: + problems.append("Python surface environment names an unknown subject") + elif any( + not isinstance(values, list) + or any(not isinstance(name, str) or _python_key(name) for name in values) + for values in inherited.values() + ): + problems.append("Python surface environment contains invalid inheritance") + sibling = {path.stem for path in files.values() if path.name != PACKAGE_INIT} + stdlib = set(sys.stdlib_module_names) + actual_graph: dict[str, list[str]] = {} + external_graph: dict[str, list[str]] = {} + actual_edges: dict[str, dict[str, dict[str, Any]]] = {} + for name, path in files.items(): + source = path.read_text(encoding="utf-8") + if re.search(r"sys\.path\s*(?:\[.*\])?\s*=|sys\.path\.(?:insert|append|extend)", source): + problems.append(f"{name}: repository tools may not mutate sys.path") + try: + tree = ast.parse(source, filename=str(path), feature_version=(3, 13)) + except SyntaxError as exc: + problems.append(f"{name}: Python 3.13 syntax error: {exc}") + continue + undefined = sorted(_undefined_names(source, str(path))) + if undefined: + problems.append(f"{name}: undefined global names {undefined}") + roots = _import_roots(tree) + problems += [ + f"{name}: {issue}" + for issue in _unqualified_sibling_imports(tree, sibling) + ] + problems += [ + f"{name}: {issue}" + for issue in _environment_boundary_problems(tree, name, policy) + ] + problems += [ + f"{name}: {issue}" + for issue in _inheritance_call_problems(tree, name, policy) + ] + actual_graph[name] = sorted(roots & sibling) + actual_external = sorted(roots - stdlib - sibling) + external_graph[name] = actual_external + for issue in _import_side_effects(tree): + problems.append(f"{name}: {issue}") + counts, edge_problems = _process_edges(tree) + problems += [f"{name}: {issue}" for issue in edge_problems] + problems += [ + f"{name}: {issue}" + for issue in _process_semantic_problems(tree, name, policy) + ] + if counts: + registered = policy["process_edges"].get(name, {}) + actual_edges[name] = { + function: { + "count": count, + "profile": registered.get(function, {}).get("profile", ""), + } + for function, count in sorted(counts.items()) + } + expected_graph = { + name: sorted(policy["sibling_imports"].get(name, [])) for name in files + } + if actual_graph != expected_graph: + problems.append("Python sibling import graph differs from canonical policy") + expected_external = { + name: sorted(policy["direct_external_imports"].get(name, [])) for name in files + } + if external_graph != expected_external: + problems.append(f"Python external dependency graph drifted: {external_graph}") + declared_dependencies = set(policy["python"].get("external_dependencies", {})) + observed_dependencies = { + dependency for values in external_graph.values() for dependency in values + } + if declared_dependencies != observed_dependencies: + problems.append("Python dependency policy differs from external import graph") + if actual_edges != policy["process_edges"]: + problems.append(f"Python process-edge registry drifted: actual={actual_edges}") + profile_fields = {"api", "executable", "argv", "cwd", "environment"} + profile_values = { + "api": {"subprocess", "execve"}, + "executable": { + "current-interpreter", "path-search", "repository-venv", + "selected-interpreter", + }, + "argv": {"exact", "sequence"}, + "cwd": {"explicit", "explicit-or-preserve", "preserve"}, + "environment": { + "replace-clean", "replace-clean-inherit-named", + "replace-owned-hostile", + }, + } + for name, profile in policy.get("process_profiles", {}).items(): + if not isinstance(profile, dict) or set(profile) != profile_fields: + problems.append(f"Python process profile {name!r} is not total") + continue + for field, allowed_values in profile_values.items(): + if profile[field] not in allowed_values: + problems.append( + f"Python process profile {name!r} has invalid {field} semantics" + ) + referenced_profiles = { + edge["profile"] + for functions in policy["process_edges"].values() + for edge in functions.values() + } + if referenced_profiles != set(policy.get("process_profiles", {})): + problems.append("Python process profile inventory differs from registered edges") + return problems + + +def workflow_python_invocation_problems( + path: Path, text: str, *, enforce_embedded: bool = False, + role: SourceRole = SourceRole.WORKFLOW, + language: SourceLanguage | None = None, +) -> list[str]: + """Enforce distinct canonical shapes for subjects and embedded programs.""" + problems: list[str] = [] + surfaces = _policy_surfaces(load_policy()) + if language is None: + if role is SourceRole.WORKFLOW: + language = SourceLanguage.YAML + elif role is SourceRole.SHELL_FIXTURE: + language = SourceLanguage.SHELL + else: + return [f"{path.name}: executable source language is not registered"] + source = InvocationSource(path, text, role, language) + commands, source_problems = _source_commands(source) + problems += source_problems + launcher_count = 0 + seen: set[tuple[int, str, SourceRole, SourceLanguage, SourceLanguage]] = set() + for extracted in commands: + location = extracted.location + scalar = extracted.text + identity = ( + extracted.line, scalar, extracted.role, extracted.language, + extracted.grammar, + ) + if identity in seen: + problems.append(f"{path.name}:{location}: duplicate executable surface") + continue + seen.add(identity) + try: + logical = _logical_shell_commands(scalar) + except ValueError as exc: + problems.append(f"{path.name}:{location}: {exc}") + continue + for _, command in logical: + embedded_candidate = enforce_embedded and "<<'PY'" in command + if not _is_repository_command_candidate(command) and not embedded_candidate: + continue + try: + invocation = _parse_invocation(command) + except ValueError as exc: + problems.append(f"{path.name}:{location}: {exc}") + continue + if invocation is not None: + launcher_count += 1 + expected = _canonical_invocation(invocation.subject, invocation.arguments) + if invocation != expected or invocation.subject not in surfaces: + problems.append( + f"{path.name}:{location}: launcher invocation is not canonical" + ) + continue + try: + tokens = shlex.split(command, posix=True) + except ValueError as exc: + problems.append(f"{path.name}:{location}: malformed shell command: {exc}") + continue + direct_subjects = { + Path(token).name for token in tokens + if token.startswith("scripts/") and Path(token).name in surfaces + } + syntax_prefix = load_policy()["python"]["syntax_gate_prefix"] + if tokens == syntax_prefix: + continue + if direct_subjects: + problems.append( + f"{path.name}:{location}: repository subject must use canonical launcher" + ) + continue + if enforce_embedded and "<<'PY'" in command and not re.search( + r"\bpython3\s+-I(?:\s+-)?(?:\s+[^<]+)?\s+<<'PY'$", command, + ): + problems.append( + f"{path.name}:{location}: embedded Python must use isolated mode" + ) + if role is SourceRole.REPOSITORY_LAUNCHER and launcher_count == 0: + problems.append(f"{path.name}: repository-launcher source has no launcher invocation") + if role is SourceRole.CONSUMER_COMMAND and launcher_count: + problems.append(f"{path.name}: consumer-command source invokes repository launcher") + return problems + + +def workflow_python_provisioning_problems(path: Path, text: str) -> list[str]: + """Require deterministic venv provisioning wherever the launcher is used.""" + launcher = " ".join(load_policy()["python"]["launcher_prefix"]) + if launcher not in text: + return [] + problems: list[str] = [] + if "activate-environment: true" in text: + problems.append(f"{path.name}: setup-uv activation may shadow the repository interpreter") + if "-m venv --copies .venv" not in text: + problems.append(f"{path.name}: launcher has no copy-based repository venv provisioning") + if path.name != "runtime-fixtures.yml": + for marker in ( + "update-environment: false", + "PYTHON_PATH: ${{ steps.python.outputs.python-path }}", + ): + if marker not in text: + problems.append(f"{path.name}: launcher provisioning lacks {marker!r}") + return problems + + +def _run_import(tool: str, cwd: Path, dependency_root: Path | None) -> str | None: + command = [ + sys.executable, "-I", "-B", str(Path(__file__).resolve()), + "--bootstrap", tool, "@active" if dependency_root else "-", + "--import-only", "--", + ] + completed = subprocess.run( + command, cwd=cwd, env=clean_environment(), text=True, encoding="utf-8", + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, check=False, + ) + if completed.returncode != 0 or "Traceback (most recent call last):" in completed.stdout: + return f"{tool}: cold import failed: {completed.stdout[-1600:].strip()}" + return None + + +def _venv_selftest(root: Path, policy: Mapping[str, Any]) -> list[str]: + problems: list[str] = [] + dependency = policy["python"]["external_dependencies"]["yaml"] + active_identity, active_problems = _active_venv_identity() + if active_problems or active_identity is None: + return ["cannot derive venv selftests from the active runtime identity"] + active_config, config_problems = _read_pyvenv( + Path(active_identity["prefix"]) / "pyvenv.cfg" + ) + if config_problems: + return ["cannot derive venv selftests from the active pyvenv.cfg"] + base_executable, anchor_problems = _base_anchor( + active_identity, active_config, policy["python"]["venv"], + ) + if anchor_problems or base_executable is None: + return ["cannot derive venv selftests from the active base identity"] + valid_identities: dict[str, dict[str, Any]] = {} + for label, prefix in { + "local-captured-shape": root / "local-venv", + "relocated-venv-shape": root / "workspace" / ".venv", + }.items(): + executable = prefix / "bin" / "python3" + executable.parent.mkdir(parents=True) + executable.write_text("copied-interpreter-fixture\n", encoding="utf-8") + executable.chmod(0o700) + site_root = prefix / "lib" / "python3.13" / "site-packages" + site_root.mkdir(parents=True) + identity = dict(active_identity) + identity.update({ + "prefix": str(prefix), "exec_prefix": str(prefix), + "executable": str(executable), "purelib": str(site_root), + "platlib": str(site_root), + }) + config = dict(active_config) + if _venv_identity_problems(identity, config): + problems.append(f"{label} active-venv parity selftest failed") + valid_identities[label] = identity + + valid = valid_identities["relocated-venv-shape"] + config = dict(active_config) + optional_base = dict(valid, base_executable="") + if _venv_identity_problems(optional_base, config): + problems.append("optional _base_executable selftest rejected pyvenv home identity") + outside_site = root / "outside" / "site-packages" + outside_site.mkdir(parents=True) + wrong_prefix = dict(valid, purelib=str(outside_site), platlib=str(outside_site)) + wrong_version = dict(config, version_info="3.12.0") + stale_runtime = dict(valid, version="3.13.0") + stale_runtime["version_info"] = (3, 13, 0, "final", 0) + stale_runtime["implementation_version"] = (3, 13, 0, "final", 0) + wrong_implementation = dict(valid, implementation_name="pypy") + wrong_cache_tag = dict(valid, cache_tag="cpython-312") + wrong_config_executable = dict(config, executable=str(root / "other-python")) + user_site = dict(valid, enable_user_site=True) + wrong_base_prefix = dict(valid, base_prefix=str(root / "other-base")) + wrong_base_executable = dict(valid, base_executable=str(root / "other-python")) + symlink_site = root / "symlink" / "site-packages" + symlink_site.parent.mkdir() + symlink_site.symlink_to(outside_site, target_is_directory=True) + symlink_identity = dict(valid, purelib=str(symlink_site), platlib=str(symlink_site)) + symlink_executable = Path(valid["prefix"]) / "bin" / "linked-python" + symlink_executable.symlink_to(Path(valid["executable"])) + symlink_executable_identity = dict(valid, executable=str(symlink_executable)) + for label, identity, candidate_config in ( + ("wrong-prefix", wrong_prefix, config), + ("wrong-version", valid, wrong_version), + ("stale-runtime-version", stale_runtime, config), + ("wrong-implementation", wrong_implementation, config), + ("wrong-cache-tag", wrong_cache_tag, config), + ("wrong-config-executable", valid, wrong_config_executable), + ("user-site", user_site, config), + ("wrong-base-prefix", wrong_base_prefix, config), + ("wrong-base-executable", wrong_base_executable, config), + ("symlink-site", symlink_identity, config), + ("symlink-executable", symlink_executable_identity, config), + ): + if not _venv_identity_problems(identity, candidate_config): + problems.append(f"active-venv negative selftest passed: {label}") + linked_prefix = root / "linked-venv" + linked_prefix.symlink_to(Path(valid["prefix"]), target_is_directory=True) + if not _venv_identity_problems(valid, config, expected_prefix=linked_prefix): + problems.append("symlinked venv-root negative selftest passed") + + valid_lock = ( + f"{dependency['distribution'].lower()}=={dependency['version']} \\\n" + f" --hash=sha256:{'a' * 64}\n" + ) + if _locked_dependency_problems(valid_lock, dependency): + problems.append("hash-pinned dependency selftest rejected canonical input") + for label, text in ( + ("unpinned", f"{dependency['distribution'].lower()}=={dependency['version']}\n"), + ("wrong-version", f"{dependency['distribution'].lower()}==0.0.0 \\\n" + f" --hash=sha256:{'a' * 64}\n"), + ): + if not _locked_dependency_problems(text, dependency): + problems.append(f"dependency-lock negative selftest passed: {label}") + for label, name, version in ( + ("wrong-distribution", "NotPyYAML", dependency["version"]), + ("wrong-installed-version", dependency["distribution"], "0.0.0"), + ): + if not _distribution_identity_problems(name, version, dependency): + problems.append(f"dependency-identity negative selftest passed: {label}") + + site_root = Path(valid["purelib"]) + dist_info = site_root / "pyyaml-6.0.3.dist-info" + dist_info.mkdir() + module = site_root / "yaml" + module.mkdir() + origin = module / "__init__.py" + origin.write_text("fixture\n", encoding="utf-8") + if _dependency_path_problems( + {site_root.resolve()}, site_root, dist_info, origin, dependency["module_path"], + ): + problems.append("dependency-origin selftest rejected canonical venv paths") + escaped = root / "escaped-yaml.py" + escaped.write_text("fixture\n", encoding="utf-8") + origin.unlink() + origin.symlink_to(escaped) + if not _dependency_path_problems( + {site_root.resolve()}, site_root, dist_info, origin, dependency["module_path"], + ): + problems.append("dependency-origin symlink escape selftest passed") + config_file = root / "pyvenv.cfg" + config_file.write_text("home = /fixture\n", encoding="utf-8") + config_link = root / "linked-pyvenv.cfg" + config_link.symlink_to(config_file) + if not _read_pyvenv(config_link)[1]: + problems.append("pyvenv.cfg symlink negative selftest passed") + missing_config = root / "missing-pyvenv.cfg" + if not _read_pyvenv(missing_config)[1]: + problems.append("missing pyvenv.cfg negative selftest passed") + malformed_config = root / "malformed-pyvenv.cfg" + malformed_config.write_text("home=/x\nhome=/y\nunknown\n", encoding="utf-8") + if len(_read_pyvenv(malformed_config)[1]) != 2: + problems.append("duplicate/malformed pyvenv.cfg negative selftest failed") + return problems + + +def _selftest(policy: Mapping[str, Any]) -> list[str]: + problems: list[str] = [] + with tempfile.TemporaryDirectory(prefix="ci-workflows-package-fixture-") as raw_package: + package_root = Path(raw_package) / "scripts" + package_root.mkdir() + package_init = package_root / PACKAGE_INIT + canonical_manifest = (SCRIPTS / PACKAGE_INIT).read_text(encoding="utf-8") + package_init.write_text(canonical_manifest, encoding="utf-8") + helper = package_root / "helper.py" + helper.write_text("VALUE = 1\n", encoding="utf-8") + if _helper_origin_problems(package_root, "helper", str(helper)): + problems.append("repository helper valid-origin selftest failed") + if not _helper_origin_problems(package_root, "missing", None): + problems.append("missing repository helper negative selftest passed") + if not _helper_origin_problems(package_root, "helper", str(outside := Path(raw_package) / "shadow.py")): + problems.append("shadowed repository helper negative selftest passed") + outside.write_text("VALUE = 2\n", encoding="utf-8") + helper.unlink() + helper.symlink_to(outside) + if not _helper_origin_problems(package_root, "helper", str(helper)): + problems.append("symlinked repository helper negative selftest passed") + valid_spec, valid_problems = _repository_package_spec(package_root) + if valid_problems or valid_spec is None: + problems.append("repository package valid-origin selftest failed") + package_init.write_text("PACKAGE_CONTRACT = 'stale'\n", encoding="utf-8") + if not _repository_package_spec(package_root)[1]: + problems.append("stale package manifest negative selftest passed") + package_init.unlink() + if not _repository_package_spec(package_root)[1]: + problems.append("missing package manifest negative selftest passed") + outside.write_text(canonical_manifest, encoding="utf-8") + package_init.symlink_to(outside) + if not _repository_package_spec(package_root)[1]: + problems.append("symlinked package manifest negative selftest passed") + if valid_spec is not None: + valid_module = importlib.util.module_from_spec(valid_spec) + valid_module.PACKAGE_CONTRACT = "ci-workflows-tools-v1" + if _loaded_package_problems(valid_module, package_root): + problems.append("loaded package identity selftest rejected canonical origin") + wrong_module = ModuleType(PACKAGE_NAME) + wrong_module.__spec__ = importlib.util.spec_from_file_location( + PACKAGE_NAME, outside, submodule_search_locations=[str(package_root)], + ) + wrong_module.PACKAGE_CONTRACT = "ci-workflows-tools-v1" + if not _loaded_package_problems(wrong_module, package_root): + problems.append("wrong-origin package negative selftest passed") + stale_module = importlib.util.module_from_spec(valid_spec) + stale_module.PACKAGE_CONTRACT = "stale" + if not _loaded_package_problems(stale_module, package_root): + problems.append("stale loaded package negative selftest passed") + valid = "import json\nVALUE=1\ndef f(x):\n return json.dumps(x+VALUE)\n" + invalid = valid + "def broken():\n return NEVER_IMPORTED\n" + if _undefined_names(valid, "") or _undefined_names(invalid, "") != {"NEVER_IMPORTED"}: + problems.append("undefined-name selftest failed") + sibling_names = {"helper"} + if _unqualified_sibling_imports( + ast.parse("from ci_workflows_tools import helper\n"), sibling_names, + ): + problems.append("qualified sibling-import selftest failed") + for source in ("import helper\n", "from helper import VALUE\n"): + if not _unqualified_sibling_imports(ast.parse(source), sibling_names): + problems.append("unqualified sibling-import negative selftest failed") + canonical_dependency = shlex.join([sys.executable, "-m", "yaml"]) + try: + observed_dependency = dependency_python_command("-m yaml", "yaml") + except ValueError as exc: + problems.append(f"dependency Python binding selftest rejected canonical input: {exc}") + else: + if observed_dependency != canonical_dependency: + problems.append("dependency Python binding selftest rendered the wrong command") + for arguments, module in ( + ("", "yaml"), ("-m yaml && echo bypass", "yaml"), + ("-m missing", "module-that-cannot-exist"), + ): + try: + dependency_python_command(arguments, module) + except ValueError: + pass + else: + problems.append("dependency Python binding negative selftest passed") + with tempfile.TemporaryDirectory(prefix="ci-workflows-python-binding-") as raw: + root = Path(raw) + expected = root / ".venv" / "bin" / "python" + origin = root / ".venv" / "lib" / "python3.13" / "site-packages" / "pytest" / "__init__.py" + expected.parent.mkdir(parents=True) + origin.parent.mkdir(parents=True) + expected.write_text("fixture\n", encoding="utf-8") + origin.write_text("fixture\n", encoding="utf-8") + if _dependency_python_argv(expected, expected, root / ".venv", origin, "-m pytest") != [ + str(expected), "-m", "pytest", + ]: + problems.append("captured dependency Python binding selftest drifted") + outside = root / "outside" / "pytest.py" + outside.parent.mkdir() + outside.write_text("fixture\n", encoding="utf-8") + for executable, module_origin in ( + (root / "foreign" / "python", origin), (expected, outside), + ): + try: + _dependency_python_argv( + executable, expected, root / ".venv", module_origin, "-m pytest", + ) + except ValueError: + pass + else: + problems.append("foreign dependency Python binding negative selftest passed") + if not _import_side_effects(ast.parse("import pathlib\npathlib.Path('x').write_text('x')\n")): + problems.append("import-write selftest failed") + safe_surfaces = {"registered.py"} + for candidate, accepted in { + "registered.py": True, + "missing.py": False, + "../registered.py": False, + "sub/registered.py": False, + "registered": False, + }.items(): + if _registered_tool(candidate, safe_surfaces) != accepted: + problems.append(f"registered-tool selftest failed: {candidate}") + with tempfile.TemporaryDirectory() as raw_interpreter: + interpreter_root = Path(raw_interpreter) + dependency_python = interpreter_root / policy["python"]["dependency_interpreter"] + dependency_python.parent.mkdir(parents=True) + dependency_python.write_text("fixture\n", encoding="utf-8") + dependency_python.chmod(0o700) + yaml_executable, yaml_claim, yaml_problems = _subject_interpreter( + "validate_catalog.py", policy, root=interpreter_root, + current=Path("/ambient/python3"), + ) + stdlib_executable, stdlib_claim, stdlib_problems = _subject_interpreter( + "check_docs_links.py", policy, root=interpreter_root, + current=Path("/trusted/setup-python"), + ) + if yaml_problems or yaml_executable != dependency_python or yaml_claim != "@active": + problems.append("dependency subject did not select the repository venv") + if stdlib_problems or stdlib_executable != Path("/trusted/setup-python") \ + or stdlib_claim != "-": + problems.append("stdlib-only subject did not preserve its isolated interpreter") + linked_python = dependency_python.with_name("linked-python") + linked_python.symlink_to(dependency_python) + linked_policy = json.loads(json.dumps(policy)) + linked_policy["python"]["dependency_interpreter"] = str( + linked_python.relative_to(interpreter_root) + ) + if not _subject_interpreter( + "validate_catalog.py", linked_policy, root=interpreter_root, + )[2]: + problems.append("symlinked dependency interpreter negative selftest passed") + dependency_python.unlink() + if not _subject_interpreter( + "validate_catalog.py", policy, root=interpreter_root, + )[2]: + problems.append("missing dependency interpreter negative selftest passed") + dependency_python.write_text("fixture\n", encoding="utf-8") + dependency_python.chmod(0o600) + if not _subject_interpreter( + "validate_catalog.py", policy, root=interpreter_root, + )[2]: + problems.append("non-executable dependency interpreter negative selftest passed") + for source in ( + "from subprocess import run\nrun(['true'], env={})\n", + "import subprocess as sp\nsp.run(['true'], env={})\n", + ): + if not _process_edges(ast.parse(source))[1]: + problems.append("process-alias selftest failed") + exec_valid = ast.parse("import os\ndef edge():\n os.execve('/x', ['/x'], {})\n") + exec_missing = ast.parse("import os\ndef edge():\n os.execv('/x', ['/x'])\n") + duplicate = ast.parse( + "import subprocess\ndef edge():\n" + " subprocess.run(['x'], env={})\n subprocess.run(['y'], env={})\n" + ) + if _process_edges(exec_valid) != ({"edge": 1}, []): + problems.append("execve replacement-environment selftest failed") + if not _process_edges(exec_missing)[1]: + problems.append("inherited exec environment negative selftest passed") + if _process_edges(duplicate)[0] != {"edge": 2}: + problems.append("duplicate process-edge selftest failed") + environment = policy["environment"] + stripped_name = environment["stripped_names_evidence"] + inherited_name = environment["inherited_names_evidence"] + source = { + "PATH": "/fixture/bin", "LANG": "hostile", "LC_ALL": "hostile", + "GH_TOKEN": "token", "UNNAMED": "must-not-pass", + "PYTHONHOME": "/hostile", "pythonpath": "/shadow", + stripped_name: '["PYTHONWARNINGS"]', inherited_name: '["HOME"]', + } + transitioned = _transition_environment(source, inherit=("GH_TOKEN",)) + if any(_python_key(name) for name in transitioned): + problems.append("environment transition retained interpreter-control state") + if transitioned.get("LANG") != "C" or transitioned.get("LC_ALL") != "C": + problems.append("environment transition did not canonicalize locale") + if "UNNAMED" in transitioned or transitioned.get("GH_TOKEN") != "token": + problems.append("environment transition inheritance allowlist failed") + if json.loads(transitioned[stripped_name]) != [ + "PYTHONHOME", "PYTHONWARNINGS", "pythonpath", + ]: + problems.append("environment transition stripped evidence is not exact") + if json.loads(transitioned[inherited_name]) != [ + "GH_TOKEN", "HOME", "PATH", + ]: + problems.append("environment transition inherited evidence is not exact") + rejected = ( + ({"PYTHONPATH": "forbidden"}, (), "python override"), + ({"LANG": "hostile"}, (), "locale override"), + ({}, ("UNREGISTERED",), "unknown inheritance"), + ) + for overrides, inherit, label in rejected: + try: + _transition_environment(source, overrides, inherit=inherit) + except ValueError: + pass + else: + problems.append(f"environment transition accepted {label}") + for evidence_name in (stripped_name, inherited_name): + malformed = dict(source) + malformed[evidence_name] = "not-json" + try: + _transition_environment(malformed) + except ValueError: + pass + else: + problems.append(f"environment transition accepted malformed {evidence_name}") + invocation_cases = { + "canonical-launcher": ( + _render_invocation(_canonical_invocation("validate_all.py")), + False, + ), + "multiline-launcher": ( + ".venv/bin/python -I -B scripts/check_python_execution_contract.py " + "--launch " + "\\" + "\nvalidate_all.py -- --tier core", + False, + ), + "quoted-doc-launcher": ( + "\".venv/bin/python -I -B " + "scripts/check_python_execution_contract.py --launch validate_all.py --\"", + False, + ), + "truncated-continuation": ("run: command \\", True), + "setup-python-path-shadow": ( + "python3 -I -B scripts/check_python_execution_contract.py " + "--launch validate_all.py --", + True, + ), + "foreign-venv-launcher": ( + "/tmp/foreign/.venv/bin/python -I -B " + "scripts/check_python_execution_contract.py --launch validate_all.py --", + True, + ), + "direct-subject": ("python3 -I scripts/validate_all.py", True), + "canonical-cold-syntax": ( + ".venv/bin/python -I -B scripts/check_python_syntax.py", False, + ), + "ambient-cold-syntax": ( + "python3 -I -B scripts/check_python_syntax.py", True, + ), + "missing-bytecode-guard": ( + "python3 -I scripts/check_python_execution_contract.py " + "--launch validate_all.py --", + True, + ), + "missing-separator": ( + ".venv/bin/python -I -B scripts/check_python_execution_contract.py " + "--launch validate_all.py", + True, + ), + "duplicate-separator": ( + ".venv/bin/python -I -B scripts/check_python_execution_contract.py " + "--launch validate_all.py -- --", + True, + ), + "embedded-isolated": ("python3 -I - '$VALUE' <<'PY'", False), + "embedded-ambient": ("python3 - <<'PY'", True), + } + for label, (text, should_fail) in invocation_cases.items(): + failed = bool(workflow_python_invocation_problems( + Path(label), text, enforce_embedded=label.startswith("embedded-"), + role=SourceRole.SHELL_FIXTURE, + )) + if failed != should_fail: + problems.append(f"workflow invocation selftest failed: {label}") + canonical = _render_invocation(_canonical_invocation("validate_all.py")) + workflow_cases = { + "nested-run-mapping": ( + "jobs:\n test:\n defaults:\n run:\n shell: bash\n" + " steps:\n - run: echo ok\n", + False, + ), + "literal-command": ( + f"jobs:\n test:\n steps:\n - run: |\n {canonical}\n", + False, + ), + "folded-command": ( + "jobs:\n test:\n steps:\n - run: >-\n .venv/bin/python -I -B\n" + " scripts/check_python_execution_contract.py --launch validate_all.py --\n", + False, + ), + "quoted-command": ( + f"jobs:\n test:\n steps:\n - run: '{canonical}'\n", + False, + ), + "null-step-command": ("jobs:\n test:\n steps:\n - run:\n", True), + "mapping-step-command": ( + "jobs:\n test:\n steps:\n - run:\n shell: bash\n", True, + ), + "sequence-step-command": ( + "jobs:\n test:\n steps:\n - run: [echo, ok]\n", True, + ), + "malformed-delimiter": ( + "jobs:\n test:\n steps:\n - run: {broken]\n", True, + ), + "truncated-scalar": ( + "jobs:\n test:\n steps:\n - run: 'unterminated\n", True, + ), + } + for label, (text, should_fail) in workflow_cases.items(): + failed = bool(workflow_python_invocation_problems(Path(label), text)) + if failed != should_fail: + problems.append(f"structured workflow invocation selftest failed: {label}") + document_cases = ( + (SourceRole.REPOSITORY_LAUNCHER, f"```bash\n{canonical}\n```\n", False), + (SourceRole.REPOSITORY_LAUNCHER, "```bash\necho consumer\n```\n", True), + (SourceRole.ADOPTION_GUIDE, f"prose with ' unmatched\n```bash\n{canonical}\n```\n", False), + (SourceRole.CONSUMER_COMMAND, "```bash\necho consumer\n```\n", False), + (SourceRole.CONSUMER_COMMAND, f"```bash\n{canonical}\n```\n", True), + ) + for index, (role, text, should_fail) in enumerate(document_cases): + failed = bool(workflow_python_invocation_problems( + Path(f"document-{index}.md"), text, role=role, + language=SourceLanguage.MARKDOWN, + )) + if failed != should_fail: + problems.append(f"typed invocation-source selftest failed: {role.value}/{index}") + typed_sources = ( + InvocationSource( + Path("typed.md"), + f"prose `{canonical}`\n```text\n{canonical}\n```\n```bash\n{canonical}\n```\n", + SourceRole.ADOPTION_GUIDE, SourceLanguage.MARKDOWN, + ), + InvocationSource( + Path("typed.py"), + f"# {canonical}\nCOMMAND = {canonical!r}\n", + SourceRole.REPOSITORY_LAUNCHER, SourceLanguage.PYTHON, + ), + InvocationSource( + Path("typed.yml"), + f"name: {canonical!r}\njobs:\n test:\n steps:\n - run: {canonical!r}\n", + SourceRole.WORKFLOW, SourceLanguage.YAML, + ), + ) + for source in typed_sources: + extracted, extraction_problems = _source_commands(source) + if extraction_problems or len(extracted) != 1: + problems.append( + f"typed {source.language.value} extraction selftest failed: " + f"count={len(extracted)} problems={extraction_problems}" + ) + elif extracted[0].role is not source.role \ + or extracted[0].language is not source.language \ + or extracted[0].grammar is not SourceLanguage.SHELL \ + or extracted[0].line < 1: + problems.append(f"typed {source.language.value} metadata selftest failed") + negative_yaml = InvocationSource( + Path("negative.yml"), "jobs:\n broken: {steps: [}\n", + SourceRole.WORKFLOW, SourceLanguage.YAML, + ) + negative_commands, negative_problems = _source_commands(negative_yaml) + if negative_commands or not negative_problems: + problems.append("negative YAML corpus rejection selftest failed") + unclassified = workflow_python_invocation_problems( + Path("unclassified.txt"), canonical, role=SourceRole.ADOPTION_GUIDE, + ) + if not unclassified: + problems.append("unclassified executable-source selftest failed") + provisioning_cases = { + "canonical": ( + "uses: actions/setup-python@sha\n" + "id: python\npython-version: '3.13'\nupdate-environment: false\n" + "PYTHON_PATH: ${{ steps.python.outputs.python-path }}\n" + "run: \"$PYTHON_PATH\" -I -B -m venv --copies .venv\n" + ".venv/bin/python -I -B scripts/check_python_execution_contract.py " + "--launch validate_all.py --\n", + False, + ), + "setup-action-shadow": ( + "activate-environment: true\n-m venv --copies .venv\n" + ".venv/bin/python -I -B scripts/check_python_execution_contract.py " + "--launch validate_all.py --\n", + True, + ), + "missing-copy-provision": ( + "update-environment: false\n" + "PYTHON_PATH: ${{ steps.python.outputs.python-path }}\n" + ".venv/bin/python -I -B scripts/check_python_execution_contract.py " + "--launch validate_all.py --\n", + True, + ), + } + for label, (text, should_fail) in provisioning_cases.items(): + failed = bool(workflow_python_provisioning_problems(Path(label), text)) + if failed != should_fail: + problems.append(f"workflow provisioning selftest failed: {label}") + with tempfile.TemporaryDirectory() as raw_bootstrap: + root = Path(raw_bootstrap) + expected = root / "repository" / ".venv" / "bin" / "python" + current = root / "setup-python" / "bin" / "python" + expected.parent.mkdir(parents=True) + current.parent.mkdir(parents=True) + expected.write_text("expected\n", encoding="utf-8") + current.write_text("current\n", encoding="utf-8") + receipt = policy["python"]["bootstrap"]["receipt_version"] + decisions = { + "transition": _bootstrap_decision(current, expected, None, receipt), + "loop": _bootstrap_decision(current, expected, receipt, receipt), + "current": _bootstrap_decision(expected, expected, None, receipt), + "arrived": _bootstrap_decision(expected, expected, receipt, receipt), + "malformed": _bootstrap_decision(current, expected, "wrong", receipt), + } + if decisions != { + "transition": "transition", "loop": "loop-or-wrong-target", + "current": "current", "arrived": "arrived", + "malformed": "malformed-receipt", + }: + problems.append(f"bootstrap decision selftest drifted: {decisions}") + with tempfile.TemporaryDirectory() as raw: + root = Path(raw) + if not _resource_problems(root, policy): + problems.append("missing-resource selftest failed") + problems += _venv_selftest(root, policy) + return problems + + +def _registered_invocation_problems(policy: Mapping[str, Any]) -> list[str]: + problems: list[str] = [] + for workflow in sorted((REPO_ROOT / ".github" / "workflows").glob("*.yml")): + workflow_text = workflow.read_text(encoding="utf-8") + problems += workflow_python_invocation_problems(workflow, workflow_text) + problems += workflow_python_provisioning_problems(workflow, workflow_text) + for relative, registration in policy["python"].get("invocation_documents", {}).items(): + document = REPO_ROOT / relative + if not document.is_file() or document.is_symlink(): + problems.append(f"Python invocation document is missing or unsafe: {relative}") + continue + problems += workflow_python_invocation_problems( + document, document.read_text(encoding="utf-8"), + role=SourceRole(registration["role"]), + language=SourceLanguage(registration["language"]), + ) + return problems + + +def check() -> list[str]: + try: + _register_repository_package() + except RuntimeError as exc: + return [str(exc)] + try: + policy = load_policy() + except RuntimeError as exc: + return [str(exc)] + problems = _runtime_problems() + files = _files() + problems += _static_problems(policy, files) + dependency_root, dependency_problems = _dependency_root() + problems += dependency_problems + problems += _resource_problems(REPO_ROOT, policy) + if dependency_root is None: + return problems + with tempfile.TemporaryDirectory(prefix="ci-workflows-python-contract-") as raw: + root = Path(raw) + hostile_cwd = root / "hostile-cwd" + cache = root / "bytecode" + hostile_cwd.mkdir() + cache.mkdir() + (hostile_cwd / "_workflow_yaml.py").write_text("raise RuntimeError('shadow')\n") + (hostile_cwd / "ci_workflows_tools.py").write_text( + "raise RuntimeError('package shadow')\n" + ) + (hostile_cwd / "yaml.py").write_text("raise RuntimeError('shadow')\n") + (hostile_cwd / "sitecustomize.py").write_text("raise RuntimeError('shadow')\n") + for name, path in files.items(): + try: + py_compile.compile(str(path), cfile=str(cache / f"{name}c"), doraise=True) + except py_compile.PyCompileError as exc: + problems.append(f"{name}: bytecode compilation failed: {exc.msg}") + for tool in sorted(_policy_surfaces(policy)): + issue = _run_import( + tool, hostile_cwd, dependency_root if _needs_yaml(tool, policy) else None, + ) + if issue: + problems.append(issue) + if problems: + return problems + # Semantic assertions run only after every registered surface has + # passed syntax, dependency-graph, origin and cold-import proof. + problems += _selftest(policy) + problems += _registered_invocation_problems(policy) + problems += _probe_generations(hostile_cwd) + return problems + + +def main() -> int: + problems = check() + if problems: + print("check_python_execution_contract: FAIL", file=sys.stderr) + for problem in problems: + print(f" - {problem}", file=sys.stderr) + return 1 + print("check_python_execution_contract: OK") + print( + "python-execution-receipt: " + + json.dumps(_venv_receipt(), sort_keys=True, separators=(",", ":")) + ) + return 0 + + +if __name__ == "__main__": + try: + _register_repository_package() + except RuntimeError as exc: + print(f"python-launcher: {exc}", file=sys.stderr) + raise SystemExit(2) + _ensure_repository_interpreter() + if len(sys.argv) >= 3 and sys.argv[1] == "--launch": + tool = sys.argv[2] + tail = sys.argv[3:] + if not tail or tail[0] != "--": + print("python-launcher: malformed launch arguments", file=sys.stderr) + raise SystemExit(2) + raise SystemExit(_launch(tool, tail[1:])) + if len(sys.argv) >= 5 and sys.argv[1] == "--bootstrap": + bootstrap_tool = sys.argv[2] + bootstrap_dependency = sys.argv[3] + bootstrap_tail = sys.argv[4:] + import_only = False + if bootstrap_tail and bootstrap_tail[0] == "--import-only": + import_only = True + bootstrap_tail = bootstrap_tail[1:] + if not bootstrap_tail or "--" not in bootstrap_tail: + print("python-bootstrap: malformed bootstrap arguments", file=sys.stderr) + raise SystemExit(2) + separator = bootstrap_tail.index("--") + if bootstrap_tail[separator + 1:] and import_only: + print("python-bootstrap: import-only mode takes no subject arguments", file=sys.stderr) + raise SystemExit(2) + raise SystemExit(_bootstrap( + bootstrap_tool, bootstrap_dependency, bootstrap_tail[separator + 1:], + import_only=import_only, + )) + if sys.argv[1:] == ["--probe-middle"]: + raise SystemExit(_probe_child()) + if sys.argv[1:] == ["--probe-sanitize"]: + raise SystemExit(_probe_sanitize()) + if sys.argv[1:] == ["--probe-leaf"]: + print(json.dumps(_probe_payload("leaf"), sort_keys=True, separators=(",", ":"))) + raise SystemExit(0) + if sys.argv[1:] == ["--probe-fail"]: + print("probe-child-diagnostic", file=sys.stderr) + raise SystemExit(37) + raise SystemExit(main()) diff --git a/scripts/check_python_syntax.py b/scripts/check_python_syntax.py new file mode 100644 index 0000000..7572949 --- /dev/null +++ b/scripts/check_python_syntax.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Cold, dependency-free syntax gate for every repository Python surface.""" +from __future__ import annotations + +import ast +import py_compile +import tempfile +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SCRIPTS = REPO_ROOT / "scripts" + + +def _fixture_problems() -> list[str]: + problems: list[str] = [] + valid = { + "balanced-set": 'VALUES = {"|", ">", "|-", ">+"}\n', + "quoted": 'VALUE = "run: >1- # folded"\n', + "multiline": "VALUE = (\n 'first'\n 'second'\n)\n", + } + invalid = { + "mismatched-delimiter": 'VALUES = {"|", ">-")\n', + "truncated-set": 'VALUES = {"|", ">-"\n', + "truncated-quoted": 'VALUE = "run: |\n', + "truncated-multiline": "VALUE = (\n 'first'\n", + } + for label, source in valid.items(): + try: + ast.parse(source, filename=f"", feature_version=(3, 13)) + except SyntaxError as exc: + problems.append(f"valid syntax fixture {label!r} was rejected: {exc}") + for label, source in invalid.items(): + try: + ast.parse(source, filename=f"", feature_version=(3, 13)) + except SyntaxError: + continue + problems.append(f"invalid syntax fixture {label!r} was accepted") + return problems + + +def check() -> list[str]: + problems = _fixture_problems() + paths = sorted(SCRIPTS.glob("*.py")) + with tempfile.TemporaryDirectory(prefix="ci-workflows-python-syntax-") as raw: + cache = Path(raw) + for path in paths: + try: + source = path.read_text(encoding="utf-8") + ast.parse(source, filename=str(path), feature_version=(3, 13)) + except (OSError, UnicodeError, SyntaxError) as exc: + problems.append(f"{path.name}: AST parse failed: {exc}") + continue + try: + py_compile.compile( + str(path), cfile=str(cache / f"{path.name}c"), doraise=True, + ) + except py_compile.PyCompileError as exc: + problems.append(f"{path.name}: bytecode compilation failed: {exc.msg}") + return problems + + +def main() -> int: + problems = check() + if problems: + print("check_python_syntax: FAIL") + for problem in problems: + print(f" - {problem}") + return 1 + print("check_python_syntax: OK") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/check_release_graph.py b/scripts/check_release_graph.py index 55aed89..7b48603 100644 --- a/scripts/check_release_graph.py +++ b/scripts/check_release_graph.py @@ -23,8 +23,8 @@ from pathlib import Path from typing import Any -from _strict_yaml import strict_loads -from _workflow_yaml import REPO_ROOT, load_yaml +from ci_workflows_tools._strict_yaml import strict_loads +from ci_workflows_tools._workflow_yaml import REPO_ROOT, load_yaml RELEASE = REPO_ROOT / ".github" / "workflows" / "release.yml" DOC = REPO_ROOT / "docs" / "09-releases-packages.md" @@ -216,5 +216,4 @@ def main() -> int: if __name__ == "__main__": - sys.path.insert(0, str(Path(__file__).resolve().parent)) raise SystemExit(main()) diff --git a/scripts/check_release_promotion_gate.py b/scripts/check_release_promotion_gate.py index 6955a1f..50c09ca 100644 --- a/scripts/check_release_promotion_gate.py +++ b/scripts/check_release_promotion_gate.py @@ -13,7 +13,8 @@ from pathlib import Path from typing import Any, Callable -from _workflow_yaml import WORKFLOWS_DIR, load_yaml +from ci_workflows_tools._workflow_yaml import WORKFLOWS_DIR, load_yaml +from ci_workflows_tools.check_python_execution_contract import clean_environment WORKFLOW = WORKFLOWS_DIR / "release-promotion-gate.yml" NOW = dt.datetime(2026, 8, 5, 12, 0, tzinfo=dt.timezone.utc) @@ -150,17 +151,14 @@ def _run(program: str, record: dict[str, Any], *, verified: bool = True) -> subp tag_path = root / "tag.json" ref_path.write_text(json.dumps(ref), encoding="utf-8") tag_path.write_text(json.dumps(tag), encoding="utf-8") - env = os.environ.copy() - env.update( - { + env = clean_environment({ "CONTROL_PLANE_REPOSITORY": "NDDev-it-com/nddev-harnesses", "PROMOTION_NOW": _timestamp(NOW), "PROMOTION_REF_JSON": str(ref_path), "PROMOTION_TAG_JSON": str(tag_path), "PUBLIC_REPOSITORY": PUBLIC_REPOSITORY, "RELEASE_VERSION": "1.2.3", - } - ) + }) return subprocess.run( [sys.executable, "-I", "-"], input=program, diff --git a/scripts/check_release_supply_chain.py b/scripts/check_release_supply_chain.py index 44cd839..0d3f7c4 100644 --- a/scripts/check_release_supply_chain.py +++ b/scripts/check_release_supply_chain.py @@ -23,7 +23,11 @@ from pathlib import Path from typing import Any -from _workflow_yaml import WORKFLOWS_DIR, load_yaml +from ci_workflows_tools._workflow_yaml import WORKFLOWS_DIR, load_yaml +from ci_workflows_tools.check_python_execution_contract import ( + clean_environment, + workflow_python_invocation_problems, +) REUSABLE = WORKFLOWS_DIR / "release-supply-chain.yml" FREE = WORKFLOWS_DIR / "release-supply-chain-free.yml" @@ -98,9 +102,7 @@ def _run( env: dict[str, str] | None = None, input_text: str | None = None, ) -> subprocess.CompletedProcess[str]: - process_env = os.environ.copy() - if env: - process_env.update(env) + process_env = clean_environment(env) return subprocess.run( command, cwd=cwd, @@ -167,6 +169,7 @@ def _find_gnu_tar() -> str | None: continue result = subprocess.run( [candidate, "--version"], + env=clean_environment(), stdout=subprocess.PIPE, stderr=subprocess.DEVNULL, check=False, @@ -206,6 +209,7 @@ def _check_gnu_tar_archive( tar_result = subprocess.run( command, cwd=root, + env=clean_environment(), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, @@ -218,6 +222,7 @@ def _check_gnu_tar_archive( return gzip_result = subprocess.run( ["gzip", "-n"], + env=clean_environment(), input=tar_result.stdout, stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -232,6 +237,7 @@ def _check_gnu_tar_archive( listing = subprocess.run( [gnu_tar, "--list", "--file=-"], + env=clean_environment(), input=gzip.decompress(archives[0]), stdout=subprocess.PIPE, stderr=subprocess.PIPE, @@ -1147,16 +1153,14 @@ def check() -> list[str]: free_text = FREE.read_text(encoding="utf-8") self_text = SELF_RELEASE.read_text(encoding="utf-8") - for name, text in ( - (REUSABLE.name, reusable_text), - (FREE.name, free_text), - (SELF_RELEASE.name, self_text), + for path, text in ( + (REUSABLE, reusable_text), + (FREE, free_text), + (SELF_RELEASE, self_text), ): - for line_number, line in enumerate(text.splitlines(), start=1): - if "python3" in line and re.search(r"\bpython3\s+(?!-I\b)", line): - problems.append( - f"{name}:{line_number}: embedded Python must use isolated mode (-I)" - ) + problems += workflow_python_invocation_problems( + path, text, enforce_embedded=True, + ) checkout = _step(reusable, "release", "Checkout") checkout_with = checkout.get("with", {}) if isinstance(checkout, dict) else {} diff --git a/scripts/check_runner_routing.py b/scripts/check_runner_routing.py index 6825bf9..2ed2f65 100644 --- a/scripts/check_runner_routing.py +++ b/scripts/check_runner_routing.py @@ -8,11 +8,9 @@ from pathlib import Path from typing import Any -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from _strict_yaml import strict_load # noqa: E402 -from _workflow_yaml import REPO_ROOT, get_on, workflow_files # noqa: E402 -import check_runtime_requirements # noqa: E402 +from ci_workflows_tools._strict_yaml import strict_load # noqa: E402 +from ci_workflows_tools._workflow_yaml import REPO_ROOT, get_on, workflow_files # noqa: E402 +from ci_workflows_tools import check_runtime_requirements # noqa: E402 CONTRACT = REPO_ROOT / "catalog" / "workflow-routing.yml" CONTRACT_SCHEMA = REPO_ROOT / "catalog" / "schema" / "workflow-routing.schema.yaml" diff --git a/scripts/check_runtime_requirements.py b/scripts/check_runtime_requirements.py index 51e90be..442162a 100755 --- a/scripts/check_runtime_requirements.py +++ b/scripts/check_runtime_requirements.py @@ -28,10 +28,8 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from _strict_yaml import strict_load # noqa: E402 -from _workflow_yaml import REPO_ROOT, get_on, workflow_files # noqa: E402 +from ci_workflows_tools._strict_yaml import strict_load # noqa: E402 +from ci_workflows_tools._workflow_yaml import REPO_ROOT, get_on, workflow_files # noqa: E402 CONTAINER_RUNTIME = "container-runtime" diff --git a/scripts/check_scorecard_evidence_contract.py b/scripts/check_scorecard_evidence_contract.py index 6b9780e..be4e7d6 100644 --- a/scripts/check_scorecard_evidence_contract.py +++ b/scripts/check_scorecard_evidence_contract.py @@ -10,9 +10,10 @@ from copy import deepcopy from typing import Any -from _strict_yaml import strict_load -from _workflow_yaml import REPO_ROOT, WORKFLOWS_DIR, get_on, load_yaml -from check_harden_runner_contract import HARDENED_WORKFLOWS, HARDEN_RUNNER +from ci_workflows_tools._strict_yaml import strict_load +from ci_workflows_tools._workflow_yaml import REPO_ROOT, WORKFLOWS_DIR, get_on, load_yaml +from ci_workflows_tools.check_harden_runner_contract import HARDENED_WORKFLOWS, HARDEN_RUNNER +from ci_workflows_tools.check_python_execution_contract import clean_environment CONTRACT_PATH = REPO_ROOT / "catalog" / "scorecard-evidence.yml" CALLER_PATH = WORKFLOWS_DIR / "scorecard.yml" @@ -187,7 +188,6 @@ def _embedded_guard(step: dict[str, Any]) -> str: def _run_guard(program: str, **overrides: str) -> int: env = { - **os.environ, "SCORECARD_EVENT_NAME": "push", "SCORECARD_REF": "refs/heads/main", "SCORECARD_DEFAULT_BRANCH": "main", @@ -198,7 +198,8 @@ def _run_guard(program: str, **overrides: str) -> int: **overrides, } return subprocess.run( - [sys.executable, "-I", "-"], input=program, env=env, text=True, + [sys.executable, "-I", "-"], input=program, + env=clean_environment(env), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ).returncode diff --git a/scripts/check_secret_scan_contract.py b/scripts/check_secret_scan_contract.py index 1e1fe0c..5781c8f 100644 --- a/scripts/check_secret_scan_contract.py +++ b/scripts/check_secret_scan_contract.py @@ -12,8 +12,9 @@ from pathlib import Path from typing import Any -from _strict_yaml import strict_load -from _workflow_yaml import REPO_ROOT, WORKFLOWS_DIR, get_on, load_yaml +from ci_workflows_tools._strict_yaml import strict_load +from ci_workflows_tools._workflow_yaml import REPO_ROOT, WORKFLOWS_DIR, get_on, load_yaml +from ci_workflows_tools.check_python_execution_contract import clean_environment WORKFLOW = WORKFLOWS_DIR / "secret-scan.yml" EXPECTED_VERSION = "8.30.1" @@ -39,7 +40,7 @@ def _python(step: dict[str, Any], marker: str = "python3 -I <<'PY'") -> str: def _run(program: str, env: dict[str, str]) -> subprocess.CompletedProcess[str]: return subprocess.run( [sys.executable, "-I", "-"], input=program, text=True, - capture_output=True, check=False, env={**os.environ, **env}, + capture_output=True, check=False, env=clean_environment(env), ) diff --git a/scripts/check_side_effect_fixture_contract.py b/scripts/check_side_effect_fixture_contract.py index a755b98..e1e3906 100644 --- a/scripts/check_side_effect_fixture_contract.py +++ b/scripts/check_side_effect_fixture_contract.py @@ -10,8 +10,9 @@ from pathlib import Path from typing import Any, Callable -from _strict_yaml import strict_load -from _workflow_yaml import get_on +from ci_workflows_tools._strict_yaml import strict_load +from ci_workflows_tools._workflow_yaml import get_on +from ci_workflows_tools.check_python_execution_contract import clean_environment ROOT = Path(__file__).resolve().parent.parent WORKFLOW = ROOT / ".github/workflows/runtime-fixtures-event-write.yml" @@ -50,17 +51,17 @@ def _run_with_fake_gh(run: str, mode: str, extra_env: dict[str, str]) -> subproc """, encoding="utf-8") fake_gh.chmod(0o755) output = root / "output" - env = { - **os.environ, + base_env = clean_environment() + env = clean_environment({ "FAKE_COUNTER": str(root / "counter"), "FAKE_GH_MODE": mode, "FAKE_LABEL_STATE": str(root / "label-state"), "GITHUB_OUTPUT": str(output), "GH_TOKEN": "fixture-token", - "PATH": f"{fake_bin}{os.pathsep}{os.environ['PATH']}", + "PATH": f"{fake_bin}{os.pathsep}{base_env.get('PATH', '')}", "RUNNER_TEMP": str(root), **extra_env, - } + }) if mode == "label-lifecycle": Path(env["FAKE_LABEL_STATE"]).write_text("present\n", encoding="utf-8") result = subprocess.run( diff --git a/scripts/check_skills.py b/scripts/check_skills.py index 269027e..5d8a39c 100644 --- a/scripts/check_skills.py +++ b/scripts/check_skills.py @@ -12,9 +12,10 @@ import hashlib import re import sys +import tempfile from pathlib import Path -from _strict_yaml import strict_loads +from ci_workflows_tools._strict_yaml import strict_loads REPO_ROOT = Path(__file__).resolve().parent.parent CANONICAL = REPO_ROOT / ".agents" / "skills" @@ -71,6 +72,67 @@ def _guard_selftest() -> list[str]: return problems +def _mirror_problems(source: Path, mirror: Path) -> list[str]: + """Require an exact source tree plus the one declared generation marker.""" + problems: list[str] = [] + source_files = { + path.relative_to(source): path + for path in source.rglob("*") if path.is_file() + } + mirror_files = { + path.relative_to(mirror): path + for path in mirror.rglob("*") if path.is_file() + } if mirror.is_dir() else {} + expected = set(source_files) | {Path(MARKER)} + if set(mirror_files) != expected: + problems.append( + f"mirror file inventory drift: missing={sorted(str(p) for p in expected - set(mirror_files))} " + f"extra={sorted(str(p) for p in set(mirror_files) - expected)}" + ) + for relative, source_path in source_files.items(): + mirror_path = mirror_files.get(relative) + if mirror_path is not None and source_path.read_bytes() != mirror_path.read_bytes(): + problems.append(f"mirror byte drift: {relative}") + skill = source / "SKILL.md" + marker = mirror_files.get(Path(MARKER)) + if skill.is_file() and marker is not None: + expected_marker = ( + f"source=.agents/skills/{source.name}/SKILL.md\n" + f"sha256={hashlib.sha256(skill.read_bytes()).hexdigest()}\n" + ).encode() + if marker.read_bytes() != expected_marker: + problems.append("mirror generation marker drift") + return problems + + +def _mirror_selftest() -> list[str]: + problems: list[str] = [] + with tempfile.TemporaryDirectory(prefix="ci-workflows-skill-mirror-") as raw: + root = Path(raw) + source = root / ".agents" / "skills" / "fixture" + mirror = root / ".claude" / "skills" / "fixture" + source.mkdir(parents=True) + mirror.mkdir(parents=True) + payload = b"---\nname: fixture\n---\n" + (source / "SKILL.md").write_bytes(payload) + (mirror / "SKILL.md").write_bytes(payload) + (mirror / MARKER).write_text( + "source=.agents/skills/fixture/SKILL.md\n" + f"sha256={hashlib.sha256(payload).hexdigest()}\n", + encoding="utf-8", + ) + if _mirror_problems(source, mirror): + problems.append("check_skills self-test: canonical mirror rejected") + (mirror / "unexpected.txt").write_text("drift\n", encoding="utf-8") + if not _mirror_problems(source, mirror): + problems.append("check_skills self-test: unexpected generated output accepted") + (mirror / "unexpected.txt").unlink() + (mirror / "SKILL.md").write_text("stale\n", encoding="utf-8") + if not _mirror_problems(source, mirror): + problems.append("check_skills self-test: mirror byte drift accepted") + return problems + + def check() -> list[str]: problems: list[str] = [] if not CANONICAL.is_dir(): @@ -130,6 +192,8 @@ def check() -> list[str]: problems.append( f".claude/skills/{skill_dir.name}/{MARKER}: missing generation marker" ) + for issue in _mirror_problems(skill_dir, MIRROR / skill_dir.name): + problems.append(f".claude/skills/{skill_dir.name}: {issue}") if len(names) != len(set(names)): problems.append("skills: duplicate skill names") @@ -149,6 +213,7 @@ def check() -> list[str]: "(run scripts/sync_skills.py)" ) problems += _guard_selftest() + problems += _mirror_selftest() return problems diff --git a/scripts/check_tool_pinning.py b/scripts/check_tool_pinning.py index 8100590..07cdaf5 100644 --- a/scripts/check_tool_pinning.py +++ b/scripts/check_tool_pinning.py @@ -21,7 +21,7 @@ import sys from typing import Any -from _workflow_yaml import get_on, load_yaml, workflow_files +from ci_workflows_tools._workflow_yaml import get_on, load_yaml, workflow_files # `${VAR:+@$VAR}` / `${VAR:+@${VAR}}` — a conditional "@version" that expands to # nothing (→ latest) when VAR is empty. diff --git a/scripts/check_tool_registry.py b/scripts/check_tool_registry.py index a8a9f40..5ab251a 100644 --- a/scripts/check_tool_registry.py +++ b/scripts/check_tool_registry.py @@ -27,8 +27,8 @@ from collections import defaultdict from pathlib import Path -from _strict_yaml import strict_load -from _workflow_yaml import REPO_ROOT, load_yaml, workflow_files +from ci_workflows_tools._strict_yaml import strict_load +from ci_workflows_tools._workflow_yaml import REPO_ROOT, load_yaml, workflow_files TOOLS = REPO_ROOT / "catalog" / "tools.yml" # Calls to this repository's own reusables are not third-party tools. @@ -167,5 +167,4 @@ def main() -> int: if __name__ == "__main__": - sys.path.insert(0, str(Path(__file__).resolve().parent)) raise SystemExit(main()) diff --git a/scripts/check_workflow_contracts.py b/scripts/check_workflow_contracts.py index ec91061..516224e 100644 --- a/scripts/check_workflow_contracts.py +++ b/scripts/check_workflow_contracts.py @@ -16,8 +16,8 @@ import shlex import sys -from _runners import is_standard_hosted, resolve_runner_labels -from _workflow_yaml import SELF_WORKFLOWS, get_on, is_reusable, load_yaml, workflow_files +from ci_workflows_tools._runners import is_standard_hosted, resolve_runner_labels +from ci_workflows_tools._workflow_yaml import SELF_WORKFLOWS, get_on, is_reusable, load_yaml, workflow_files def check() -> list[str]: diff --git a/scripts/compile_evidence_plan.py b/scripts/compile_evidence_plan.py index 30a012e..a0e961b 100644 --- a/scripts/compile_evidence_plan.py +++ b/scripts/compile_evidence_plan.py @@ -10,8 +10,8 @@ from pathlib import Path from typing import Any -from _strict_yaml import strict_load -from _workflow_yaml import get_on +from ci_workflows_tools._strict_yaml import strict_load +from ci_workflows_tools._workflow_yaml import get_on REPO_ROOT = Path(__file__).resolve().parent.parent CATALOG = REPO_ROOT / "catalog" / "evidence-orchestration.yml" diff --git a/scripts/generate_docs.py b/scripts/generate_docs.py index 4a37ac1..2ce787a 100644 --- a/scripts/generate_docs.py +++ b/scripts/generate_docs.py @@ -11,9 +11,9 @@ from pathlib import Path from typing import Any -from _strict_yaml import strict_load +from ci_workflows_tools._strict_yaml import strict_load -from _workflow_yaml import SELF_WORKFLOWS +from ci_workflows_tools._workflow_yaml import SELF_WORKFLOWS REPO_ROOT = Path(__file__).resolve().parent.parent CATALOG = REPO_ROOT / "catalog" / "capabilities.yml" @@ -357,7 +357,7 @@ def free_tier_matrix(facts: list[dict[str, Any]]) -> str: def profile_matrix(doc: dict[str, Any]) -> str: - import resolve_profile + from ci_workflows_tools import resolve_profile caps = _load_capabilities() profiles = doc.get("profiles") or [] matrix = doc.get("entitlement_matrix") or [] diff --git a/scripts/negative_gate_probe.py b/scripts/negative_gate_probe.py index 124d5b4..d993941 100755 --- a/scripts/negative_gate_probe.py +++ b/scripts/negative_gate_probe.py @@ -45,14 +45,16 @@ import argparse import os import re +import shlex import subprocess import sys import tempfile from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -from _strict_yaml import strict_load # noqa: E402 +from ci_workflows_tools._strict_yaml import strict_load # noqa: E402 +from ci_workflows_tools.check_python_execution_contract import ( + clean_environment, dependency_python_command, +) INPUT_EXPR = re.compile(r"\$\{\{\s*inputs\.([A-Za-z_][A-Za-z0-9_]*)\s*\}\}") CONTEXT_EXPR = re.compile(r"\$\{\{\s*([A-Za-z_][A-Za-z0-9_.]*)\s*\}\}") @@ -183,12 +185,39 @@ def main() -> int: "--input", action="append", default=[], metavar="NAME=VALUE", help="Value for a `${{ inputs.NAME }}` reference in the step's env.", ) + parser.add_argument( + "--python-input", action="append", default=[], metavar="NAME=ARGS", + help="Bind an input command to the exact repository interpreter. ARGS " + "is parsed as argv; the interpreter is never accepted from PATH " + "or caller text. The required pytest module must resolve inside " + "the same repository venv before either fixture runs.", + ) args = parser.parse_args() inputs = declared_defaults(Path(args.workflow)) for item in args.input: name, _, value = item.partition("=") + if not name or "=" not in item: + raise SystemExit("negative_gate_probe: --input requires NAME=VALUE") inputs[name] = value + ordinary_names = {item.partition("=")[0] for item in args.input} + python_names: set[str] = set() + for item in args.python_input: + name, separator, value = item.partition("=") + if not separator or not name or name in ordinary_names or name in python_names: + raise SystemExit( + "negative_gate_probe: --python-input requires one unique NAME=ARGS " + "binding that is not also supplied by --input" + ) + try: + inputs[name] = dependency_python_command(value, "pytest") + except ValueError as exc: + raise SystemExit(f"negative_gate_probe: Python fixture setup is NOT_PROVEN: {exc}") + python_names.add(name) + print( + f"python-fixture-binding: input={name} " + f"interpreter={shlex.split(inputs[name], posix=True)[0]} dependency=pytest" + ) contexts = {} for item in args.context: name, _, value = item.partition("=") @@ -213,10 +242,15 @@ def parse(pairs: list[str]) -> dict[str, str]: return out step = find_step(Path(args.workflow), args.job, args.step) + prepared_path = os.environ.get("PATH", "") + prepared_runner_temp = "" def env_for(overrides: dict[str, str]) -> dict[str, str]: merged = {**inputs, **overrides} - env = dict(os.environ) + env = clean_environment(inherit=("GH_TOKEN", "GITHUB_TOKEN")) + env["PATH"] = prepared_path + if prepared_runner_temp: + env["RUNNER_TEMP"] = prepared_runner_temp for key, value in (step.get("env") or {}).items(): env[key] = _resolve(str(value), merged, contexts) # Steps append to the job summary; give them somewhere harmless. @@ -238,7 +272,7 @@ def run_in(directory: str, body: str, env: dict[str, str]) -> int: with tempfile.TemporaryDirectory() as scratch: path_file = Path(scratch) / "github_path" path_file.touch() - prior_env = dict(os.environ) + prior_env = clean_environment(inherit=("GH_TOKEN", "GITHUB_TOKEN")) for key, value in (prior.get("env") or {}).items(): prior_env[key] = _resolve(str(value), inputs, contexts) prior_env.setdefault("GITHUB_STEP_SUMMARY", os.devnull) @@ -258,12 +292,9 @@ def run_in(directory: str, body: str, env: dict[str, str]) -> int: if line.strip() ] if added: - os.environ["PATH"] = os.pathsep.join( - added + [os.environ.get("PATH", "")] - ) + prepared_path = os.pathsep.join(added + [prepared_path]) print(f" PATH += {', '.join(added)}") - os.environ.setdefault("RUNNER_TEMP", scratch) - # env_for() copies os.environ, so both cases below see the tool. + prepared_runner_temp = scratch bad_code = run_in(args.bad, step["run"], env_for(parse(args.bad_input))) good_code = run_in(args.good, step["run"], env_for(parse(args.good_input))) return _verdict(label, args, bad_code, good_code) diff --git a/scripts/resolve_profile.py b/scripts/resolve_profile.py index 10dd4fe..3910cd2 100644 --- a/scripts/resolve_profile.py +++ b/scripts/resolve_profile.py @@ -7,9 +7,9 @@ repository's shape and it returns the profile, the workflows available in that mode, and the free substitute for every capability the mode does not entitle. - python3 scripts/resolve_profile.py --visibility private --plan enterprise-cloud \ + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch resolve_profile.py -- --visibility private --plan enterprise-cloud \ --code-security --secret-protection --code-quality - python3 scripts/resolve_profile.py --profile public-free-standalone + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch resolve_profile.py -- --profile public-free-standalone Availability is resolved per capability, not by moving the whole repository to a `private_paid` column when it buys one add-on. Code Security, Secret Protection @@ -26,7 +26,7 @@ from pathlib import Path from typing import Any -from _strict_yaml import strict_load +from ci_workflows_tools._strict_yaml import strict_load REPO_ROOT = Path(__file__).resolve().parent.parent PROFILES = REPO_ROOT / "catalog" / "profiles.yml" diff --git a/scripts/validate_all.py b/scripts/validate_all.py index fc84bc3..79e480b 100644 --- a/scripts/validate_all.py +++ b/scripts/validate_all.py @@ -28,10 +28,10 @@ are maintenance debt rather than a defect in someone's change. Usage: - python3 scripts/validate_all.py # everything (default) - python3 scripts/validate_all.py --tier core # what ci-gate blocks on - python3 scripts/validate_all.py --tier touched --changed-from origin/main - python3 scripts/validate_all.py --tier scheduled # the advisory sweep + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch \ + validate_all.py -- # everything (default) + .venv/bin/python -I -B scripts/check_python_execution_contract.py --launch \ + validate_all.py -- --tier core # blocking gate """ from __future__ import annotations @@ -41,43 +41,45 @@ import sys from pathlib import Path -sys.path.insert(0, str(Path(__file__).resolve().parent)) - -import _strict_yaml -import check_actionlint_contract -import check_benchmark_contract -import check_docs_links -import check_examples -import check_gate_contract -import check_harden_runner_contract -import check_merge_group -import check_monorepo_routing -import check_permissions -import check_public_docs -import check_runtime_requirements -import check_runner_routing -import check_secret_scan_contract -import check_scorecard_evidence_contract -import check_side_effect_fixture_contract -import compile_evidence_plan -import check_pinned_actions -import check_privileged_ref_guard -import check_pr_hygiene_contract -import check_release_graph -import check_release_promotion_gate -import check_release_supply_chain -import check_rulesets -import check_skills -import check_tool_pinning -import check_tool_registry -import check_workflow_contracts -import generate_docs -import resolve_profile -import render_runtime_evidence -import validate_catalog -import validate_product_facts -import validate_profiles -import validate_runtime_coverage +from ci_workflows_tools import ( + _strict_yaml, + check_actionlint_contract, + check_benchmark_contract, + check_docs_links, + check_examples, + check_gate_contract, + check_harden_runner_contract, + check_merge_group, + check_monorepo_routing, + check_permissions, + check_pinned_actions, + check_pr_hygiene_contract, + check_privileged_ref_guard, + check_public_docs, + check_python_execution_contract, + check_python_syntax, + check_release_graph, + check_release_promotion_gate, + check_release_supply_chain, + check_rulesets, + check_runner_routing, + check_runtime_requirements, + check_scorecard_evidence_contract, + check_secret_scan_contract, + check_side_effect_fixture_contract, + check_skills, + check_tool_pinning, + check_tool_registry, + check_workflow_contracts, + compile_evidence_plan, + generate_docs, + render_runtime_evidence, + resolve_profile, + validate_catalog, + validate_product_facts, + validate_profiles, + validate_runtime_coverage, +) REPO_ROOT = Path(__file__).resolve().parent.parent @@ -85,7 +87,9 @@ # because of the change in hand. CORE = [ # Parse integrity first: every later check reads these files. + ("python-syntax", check_python_syntax.check), ("strict-yaml", _strict_yaml.check), + ("python-execution-contract", check_python_execution_contract.check), ("pinned-actions", check_pinned_actions.check), ("tool-pinning", check_tool_pinning.check), ("tool-registry", check_tool_registry.check), @@ -145,12 +149,14 @@ def changed_paths(base: str | None, explicit: list[str]) -> set[str]: return set() merge_base = subprocess.run( ["git", "merge-base", "HEAD", base], - cwd=REPO_ROOT, capture_output=True, text=True, check=False, + cwd=REPO_ROOT, env=check_python_execution_contract.clean_environment(), + capture_output=True, text=True, check=False, ) ref = merge_base.stdout.strip() if merge_base.returncode == 0 else base diff = subprocess.run( ["git", "diff", "--name-only", f"{ref}...HEAD"], - cwd=REPO_ROOT, capture_output=True, text=True, check=False, + cwd=REPO_ROOT, env=check_python_execution_contract.clean_environment(), + capture_output=True, text=True, check=False, ) if diff.returncode != 0: # Fail closed. An unresolvable base means the change cannot be scoped, diff --git a/scripts/validate_catalog.py b/scripts/validate_catalog.py index 9645b0b..d553f0a 100644 --- a/scripts/validate_catalog.py +++ b/scripts/validate_catalog.py @@ -12,9 +12,9 @@ import yaml -from _strict_yaml import strict_load +from ci_workflows_tools._strict_yaml import strict_load -from _workflow_yaml import SELF_WORKFLOWS +from ci_workflows_tools._workflow_yaml import SELF_WORKFLOWS REPO_ROOT = Path(__file__).resolve().parent.parent CATALOG_DIR = REPO_ROOT / "catalog" diff --git a/scripts/validate_product_facts.py b/scripts/validate_product_facts.py index 7a0ec0a..23d4687 100644 --- a/scripts/validate_product_facts.py +++ b/scripts/validate_product_facts.py @@ -24,7 +24,7 @@ import yaml -from _strict_yaml import strict_load +from ci_workflows_tools._strict_yaml import strict_load REPO_ROOT = Path(__file__).resolve().parent.parent LEDGER = REPO_ROOT / "catalog" / "product-facts.yml" diff --git a/scripts/validate_profiles.py b/scripts/validate_profiles.py index 70c18b8..4444f10 100644 --- a/scripts/validate_profiles.py +++ b/scripts/validate_profiles.py @@ -24,7 +24,7 @@ from pathlib import Path from typing import Any -from _strict_yaml import strict_load +from ci_workflows_tools._strict_yaml import strict_load REPO_ROOT = Path(__file__).resolve().parent.parent PROFILES = REPO_ROOT / "catalog" / "profiles.yml" diff --git a/scripts/validate_runtime_coverage.py b/scripts/validate_runtime_coverage.py index c42a46e..96a875d 100644 --- a/scripts/validate_runtime_coverage.py +++ b/scripts/validate_runtime_coverage.py @@ -22,9 +22,9 @@ import yaml -from _strict_yaml import strict_load +from ci_workflows_tools._strict_yaml import strict_load -from _workflow_yaml import SELF_WORKFLOWS, WORKFLOWS_DIR +from ci_workflows_tools._workflow_yaml import SELF_WORKFLOWS, WORKFLOWS_DIR REPO_ROOT = Path(__file__).resolve().parent.parent COVERAGE = REPO_ROOT / "catalog" / "runtime-coverage.yml" diff --git a/scripts/verify_scorecard_runtime.py b/scripts/verify_scorecard_runtime.py index c4d4410..0635f9d 100644 --- a/scripts/verify_scorecard_runtime.py +++ b/scripts/verify_scorecard_runtime.py @@ -19,10 +19,10 @@ import yaml -sys.path.insert(0, str(Path(__file__).resolve().parent)) -from _strict_yaml import strict_load # noqa: E402 -from _workflow_yaml import REPO_ROOT # noqa: E402 -from check_scorecard_evidence_contract import verify_proof, verify_sarif_contract # noqa: E402 +from ci_workflows_tools._strict_yaml import strict_load # noqa: E402 +from ci_workflows_tools._workflow_yaml import REPO_ROOT # noqa: E402 +from ci_workflows_tools.check_scorecard_evidence_contract import verify_proof, verify_sarif_contract # noqa: E402 +from ci_workflows_tools.check_python_execution_contract import clean_environment # noqa: E402 def fail(message: str) -> None: @@ -32,7 +32,8 @@ def fail(message: str) -> None: def api(endpoint: str) -> Any: result = subprocess.run( - ["gh", "api", endpoint], cwd=REPO_ROOT, text=True, + ["gh", "api", endpoint], cwd=REPO_ROOT, + env=clean_environment(inherit=("GH_HOST", "GH_TOKEN")), text=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if result.returncode != 0: @@ -46,6 +47,7 @@ def api(endpoint: str) -> Any: def api_bytes(endpoint: str) -> bytes: result = subprocess.run( ["gh", "api", endpoint], cwd=REPO_ROOT, + env=clean_environment(inherit=("GH_HOST", "GH_TOKEN")), stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=False, ) if result.returncode != 0: