diff --git a/.github/workflows/quality-gates.yml b/.github/workflows/quality-gates.yml index 4f1ed29..2e94d8b 100644 --- a/.github/workflows/quality-gates.yml +++ b/.github/workflows/quality-gates.yml @@ -87,9 +87,10 @@ jobs: working-directory: docs run: pnpm run build - candidate-channel: - name: Build exact Candidate channel + candidate-base: + name: Build Candidate base runs-on: ubuntu-24.04 + timeout-minutes: 20 env: CANDIDATE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} steps: @@ -101,36 +102,192 @@ jobs: uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 with: python-version: '3.12' - - name: Install pinned build and lock tooling - run: python -m pip --disable-pip-version-check install 'build==1.2.2.post1' 'uv==0.12.5' + - name: Install pinned build and lock tooling from the fixed public source + run: >- + env -i PATH="$PATH" HOME="${RUNNER_TEMP}/candidate-home" + PIP_CONFIG_FILE=/dev/null PIP_DISABLE_PIP_VERSION_CHECK=1 PYTHONNOUSERSITE=1 + python -I -m pip --isolated install --no-input --no-cache-dir --only-binary=:all: + --index-url https://pypi.org/simple 'build==1.2.2.post1' 'uv==0.12.5' - name: Build all Candidate wheels run: | python -m build --wheel --outdir dist agent-contract python -m build --wheel --outdir dist appium-mcp-server python -m build --wheel --outdir dist pywinauto-mcp-server python -m build --wheel --outdir dist autogenesis-cli + - name: Bind platform-qualified local Candidate roots + run: >- + python agent-contract/tools/candidate_wheelhouse.py requirements + --wheel-dir dist + --output-file candidate-requirements.in - name: Resolve one universal hashed dependency lock run: | - printf '%s\n' dist/*.whl > candidate-requirements.in - uv pip compile --universal --generate-hashes \ - --no-emit-package autogenesis-agent-contract \ - --no-emit-package autogenesis-appium-mcp \ - --no-emit-package autogenesis-windows-mcp \ - --no-emit-package autogenesis-cli \ - candidate-requirements.in --output-file candidate-dependencies.lock + env -i PATH="$PATH" HOME="${RUNNER_TEMP}/candidate-home" \ + UV_NO_CONFIG=1 UV_NO_CACHE=1 \ + uv pip compile --no-config --no-cache --universal --generate-hashes \ + --default-index https://pypi.org/simple \ + --no-emit-package autogenesis-agent-contract \ + --no-emit-package autogenesis-appium-mcp \ + --no-emit-package autogenesis-windows-mcp \ + --no-emit-package autogenesis-cli \ + candidate-requirements.in --output-file candidate-dependencies.lock + - name: Bind Candidate base provenance + run: >- + python agent-contract/tools/candidate_wheelhouse.py base + --wheel-dir dist + --dependency-lock candidate-dependencies.lock + --output-dir candidate-base + --commit "${{ env.CANDIDATE_COMMIT }}" + --workflow-run-id "${{ github.run_id }}" + --workflow-run-attempt "${{ github.run_attempt }}" + - name: Upload same-run Candidate base + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: autogenesis-candidate-base-${{ github.run_id }}-${{ github.run_attempt }}-${{ env.CANDIDATE_COMMIT }} + path: candidate-base + if-no-files-found: error + retention-days: 1 + + candidate-wheelhouses: + name: Acquire ${{ matrix.target }} wheelhouse + needs: candidate-base + runs-on: ${{ matrix.os }} + timeout-minutes: 25 + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64-py311 + os: macos-26 + python: '3.11' + - target: win32-amd64-py312 + os: windows-2022 + python: '3.12' + env: + CANDIDATE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - name: Checkout exact Candidate commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ env.CANDIDATE_COMMIT }} + - name: Setup exact target Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python }} + - name: Download same-run Candidate base + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: autogenesis-candidate-base-${{ github.run_id }}-${{ github.run_attempt }}-${{ env.CANDIDATE_COMMIT }} + path: candidate-base + - name: Acquire and verify exact target wheelhouse + run: >- + python agent-contract/tools/candidate_wheelhouse.py acquire + --base-dir candidate-base + --target "${{ matrix.target }}" + --output-dir candidate-wheelhouse + --commit "${{ env.CANDIDATE_COMMIT }}" + --workflow-run-id "${{ github.run_id }}" + --workflow-run-attempt "${{ github.run_attempt }}" + - name: Upload same-run target wheelhouse + uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: autogenesis-candidate-wheelhouse-${{ matrix.target }}-${{ github.run_id }}-${{ github.run_attempt }}-${{ env.CANDIDATE_COMMIT }} + path: candidate-wheelhouse + if-no-files-found: error + retention-days: 1 + + candidate-channel: + name: Build exact Candidate channel + needs: candidate-wheelhouses + runs-on: ubuntu-24.04 + timeout-minutes: 15 + env: + CANDIDATE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - name: Checkout exact Candidate commit + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ env.CANDIDATE_COMMIT }} + - name: Setup Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: '3.12' + - name: Download same-run Candidate base + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: autogenesis-candidate-base-${{ github.run_id }}-${{ github.run_attempt }}-${{ env.CANDIDATE_COMMIT }} + path: candidate-base + - name: Download macOS target wheelhouse + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: autogenesis-candidate-wheelhouse-darwin-arm64-py311-${{ github.run_id }}-${{ github.run_attempt }}-${{ env.CANDIDATE_COMMIT }} + path: wheelhouse-darwin + - name: Download Windows target wheelhouse + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: autogenesis-candidate-wheelhouse-win32-amd64-py312-${{ github.run_id }}-${{ github.run_attempt }}-${{ env.CANDIDATE_COMMIT }} + path: wheelhouse-windows - name: Build commit-bound Candidate bundle run: >- python agent-contract/tools/build_candidate_release.py - --wheel-dir dist + --base-dir candidate-base + --darwin-wheelhouse wheelhouse-darwin + --windows-wheelhouse wheelhouse-windows --output-dir candidate-channel --base-url . - --commit "${CANDIDATE_COMMIT}" - --dependency-lock candidate-dependencies.lock + --commit "${{ env.CANDIDATE_COMMIT }}" --workflow-run-id "${{ github.run_id }}" --workflow-run-attempt "${{ github.run_attempt }}" - name: Upload exact Candidate channel + id: candidate-upload uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: autogenesis-candidate-${{ github.run_id }}-${{ github.run_attempt }}-${{ env.CANDIDATE_COMMIT }} path: candidate-channel if-no-files-found: error + retention-days: 14 + - name: Record final Candidate service identity + run: | + artifact_id='${{ steps.candidate-upload.outputs.artifact-id }}' + artifact_digest='${{ steps.candidate-upload.outputs.artifact-digest }}' + [[ "$artifact_id" =~ ^[1-9][0-9]*$ ]] + [[ "$artifact_digest" =~ ^(sha256:)?[0-9a-f]{64}$ ]] + printf 'Candidate artifact ID: `%s`\n\nCandidate artifact service digest: `%s`\n' \ + "$artifact_id" "$artifact_digest" >> "$GITHUB_STEP_SUMMARY" + + candidate-offline-install: + name: Offline install ${{ matrix.target }} Candidate + needs: candidate-channel + runs-on: ${{ matrix.os }} + timeout-minutes: 20 + strategy: + fail-fast: false + matrix: + include: + - target: darwin-arm64-py311 + os: macos-26 + python: '3.11' + - target: win32-amd64-py312 + os: windows-2022 + python: '3.12' + env: + CANDIDATE_COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + steps: + - name: Checkout exact Candidate verifier + uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4.4.0 + with: + ref: ${{ env.CANDIDATE_COMMIT }} + - name: Setup exact target Python + uses: actions/setup-python@a26af69be951a213d495a4c3e4e4022e16d87065 # v5 + with: + python-version: ${{ matrix.python }} + - name: Download final Candidate artifact + uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 + with: + name: autogenesis-candidate-${{ github.run_id }}-${{ github.run_attempt }}-${{ env.CANDIDATE_COMMIT }} + path: candidate-channel + - name: Prove network-denied built-artifact install + run: >- + python agent-contract/tools/candidate_wheelhouse.py offline-install + --candidate-dir candidate-channel + --target "${{ matrix.target }}" + --environment-dir "${{ runner.temp }}/autogenesis-candidate-venv" diff --git a/agent-contract/README.md b/agent-contract/README.md index f56aeea..27e5d78 100644 --- a/agent-contract/README.md +++ b/agent-contract/README.md @@ -106,11 +106,32 @@ never returns appendix text or a user's absolute project path. Its schema is Candidate and public generators share one fail-closed installation contract. The Candidate prompt is externally anchored to one canonical repository, positive Actions run and attempt, head commit, attempt-qualified name, and exactly one unexpired -immutable artifact with a positive observed artifact ID. The public prompt is anchored -to the exact canonical immutable GitHub Release ID, tag, commit, and asset set. The -builders accept only local `.` for Candidate assets and the exact canonical GitHub -Release URLs for public assets. `SHA256SUMS` is generated after the final prompt and -covers every shipped asset except itself; self-consistent copied bytes are insufficient. +immutable artifact with a positive observed artifact ID. Before extraction, the +downloaded archive must also match GitHub's independently supplied positive artifact +service digest; internal checksums never substitute for this external anchor. Candidate +schema 3 contains one complete `darwin-arm64-py311` wheelhouse and one complete +`win32-amd64-py312` wheelhouse. Each target manifest binds normalized package/version, +wheel filename and tags, byte size, SHA-256, universal-lock digest, first-party wheel-set +digest, fixed `pypi.org`/`default-public` acquisition source, and same-run provenance. +The universal dependency lock is resolved from verified local first-party wheel paths; +the Windows Runtime root is qualified with `sys_platform == 'win32'` before resolution +so its unique transitive closure is absent from the macOS target. + +Acquisition runs in a newly created private empty home with null netrc, disabled pip +keyring, null pip configuration, no ambient proxy/source/cache/target/prefix/user +settings, and wheel-only strict hashes. Because the supported Python interface is a +minor version rather than one patch release, locks containing +`python_full_version` or `implementation_version` markers fail closed. Compatibility +uses packaging's CPython tag model; the macOS wheelhouse is bounded to deployment +version 15.0 and requires a trusted local macOS version of 15.0 or newer before target +selection. Compatible deployment tags through macOS 15, including `abi3` and +`universal2`, are accepted. + +The public prompt remains anchored to the exact canonical immutable GitHub Release ID, +tag, commit, and asset set. Builders accept only local `.` for Candidate assets and the +exact canonical GitHub Release URLs for public assets. `SHA256SUMS` is generated after +the final prompt and covers every shipped file at its canonical relative path except +itself; self-consistent copied bytes are insufficient. Each prompt selects one local private per-user parent with stable native identity, current-user ownership, and permissions/ACLs that deny every other OS identity @@ -142,22 +163,27 @@ minimal environment that omits all `PYTHON*` and startup customization. Ambient `PIP_*` and `UV_*` names are removed without reading their values as defensive scrubbing. Pip is the sole package manager for Candidate and public Release prompt installations; uv and other package managers are never invoked. Pip receives only -`PIP_CONFIG_FILE=/dev/null` on macOS or the exact pip sentinel `PIP_CONFIG_FILE=nul` on Windows and always -runs with `--isolated`; failure to establish the platform null configuration stops -installation. Acquisition binds destination, staging, cache, authenticated -requirements/hashes, and the approved opaque source/auth interface to transaction -roots. Offline pip installation additionally fixes `--no-index`, `--no-cache-dir`, -`--find-links`, `--require-hashes`, and `--no-deps` and targets only the reserved -venv. No ambient user/global configuration, source, cache, target, prefix, or -credentials are inherited. All subsequent operations use absolute disposable- -environment entrypoints while the phase-boundary checks continue to pass. - -Before echo/use, source metadata must be a canonical lowercase ASCII DNS hostname, -canonical IP literal, and one finite class: `managed-policy`, `user-approved`, -`default-public`, or `undisclosed`. Interface trust is independent, not self-attested. -Invalid metadata is never echoed and becomes `undisclosed`, stopping installation. -Full URLs, credentials, environment/config values, errors, output, and evidence are -never read for disclosure; redaction after reading is forbidden. +`PIP_CONFIG_FILE=/dev/null` on macOS or the exact pip sentinel `PIP_CONFIG_FILE=nul` on +Windows and always runs with `--isolated`; failure to establish the platform null +configuration stops installation. + +Candidate installation proves native host architecture, CPython minor version, and +exactly one matching target manifest, then uses only that authenticated local +wheelhouse with `--no-index`, `--no-cache-dir`, `--only-binary=:all:`, `--find-links`, +`--require-hashes`, `--no-deps`, and `--force-reinstall`. It never requests or reads a +package source, mirror, proxy, credential, configuration, cache, source tree, VCS +checkout, sdist, editable input, or installed-distribution substitute after the exact +Actions artifact is obtained. The public Release prompt retains its approved opaque +source/auth acquisition before switching to the same offline installation policy. All +subsequent operations use absolute disposable-environment entrypoints while the +phase-boundary checks continue to pass. + +For public Release acquisition only, source metadata must be a canonical lowercase +ASCII DNS hostname or canonical IP literal and one finite class: `managed-policy`, +`user-approved`, `default-public`, or `undisclosed`. Interface trust is independent, +not self-attested. Invalid metadata is never echoed and becomes `undisclosed`, stopping +installation. Full URLs, credentials, environment/config values, errors, output, and +evidence are never read for disclosure; redaction after reading is forbidden. ## Integrity and diagnostics diff --git a/agent-contract/evals/skill_cases.json b/agent-contract/evals/skill_cases.json index fd55d2c..a31e510 100644 --- a/agent-contract/evals/skill_cases.json +++ b/agent-contract/evals/skill_cases.json @@ -56,8 +56,8 @@ "references": ["install"], "root_context": {"platform": "posix", "parent": "/private"}, "user_request": "Follow this authenticated Candidate Agent Install Prompt.", - "stimuli": ["The Candidate external anchor, artifact identities and hashes, package-source host and config-source class, supported native host, exact project and Coding Agent, selected base Python, package policy, Skill scope, MCP destination, rollback, and verification effects are authenticated and ready for planning. A verified local private per-user parent is owned by the current user, has a stable native identity, denies every other OS identity write/create/delete/delete-child control, is not network-backed, and has no symlink/reparse component. No installation path has been chosen and the user has not approved a plan. An ambient project environment with PYTHONPATH/sitecustomize and pip/uv source/config/cache/target/prefix/user override variables is active."], - "expect": {"cryptographic_randomness_bits_minimum": 128, "one_exact_path_for_this_installation": true, "exact_path_in_immutable_plan": true, "reserve_before_approval": false, "reuse_existing_environment": false, "ambient_environment_mutation": false, "isolated_base_python": true, "explicit_minimal_child_environment": true, "inherit_python_or_package_overrides": false, "approval_requests": 1} + "stimuli": ["The Candidate external anchor, artifact identities and hashes, both exact target-wheelhouse manifests, complete checksum closure, supported native host, exact project and Coding Agent, selected base Python, package policy, Skill scope, MCP destination, rollback, and verification effects are authenticated and ready for planning. No package source, mirror, proxy, credential, configuration, cache, or dependency-download network access is required or permitted. A verified local private per-user parent is owned by the current user, has a stable native identity, denies every other OS identity write/create/delete/delete-child control, is not network-backed, and has no symlink/reparse component. No installation path has been chosen and the user has not approved a plan. An ambient project environment with PYTHONPATH/sitecustomize and pip/uv source/config/cache/target/prefix/user override variables is active."], + "expect": {"cryptographic_randomness_bits_minimum": 128, "one_exact_path_for_this_installation": true, "exact_path_in_immutable_plan": true, "reserve_before_approval": false, "reuse_existing_environment": false, "ambient_environment_mutation": false, "isolated_base_python": true, "explicit_minimal_child_environment": true, "inherit_python_or_package_overrides": false, "package_source_access": false, "select_one_verified_target_wheelhouse": true, "approval_requests": 1} }, { "id": "install-prompt-private-parent-rejected", diff --git a/agent-contract/tests/test_candidate_wheelhouse.py b/agent-contract/tests/test_candidate_wheelhouse.py new file mode 100644 index 0000000..14fb2e9 --- /dev/null +++ b/agent-contract/tests/test_candidate_wheelhouse.py @@ -0,0 +1,457 @@ +import hashlib +import json +from pathlib import Path +import runpy +import shutil +import subprocess +import sys +import zipfile +from types import SimpleNamespace + +import pytest + + +COMMIT_RUN = ("1" * 40, "123456", "2") +EXPECTED = { + "autogenesis-agent-contract": "2.1.0", + "autogenesis-appium-mcp": "0.5.0", + "autogenesis-windows-mcp": "0.5.0", + "autogenesis-cli": "0.2.0", +} + + +@pytest.fixture +def wheelhouse_module(repo_root): + tools = str(repo_root / "agent-contract/tools") + sys.path.insert(0, tools) + try: + yield runpy.run_path(str(Path(tools) / "candidate_wheelhouse.py")) + finally: + sys.path.remove(tools) + + +def _wheel(root, package, version, tag="py3-none-any", build=None): + distribution = package.replace("-", "_").replace(".", "_") + build_part = f"-{build}" if build else "" + filename = f"{distribution}-{version}{build_part}-{tag}.whl" + metadata_root = f"{distribution}-{version}.dist-info" + with zipfile.ZipFile(root / filename, "w") as archive: + archive.writestr(f"{metadata_root}/METADATA", f"Name: {package}\nVersion: {version}\n\n") + archive.writestr(f"{metadata_root}/WHEEL", f"Wheel-Version: 1.0\nTag: {tag}\n") + return root / filename + + +def _fixture(tmp_path, module): + wheels = tmp_path / "first-party" + wheels.mkdir() + for package, version in EXPECTED.items(): + _wheel(wheels, package, version) + third_party = tmp_path / "third-party" + third_party.mkdir() + dependency = _wheel(third_party, "Any.IO", "4.12.0") + digest = hashlib.sha256(dependency.read_bytes()).hexdigest() + lock = tmp_path / "candidate-dependencies.lock" + lock.write_text(f"Any.IO==4.12.0 --hash=sha256:{digest}\n", encoding="utf-8") + base_dir = tmp_path / "base" + module["prepare_base"](wheels, lock, base_dir, *COMMIT_RUN) + base, lock_raw = module["verify_base"](base_dir, *COMMIT_RUN) + wheelhouses = {} + for target in module["TARGETS"]: + directory = tmp_path / target + directory.mkdir() + for row in base["wheels"]: + if module["TARGETS"][target]["sys_platform"] in row["platforms"]: + shutil.copyfile(base_dir / "wheels" / row["filename"], directory / row["filename"]) + shutil.copyfile(dependency, directory / dependency.name) + document = module["wheelhouse_document"]( + directory, base, lock_raw, target, *COMMIT_RUN + ) + (directory / module["WHEELHOUSE_MANIFEST"]).write_bytes(module["canonical_json"](document)) + wheelhouses[target] = directory + return base_dir, wheelhouses + + +def _checksums(root): + rows = {} + for line in (root / "SHA256SUMS").read_text(encoding="ascii").splitlines(): + digest, relative = line.split(" ", 1) + path = root / relative + assert path.is_file() + assert hashlib.sha256(path.read_bytes()).hexdigest() == digest + rows[relative] = digest + actual = { + path.relative_to(root).as_posix() + for path in root.rglob("*") + if path.is_file() and path.name != "SHA256SUMS" + } + assert set(rows) == actual + return rows + + +def test_exact_target_manifests_are_deterministic_and_complete(tmp_path, wheelhouse_module): + module = wheelhouse_module + base_dir, wheelhouses = _fixture(tmp_path, module) + base, lock_raw = module["verify_base"](base_dir, *COMMIT_RUN) + assert base["first_party_wheel_set_sha256"] == hashlib.sha256( + module["canonical_json"](base["wheels"]) + ).hexdigest() + for target, directory in wheelhouses.items(): + document, raw = module["verify_wheelhouse"]( + directory, base, lock_raw, target, *COMMIT_RUN + ) + assert raw == module["canonical_json"](document) + assert document["source"] == {"host": "pypi.org", "class": "default-public"} + assert document["target"]["key"] == target + assert {row["package"] for row in document["files"] if row["kind"] == "third-party"} == {"any-io"} + required_first_party = { + name for name, platforms in module["PLATFORMS"].items() + if module["TARGETS"][target]["sys_platform"] in platforms + } + assert {row["package"] for row in document["files"] if row["kind"] == "first-party"} == required_first_party + assert all(row["tags"] == ["py3-none-any"] for row in document["files"]) + + +def test_universal_lock_projects_exact_marker_closure(wheelhouse_module): + module = wheelhouse_module + raw = ( + f"common==1 --hash=sha256:{'a' * 64}\n" + f"mac-only==2 ; sys_platform == 'darwin' --hash=sha256:{'b' * 64}\n" + f"win-only==3 ; sys_platform == 'win32' --hash=sha256:{'c' * 64}\n" + f"old-python==4 ; python_version < '3.12' --hash=sha256:{'d' * 64}\n" + ).encode() + mac = module["target_lock"](raw, "darwin-arm64-py311") + windows = module["target_lock"](raw, "win32-amd64-py312") + assert set(mac) == {"common", "mac-only", "old-python"} + assert set(windows) == {"common", "win-only"} + rendered = module["render_target_lock"](mac) + assert module["target_lock"](rendered, "darwin-arm64-py311").keys() == mac.keys() + + +def test_platform_qualified_root_keeps_unique_windows_transitive_out_of_mac( + tmp_path, wheelhouse_module, +): + wheels = tmp_path / "dist" + wheels.mkdir() + for package, version in EXPECTED.items(): + _wheel(wheels, package, version) + requirements = wheelhouse_module["render_candidate_requirements"](wheels).decode().splitlines() + windows_root = next(line for line in requirements if "autogenesis_windows_mcp" in line) + assert windows_root.endswith(" ; sys_platform == 'win32'") + assert all(" ; " not in line for line in requirements if line != windows_root) + + propagated = ( + f"common==1 --hash=sha256:{'b' * 64}\n" + f"pympler==1.1 ; sys_platform == 'win32' --hash=sha256:{'a' * 64}\n" + ).encode() + assert "pympler" not in wheelhouse_module["target_lock"](propagated, "darwin-arm64-py311") + assert "pympler" in wheelhouse_module["target_lock"](propagated, "win32-amd64-py312") + + +@pytest.mark.parametrize("mutation", [ + "missing", "extra", "renamed", "hash", "wrong-target", "wrong-run", "wrong-attempt", + "wrong-commit", "self-consistent-substitution", +]) +def test_wheelhouse_mutation_and_provenance_mixing_fail_closed( + tmp_path, wheelhouse_module, mutation, +): + module = wheelhouse_module + base_dir, wheelhouses = _fixture(tmp_path, module) + base, lock_raw = module["verify_base"](base_dir, *COMMIT_RUN) + target = "darwin-arm64-py311" + directory = wheelhouses[target] + manifest_path = directory / module["WHEELHOUSE_MANIFEST"] + document = json.loads(manifest_path.read_bytes()) + dependency = next(path for path in directory.glob("Any_IO-*.whl")) + if mutation == "missing": + dependency.unlink() + elif mutation == "extra": + _wheel(directory, "unexpected", "1.0") + elif mutation == "renamed": + dependency.rename(directory / "renamed-4.12.0-py3-none-any.whl") + elif mutation == "hash": + dependency.write_bytes(dependency.read_bytes() + b"changed") + elif mutation == "wrong-target": + document["target"]["key"] = "win32-amd64-py312" + manifest_path.write_bytes(module["canonical_json"](document)) + elif mutation == "wrong-run": + document["provenance"]["workflow_run_id"] = "654321" + manifest_path.write_bytes(module["canonical_json"](document)) + elif mutation == "wrong-attempt": + document["provenance"]["workflow_run_attempt"] = "3" + manifest_path.write_bytes(module["canonical_json"](document)) + elif mutation == "wrong-commit": + document["provenance"]["commit"] = "2" * 40 + manifest_path.write_bytes(module["canonical_json"](document)) + else: + dependency.write_bytes(dependency.read_bytes() + b"substituted") + row = next(row for row in document["files"] if row["package"] == "any-io") + row["size"] = dependency.stat().st_size + row["sha256"] = hashlib.sha256(dependency.read_bytes()).hexdigest() + manifest_path.write_bytes(module["canonical_json"](document)) + with pytest.raises(module["WheelhouseError"]): + module["verify_wheelhouse"](directory, base, lock_raw, target, *COMMIT_RUN) + + +def test_stale_cross_attempt_candidate_base_fails_closed(tmp_path, wheelhouse_module): + module = wheelhouse_module + base_dir, _wheelhouses = _fixture(tmp_path, module) + manifest_path = base_dir / module["BASE_MANIFEST"] + document = json.loads(manifest_path.read_bytes()) + document["provenance"]["workflow_run_attempt"] = "1" + manifest_path.write_bytes(module["canonical_json"](document)) + with pytest.raises(module["WheelhouseError"], match="provenance"): + module["verify_base"](base_dir, *COMMIT_RUN) + + +def test_incompatible_and_duplicate_wheels_fail_closed(tmp_path, wheelhouse_module): + module = wheelhouse_module + base_dir, wheelhouses = _fixture(tmp_path, module) + base, _old_lock = module["verify_base"](base_dir, *COMMIT_RUN) + target = "darwin-arm64-py311" + directory = wheelhouses[target] + dependency = next(path for path in directory.glob("Any_IO-*.whl")) + dependency.unlink() + incompatible = _wheel(directory, "Any.IO", "4.12.0", "cp311-cp311-manylinux_2_17_x86_64") + digest = hashlib.sha256(incompatible.read_bytes()).hexdigest() + lock_raw = f"Any.IO==4.12.0 --hash=sha256:{digest}\n".encode() + with pytest.raises(module["WheelhouseError"], match="target-incompatible"): + module["wheelhouse_document"](directory, base, lock_raw, target, *COMMIT_RUN) + + incompatible.unlink() + first = _wheel(directory, "Any.IO", "4.12.0") + second = _wheel(directory, "Any.IO", "4.12.0", build="1") + hashes = " \\\n".join( + f" --hash=sha256:{hashlib.sha256(path.read_bytes()).hexdigest()}" + for path in (first, second) + ) + lock_raw = f"Any.IO==4.12.0 \\\n{hashes}\n".encode() + with pytest.raises(module["WheelhouseError"], match="duplicate package identity"): + module["wheelhouse_document"](directory, base, lock_raw, target, *COMMIT_RUN) + +def test_target_tags_accept_abi3_and_reject_newer_macos_or_wrong_architecture(wheelhouse_module): + compatible = wheelhouse_module["_tag_compatible"] + assert compatible("cp310-abi3-macosx_11_0_arm64", "darwin-arm64-py311") + assert compatible("cp310-abi3-macosx_11_0_universal2", "darwin-arm64-py311") + assert compatible("cp310-abi3-win_amd64", "win32-amd64-py312") + assert compatible("cp310-abi3-macosx_12_0_arm64", "darwin-arm64-py311") + assert not compatible("cp310-abi3-macosx_16_0_arm64", "darwin-arm64-py311") + assert not compatible("cp311-cp311-macosx_11_0_x86_64", "darwin-arm64-py311") + assert not compatible("cp312-cp312-win32", "win32-amd64-py312") + + + +@pytest.mark.parametrize("unsafe", [ + f"package @ https://example.invalid/a.whl --hash=sha256:{'a' * 64}\n", + f"-e ./package --hash=sha256:{'a' * 64}\n", + f"autogenesis-cli==0.2.0 --hash=sha256:{'a' * 64}\n", + f"package==1 ; python_full_version >= '3.11.1' --hash=sha256:{'a' * 64}\n", + f"package==1 ; implementation_version < '3.12.2' --hash=sha256:{'a' * 64}\n", + "package==1\n", +]) +def test_lock_rejects_vcs_editable_first_party_and_unhashed_inputs(wheelhouse_module, unsafe): + with pytest.raises(wheelhouse_module["WheelhouseError"]): + wheelhouse_module["target_lock"](unsafe.encode(), "darwin-arm64-py311") + + +def test_acquisition_uses_only_fixed_isolated_source_and_no_cache( + tmp_path, wheelhouse_module, monkeypatch, +): + module = wheelhouse_module + base_dir, wheelhouses = _fixture(tmp_path, module) + dependency = next(wheelhouses["darwin-arm64-py311"].glob("Any_IO-*.whl")) + captured = {} + + def download(command, **kwargs): + captured["command"] = command + captured["environment"] = kwargs["env"] + destination = Path(command[command.index("--dest") + 1]) + shutil.copyfile(dependency, destination / dependency.name) + return subprocess.CompletedProcess(command, 0) + + monkeypatch.setattr(module["subprocess"], "run", download) + poisoned_home = tmp_path / "poisoned-home" + poisoned_home.mkdir() + (poisoned_home / ".netrc").write_text("machine pypi.org login SYNTHETIC_USER password SYNTHETIC_SECRET\n") + monkeypatch.setenv("HOME", str(poisoned_home)) + monkeypatch.setenv("USERPROFILE", str(poisoned_home)) + monkeypatch.setenv("NETRC", str(poisoned_home / ".netrc")) + monkeypatch.setenv("PIP_KEYRING_PROVIDER", "import") + for name in ("PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CACHE_DIR", "HTTPS_PROXY", "UV_INDEX"): + monkeypatch.setenv(name, f"SYNTHETIC_{name}_SECRET") + monkeypatch.setattr(module["platform"], "machine", lambda: "arm64") + monkeypatch.setattr(module["sys"], "platform", "darwin") + monkeypatch.setattr(module["sys"], "version_info", SimpleNamespace(major=3, minor=11)) + monkeypatch.setattr(module["platform"], "mac_ver", lambda: ("15.7.1", ("", "", ""), "")) + output = tmp_path / "acquired" + module["acquire_wheelhouse"](base_dir, "darwin-arm64-py311", output, *COMMIT_RUN) + command = captured["command"] + assert command.count("--index-url") == 1 + assert command[command.index("--index-url") + 1] == "https://pypi.org/simple" + assert "--extra-index-url" not in command + assert all(flag in command for flag in ( + "--isolated", "--no-cache-dir", "--only-binary=:all:", "--require-hashes", "--no-deps", + )) + assert all(name not in captured["environment"] for name in ( + "PIP_INDEX_URL", "PIP_EXTRA_INDEX_URL", "PIP_CACHE_DIR", "HTTPS_PROXY", "UV_INDEX", + )) + assert captured["environment"]["HOME"] != str(poisoned_home) + assert captured["environment"]["USERPROFILE"] == captured["environment"]["HOME"] + assert captured["environment"]["NETRC"] == module["os"].devnull + assert captured["environment"]["PIP_KEYRING_PROVIDER"] == "disabled" + assert "SYNTHETIC_SECRET" not in repr(captured) + assert module["verify_wheelhouse"]( + output, module["verify_base"](base_dir, *COMMIT_RUN)[0], + (base_dir / module["LOCK_NAME"]).read_bytes(), "darwin-arm64-py311", *COMMIT_RUN, + )[0]["source"] == {"host": "pypi.org", "class": "default-public"} + + +@pytest.mark.parametrize("macos_version", ["15.0", "15.7.1", "16.0"]) +def test_supported_macos_deployment_selects_target(wheelhouse_module, monkeypatch, macos_version): + module = wheelhouse_module + monkeypatch.setattr(module["platform"], "machine", lambda: "arm64") + monkeypatch.setattr(module["platform"], "mac_ver", lambda: (macos_version, ("", "", ""), "")) + monkeypatch.setattr(module["sys"], "platform", "darwin") + monkeypatch.setattr(module["sys"], "version_info", SimpleNamespace(major=3, minor=11)) + module["_assert_target_host"]("darwin-arm64-py311") + + +def test_older_macos_deployment_fails_before_acquisition(wheelhouse_module, monkeypatch): + module = wheelhouse_module + monkeypatch.setattr(module["platform"], "machine", lambda: "arm64") + monkeypatch.setattr(module["platform"], "mac_ver", lambda: ("14.7", ("", "", ""), "")) + monkeypatch.setattr(module["sys"], "platform", "darwin") + monkeypatch.setattr(module["sys"], "version_info", SimpleNamespace(major=3, minor=11)) + with pytest.raises(module["WheelhouseError"], match="does not match"): + module["_assert_target_host"]("darwin-arm64-py311") + + +@pytest.mark.parametrize("target, machine, sys_platform, version", [ + ("darwin-arm64-py311", "x86_64", "darwin", (3, 11)), + ("darwin-arm64-py311", "arm64", "darwin", (3, 12)), + ("win32-amd64-py312", "arm64", "win32", (3, 12)), + ("win32-amd64-py312", "AMD64", "darwin", (3, 12)), +]) +def test_target_host_mismatch_fails_before_acquisition( + wheelhouse_module, monkeypatch, target, machine, sys_platform, version, +): + module = wheelhouse_module + monkeypatch.setattr(module["platform"], "machine", lambda: machine) + monkeypatch.setattr(module["sys"], "platform", sys_platform) + monkeypatch.setattr(module["sys"], "version_info", SimpleNamespace(major=version[0], minor=version[1])) + with pytest.raises(module["WheelhouseError"], match="does not match"): + module["_assert_target_host"](target) + + +def test_offline_pip_subprocess_denies_network_before_download(tmp_path, repo_root): + destination = tmp_path / "download" + destination.mkdir() + result = subprocess.run([ + sys.executable, + str(repo_root / "agent-contract/tools/candidate_wheelhouse.py"), + "_offline-pip", "download", "--retries", "0", "--timeout", "1", "--no-cache-dir", + "--dest", str(destination), "--index-url", "https://example.invalid/simple", + "autogenesis-network-denial-probe==0", + ], capture_output=True, text=True, check=False, timeout=30) + assert result.returncode != 0 + assert not list(destination.iterdir()) + + + +def test_candidate_builder_covers_nested_wheelhouses_and_offline_prompt( + tmp_path, repo_root, wheelhouse_module, +): + base_dir, wheelhouses = _fixture(tmp_path, wheelhouse_module) + source = tmp_path / "source" + source.mkdir() + for relative in ("agent-contract", "appium-mcp-server", "pywinauto-mcp-server", "autogenesis-cli"): + shutil.copytree(repo_root / relative, source / relative, ignore=shutil.ignore_patterns("__pycache__", ".pytest_cache", ".venv")) + subprocess.run(["git", "init", "-q", str(source)], check=True) + subprocess.run(["git", "-C", str(source), "config", "core.longpaths", "true"], check=True) + subprocess.run(["git", "-C", str(source), "add", "."], check=True) + subprocess.run([ + "git", "-C", str(source), "-c", "user.name=Candidate Contract Test", + "-c", "user.email=candidate-contract@example.invalid", "commit", "-qm", "fixture", + ], check=True) + repo_root = source + commit = subprocess.run( + ["git", "-C", str(repo_root), "rev-parse", "HEAD"], + check=True, capture_output=True, text=True, + ).stdout.strip() + # Bind fixtures to the actual clean checkout commit used by the builder. + base_manifest = json.loads((base_dir / wheelhouse_module["BASE_MANIFEST"]).read_bytes()) + base_manifest["provenance"] = wheelhouse_module["provenance"]( + commit, "123456", "2", f"autogenesis-candidate-base-123456-2-{commit}" + ) + (base_dir / wheelhouse_module["BASE_MANIFEST"]).write_bytes( + wheelhouse_module["canonical_json"](base_manifest) + ) + lock_raw = (base_dir / wheelhouse_module["LOCK_NAME"]).read_bytes() + for target, directory in wheelhouses.items(): + document = wheelhouse_module["wheelhouse_document"]( + directory, base_manifest, lock_raw, target, commit, "123456", "2" + ) + (directory / wheelhouse_module["WHEELHOUSE_MANIFEST"]).write_bytes( + wheelhouse_module["canonical_json"](document) + ) + output = tmp_path / "candidate" + result = subprocess.run([ + sys.executable, + str(repo_root / "agent-contract/tools/build_candidate_release.py"), + "--base-dir", str(base_dir), + "--darwin-wheelhouse", str(wheelhouses["darwin-arm64-py311"]), + "--windows-wheelhouse", str(wheelhouses["win32-amd64-py312"]), + "--output-dir", str(output), + "--base-url", ".", + "--commit", commit, + "--workflow-run-id", "123456", + "--workflow-run-attempt", "2", + ], cwd=repo_root, capture_output=True, text=True, check=False, timeout=30) + assert result.returncode == 0, result.stderr + manifest_raw = (output / "candidate-manifest.json").read_bytes() + manifest = json.loads(manifest_raw) + assert manifest["schema_version"] == 3 + assert manifest["third_party_policy"] == "bundled-exact-target-wheelhouses-no-index" + assert [row["target"]["key"] for row in manifest["wheelhouses"]] == list(wheelhouse_module["TARGETS"]) + for wheelhouse in manifest["wheelhouses"]: + assert wheelhouse["manifest"]["path"].startswith(f"wheelhouse/{wheelhouse['target']['key']}/") + assert all((output / row["path"]).is_file() for row in wheelhouse["files"]) + checksums = _checksums(output) + assert "wheelhouse/darwin-arm64-py311/wheelhouse-manifest.json" in checksums + assert "wheelhouse/win32-amd64-py312/wheelhouse-manifest.json" in checksums + wheelhouse_module["verify_checksum_closure"](output) + prompt = (output / "candidate-install-prompt.txt").read_text(encoding="utf-8") + assert "No package-source disclosure or network acquisition is required or permitted" in prompt + assert "select exactly one compatible target" in prompt + assert "independently supplied positive GitHub artifact service digest" in prompt + assert "verify the archive digest before extraction" in prompt + assert "--find-links " in prompt + assert "package index, mirror, proxy, credential" in prompt + assert hashlib.sha256(manifest_raw).hexdigest() in prompt + + +@pytest.mark.parametrize("mutation", ["extra", "missing", "changed", "self-entry"]) +def test_candidate_checksum_closure_rejects_every_mutation( + tmp_path, wheelhouse_module, mutation, +): + candidate = tmp_path / "candidate" + candidate.mkdir() + (candidate / "nested").mkdir() + (candidate / "one").write_bytes(b"one") + (candidate / "nested/two").write_bytes(b"two") + rows = [ + f"{hashlib.sha256((candidate / relative).read_bytes()).hexdigest()} {relative}\n" + for relative in ("nested/two", "one") + ] + (candidate / "SHA256SUMS").write_text("".join(rows), encoding="ascii") + if mutation == "extra": + (candidate / "extra").write_bytes(b"extra") + elif mutation == "missing": + (candidate / "nested/two").unlink() + elif mutation == "changed": + (candidate / "one").write_bytes(b"changed") + else: + digest = hashlib.sha256((candidate / "SHA256SUMS").read_bytes()).hexdigest() + with (candidate / "SHA256SUMS").open("a", encoding="ascii") as stream: + stream.write(f"{digest} SHA256SUMS\n") + with pytest.raises(wheelhouse_module["WheelhouseError"]): + wheelhouse_module["verify_checksum_closure"](candidate) diff --git a/agent-contract/tests/test_skills.py b/agent-contract/tests/test_skills.py index ab55be0..8ea6961 100644 --- a/agent-contract/tests/test_skills.py +++ b/agent-contract/tests/test_skills.py @@ -447,6 +447,7 @@ def test_skill_release_assets_match_contract_and_bootstrap_prompt(tmp_path, repo assert "generateScenarioText" not in command_handlers assert "generateBackgroundText" not in command_handlers + package = json.loads((repo_root / "bdd_ai_toolkit/package.json").read_bytes()) configuration = package["contributes"].get("configuration", {}).get("properties", {}) assert "bddAiToolkit.cucumber.copilotPrompt" not in configuration @@ -480,189 +481,6 @@ def test_skill_release_assets_match_contract_and_bootstrap_prompt(tmp_path, repo assert "preview destinations" in project_root["description"] assert "no generated-file write occurs" in project_root["description"] -def _candidate_wheel(root, package, version): - filename = f"{package.replace('-', '_')}-{version}-py3-none-any.whl" - metadata_root = f"{package.replace('-', '_')}-{version}.dist-info" - with zipfile.ZipFile(root / filename, "w") as archive: - archive.writestr(f"{metadata_root}/METADATA", f"Name: {package}\nVersion: {version}\n\n") - archive.writestr(f"{metadata_root}/WHEEL", "Wheel-Version: 1.0\nTag: py3-none-any\n") - return filename - - -def test_candidate_channel_binds_exact_first_party_wheels_and_skill(tmp_path, repo_root): - wheel_dir = tmp_path / "wheels" - wheel_dir.mkdir() - expected = { - "autogenesis-agent-contract": "2.1.0", - "autogenesis-appium-mcp": "0.5.0", - "autogenesis-windows-mcp": "0.5.0", - "autogenesis-cli": "0.2.0", - } - for package, version in expected.items(): - _candidate_wheel(wheel_dir, package, version) - dependency_lock = tmp_path / "candidate-dependencies.lock" - dependency_lock.write_text( - f"anyio==4.12.0 --hash=sha256:{'b' * 64}\n", encoding="utf-8" - ) - commit = subprocess.run( - ["git", "-C", str(repo_root), "rev-parse", "HEAD"], - check=True, capture_output=True, text=True, - ).stdout.strip() - output = tmp_path / "candidate" - result = subprocess.run( - [ - sys.executable, - str(repo_root / "agent-contract/tools/build_candidate_release.py"), - "--wheel-dir", str(wheel_dir), - "--output-dir", str(output), - "--base-url", ".", - "--commit", commit, - "--dependency-lock", str(dependency_lock), - "--workflow-run-id", "123456", - "--workflow-run-attempt", "2", - ], - cwd=repo_root, - capture_output=True, - text=True, - check=False, - timeout=10, - ) - assert result.returncode == 0, result.stderr - manifest_raw = (output / "candidate-manifest.json").read_bytes() - manifest = json.loads(manifest_raw) - assert manifest["schema_version"] == 2 - assert manifest["channel"] == "candidate" and manifest["commit"] == commit - assert manifest["provenance"] == { - "repository": "FSQ-lab/AutoGenesis", - "workflow_run_id": "123456", - "workflow_run_attempt": "2", - "artifact": f"autogenesis-candidate-123456-2-{commit}", - } - assert manifest["first_party_index_fallback"] is False - assert manifest["third_party_policy"] == "sanitized-source-with-release-hash-lock" - assert {wheel["package"]: wheel["version"] for wheel in manifest["wheels"]} == expected - platforms = {wheel["package"]: wheel["platforms"] for wheel in manifest["wheels"]} - assert platforms["autogenesis-windows-mcp"] == ["win32"] - assert all("win32" in values for values in platforms.values()) - assert all("darwin" in values for package, values in platforms.items() if package != "autogenesis-windows-mcp") - for wheel in manifest["wheels"]: - raw = (output / wheel["filename"]).read_bytes() - assert len(raw) == wheel["size"] - assert hashlib.sha256(raw).hexdigest() == wheel["sha256"] - assert wheel["url"].endswith("/" + wheel["filename"]) - skill = (output / "autogenesis-SKILL.md").read_bytes() - assert hashlib.sha256(skill).hexdigest() == manifest["skill"]["sha256"] - for lock in manifest["dependency_locks"].values(): - raw = (output / lock["filename"]).read_bytes() - assert len(raw) == lock["size"] - assert hashlib.sha256(raw).hexdigest() == lock["sha256"] - prompt = (output / "candidate-install-prompt.txt").read_text(encoding="utf-8") - assert hashlib.sha256(manifest_raw).hexdigest() in prompt - install_lock = (output / "candidate-install.lock").read_text(encoding="utf-8") - assert "autogenesis-windows-mcp==0.5.0 ; sys_platform == 'win32'" in install_lock - assert "never resolve or replace a first-party candidate package from an index" in prompt.lower() - assert "complete offline-install lock" in prompt - - assert "force-reinstalled" in prompt - _assert_hardened_install_prompt(prompt, "Candidate") - assert "attempt-qualified artifact name" in prompt - assert "positive run attempt `2`" in prompt - assert "positive observed artifact ID" in prompt - assert "exactly one unexpired" in prompt - assert "superseded run 42843125727" in prompt - candidate_checksums = _checksums(output) - assert set(candidate_checksums) == {path.name for path in output.iterdir()} - {"SHA256SUMS"} - assert "candidate-install-prompt.txt" in candidate_checksums - (output / "candidate-install-prompt.txt").write_text(prompt + "mutated", encoding="utf-8") - with pytest.raises(AssertionError): - _checksums(output) - -def test_candidate_channel_rejects_incomplete_wheel_set(tmp_path, repo_root): - wheel_dir = tmp_path / "wheels" - wheel_dir.mkdir() - _candidate_wheel(wheel_dir, "autogenesis-agent-contract", "2.1.0") - dependency_lock = tmp_path / "candidate-dependencies.lock" - dependency_lock.write_text( - f"anyio==4.12.0 --hash=sha256:{'b' * 64}\n", encoding="utf-8" - ) - commit = subprocess.run( - ["git", "-C", str(repo_root), "rev-parse", "HEAD"], - check=True, capture_output=True, text=True, - ).stdout.strip() - result = subprocess.run( - [ - sys.executable, - str(repo_root / "agent-contract/tools/build_candidate_release.py"), - "--wheel-dir", str(wheel_dir), - "--output-dir", str(tmp_path / "candidate"), - "--base-url", ".", - "--commit", commit, - "--dependency-lock", str(dependency_lock), - "--workflow-run-id", "123456", - "--workflow-run-attempt", "2", - ], - cwd=repo_root, - capture_output=True, - text=True, - check=False, - timeout=10, - ) - assert result.returncode != 0 - assert "Candidate wheel set is incomplete" in result.stderr - -def test_candidate_channel_rejects_unhashed_dependency_lock(tmp_path, repo_root): - wheel_dir = tmp_path / "wheels" - wheel_dir.mkdir() - for package, version in { - "autogenesis-agent-contract": "2.1.0", - "autogenesis-appium-mcp": "0.5.0", - "autogenesis-windows-mcp": "0.5.0", - "autogenesis-cli": "0.2.0", - }.items(): - _candidate_wheel(wheel_dir, package, version) - dependency_lock = tmp_path / "candidate-dependencies.lock" - dependency_lock.write_text("anyio==4.12.0\n", encoding="utf-8") - commit = subprocess.run( - ["git", "-C", str(repo_root), "rev-parse", "HEAD"], - check=True, capture_output=True, text=True, - ).stdout.strip() - result = subprocess.run( - [ - sys.executable, - str(repo_root / "agent-contract/tools/build_candidate_release.py"), - "--wheel-dir", str(wheel_dir), - "--output-dir", str(tmp_path / "candidate"), - "--base-url", ".", - "--commit", commit, - "--dependency-lock", str(dependency_lock), - "--workflow-run-id", "123456", - "--workflow-run-attempt", "2", - ], - cwd=repo_root, capture_output=True, text=True, check=False, timeout=10, - ) - assert result.returncode != 0 - assert "dependency lock contains an unpinned or unsafe entry" in result.stderr - - -@pytest.mark.parametrize("attempt", ["0", "-1", "not-a-number"]) -def test_candidate_channel_requires_positive_workflow_run_attempt(tmp_path, repo_root, attempt): - commit = subprocess.run( - ["git", "-C", str(repo_root), "rev-parse", "HEAD"], - check=True, capture_output=True, text=True, - ).stdout.strip() - result = subprocess.run([ - sys.executable, - str(repo_root / "agent-contract/tools/build_candidate_release.py"), - "--wheel-dir", str(tmp_path / "unused-wheels"), - "--output-dir", str(tmp_path / "candidate"), - "--base-url", ".", - "--commit", commit, - "--dependency-lock", str(tmp_path / "unused.lock"), - "--workflow-run-id", "123456", - "--workflow-run-attempt", attempt, - ], cwd=repo_root, capture_output=True, text=True, check=False, timeout=10) - assert result.returncode != 0 - assert "run attempt must be a positive integer" in result.stderr @pytest.mark.parametrize("unsafe", [ "https://user:SYNTHETIC_URL_SECRET@example.invalid/release", @@ -691,11 +509,12 @@ def test_release_generators_reject_secret_bearing_asset_locations_without_echo( command = [ sys.executable, str(repo_root / "agent-contract/tools/build_candidate_release.py"), - "--wheel-dir", str(tmp_path / "unused-wheels"), + "--base-dir", str(tmp_path / "unused-base"), + "--darwin-wheelhouse", str(tmp_path / "unused-darwin"), + "--windows-wheelhouse", str(tmp_path / "unused-windows"), "--output-dir", str(output), "--base-url", unsafe, "--commit", commit, - "--dependency-lock", str(tmp_path / "unused.lock"), "--workflow-run-id", "123456", "--workflow-run-attempt", "2", ] @@ -770,6 +589,8 @@ def test_source_metadata_accepts_only_canonical_host_and_finite_source_class(rep + + @pytest.fixture def projection_repo(tmp_path, repo_root): shutil.copytree(repo_root / "agent-contract/tools", tmp_path / "agent-contract/tools", ignore=shutil.ignore_patterns("__pycache__")) diff --git a/agent-contract/tools/build_candidate_release.py b/agent-contract/tools/build_candidate_release.py index 0e35a65..61c241f 100644 --- a/agent-contract/tools/build_candidate_release.py +++ b/agent-contract/tools/build_candidate_release.py @@ -4,112 +4,42 @@ import argparse import hashlib -import io import json -import os import re -import stat +import shutil import subprocess -import zipfile -from email.parser import BytesParser from pathlib import Path from _files import atomic_write +from candidate_wheelhouse import ( + EXPECTED, + LOCK_NAME, + PLATFORMS, + TARGETS, + WHEELHOUSE_MANIFEST, + WheelhouseError, + canonical_json, + read_regular, + verify_base, + verify_wheelhouse, +) from freeze_assets import PACKAGE, load_asset_io, release_bytes from install_prompt import REPOSITORY, candidate_asset_location, hardened_install_contract -EXPECTED = { - "autogenesis-agent-contract": "2.1.0", - "autogenesis-appium-mcp": "0.5.0", - "autogenesis-windows-mcp": "0.5.0", - "autogenesis-cli": "0.2.0", -} -PLATFORMS = { - "autogenesis-agent-contract": ("darwin", "win32"), - "autogenesis-appium-mcp": ("darwin", "win32"), - "autogenesis-windows-mcp": ("win32",), - "autogenesis-cli": ("darwin", "win32"), -} -_MAX_LOCK_BYTES = 4 * 1024 * 1024 -_HASH = re.compile(r"--hash=sha256:[0-9a-f]{64}(?:\s|$)") SKILL = "skills/autogenesis/SKILL.md" -_MAX_WHEEL_BYTES = 64 * 1024 * 1024 -_MAX_METADATA_BYTES = 256 * 1024 +_MAX_LOCK_BYTES = 4 * 1024 * 1024 +_MAX_WHEEL_BYTES = 128 * 1024 * 1024 -def _read_regular(path: Path, maximum: int) -> bytes: - nofollow = getattr(os, "O_NOFOLLOW", 0) - try: - expected = os.lstat(path) - if not stat.S_ISREG(expected.st_mode) or expected.st_size <= 0 or expected.st_size > maximum: - raise ValueError("candidate input must be a bounded regular non-symlink file") - descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0) | nofollow) - try: - opened = os.fstat(descriptor) - if (opened.st_dev, opened.st_ino, opened.st_size) != (expected.st_dev, expected.st_ino, expected.st_size): - raise ValueError("candidate input changed before read") - raw = bytearray() - while len(raw) <= maximum: - chunk = os.read(descriptor, min(1024 * 1024, maximum + 1 - len(raw))) - if not chunk: - break - raw.extend(chunk) - final = os.fstat(descriptor) - finally: - os.close(descriptor) - except OSError: - raise ValueError("candidate input could not be read securely") from None - if len(raw) != expected.st_size or len(raw) > maximum or (final.st_dev, final.st_ino, final.st_size) != (expected.st_dev, expected.st_ino, expected.st_size): - raise ValueError("candidate input changed during read") - return bytes(raw) +def _copy_verified(root: Path, relative: str, source: Path, maximum: int) -> tuple[str, int]: + raw = read_regular(source, maximum) + atomic_write(root, relative, raw) + return hashlib.sha256(raw).hexdigest(), len(raw) -def _wheel_identity(raw: bytes) -> tuple[str, str]: - try: - with zipfile.ZipFile(io.BytesIO(raw)) as archive: - names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] - if len(names) != 1 or archive.getinfo(names[0]).file_size > _MAX_METADATA_BYTES: - raise ValueError("candidate wheel metadata is invalid") - metadata = BytesParser().parsebytes(archive.read(names[0])) - except (OSError, zipfile.BadZipFile, KeyError): - raise ValueError("candidate wheel is invalid") from None - name = metadata.get("Name") - version = metadata.get("Version") - if not isinstance(name, str) or not isinstance(version, str): - raise ValueError("candidate wheel identity is missing") - return name.lower().replace("_", "-"), version -def _dependency_lock(raw: bytes) -> None: - try: - text = raw.decode("utf-8") - except UnicodeError: - raise ValueError("dependency lock must be UTF-8") from None - entries: list[str] = [] - current = "" - for line in text.splitlines(): - stripped = line.strip() - if not stripped or stripped.startswith("#"): - continue - current = f"{current} {stripped}".strip() - if current.endswith("\\"): - current = current[:-1].rstrip() - continue - entries.append(current) - current = "" - if current or not entries: - raise ValueError("dependency lock is empty or truncated") - for entry in entries: - requirement = entry.split(";", 1)[0] - if ( - "==" not in requirement - or not _HASH.search(entry) - or requirement.startswith("-") - or " @ " in requirement - or "/" in requirement - or "\\" in requirement - or "${" in entry - ): - raise ValueError("dependency lock contains an unpinned or unsafe entry") - +def _checksum_row(checksum_rows: list[tuple[str, str]], root: Path, relative: str) -> None: + raw = read_regular(root / relative, _MAX_WHEEL_BYTES) + checksum_rows.append((hashlib.sha256(raw).hexdigest(), relative)) @@ -140,22 +70,20 @@ def _verify_commit(repo: Path, expected: str) -> None: def main() -> None: parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--wheel-dir", required=True, type=Path) + parser.add_argument("--base-dir", required=True, type=Path) + parser.add_argument("--darwin-wheelhouse", required=True, type=Path) + parser.add_argument("--windows-wheelhouse", required=True, type=Path) parser.add_argument("--output-dir", required=True, type=Path) parser.add_argument("--base-url", required=True) parser.add_argument("--commit", required=True) - parser.add_argument("--dependency-lock", required=True, type=Path) + parser.add_argument("--workflow-run-id", required=True) parser.add_argument("--workflow-run-attempt", required=True) args = parser.parse_args() if re.fullmatch(r"[0-9a-f]{40}", args.commit) is None: raise SystemExit("Candidate commit must be an exact lowercase Git commit.") - if not args.workflow_run_id.isdigit() or int(args.workflow_run_id) < 1: - raise SystemExit("Candidate workflow run ID must be a positive integer.") - if not args.workflow_run_attempt.isdigit() or int(args.workflow_run_attempt) < 1: - raise SystemExit("Candidate workflow run attempt must be a positive integer.") try: - base = candidate_asset_location(args.base_url) + base_url = candidate_asset_location(args.base_url) except ValueError: raise SystemExit("Candidate asset location is unsafe.") from None @@ -169,49 +97,39 @@ def main() -> None: asset_io["read_asset"](package, "_release.py", asset_io["MAX_MANIFEST_BYTES"]), ) != (expected_manifest, expected_pin): raise SystemExit("Release drift: refusing to build a Candidate from unverified assets.") - wheel_dir = args.wheel_dir.resolve(strict=True) - found: dict[str, tuple[str, str, bytes]] = {} try: - wheel_paths = tuple(wheel_dir.glob("*.whl")) - for path in wheel_paths: - raw = _read_regular(path, _MAX_WHEEL_BYTES) - name, version = _wheel_identity(raw) - if name not in EXPECTED: - continue - if version != EXPECTED[name] or name in found: - raise ValueError("candidate wheel set has a version mismatch or duplicate") - found[name] = (path.name, version, raw) - except (OSError, ValueError) as error: + base_dir = args.base_dir.resolve(strict=True) + base_manifest, lock_raw = verify_base( + base_dir, args.commit, args.workflow_run_id, args.workflow_run_attempt + ) + wheelhouse_inputs = { + "darwin-arm64-py311": args.darwin_wheelhouse, + "win32-amd64-py312": args.windows_wheelhouse, + } + verified_wheelhouses = { + target: verify_wheelhouse( + path, base_manifest, lock_raw, target, + args.commit, args.workflow_run_id, args.workflow_run_attempt, + ) + for target, path in wheelhouse_inputs.items() + } + except (OSError, WheelhouseError) as error: raise SystemExit(f"Candidate integrity error: {error}") from None - if set(found) != set(EXPECTED): - raise SystemExit("Candidate wheel set is incomplete.") - try: - lock_raw = _read_regular(args.dependency_lock, _MAX_LOCK_BYTES) - _dependency_lock(lock_raw) - except ValueError as error: - raise SystemExit(f"Candidate dependency lock error: {error}") from None output = args.output_dir.resolve() output.mkdir(parents=True, exist_ok=True) if any(output.iterdir()): raise SystemExit("Candidate output directory must be empty.") + checksum_rows: list[tuple[str, str]] = [] wheels = [] - checksum_rows = [] - for name in EXPECTED: - filename, version, raw = found[name] - digest = hashlib.sha256(raw).hexdigest() - atomic_write(output, filename, raw) - wheels.append({ - "package": name, - "version": version, - "filename": filename, - "url": f"{base}/{filename}", - "size": len(raw), - "platforms": list(PLATFORMS[name]), - "sha256": digest, - }) - checksum_rows.append((digest, filename)) + for row in base_manifest["wheels"]: + relative = row["filename"] + _copy_verified(output, relative, base_dir / "wheels" / relative, _MAX_WHEEL_BYTES) + wheel = {**row, "url": f"{base_url}/{relative}"} + wheels.append(wheel) + checksum_rows.append((row["sha256"], relative)) + install_lock_name = "candidate-install.lock" first_party_rows = [] for wheel in wheels: @@ -220,16 +138,41 @@ def main() -> None: f"{wheel['package']}=={wheel['version']}{marker} \\\n" f" --hash=sha256:{wheel['sha256']}\n" ) - first_party_lock = "".join(first_party_rows).encode("ascii") - install_lock = first_party_lock + lock_raw - _dependency_lock(install_lock) + install_lock = "".join(first_party_rows).encode("ascii") + lock_raw install_lock_digest = hashlib.sha256(install_lock).hexdigest() atomic_write(output, install_lock_name, install_lock) checksum_rows.append((install_lock_digest, install_lock_name)) - lock_name = "candidate-dependencies.lock" lock_digest = hashlib.sha256(lock_raw).hexdigest() - atomic_write(output, lock_name, lock_raw) - checksum_rows.append((lock_digest, lock_name)) + atomic_write(output, LOCK_NAME, lock_raw) + checksum_rows.append((lock_digest, LOCK_NAME)) + + wheelhouses = [] + for target in TARGETS: + document, manifest_raw = verified_wheelhouses[target] + source = wheelhouse_inputs[target].resolve(strict=True) + prefix = f"wheelhouse/{target}" + manifest_relative = f"{prefix}/{WHEELHOUSE_MANIFEST}" + atomic_write(output, manifest_relative, manifest_raw) + checksum_rows.append((hashlib.sha256(manifest_raw).hexdigest(), manifest_relative)) + files = [] + for row in document["files"]: + relative = f"{prefix}/{row['filename']}" + _copy_verified(output, relative, source / row["filename"], _MAX_WHEEL_BYTES) + checksum_rows.append((row["sha256"], relative)) + files.append({**row, "path": relative, "url": f"{base_url}/{relative}"}) + wheelhouses.append({ + "target": document["target"], + "source": document["source"], + "dependency_lock_sha256": document["dependency_lock_sha256"], + "first_party_wheel_set_sha256": document["first_party_wheel_set_sha256"], + "manifest": { + "path": manifest_relative, + "url": f"{base_url}/{manifest_relative}", + "size": len(manifest_raw), + "sha256": hashlib.sha256(manifest_raw).hexdigest(), + }, + "files": files, + }) skill = asset_io["read_asset"](package / "assets", SKILL) skill_name = "autogenesis-SKILL.md" @@ -238,7 +181,7 @@ def main() -> None: checksum_rows.append((skill_digest, skill_name)) identity = json.loads(expected_manifest) candidate = { - "schema_version": 2, + "schema_version": 3, "channel": "candidate", "provenance": { "repository": REPOSITORY, @@ -252,17 +195,17 @@ def main() -> None: "manifest_sha256": hashlib.sha256(expected_manifest).hexdigest(), }, "first_party_index_fallback": False, - "third_party_policy": "sanitized-source-with-release-hash-lock", + "third_party_policy": "bundled-exact-target-wheelhouses-no-index", "dependency_locks": { - "download": { - "filename": lock_name, - "url": f"{base}/{lock_name}", + "universal": { + "filename": LOCK_NAME, + "url": f"{base_url}/{LOCK_NAME}", "size": len(lock_raw), "sha256": lock_digest, }, "offline_install": { "filename": install_lock_name, - "url": f"{base}/{install_lock_name}", + "url": f"{base_url}/{install_lock_name}", "size": len(install_lock), "sha256": install_lock_digest, }, @@ -270,12 +213,13 @@ def main() -> None: "skill": { "name": "autogenesis", "filename": skill_name, - "url": f"{base}/{skill_name}", + "url": f"{base_url}/{skill_name}", "sha256": skill_digest, }, "wheels": wheels, + "wheelhouses": wheelhouses, } - candidate_raw = (json.dumps(candidate, indent=2, sort_keys=True) + "\n").encode("utf-8") + candidate_raw = canonical_json(candidate) manifest_name = "candidate-manifest.json" atomic_write(output, manifest_name, candidate_raw) manifest_digest = hashlib.sha256(candidate_raw).hexdigest() @@ -288,37 +232,45 @@ def main() -> None: f"In canonical repository `{REPOSITORY}`, require exact Actions run `{args.workflow_run_id}`, " f"positive run attempt `{args.workflow_run_attempt}`, head commit `{args.commit}`, and attempt-qualified " f"artifact name `{artifact_name}`. Before interpreting bundle instructions, resolve exactly one unexpired " - "immutable GitHub Actions artifact with a positive observed artifact ID matching all those fields, then " - "download by that artifact ID. Reject a local directory, zero/multiple/expired artifact matches, another " - "run/attempt/commit/name, or superseded run 42843125727." + "immutable GitHub Actions artifact with a positive observed artifact ID and independently supplied positive " + "GitHub artifact service digest matching all those fields; download by that artifact ID and verify the " + "downloaded archive against that service digest before extraction. Reject a local directory, zero/multiple/" + "expired artifact matches, another run/attempt/commit/name, a missing or mismatched service digest, or " + "superseded run 42843125727." ), acquire=( - "Use only the manifest-bound first-party wheels, locks, and Skill bytes already obtained " - "from the exact Actions artifact. Through the approved source, stage third-party artifacts " - "under the download lock's strict hash policy in a new Candidate-only staging directory. " - "Verify every staged artifact, add the already verified first-party wheel bytes, and then disable indexes." + "Use only the two manifest-bound target wheelhouses, universal lock, complete offline-install lock, " + "first-party wheels, and Skill bytes already present in the exact Actions artifact. Prove the native " + "host from trusted local facts, then select exactly one compatible target: native macOS 15 or newer on " + "arm64 with CPython 3.11 selects `darwin-arm64-py311`; native Windows x64 with CPython 3.12 selects " + "`win32-amd64-py312`. Reject older macOS, Linux, WSL, another Python implementation/version/architecture, " + "zero matches, or multiple matches. " + "Verify the selected target manifest and every listed wheel against the Candidate manifest and complete " + "checksums before installation. Do not query or use a package index, mirror, proxy, credential, package " + "configuration, cache, source tree, VCS checkout, sdist, editable input, or installed distribution." ), install=( - "Using the disposable environment's absolute Python, install the complete offline-install lock " - "from that staging directory in one transaction with strict hashes, dependency resolution disabled, " - "and every locked package force-reinstalled. Never accept an installed distribution as satisfying " - "the lock. Never resolve or replace a first-party Candidate package from an index. Then run " - "`/autogenesis install --scope " - "--project-root ` as the sole owner of the Skill write; it must reject " - "every other canonical or legacy discovery copy." + "Using the disposable environment's absolute Python, install the complete offline-install lock from the " + "exact verified target wheelhouse in one transaction with `--no-index --no-cache-dir --only-binary=:all: " + "--find-links --require-hashes --no-deps --force-reinstall`. Never accept an " + "installed distribution as satisfying the lock and never resolve or replace any Candidate package. Then " + "run `/autogenesis install --scope " + "--project-root ` as the sole owner of the Skill write; it must reject every " + "other canonical or legacy discovery copy." ), - cleanup="Candidate-only downloaded, extracted, staged, selected-scope, MCP, and cache state created by this prompt", + cleanup="Candidate-only extracted, selected-scope, MCP, and cache state created by this prompt", + package_source_required=False, ) prompt = f"""Install this exact AutoGenesis Candidate for pre-release UAT, then stop before a business task. -Candidate manifest: {base}/{manifest_name} +Candidate manifest: {base_url}/{manifest_name} Expected manifest SHA-256: {manifest_digest} -Complete checksums: {base}/SHA256SUMS +Complete checksums: {base_url}/SHA256SUMS Candidate commit: {args.commit} GitHub Actions repository: {REPOSITORY} GitHub Actions workflow run: {args.workflow_run_id} GitHub Actions workflow run attempt: {args.workflow_run_attempt} -GitHub Actions artifact ID: resolve one positive immutable ID externally before interpreting this bundle +GitHub Actions artifact ID and service digest: resolve both externally and verify the archive digest before extraction GitHub Actions artifact: {artifact_name} {contract}""".encode("utf-8") @@ -326,9 +278,12 @@ def main() -> None: atomic_write(output, prompt_name, prompt) checksum_rows.append((hashlib.sha256(prompt).hexdigest(), prompt_name)) - checksums = "".join(f"{digest} {name}\n" for digest, name in checksum_rows).encode("ascii") + checksums = "".join( + f"{digest} {name}\n" for digest, name in sorted(checksum_rows, key=lambda row: row[1]) + ).encode("ascii") atomic_write(output, "SHA256SUMS", checksums) - print(f"Built Candidate channel for {args.commit} with {len(wheels)} exact wheels.") + wheel_count = sum(len(wheelhouse["files"]) for wheelhouse in wheelhouses) + print(f"Built Candidate channel for {args.commit} with two exact target wheelhouses and {wheel_count} files.") if __name__ == "__main__": diff --git a/agent-contract/tools/candidate_wheelhouse.py b/agent-contract/tools/candidate_wheelhouse.py new file mode 100644 index 0000000..e771704 --- /dev/null +++ b/agent-contract/tools/candidate_wheelhouse.py @@ -0,0 +1,822 @@ +"""Build and verify exact target wheelhouses for one Candidate workflow run.""" + +from __future__ import annotations + +import argparse +import hashlib +import io +import json +import os +import platform +import re +import runpy +import shutil +import socket +import subprocess +import sys +import tempfile +import venv +import zipfile +from email.parser import BytesParser +from pathlib import Path + +try: + from packaging.requirements import Requirement + from packaging.tags import compatible_tags, cpython_tags, mac_platforms, parse_tag + from packaging.utils import canonicalize_name, parse_wheel_filename + from packaging.version import Version +except ModuleNotFoundError: + from pip._vendor.packaging.requirements import Requirement + from pip._vendor.packaging.tags import compatible_tags, cpython_tags, mac_platforms, parse_tag + from pip._vendor.packaging.utils import canonicalize_name, parse_wheel_filename + from pip._vendor.packaging.version import Version + +from _files import atomic_write +from install_prompt import REPOSITORY + +EXPECTED = { + "autogenesis-agent-contract": "2.1.0", + "autogenesis-appium-mcp": "0.5.0", + "autogenesis-windows-mcp": "0.5.0", + "autogenesis-cli": "0.2.0", +} +PLATFORMS = { + "autogenesis-agent-contract": ("darwin", "win32"), + "autogenesis-appium-mcp": ("darwin", "win32"), + "autogenesis-windows-mcp": ("win32",), + "autogenesis-cli": ("darwin", "win32"), +} +TARGETS = { + "darwin-arm64-py311": { + "implementation": "CPython", + "python_version": "3.11", + "sys_platform": "darwin", + "platform": "macos", + "architecture": "arm64", + "deployment_version": "15.0", + "os_name": "posix", + "platform_system": "Darwin", + "platform_machine": "arm64", + }, + "win32-amd64-py312": { + "implementation": "CPython", + "python_version": "3.12", + "sys_platform": "win32", + "platform": "windows", + "architecture": "amd64", + "os_name": "nt", + "platform_system": "Windows", + "platform_machine": "AMD64", + }, +} +SOURCE = {"host": "pypi.org", "class": "default-public", "index": "https://pypi.org/simple"} +BASE_MANIFEST = "candidate-base-manifest.json" +WHEELHOUSE_MANIFEST = "wheelhouse-manifest.json" +LOCK_NAME = "candidate-dependencies.lock" +_MAX_LOCK_BYTES = 4 * 1024 * 1024 +_MAX_MANIFEST_BYTES = 4 * 1024 * 1024 +_MAX_WHEEL_BYTES = 128 * 1024 * 1024 +_MAX_WHEELS = 256 +_MAX_WHEELHOUSE_BYTES = 512 * 1024 * 1024 +_HASH = re.compile(r"--hash=sha256:([0-9a-f]{64})(?:\s|$)") + + +class WheelhouseError(ValueError): + """One fixed diagnostic boundary for untrusted package input.""" + + +def canonical_json(document: object) -> bytes: + return (json.dumps(document, indent=2, sort_keys=True) + "\n").encode("utf-8") + + +def read_regular(path: Path, maximum: int) -> bytes: + nofollow = getattr(os, "O_NOFOLLOW", 0) + try: + expected = os.lstat(path) + if not os.path.isfile(path) or os.path.islink(path) or expected.st_size <= 0 or expected.st_size > maximum: + raise WheelhouseError("input must be a bounded regular non-symlink file") + descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_BINARY", 0) | nofollow) + try: + opened = os.fstat(descriptor) + if (opened.st_dev, opened.st_ino, opened.st_size) != (expected.st_dev, expected.st_ino, expected.st_size): + raise WheelhouseError("input changed before read") + raw = bytearray() + while len(raw) <= maximum: + chunk = os.read(descriptor, min(1024 * 1024, maximum + 1 - len(raw))) + if not chunk: + break + raw.extend(chunk) + final = os.fstat(descriptor) + finally: + os.close(descriptor) + except OSError: + raise WheelhouseError("input could not be read securely") from None + if ( + len(raw) != expected.st_size + or len(raw) > maximum + or (final.st_dev, final.st_ino, final.st_size) != (expected.st_dev, expected.st_ino, expected.st_size) + ): + raise WheelhouseError("input changed during read") + return bytes(raw) + + +def _read_json(path: Path) -> dict: + raw = read_regular(path, _MAX_MANIFEST_BYTES) + try: + document = json.loads(raw) + except (UnicodeError, json.JSONDecodeError): + raise WheelhouseError("manifest is not canonical JSON") from None + if not isinstance(document, dict) or canonical_json(document) != raw: + raise WheelhouseError("manifest is not canonical JSON") + return document + + +def provenance(commit: str, run_id: str, run_attempt: str, artifact: str) -> dict[str, str]: + if re.fullmatch(r"[0-9a-f]{40}", commit) is None: + raise WheelhouseError("commit must be an exact lowercase Git commit") + if not run_id.isdigit() or int(run_id) < 1 or not run_attempt.isdigit() or int(run_attempt) < 1: + raise WheelhouseError("workflow run identity must contain positive integers") + return { + "repository": REPOSITORY, + "workflow_run_id": run_id, + "workflow_run_attempt": run_attempt, + "commit": commit, + "artifact": artifact, + } + + +def _lock_entries(raw: bytes) -> list[tuple[Requirement, tuple[str, ...]]]: + try: + text = raw.decode("utf-8") + except UnicodeError: + raise WheelhouseError("dependency lock must be UTF-8") from None + entries: list[str] = [] + current = "" + for line in text.splitlines(): + stripped = line.strip() + if not stripped or stripped.startswith("#"): + continue + current = f"{current} {stripped}".strip() + if current.endswith("\\"): + current = current[:-1].rstrip() + continue + entries.append(current) + current = "" + if current or not entries: + raise WheelhouseError("dependency lock is empty or truncated") + + parsed = [] + for entry in entries: + hashes = tuple(sorted(set(_HASH.findall(entry)))) + requirement_text = re.sub(r"\s*--hash=sha256:[0-9a-f]{64}(?=\s|$)", "", entry).strip() + try: + requirement = Requirement(requirement_text) + except Exception: + raise WheelhouseError("dependency lock contains an unsafe requirement") from None + specifiers = tuple(requirement.specifier) + marker_text = str(requirement.marker) if requirement.marker is not None else "" + if ( + len(specifiers) != 1 + or specifiers[0].operator != "==" + or "*" in specifiers[0].version + or requirement.url is not None + or requirement.extras + or not hashes + or canonicalize_name(requirement.name) in EXPECTED + or re.search(r"\b(?:python_full_version|implementation_version)\b", marker_text) + ): + raise WheelhouseError("dependency lock contains an unpinned, first-party, patch-sensitive, or unsafe entry") + parsed.append((requirement, hashes)) + return parsed + + +def _marker_environment(target_key: str) -> dict[str, str]: + target = TARGETS[target_key] + version = target["python_version"] + return { + "implementation_name": "cpython", + "implementation_version": f"{version}.0", + "os_name": target["os_name"], + "platform_machine": target["platform_machine"], + "platform_release": "", + "platform_system": target["platform_system"], + "platform_version": "", + "python_full_version": f"{version}.0", + "platform_python_implementation": "CPython", + "python_version": version, + "sys_platform": target["sys_platform"], + "extra": "", + } + + +def target_lock(raw: bytes, target_key: str) -> dict[str, dict]: + if target_key not in TARGETS: + raise WheelhouseError("unsupported Candidate target") + selected: dict[str, dict] = {} + environment = _marker_environment(target_key) + for requirement, hashes in _lock_entries(raw): + if requirement.marker is not None and not requirement.marker.evaluate(environment=environment): + continue + name = canonicalize_name(requirement.name) + version = tuple(requirement.specifier)[0].version + if name in selected: + raise WheelhouseError("target dependency lock contains a duplicate identity") + selected[name] = {"version": version, "hashes": hashes} + if not selected: + raise WheelhouseError("target dependency lock is empty") + return selected + + +def render_target_lock(selected: dict[str, dict]) -> bytes: + rows = [] + for name in sorted(selected): + hashes = " \\\n".join(f" --hash=sha256:{digest}" for digest in selected[name]["hashes"]) + rows.append(f"{name}=={selected[name]['version']} \\\n{hashes}\n") + return "".join(rows).encode("ascii") + + +def inspect_wheel(path: Path) -> tuple[dict, bytes]: + raw = read_regular(path, _MAX_WHEEL_BYTES) + try: + filename_name, filename_version, _build, filename_tags = parse_wheel_filename(path.name) + with zipfile.ZipFile(io.BytesIO(raw)) as archive: + metadata_names = [name for name in archive.namelist() if name.endswith(".dist-info/METADATA")] + wheel_names = [name for name in archive.namelist() if name.endswith(".dist-info/WHEEL")] + if len(metadata_names) != 1 or len(wheel_names) != 1: + raise WheelhouseError("wheel metadata layout is invalid") + if archive.getinfo(metadata_names[0]).file_size > 256 * 1024 or archive.getinfo(wheel_names[0]).file_size > 64 * 1024: + raise WheelhouseError("wheel metadata is too large") + metadata = BytesParser().parsebytes(archive.read(metadata_names[0])) + wheel_metadata = BytesParser().parsebytes(archive.read(wheel_names[0])) + except (OSError, ValueError, zipfile.BadZipFile, KeyError): + raise WheelhouseError("wheel is invalid") from None + + metadata_name = metadata.get("Name") + metadata_version = metadata.get("Version") + declared_tags = wheel_metadata.get_all("Tag", []) + try: + wheel_tags = frozenset(tag for value in declared_tags for tag in parse_tag(value)) + same_version = Version(str(metadata_version)) == filename_version + except Exception: + raise WheelhouseError("wheel identity or tags are invalid") from None + name = canonicalize_name(str(metadata_name)) + if name != canonicalize_name(filename_name) or not same_version or wheel_tags != filename_tags: + raise WheelhouseError("wheel filename, metadata, and tags disagree") + return ({ + "package": name, + "version": str(filename_version), + "filename": path.name, + "size": len(raw), + "tags": sorted(str(tag) for tag in filename_tags), + "sha256": hashlib.sha256(raw).hexdigest(), + }, raw) + + +def _target_tags(target_key: str) -> frozenset[str]: + target = TARGETS[target_key] + python_version = tuple(int(value) for value in target["python_version"].split(".")) + if target["sys_platform"] == "darwin": + deployment_version = tuple(int(value) for value in target["deployment_version"].split(".")) + platforms = tuple(mac_platforms(version=deployment_version, arch="arm64")) + else: + platforms = ("win_amd64",) + tags = set(cpython_tags(python_version=python_version, platforms=platforms)) + tags.update(compatible_tags(python_version=python_version, interpreter=f"cp{python_version[0]}{python_version[1]}", platforms=platforms)) + return frozenset(str(tag) for tag in tags) + + +def _tag_compatible(tag_text: str, target_key: str) -> bool: + return tag_text in _target_tags(target_key) + + +def _validate_inventory( + directory: Path, + target_key: str, + dependency_lock: bytes, + first_party: dict[str, dict], +) -> list[dict]: + expected_third_party = target_lock(dependency_lock, target_key) + records: dict[str, dict] = {} + total = 0 + paths = sorted(directory.glob("*.whl"), key=lambda path: path.name) + if not paths or len(paths) > _MAX_WHEELS: + raise WheelhouseError("wheelhouse file count is invalid") + for path in paths: + record, _raw = inspect_wheel(path) + total += record["size"] + name = record["package"] + if name in records: + raise WheelhouseError("wheelhouse contains a duplicate package identity") + if not any(_tag_compatible(tag, target_key) for tag in record["tags"]): + raise WheelhouseError("wheelhouse contains a target-incompatible wheel") + if name in EXPECTED: + expected = first_party.get(name) + if expected is None or any(record[key] != expected[key] for key in ("version", "filename", "size", "sha256")): + raise WheelhouseError("wheelhouse contains a substituted first-party wheel") + record["kind"] = "first-party" + else: + locked = expected_third_party.get(name) + if locked is None or Version(record["version"]) != Version(locked["version"]) or record["sha256"] not in locked["hashes"]: + raise WheelhouseError("wheelhouse contains an extra or substituted third-party wheel") + record["kind"] = "third-party" + records[name] = record + if total > _MAX_WHEELHOUSE_BYTES: + raise WheelhouseError("wheelhouse exceeds the total byte limit") + required_first_party = { + name for name, row in first_party.items() if TARGETS[target_key]["sys_platform"] in row["platforms"] + } + if set(records) != required_first_party | set(expected_third_party): + raise WheelhouseError("wheelhouse dependency closure is incomplete") + return [records[name] for name in sorted(records)] + + +def _first_party_rows(wheel_dir: Path) -> list[dict]: + records = {} + paths = sorted(wheel_dir.glob("*.whl"), key=lambda path: path.name) + for path in paths: + record, _raw = inspect_wheel(path) + name = record["package"] + if name not in EXPECTED or name in records or Version(record["version"]) != Version(EXPECTED[name]): + raise WheelhouseError("first-party wheel set contains an unknown, duplicate, or mismatched identity") + record.pop("tags") + record["platforms"] = list(PLATFORMS[name]) + records[name] = record + if set(records) != set(EXPECTED) or len(paths) != len(EXPECTED): + raise WheelhouseError("first-party wheel set is incomplete") + return [records[name] for name in EXPECTED] + + +def _set_digest(rows: list[dict]) -> str: + return hashlib.sha256(canonical_json(rows)).hexdigest() + + +def render_candidate_requirements(wheel_dir: Path) -> bytes: + rows = _first_party_rows(wheel_dir.resolve(strict=True)) + lines = [] + for row in rows: + marker = " ; sys_platform == 'win32'" if row["platforms"] == ["win32"] else "" + lines.append(f"{(wheel_dir / row['filename']).as_posix()}{marker}\n") + return "".join(lines).encode("utf-8") + + +def write_candidate_requirements(wheel_dir: Path, output: Path) -> None: + output = output.resolve() + if output.exists(): + raise WheelhouseError("Candidate requirements output already exists") + output.parent.mkdir(parents=True, exist_ok=True) + atomic_write(output.parent, output.name, render_candidate_requirements(wheel_dir)) + + +def _replace_directory(staging: Path, output: Path) -> None: + if output.exists(): + raise WheelhouseError("output directory already exists") + os.replace(staging, output) + + +def prepare_base( + wheel_dir: Path, + dependency_lock_path: Path, + output: Path, + commit: str, + run_id: str, + run_attempt: str, +) -> dict: + rows = _first_party_rows(wheel_dir.resolve(strict=True)) + lock_raw = read_regular(dependency_lock_path.resolve(strict=True), _MAX_LOCK_BYTES) + for target_key in TARGETS: + target_lock(lock_raw, target_key) + artifact = f"autogenesis-candidate-base-{run_id}-{run_attempt}-{commit}" + document = { + "schema_version": 1, + "kind": "candidate-base", + "provenance": provenance(commit, run_id, run_attempt, artifact), + "dependency_lock": {"filename": LOCK_NAME, "size": len(lock_raw), "sha256": hashlib.sha256(lock_raw).hexdigest()}, + "first_party_wheel_set_sha256": _set_digest(rows), + "wheels": rows, + } + output = output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + staging = Path(tempfile.mkdtemp(prefix=".candidate-base-", dir=output.parent)) + try: + (staging / "wheels").mkdir() + for row in rows: + shutil.copyfile(wheel_dir / row["filename"], staging / "wheels" / row["filename"]) + atomic_write(staging, LOCK_NAME, lock_raw) + atomic_write(staging, BASE_MANIFEST, canonical_json(document)) + _replace_directory(staging, output) + except Exception: + shutil.rmtree(staging, ignore_errors=True) + raise + return document + + +def verify_base(base_dir: Path, commit: str, run_id: str, run_attempt: str) -> tuple[dict, bytes]: + base_dir = base_dir.resolve(strict=True) + document = _read_json(base_dir / BASE_MANIFEST) + rows = _first_party_rows(base_dir / "wheels") + lock_raw = read_regular(base_dir / LOCK_NAME, _MAX_LOCK_BYTES) + expected = { + "schema_version": 1, + "kind": "candidate-base", + "provenance": provenance( + commit, run_id, run_attempt, f"autogenesis-candidate-base-{run_id}-{run_attempt}-{commit}" + ), + "dependency_lock": {"filename": LOCK_NAME, "size": len(lock_raw), "sha256": hashlib.sha256(lock_raw).hexdigest()}, + "first_party_wheel_set_sha256": _set_digest(rows), + "wheels": rows, + } + wheel_names = {row["filename"] for row in rows} + if {path.name for path in (base_dir / "wheels").iterdir()} != wheel_names: + raise WheelhouseError("Candidate base wheel inventory is invalid") + allowed = {BASE_MANIFEST, LOCK_NAME, "wheels"} + if document != expected or {path.name for path in base_dir.iterdir()} != allowed: + raise WheelhouseError("Candidate base provenance or inventory is invalid") + return expected, lock_raw + + +def _minimal_pip_environment(private_home: Path) -> dict[str, str]: + environment: dict[str, str] = {} + for name in ("SYSTEMROOT", "WINDIR", "COMSPEC", "PATHEXT", "TEMP", "TMP"): + if name in os.environ: + environment[name] = os.environ[name] + environment.update({ + "HOME": str(private_home), + "USERPROFILE": str(private_home), + "NETRC": "nul" if os.name == "nt" else os.devnull, + "PIP_CONFIG_FILE": "nul" if os.name == "nt" else os.devnull, + "PIP_DISABLE_PIP_VERSION_CHECK": "1", + "PIP_KEYRING_PROVIDER": "disabled", + "PIP_NO_INPUT": "1", + "PYTHONNOUSERSITE": "1", + }) + return environment + + +def _assert_target_host(target_key: str) -> None: + target = TARGETS[target_key] + machine = platform.machine().lower() + architecture = "arm64" if machine in {"arm64", "aarch64"} else "amd64" if machine in {"amd64", "x86_64"} else machine + observed_version = f"{sys.version_info.major}.{sys.version_info.minor}" + deployment_ok = True + if target["sys_platform"] == "darwin": + try: + deployment_ok = Version(platform.mac_ver()[0]) >= Version(target["deployment_version"]) + except Exception: + deployment_ok = False + if ( + sys.implementation.name != "cpython" + or observed_version != target["python_version"] + or sys.platform != target["sys_platform"] + or architecture != target["architecture"] + or not deployment_ok + ): + raise WheelhouseError("acquisition host does not match the exact Candidate target") + + +def wheelhouse_document( + directory: Path, + base: dict, + dependency_lock: bytes, + target_key: str, + commit: str, + run_id: str, + run_attempt: str, +) -> dict: + if target_key not in TARGETS: + raise WheelhouseError("unsupported Candidate target") + first_party = {row["package"]: row for row in base["wheels"]} + inventory = _validate_inventory(directory, target_key, dependency_lock, first_party) + artifact = f"autogenesis-candidate-wheelhouse-{target_key}-{run_id}-{run_attempt}-{commit}" + return { + "schema_version": 1, + "kind": "candidate-target-wheelhouse", + "provenance": provenance(commit, run_id, run_attempt, artifact), + "target": {"key": target_key, **TARGETS[target_key]}, + "source": {"host": SOURCE["host"], "class": SOURCE["class"]}, + "dependency_lock_sha256": base["dependency_lock"]["sha256"], + "first_party_wheel_set_sha256": base["first_party_wheel_set_sha256"], + "files": inventory, + } + + +def acquire_wheelhouse( + base_dir: Path, + target_key: str, + output: Path, + commit: str, + run_id: str, + run_attempt: str, +) -> dict: + if target_key not in TARGETS: + raise WheelhouseError("unsupported Candidate target") + _assert_target_host(target_key) + base, lock_raw = verify_base(base_dir, commit, run_id, run_attempt) + selected = target_lock(lock_raw, target_key) + artifact = f"autogenesis-candidate-wheelhouse-{target_key}-{run_id}-{run_attempt}-{commit}" + output = output.resolve() + output.parent.mkdir(parents=True, exist_ok=True) + transaction = Path(tempfile.mkdtemp(prefix=f".{target_key}-", dir=output.parent)) + staging = transaction / "wheelhouse" + private_home = transaction / "home" + staging.mkdir() + private_home.mkdir(mode=0o700) + try: + target_lock_path = staging / ".target.lock" + atomic_write(staging, target_lock_path.name, render_target_lock(selected)) + command = [ + sys.executable, "-I", "-m", "pip", "download", "--isolated", "--disable-pip-version-check", + "--no-cache-dir", "--only-binary=:all:", "--require-hashes", "--no-deps", + "--index-url", SOURCE["index"], "--dest", str(staging), "--requirement", str(target_lock_path), + ] + result = subprocess.run( + command, + env=_minimal_pip_environment(private_home), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + timeout=900, + check=False, + ) + target_lock_path.unlink(missing_ok=True) + if result.returncode != 0: + raise WheelhouseError("fixed-source wheel-only acquisition failed") + first_party = {row["package"]: row for row in base["wheels"]} + for row in base["wheels"]: + if TARGETS[target_key]["sys_platform"] in row["platforms"]: + destination = staging / row["filename"] + if destination.exists(): + raise WheelhouseError("first-party wheel filename collides with acquired content") + shutil.copyfile(base_dir / "wheels" / row["filename"], destination) + document = wheelhouse_document( + staging, base, lock_raw, target_key, commit, run_id, run_attempt + ) + atomic_write(staging, WHEELHOUSE_MANIFEST, canonical_json(document)) + _replace_directory(staging, output) + finally: + shutil.rmtree(transaction, ignore_errors=True) + return document + + +def verify_wheelhouse( + directory: Path, + base: dict, + dependency_lock: bytes, + target_key: str, + commit: str, + run_id: str, + run_attempt: str, +) -> tuple[dict, bytes]: + directory = directory.resolve(strict=True) + document = _read_json(directory / WHEELHOUSE_MANIFEST) + expected = wheelhouse_document( + directory, base, dependency_lock, target_key, commit, run_id, run_attempt + ) + allowed = {WHEELHOUSE_MANIFEST} | {row["filename"] for row in expected["files"]} + if document != expected or {path.name for path in directory.iterdir()} != allowed: + raise WheelhouseError("target wheelhouse provenance or inventory is invalid") + return expected, canonical_json(expected) + + +def verify_checksum_closure(candidate_dir: Path) -> dict[str, str]: + candidate_dir = candidate_dir.resolve(strict=True) + raw = read_regular(candidate_dir / "SHA256SUMS", _MAX_MANIFEST_BYTES) + try: + lines = raw.decode("ascii").splitlines() + except UnicodeError: + raise WheelhouseError("Candidate checksum closure is invalid") from None + checksums: dict[str, str] = {} + for line in lines: + match = re.fullmatch(r"([0-9a-f]{64}) ([A-Za-z0-9._/-]+)", line) + if match is None: + raise WheelhouseError("Candidate checksum closure is invalid") + digest, relative = match.groups() + path = Path(relative) + if path.is_absolute() or ".." in path.parts or relative == "SHA256SUMS" or relative in checksums: + raise WheelhouseError("Candidate checksum closure is invalid") + checksums[relative] = digest + actual = { + path.relative_to(candidate_dir).as_posix() + for path in candidate_dir.rglob("*") + if path.is_file() and path.name != "SHA256SUMS" + } + if set(checksums) != actual: + raise WheelhouseError("Candidate checksum closure is incomplete") + for relative, digest in checksums.items(): + if hashlib.sha256(read_regular(candidate_dir / relative, _MAX_WHEEL_BYTES)).hexdigest() != digest: + raise WheelhouseError("Candidate checksum closure contains a mismatch") + return checksums + + +def _offline_pip(arguments: list[str]) -> int: + def denied(*_args, **_kwargs): + raise RuntimeError("network access is denied during Candidate installation") + + class DeniedSocket(socket.socket): + def connect(self, *_args, **_kwargs): + denied() + + def connect_ex(self, *_args, **_kwargs): + denied() + + socket.getaddrinfo = denied + socket.create_connection = denied + socket.socket = DeniedSocket + sys.argv = ["pip", *arguments] + try: + runpy.run_module("pip", run_name="__main__") + except SystemExit as error: + return int(error.code or 0) + return 0 + + +def verify_candidate_target(candidate_dir: Path, manifest: dict, target_key: str) -> list[dict]: + if manifest.get("schema_version") != 3 or manifest.get("channel") != "candidate": + raise WheelhouseError("Candidate manifest interface is unsupported") + wheelhouses = manifest.get("wheelhouses") + matches = [ + row for row in wheelhouses + if isinstance(row, dict) and row.get("target", {}).get("key") == target_key + ] if isinstance(wheelhouses, list) else [] + if len(matches) != 1: + raise WheelhouseError("Candidate target selection is unsupported or ambiguous") + selected = matches[0] + prefix = f"wheelhouse/{target_key}" + expected_manifest_path = f"{prefix}/{WHEELHOUSE_MANIFEST}" + if selected.get("manifest", {}).get("path") != expected_manifest_path: + raise WheelhouseError("Candidate target manifest path is invalid") + manifest_raw = read_regular(candidate_dir / expected_manifest_path, _MAX_MANIFEST_BYTES) + if ( + selected["manifest"].get("size") != len(manifest_raw) + or selected["manifest"].get("sha256") != hashlib.sha256(manifest_raw).hexdigest() + ): + raise WheelhouseError("Candidate target manifest identity is invalid") + base_rows = [] + for row in manifest.get("wheels", []): + if not isinstance(row, dict): + raise WheelhouseError("Candidate first-party inventory is invalid") + base_rows.append({key: row[key] for key in ("package", "version", "filename", "size", "sha256", "platforms")}) + lock_raw = read_regular(candidate_dir / LOCK_NAME, _MAX_LOCK_BYTES) + base = { + "wheels": base_rows, + "dependency_lock": {"sha256": hashlib.sha256(lock_raw).hexdigest()}, + "first_party_wheel_set_sha256": _set_digest(base_rows), + } + provenance_row = manifest.get("provenance", {}) + expected, _canonical = verify_wheelhouse( + candidate_dir / "wheelhouse" / target_key, + base, + lock_raw, + target_key, + manifest.get("commit", ""), + provenance_row.get("workflow_run_id", ""), + provenance_row.get("workflow_run_attempt", ""), + ) + expected_files = [ + {**row, "path": f"{prefix}/{row['filename']}", "url": f"./{prefix}/{row['filename']}"} + for row in expected["files"] + ] + expected_selected = { + "target": expected["target"], + "source": expected["source"], + "dependency_lock_sha256": expected["dependency_lock_sha256"], + "first_party_wheel_set_sha256": expected["first_party_wheel_set_sha256"], + "manifest": { + "path": expected_manifest_path, + "url": f"./{expected_manifest_path}", + "size": len(manifest_raw), + "sha256": hashlib.sha256(manifest_raw).hexdigest(), + }, + "files": expected_files, + } + if selected != expected_selected: + raise WheelhouseError("Candidate target inventory is inconsistent") + return expected["files"] + + +def offline_install(candidate_dir: Path, target_key: str, environment_dir: Path) -> None: + if target_key not in TARGETS: + raise WheelhouseError("unsupported Candidate target") + _assert_target_host(target_key) + verify_checksum_closure(candidate_dir) + manifest = _read_json(candidate_dir / "candidate-manifest.json") + installed_rows = verify_candidate_target(candidate_dir, manifest, target_key) + wheelhouse = candidate_dir / "wheelhouse" / target_key + environment_dir = environment_dir.resolve() + if environment_dir.exists(): + raise WheelhouseError("offline install environment must be absent") + venv.EnvBuilder(with_pip=True).create(environment_dir) + private_home = environment_dir / ".installer-home" + private_home.mkdir(mode=0o700) + environment = _minimal_pip_environment(private_home) + python = environment_dir / ("Scripts/python.exe" if os.name == "nt" else "bin/python") + arguments = [ + "install", "--isolated", "--disable-pip-version-check", "--no-index", "--no-cache-dir", + "--only-binary=:all:", "--find-links", str(wheelhouse), "--require-hashes", "--no-deps", + "--force-reinstall", "--requirement", str(candidate_dir / "candidate-install.lock"), + ] + command = [str(python), str(Path(__file__).resolve()), "_offline-pip", *arguments] + result = subprocess.run( + command, env=environment, stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, check=False, timeout=900, + ) + if result.returncode != 0: + shutil.rmtree(private_home, ignore_errors=True) + raise WheelhouseError("network-denied offline Candidate installation failed") + check = subprocess.run( + [str(python), "-I", "-m", "pip", "check"], env=environment, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + check=False, timeout=120, + ) + if check.returncode != 0: + shutil.rmtree(private_home, ignore_errors=True) + raise WheelhouseError("installed Candidate dependency metadata is inconsistent") + evidence_path = environment_dir / ".candidate-install-evidence.json" + evidence_script = """import hashlib, importlib.metadata, json, pathlib, sys\nfrom autogenesis_agent_contract import load_contract\ncontract = load_contract()\nevidence = {'inventory': sorted((item.metadata['Name'], item.version) for item in importlib.metadata.distributions()), 'identity': contract.identity.to_dict(), 'skill_sha256': hashlib.sha256(contract.read_skill('autogenesis')).hexdigest()}\npathlib.Path(sys.argv[1]).write_text(json.dumps(evidence, sort_keys=True), encoding='utf-8')\n""" + evidence = subprocess.run( + [str(python), "-I", "-c", evidence_script, str(evidence_path)], env=environment, + stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL, + check=False, timeout=120, + ) + try: + observed = json.loads(read_regular(evidence_path, _MAX_MANIFEST_BYTES)) if evidence.returncode == 0 else None + except (OSError, UnicodeError, json.JSONDecodeError): + observed = None + evidence_path.unlink(missing_ok=True) + expected_inventory = { + canonicalize_name(row["package"]): str(Version(row["version"])) for row in installed_rows + } + observed_inventory = { + canonicalize_name(name): str(Version(version)) + for name, version in observed.get("inventory", []) + if canonicalize_name(name) not in {"pip", "setuptools"} + } if isinstance(observed, dict) else {} + observed_identity = observed.get("identity") if isinstance(observed, dict) else None + observed_skill = observed.get("skill_sha256") if isinstance(observed, dict) else None + expected_identity = { + "contract_version": manifest["contract"]["version"], + "manifest_sha256": manifest["contract"]["manifest_sha256"], + "schema_version": 2, + } + expected_skill = hashlib.sha256(read_regular(candidate_dir / "autogenesis-SKILL.md", _MAX_MANIFEST_BYTES)).hexdigest() + if ( + observed_inventory != expected_inventory + or observed_identity != expected_identity + or observed_skill != expected_skill + ): + shutil.rmtree(private_home, ignore_errors=True) + raise WheelhouseError("installed Candidate inventory, Contract identity, or Skill bytes are inconsistent") + shutil.rmtree(private_home, ignore_errors=True) + + +def _parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + for name in ("base", "acquire"): + command = subparsers.add_parser(name) + command.add_argument("--commit", required=True) + command.add_argument("--workflow-run-id", required=True) + command.add_argument("--workflow-run-attempt", required=True) + command.add_argument("--output-dir", required=True, type=Path) + requirements = subparsers.add_parser("requirements") + requirements.add_argument("--wheel-dir", required=True, type=Path) + requirements.add_argument("--output-file", required=True, type=Path) + base = subparsers.choices["base"] + base.add_argument("--wheel-dir", required=True, type=Path) + base.add_argument("--dependency-lock", required=True, type=Path) + acquire = subparsers.choices["acquire"] + acquire.add_argument("--base-dir", required=True, type=Path) + acquire.add_argument("--target", required=True, choices=tuple(TARGETS)) + install = subparsers.add_parser("offline-install") + install.add_argument("--candidate-dir", required=True, type=Path) + install.add_argument("--target", required=True, choices=tuple(TARGETS)) + install.add_argument("--environment-dir", required=True, type=Path) + hidden = subparsers.add_parser("_offline-pip") + hidden.add_argument("arguments", nargs=argparse.REMAINDER) + return parser + + +def main() -> None: + args = _parser().parse_args() + try: + if args.command == "requirements": + write_candidate_requirements(args.wheel_dir, args.output_file) + elif args.command == "base": + prepare_base( + args.wheel_dir, args.dependency_lock, args.output_dir, + args.commit, args.workflow_run_id, args.workflow_run_attempt, + ) + elif args.command == "acquire": + acquire_wheelhouse( + args.base_dir, args.target, args.output_dir, + args.commit, args.workflow_run_id, args.workflow_run_attempt, + ) + elif args.command == "offline-install": + offline_install(args.candidate_dir, args.target, args.environment_dir) + else: + raise SystemExit(_offline_pip(args.arguments)) + except (OSError, WheelhouseError, subprocess.TimeoutExpired) as error: + raise SystemExit(f"Candidate wheelhouse error: {error}") from None + + +if __name__ == "__main__": + main() diff --git a/agent-contract/tools/install_prompt.py b/agent-contract/tools/install_prompt.py index 1decb20..2e23719 100644 --- a/agent-contract/tools/install_prompt.py +++ b/agent-contract/tools/install_prompt.py @@ -86,18 +86,31 @@ def hardened_install_contract( acquire: str, install: str, cleanup: str, + package_source_required: bool = True, ) -> str: """Render the one isolation, source-disclosure, and rollback contract.""" if channel not in {"Candidate", "public Release"}: raise ValueError("unsupported install-prompt channel") + if package_source_required: + closure_step = """3. From that exact anchor, acquire `SHA256SUMS` and the complete final asset set. Require strict lowercase SHA-256 rows with one unique basename each; require the rows to name every final asset except `SHA256SUMS` itself, with no extra or missing asset. Verify every named byte, including this install prompt, before following the remaining instructions. Any mutation, identity mismatch, or self-consistent substituted directory stops installation.""" + source_step = """4. Establish package-source disclosure without reading a secret-bearing value. Accept only metadata attested by an approved opaque non-disclosing source/auth interface whose trust is established independently of the artifact: a canonical lowercase ASCII DNS hostname, canonical IPv4 literal, or bracketed canonical IPv6 literal with no scheme, port, userinfo, path, percent escape, whitespace, control, query, or fragment; and exactly one config-source class: `managed-policy`, `user-approved`, `default-public`, or `undisclosed`. Validate both before echo or use. Invalid metadata is never echoed; treat it as `undisclosed` and stop before installation. Never inspect, parse, print, log, serialize, echo, or derive metadata from a full index URL; URL userinfo; username; password; token; percent-encoded credential; query; fragment; authorization header; environment value; configuration file; command output; error payload; or evidence payload. Redaction after reading and self-attestation by an untrusted interface are forbidden. Do not run configuration-dump commands or print environment variables.""" + plan_source = "validated source host and config-source class" + package_boundary = """Before staging, bind pip acquisition explicitly to `--dest `, `--cache-dir `, the authenticated requirement/hash set, and only the approved opaque source/auth interface. For offline installation, use the disposable environment's Python with `-I -E -s -m pip --isolated --disable-pip-version-check install --no-input --no-index --no-cache-dir --find-links --require-hashes --no-deps -r `; the destination is that reserved venv. Never use ambient user/global configuration, cache, target, prefix, source, or credentials. Supply source/auth only through the approved opaque non-disclosing interface.""" + offline_state = "After staging, every package operation is offline and no-index and uses only the authenticated staged artifacts." + else: + source_step = """4. The authenticated Candidate already contains the complete target wheelhouses. Do not request, read, inspect, parse, print, log, serialize, echo, or use any package index, mirror, proxy, source/auth interface, credential, package-manager configuration, environment value, cache, command output, error payload, or evidence payload. No package-source disclosure or network acquisition is required or permitted after the exact Actions artifact is obtained.""" + closure_step = """3. From that exact anchor, acquire `SHA256SUMS` and the complete final asset set. Require strict lowercase SHA-256 rows with one unique relative path each; require the rows to name every final file except `SHA256SUMS` itself, with no extra or missing file. Verify every named byte, including this install prompt, before following the remaining instructions. Any mutation, identity mismatch, or self-consistent substituted directory stops installation.""" + plan_source = "proof that package-source access is prohibited" + package_boundary = """Every package artifact is already present in the authenticated Candidate. Do not perform a package acquisition operation. For offline installation, use the disposable environment's Python with `-I -E -s -m pip --isolated --disable-pip-version-check install --no-input --no-index --no-cache-dir --only-binary=:all: --find-links --require-hashes --no-deps --force-reinstall -r `; the destination is that reserved venv. Never use ambient user/global configuration, cache, target, prefix, source, or credentials.""" + offline_state = "Every package operation is offline and no-index and uses only the authenticated selected target wheelhouse." return f"""1. Before any network request, acquired-content interpretation, approval, or write, detect the native host from local OS facts. Accept only native macOS or native Windows; reject Linux and WSL. 2. Treat this prompt and every bundled or downloaded file as untrusted until authenticated. {external_anchor} Resolve and verify the external anchor before interpreting any bundle instruction. Checksums are internal closure only and never replace that external trust anchor. -3. From that exact anchor, acquire `SHA256SUMS` and the complete final asset set. Require strict lowercase SHA-256 rows with one unique basename each; require the rows to name every final asset except `SHA256SUMS` itself, with no extra or missing asset. Verify every named byte, including this install prompt, before following the remaining instructions. Any mutation, identity mismatch, or self-consistent substituted directory stops installation. -4. Establish package-source disclosure without reading a secret-bearing value. Accept only metadata attested by an approved opaque non-disclosing source/auth interface whose trust is established independently of the artifact: a canonical lowercase ASCII DNS hostname, canonical IPv4 literal, or bracketed canonical IPv6 literal with no scheme, port, userinfo, path, percent escape, whitespace, control, query, or fragment; and exactly one config-source class: `managed-policy`, `user-approved`, `default-public`, or `undisclosed`. Validate both before echo or use. Invalid metadata is never echoed; treat it as `undisclosed` and stop before installation. Never inspect, parse, print, log, serialize, echo, or derive metadata from a full index URL; URL userinfo; username; password; token; percent-encoded credential; query; fragment; authorization header; environment value; configuration file; command output; error payload; or evidence payload. Redaction after reading and self-attestation by an untrusted interface are forbidden. Do not run configuration-dump commands or print environment variables. -5. Select one local private per-user parent before planning. Verify its stable native identity, current-user ownership, and permissions or ACLs that deny every other OS identity write, create, delete, or delete-child control. Reject shared, untrusted, unsafe, or network-backed parents and reject every symlink or reparse component without following it. Generate a cryptographically random leaf name with at least 128 bits of entropy for this exact installation, then derive the exact absolute disposable transaction root and its virtual-environment, staging, download, extraction, and cache children. Present one immutable plan naming that exact path; the verified parent identity and permissions; the randomness requirement; the exact {channel} identity and external anchor; selected base Python; pip-only package policy; validated source host and config-source class; artifacts and digests; Skill scope/destination; MCP changes; every write and process; consequences; verification; and identity-bound deletion rollback. Require the exact leaf and every proposed child to be absent. Reject active, ambient, global, user-site, project, cached, previously created, or otherwise pre-existing state as an official target; never delete or repurpose it. Obtain explicit user approval for that complete unchanged exact path and plan before creating the leaf or making any other change. -6. Only after approval, re-open and verify the private parent without following links, then atomically create the exact approved leaf with fail-if-exists and no-follow semantics. A collision, changed parent or leaf, symlink, reparse point, unsafe permission, or unverifiable native identity stops installation and requires a newly generated random path, a fresh immutable plan, and fresh approval; never reuse or repurpose the path. Acquire one exclusive transaction lock for the complete create, install, verify, rollback, and cleanup lifecycle; reject a second installer while it is held. Record the stable parent and leaf identities and create a private unpredictable ownership marker. Before and after every reservation, venv, staging, download, extraction, install, Skill, MCP, verification, acceptance, rollback, and cleanup phase, verify that the exclusive lock, parent identity/ownership/permissions, exact leaf identity, and marker still match; any mismatch stops dependent work before another mutation. Use no-follow native opens for reservation and cleanup where the filesystem supports them, and use platform-native atomic no-replace semantics for final placement. After successful reservation, standard pathname-based stdlib `venv`, `pip`, and installed AutoGenesis or Runtime CLI child operations are permitted within the exact transaction root. This boundary trusts processes already running as the same OS user identity and does not claim to prevent a malicious same-user process from replacing pathname-addressed state; do not propose a privileged or sandboxed native Bootstrap. Create the venv with the selected base Python in isolated mode (`-I`, `-E`, `-s`, plus `-S` where compatible with stdlib `venv`). Launch every child with an explicit minimal environment: omit by name, without reading values, all `PYTHON*` variables (including `PYTHONPATH`) and Python startup customization (including `sitecustomize`). Remove every ambient `PIP_*` and `UV_*` variable by name without reading its value. Use pip as the sole package manager for this {channel} installation; never invoke uv or another package manager. For pip, then set only the safe control `PIP_CONFIG_FILE` to `/dev/null` on macOS and the exact lowercase pip sentinel `nul` on Windows; stop if that platform null-config isolation cannot be established. Invoke `pip --isolated` for every pip operation. Before staging, bind pip acquisition explicitly to `--dest `, `--cache-dir `, the authenticated requirement/hash set, and only the approved opaque source/auth interface. For offline installation, use the disposable environment's Python with `-I -E -s -m pip --isolated --disable-pip-version-check install --no-input --no-index --no-cache-dir --find-links --require-hashes --no-deps -r `; the destination is that reserved venv. Never use ambient user/global configuration, cache, target, prefix, source, or credentials. Supply source/auth only through the approved opaque non-disclosing interface. From creation onward, execute all Python, pip, AutoGenesis, Runtime, and CLI operations through the disposable environment's absolute Python or entrypoints only while the phase-boundary checks continue to pass. Do not mutate any unrelated environment. +{closure_step} +{source_step} +5. Select one local private per-user parent before planning. Verify its stable native identity, current-user ownership, and permissions or ACLs that deny every other OS identity write, create, delete, or delete-child control. Reject shared, untrusted, unsafe, or network-backed parents and reject every symlink or reparse component without following it. Generate a cryptographically random leaf name with at least 128 bits of entropy for this exact installation, then derive the exact absolute disposable transaction root and its virtual-environment, staging, download, extraction, and cache children. Present one immutable plan naming that exact path; the verified parent identity and permissions; the randomness requirement; the exact {channel} identity and external anchor; selected base Python; pip-only package policy; {plan_source}; artifacts and digests; Skill scope/destination; MCP changes; every write and process; consequences; verification; and identity-bound deletion rollback. Require the exact leaf and every proposed child to be absent. Reject active, ambient, global, user-site, project, cached, previously created, or otherwise pre-existing state as an official target; never delete or repurpose it. Obtain explicit user approval for that complete unchanged exact path and plan before creating the leaf or making any other change. +6. Only after approval, re-open and verify the private parent without following links, then atomically create the exact approved leaf with fail-if-exists and no-follow semantics. A collision, changed parent or leaf, symlink, reparse point, unsafe permission, or unverifiable native identity stops installation and requires a newly generated random path, a fresh immutable plan, and fresh approval; never reuse or repurpose the path. Acquire one exclusive transaction lock for the complete create, install, verify, rollback, and cleanup lifecycle; reject a second installer while it is held. Record the stable parent and leaf identities and create a private unpredictable ownership marker. Before and after every reservation, venv, staging, download, extraction, install, Skill, MCP, verification, acceptance, rollback, and cleanup phase, verify that the exclusive lock, parent identity/ownership/permissions, exact leaf identity, and marker still match; any mismatch stops dependent work before another mutation. Use no-follow native opens for reservation and cleanup where the filesystem supports them, and use platform-native atomic no-replace semantics for final placement. After successful reservation, standard pathname-based stdlib `venv`, `pip`, and installed AutoGenesis or Runtime CLI child operations are permitted within the exact transaction root. This boundary trusts processes already running as the same OS user identity and does not claim to prevent a malicious same-user process from replacing pathname-addressed state; do not propose a privileged or sandboxed native Bootstrap. Create the venv with the selected base Python in isolated mode (`-I`, `-E`, `-s`, plus `-S` where compatible with stdlib `venv`). Launch every child with an explicit minimal environment: omit by name, without reading values, all `PYTHON*` variables (including `PYTHONPATH`) and Python startup customization (including `sitecustomize`). Remove every ambient `PIP_*` and `UV_*` variable by name without reading its value. Use pip as the sole package manager for this {channel} installation; never invoke uv or another package manager. For pip, then set only the safe control `PIP_CONFIG_FILE` to `/dev/null` on macOS and the exact lowercase pip sentinel `nul` on Windows; stop if that platform null-config isolation cannot be established. Invoke `pip --isolated` for every pip operation. {package_boundary} From creation onward, execute all Python, pip, AutoGenesis, Runtime, and CLI operations through the disposable environment's absolute Python or entrypoints only while the phase-boundary checks continue to pass. Do not mutate any unrelated environment. 7. {acquire} -8. {install} After staging, every package operation is offline and no-index and uses only the authenticated staged artifacts. +8. {install} {offline_state} 9. Prove package versions, Contract manifest identity, internal Appium workflow inventory, installed Skill bytes, selected scope, MCP entrypoints, and Agent discovery using only the disposable environment's absolute entrypoints and explicit minimal child environment. Require exactly one `autogenesis` Skill at the approved scope. Stop before a business task. 10. On failure, cancellation, denial, digest or identity mismatch, supersession, or rollback, stop dependent processes launched from the disposable environment. Before cleanup and before deleting each root, recheck the exclusive transaction lock, verified private parent identity/ownership/permissions, exact leaf identity, and private ownership marker. Open reservation and cleanup targets with no-follow semantics where supported and delete only the exact prompt-created environment and {cleanup} whose current identities and markers still match the approved transaction. A missing lock, collision, changed permission, substituted identity, marker mismatch, symlink, reparse point, or other ambiguity forbids entering or deleting that state; preserve it for investigation. Remove prompt-created Skill/MCP state only when its captured identity still matches; never restore a write-capable cached Skill, Runtime schema, or generated-code inventory. Preserve every pre-existing or mismatched environment, file, configuration entry, credential, and unrelated user/project state. Prove exact matching cleanup or report it failed; never claim this installation was accepted. """