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