diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 98fb3338..7ab37e97 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,11 +57,14 @@ on: - cron: "0 7 * * 0" # Sundays 07:00 UTC: full compatibility matrix workflow_dispatch: -# Cancel any superseded in-flight run for the same ref (e.g. a force-push or a -# rapid second push to a PR branch) instead of letting both run to completion. +# Cancel a superseded ordinary run for the same ref (e.g. a force-push or a +# rapid second push to a PR branch). Release qualification dispatches use the +# exact candidate SHA as a separate group and are never canceled. A later push +# to main therefore cannot invalidate a matrix that the release gate is already +# waiting to inspect. concurrency: - group: ci-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + group: ci-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.sha || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} # GitHub Actions are pinned to full commit SHAs (not mutable tags) as a # supply-chain control; the trailing comment records the human-readable diff --git a/.github/workflows/quickstart-lifecycle.yml b/.github/workflows/quickstart-lifecycle.yml index 7d2f77f0..270f6018 100644 --- a/.github/workflows/quickstart-lifecycle.yml +++ b/.github/workflows/quickstart-lifecycle.yml @@ -8,8 +8,11 @@ on: workflow_dispatch: concurrency: - group: quickstart-lifecycle-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true + # Keep an exact-SHA release qualification isolated from scheduled and push + # activity on main. The release gate must be able to inspect the dispatched + # three-OS result even if main advances while that matrix is still running. + group: quickstart-lifecycle-${{ github.workflow }}-${{ github.event_name == 'workflow_dispatch' && github.sha || github.ref }} + cancel-in-progress: ${{ github.event_name != 'workflow_dispatch' }} permissions: contents: read @@ -41,9 +44,8 @@ jobs: python -m pip install build python -m build --wheel --outdir lifecycle-dist - # The Linux lane passes --browser-with-deps, so the harness shells out to - # `playwright install --with-deps`, and that runs apt on the hosted - # runner. Point apt at the canonical archive first, and prove the archive + # The Linux lane installs only Chromium's host libraries before the + # tutorial. Point apt at the canonical archive first, and prove the archive # answers inside a bounded step, so a mirror fault fails here in seconds # instead of stalling inside the lifecycle harness. - name: Prefer the canonical Ubuntu archive (Linux) @@ -81,7 +83,7 @@ jobs: --wheel "lifecycle-dist/*.whl" --work-dir "runs/lifecycle" --install-browser - --browser-with-deps + --browser-system-deps --source-revision "${{ github.sha }}" - name: Full lifecycle (macOS / Windows) diff --git a/README.md b/README.md index 9fd7a48f..215433b9 100644 --- a/README.md +++ b/README.md @@ -479,10 +479,15 @@ episode instead. This package keeps the worker and the HTTP client. See ```bash git clone https://github.com/OpenAdaptAI/openadapt-flow && cd openadapt-flow pip install -e '.[dev]' -playwright install chromium # optional; otherwise downloaded on first launch +python -m playwright install chromium # optional browser pre-provisioning pytest -q ``` +On Linux, Flow checks the Chromium host libraries before an automatic browser +download. A minimal host may need a one-time system-library install. Flow stops +before the download and prints a command for the exact Python environment that +runs it. + Contributions welcome, see [CONTRIBUTING.md](CONTRIBUTING.md). If you want a first one that is genuinely useful: pick a module off the mypy type-debt burn-down list (`[[tool.mypy.overrides]]` in `pyproject.toml`), tighten its diff --git a/docs/TUTORIAL.md b/docs/TUTORIAL.md index 1b02d841..0bc880a0 100644 --- a/docs/TUTORIAL.md +++ b/docs/TUTORIAL.md @@ -92,11 +92,14 @@ one-command rollback). See [`REPAIR_LIFECYCLE.md`](REPAIR_LIFECYCLE.md). The base `openadapt-flow` package stays lightweight for native desktop, RDP, and Citrix runners. The `browser` extra adds Playwright only for web workflows; -the first browser command then downloads its matching Chromium build once -(about 150 MB), with no separate `playwright install chromium` step. Prefer the -canonical `pip install 'openadapt[browser]'` launcher path for normal use. In -air-gapped or CI environments that pre-provision the browser, set -`OPENADAPT_FLOW_NO_AUTO_INSTALL=1` to disable the auto-download. +the first browser command checks its Linux host libraries and then downloads +the matching Chromium build once (about 150 MB). A minimal Linux host may need +Playwright's one-time system-library install. Flow stops before the browser +download and prints a command bound to the exact Python environment that runs +it. Playwright requests administrator access if the system package manager +needs it. Prefer the canonical `pip install 'openadapt[browser]'` launcher path +for normal use. In air-gapped or CI environments that pre-provision the +browser, set `OPENADAPT_FLOW_NO_AUTO_INSTALL=1` to disable the auto-download. The weekly clean-machine test runs this complete install-to-uninstall journey on Linux, macOS, and Windows. See the diff --git a/openadapt_flow/_browser_setup.py b/openadapt_flow/_browser_setup.py index e8c97312..4d762202 100644 --- a/openadapt_flow/_browser_setup.py +++ b/openadapt_flow/_browser_setup.py @@ -32,6 +32,7 @@ import importlib.util import os import re +import shlex import subprocess import sys import threading @@ -112,6 +113,11 @@ def _opted_out() -> bool: ) +def _playwright_module_command(*args: str) -> str: + """Return a copyable Playwright command for this exact Python environment.""" + return shlex.join([sys.executable, "-m", "playwright", *args]) + + def _missing_chromium_system_libs() -> list[str]: """Return the Chromium shared libraries missing on this Linux machine. @@ -139,11 +145,14 @@ def _require_linux_system_libs() -> None: if not missing: return libs = ", ".join(missing) + install_deps = _playwright_module_command("install-deps", "chromium") raise RuntimeError( "Chromium cannot launch on this machine yet: required system " f"libraries are missing ({libs}).\n\n" - "Install them once with:\n\n" - " sudo python -m playwright install-deps chromium\n\n" + "Install them once with the same Python environment that runs " + "OpenAdapt. Playwright requests administrator access if the system " + "package manager needs it:\n\n" + f" {install_deps}\n\n" "or, on Debian/Ubuntu:\n\n" f" sudo apt-get install -y {_LINUX_APT_PACKAGES}\n\n" "Then run your command again. Nothing was downloaded." diff --git a/public-artifacts.json b/public-artifacts.json index 0482ebb3..68dafdce 100644 --- a/public-artifacts.json +++ b/public-artifacts.json @@ -93,7 +93,7 @@ }, { "path": ".github/workflows/ci.yml", - "sha256": "f2b9a6cb8fced9af463bf3cd50cedaeafcb928baaeb4fc0bc757a3864298a20f" + "sha256": "d1a12b5a5c1a067300893a2f7805988507b435b67bb802140bd43d6a96de8f91" }, { "path": ".github/workflows/citrix-workspace-standin.yml", @@ -113,7 +113,7 @@ }, { "path": ".github/workflows/quickstart-lifecycle.yml", - "sha256": "8eae9755b0ba287c708dd078b996f4b139e949ae375cfc5bb7a6e0f441b38ece" + "sha256": "da8017d063f13283f7c257caa4071d07c14e3b99d04a6ae8b246d751766e5f94" }, { "path": ".github/workflows/release-health.yml", @@ -5801,7 +5801,7 @@ }, { "path": "source-policy.public.json", - "sha256": "b03b1f9bb0d3b4631c84db1c7e1dd7f26f1cd04e9b4d4d18f6cab2776eed7eb6" + "sha256": "7b16bdc1800a0585c8f966dbce5be6d6d9b2abce4327b1a32d631c2de3c8ef29" }, { "path": "tests/fixtures/flow_v1_34_0_terminal_verified.json", diff --git a/scripts/check_release_consistency.py b/scripts/check_release_consistency.py index af619ab6..2de1e032 100644 --- a/scripts/check_release_consistency.py +++ b/scripts/check_release_consistency.py @@ -55,6 +55,9 @@ # than a hardcoded list, because everyone believes it ran. SOURCE_POLICY_PATH = ROOT / "source-policy.public.json" SOURCE_POLICY_SCHEMA_VERSION = 1 +MAX_ARCHIVE_MEMBERS = 20_000 +MAX_ARCHIVE_MEMBER_BYTES = 64 * 1024 * 1024 +MAX_ARCHIVE_EXPANDED_BYTES = 512 * 1024 * 1024 class SourcePolicyError(RuntimeError): @@ -78,20 +81,58 @@ class SourcePolicy(NamedTuple): path_prefixes: tuple[str, ...] path_segments: frozenset[str] content_signatures: tuple[bytes, ...] + content_regex: re.Pattern[str] + tree_content_regex: re.Pattern[str] crown_jewel_categories: frozenset[str] policy_digest: str def load_source_policy(path: Path = SOURCE_POLICY_PATH) -> SourcePolicy: """Return the rendered policy, or raise so the caller fails closed.""" + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW try: - raw = path.read_text(encoding="utf-8") + before = path.lstat() + if not stat.S_ISREG(before.st_mode): + raise SourcePolicyError(f"{path} is a symlink or special file") + descriptor = os.open(path, flags) + except SourcePolicyError: + raise except OSError as exc: raise SourcePolicyError( f"cannot read the rendered source policy {path}: {exc}. It is rendered " "from OpenAdaptAI/openadapt-internal:source-policy.yaml and must be " "committed in this repository" ) from exc + try: + opened = os.fstat(descriptor) + if ( + not stat.S_ISREG(before.st_mode) + or not stat.S_ISREG(opened.st_mode) + or before.st_dev != opened.st_dev + or before.st_ino != opened.st_ino + ): + raise SourcePolicyError(f"{path} is a symlink or special file") + chunks: list[bytes] = [] + while True: + chunk = os.read(descriptor, 1024 * 1024) + if not chunk: + break + chunks.append(chunk) + after = os.fstat(descriptor) + if ( + after.st_size != opened.st_size + or after.st_mtime_ns != opened.st_mtime_ns + or after.st_dev != opened.st_dev + or after.st_ino != opened.st_ino + ): + raise SourcePolicyError(f"{path} changed while it was read") + raw = b"".join(chunks).decode("utf-8") + except UnicodeDecodeError as exc: + raise SourcePolicyError(f"{path} is not valid UTF-8") from exc + finally: + os.close(descriptor) try: document = json.loads(raw) except json.JSONDecodeError as exc: @@ -103,6 +144,15 @@ def load_source_policy(path: Path = SOURCE_POLICY_PATH) -> SourcePolicy: f"{path}: schema_version is {document.get('schema_version')!r}, expected " f"{SOURCE_POLICY_SCHEMA_VERSION}; refusing to enforce an unknown schema" ) + claimed_digest = document.get("policy_digest") + unsigned = dict(document) + unsigned.pop("policy_digest", None) + canonical_unsigned = ( + json.dumps(unsigned, indent=2, sort_keys=True, ensure_ascii=True) + "\n" + ).encode("utf-8") + expected_digest = "sha256:" + hashlib.sha256(canonical_unsigned).hexdigest() + if claimed_digest != expected_digest: + raise SourcePolicyError(f"{path}: policy_digest does not match its content") enforcement = document.get("enforcement") if not isinstance(enforcement, dict): raise SourcePolicyError(f"{path}: enforcement block is missing") @@ -114,6 +164,26 @@ def load_source_policy(path: Path = SOURCE_POLICY_PATH) -> SourcePolicy: raise SourcePolicyError(f"{path}: crown_jewel_categories must be non-empty") if not all(isinstance(name, str) and name.strip() for name in categories): raise SourcePolicyError(f"{path}: crown_jewel_categories must be strings") + repositories = document.get("public_repositories") + flow_policy = ( + repositories.get("openadapt-flow") if isinstance(repositories, dict) else None + ) + if ( + not isinstance(flow_policy, dict) + or flow_policy.get("classification") != "public" + ): + raise SourcePolicyError( + f"{path}: openadapt-flow must be classified as a public repository" + ) + must_not_contain = flow_policy.get("must_not_contain") + if ( + not isinstance(must_not_contain, list) + or not all(isinstance(item, str) for item in must_not_contain) + or not set(categories).issubset(must_not_contain) + ): + raise SourcePolicyError( + f"{path}: openadapt-flow must_not_contain omits a crown-jewel category" + ) signature_parts = enforcement.get("content_signature_parts") if not isinstance(signature_parts, list) or not signature_parts: @@ -131,6 +201,39 @@ def load_source_policy(path: Path = SOURCE_POLICY_PATH) -> SourcePolicy: raise SourcePolicyError(f"{path}: a content signature is empty") signatures.append(joined.encode("ascii")) + artifact_view = artifacts + content_patterns = _policy_strings( + artifact_view, + "content_patterns", + where="enforcement.built_artifacts", + ) + try: + content_regex = re.compile( + "|".join(f"(?:{pattern})" for pattern in content_patterns), + re.IGNORECASE, + ) + except re.error as exc: + raise SourcePolicyError( + f"{path}: enforcement.built_artifacts.content_patterns is invalid: {exc}" + ) from exc + tree_view = enforcement.get("repository_tree") + if not isinstance(tree_view, dict): + raise SourcePolicyError(f"{path}: enforcement.repository_tree is missing") + tree_content_patterns = _policy_strings( + tree_view, + "content_patterns", + where="enforcement.repository_tree", + ) + try: + tree_content_regex = re.compile( + "|".join(f"(?:{pattern})" for pattern in tree_content_patterns), + re.IGNORECASE, + ) + except re.error as exc: + raise SourcePolicyError( + f"{path}: enforcement.repository_tree.content_patterns is invalid: {exc}" + ) from exc + return SourcePolicy( path_tokens=tuple( _policy_strings(enforcement, "path_tokens", where="enforcement") @@ -144,8 +247,10 @@ def load_source_policy(path: Path = SOURCE_POLICY_PATH) -> SourcePolicy: _policy_strings(enforcement, "private_path_segments", where="enforcement") ), content_signatures=tuple(signatures), + content_regex=content_regex, + tree_content_regex=tree_content_regex, crown_jewel_categories=frozenset(str(name) for name in categories), - policy_digest=str(document.get("policy_digest", "unknown")), + policy_digest=claimed_digest, ) @@ -274,6 +379,15 @@ def load_source_policy(path: Path = SOURCE_POLICY_PATH) -> SourcePolicy: } ) PUBLIC_SOURCE_ANYWHERE_IGNORED_DIRECTORIES = frozenset({"__pycache__"}) +PUBLIC_SOURCE_PATTERN_EXEMPT_PATHS = frozenset( + { + "benchmark/vision_hardening/README.md", + "scripts/check_release_consistency.py", + "source-policy.public.json", + "tests/test_release_contract.py", + "tests/test_source_policy_binding.py", + } +) # Assembled from parts so neither this guard nor the rendered policy file (both # of which ship in the sdist) trips the content scan; every private-corpus # artifact carries the full banner. The parts come from the manifest. @@ -477,6 +591,15 @@ def _private_distribution_hits(members: set[str], signature_hits: set[str]) -> s return hits +def _private_content_pattern_hits(payloads: dict[str, bytes]) -> set[str]: + """Return renamed private material found by canonical content patterns.""" + hits: set[str] = set() + for member, payload in payloads.items(): + if SOURCE_POLICY.content_regex.search(payload.decode("utf-8", errors="ignore")): + hits.add(member) + return hits + + def _repository_only_evaluation_hits(members: set[str]) -> set[str]: """Members that are public-source evaluation data, not package runtime.""" return { @@ -597,9 +720,86 @@ def _walk_public_source_files(root: Path) -> dict[str, Path]: f"public source tree contains a symlink/special file: {relative}" ) files[relative] = candidate + if (root / ".git").exists(): + tracked_result = subprocess.run( + ["git", "-C", str(root), "ls-files", "-z"], + check=False, + capture_output=True, + ) + if tracked_result.returncode != 0: + raise ValueError("could not enumerate tracked public source files") + try: + tracked = [ + item.decode("utf-8") + for item in tracked_result.stdout.split(b"\0") + if item + ] + except UnicodeDecodeError as exc: + raise ValueError("tracked public source path is not valid UTF-8") from exc + for raw_relative in tracked: + relative = _canonical_source_path(raw_relative, source="git index") + if relative in files: + continue + candidate = root / relative + try: + mode = candidate.lstat().st_mode + except OSError as exc: + raise ValueError( + f"tracked public source file is unavailable: {relative}" + ) from exc + if not stat.S_ISREG(mode): + raise ValueError( + f"public source tree contains a symlink/special file: {relative}" + ) + files[relative] = candidate return files +def _read_public_source_file(path: Path, *, relative: str) -> bytes: + """Read one regular source file without following a replaced link.""" + + flags = os.O_RDONLY + if hasattr(os, "O_NOFOLLOW"): + flags |= os.O_NOFOLLOW + try: + before = path.lstat() + descriptor = os.open(path, flags) + except OSError as exc: + raise ValueError( + f"public source tree contains an unreadable or unsafe file: {relative}" + ) from exc + try: + opened = os.fstat(descriptor) + if ( + not stat.S_ISREG(before.st_mode) + or not stat.S_ISREG(opened.st_mode) + or before.st_dev != opened.st_dev + or before.st_ino != opened.st_ino + ): + raise ValueError( + f"public source tree contains a symlink/special file: {relative}" + ) + chunks: list[bytes] = [] + while True: + chunk = os.read(descriptor, 1024 * 1024) + if not chunk: + break + chunks.append(chunk) + after = os.fstat(descriptor) + if ( + after.st_size != opened.st_size + or after.st_mtime_ns != opened.st_mtime_ns + or after.st_dev != opened.st_dev + or after.st_ino != opened.st_ino + ): + raise ValueError( + f"public source tree file changed while it was read: {relative}" + ) + return b"".join(chunks) + finally: + os.close(descriptor) + + def build_public_artifact_inventory(root: Path = ROOT) -> dict[str, object]: """Build the deterministic inventory document for explicit human review.""" files = _walk_public_source_files(root) @@ -687,21 +887,35 @@ def _parse_public_artifact_inventory(payload: bytes, *, source: str) -> dict[str return inventory -def _load_public_artifact_inventory(root: Path = ROOT) -> dict[str, str]: +def _load_public_artifact_inventory( + root: Path = ROOT, *, payload: bytes | None = None +) -> dict[str, str]: path = root / PUBLIC_ARTIFACT_INVENTORY_PATH - try: - payload = path.read_bytes() - except OSError as error: - raise ValueError( - f"could not read reviewed public artifact inventory at {path}: {error}" - ) from error + if payload is None: + try: + payload = _read_public_source_file( + path, relative=PUBLIC_ARTIFACT_INVENTORY_PATH + ) + except ValueError as error: + raise ValueError( + f"could not read reviewed public artifact inventory at {path}: {error}" + ) from error return _parse_public_artifact_inventory(payload, source=str(path)) def _validate_public_artifact_inventory( - files: dict[str, Path], *, root: Path = ROOT + files: dict[str, Path], + *, + root: Path = ROOT, + payloads: dict[str, bytes] | None = None, ) -> dict[str, str]: - inventory = _load_public_artifact_inventory(root) + payloads = payloads or { + path: _read_public_source_file(source, relative=path) + for path, source in files.items() + } + inventory = _load_public_artifact_inventory( + root, payload=payloads.get(PUBLIC_ARTIFACT_INVENTORY_PATH) + ) observed = {path for path in files if _artifact_inventory_candidate(path)} expected = set(inventory) if observed != expected: @@ -714,7 +928,7 @@ def _validate_public_artifact_inventory( changed = [ path for path in sorted(expected) - if _sha256_file(files[path]) != inventory[path] + if _sha256_bytes(payloads[path]) != inventory[path] ] if changed: raise ValueError( @@ -781,13 +995,20 @@ def _validate_lending_rate( return value -def _validate_bounded_lending_evidence(files: dict[str, Path]) -> None: +def _validate_bounded_lending_evidence( + files: dict[str, Path], *, payloads: dict[str, bytes] | None = None +) -> None: """Recursively enforce the deterministic, aggregate-only lending schema.""" path = files.get(LENDING_PUBLIC_EVIDENCE_PATH) if path is None: return try: - payload = json.loads(path.read_text(encoding="utf-8")) + raw = ( + payloads[LENDING_PUBLIC_EVIDENCE_PATH] + if payloads is not None + else _read_public_source_file(path, relative=LENDING_PUBLIC_EVIDENCE_PATH) + ) + payload = json.loads(raw.decode("utf-8")) except (OSError, UnicodeDecodeError, json.JSONDecodeError) as error: raise _bounded_lending_error( f"could not be parsed at {path}: {error}" @@ -995,20 +1216,31 @@ def _validate_archive_artifact_inventory( def validate_public_source_tree(root: Path = ROOT) -> None: """Fail if private data/recipes/tuning re-enter the public checkout.""" files = _walk_public_source_files(root) + payloads = { + member: _read_public_source_file(path, relative=member) + for member, path in files.items() + } members = set(files) - _validate_public_artifact_inventory(files, root=root) - _validate_bounded_lending_evidence(files) + _validate_public_artifact_inventory(files, root=root, payloads=payloads) + _validate_bounded_lending_evidence(files, payloads=payloads) private_signature_hits = { member - for member, path in files.items() - if any( - signature in path.read_bytes() - for signature in PRIVATE_CORPUS_CONTENT_SIGNATURES + for member, payload in payloads.items() + if any(signature in payload for signature in PRIVATE_CORPUS_CONTENT_SIGNATURES) + } + private_pattern_hits = { + member + for member, payload in payloads.items() + if member not in PUBLIC_SOURCE_PATTERN_EXEMPT_PATHS + and SOURCE_POLICY.tree_content_regex.search( + payload.decode("utf-8", errors="ignore") ) } - private = _private_distribution_hits(members, private_signature_hits) + private = _private_distribution_hits( + members, private_signature_hits | private_pattern_hits + ) repository_only = { member for member in members @@ -1184,15 +1416,32 @@ def _wheel_members( archive: zipfile.ZipFile, ) -> tuple[dict[str, zipfile.ZipInfo], set[str]]: by_name: dict[str, zipfile.ZipInfo] = {} - for member in archive.infolist(): + casefolded: set[str] = set() + infos = archive.infolist() + if len(infos) > MAX_ARCHIVE_MEMBERS: + raise ValueError("wheel contains too many members") + expanded_size = 0 + for member in infos: parts = _archive_parts(member.filename, source="wheel") normalized = "/".join(parts) - if normalized in by_name: + folded = normalized.casefold() + if normalized in by_name or folded in casefolded: raise ValueError(f"wheel contains duplicate member: {normalized!r}") mode = member.external_attr >> 16 - if stat.S_IFMT(mode) == stat.S_IFLNK: - raise ValueError(f"wheel contains a symlink member: {normalized!r}") + kind = stat.S_IFMT(mode) + if kind not in {0, stat.S_IFREG, stat.S_IFDIR}: + raise ValueError( + f"wheel contains a link/device/special member: {normalized!r}" + ) + if member.flag_bits & 0x1: + raise ValueError(f"wheel contains an encrypted member: {normalized!r}") + if member.file_size > MAX_ARCHIVE_MEMBER_BYTES: + raise ValueError(f"wheel member is too large: {normalized!r}") + expanded_size += member.file_size + if expanded_size > MAX_ARCHIVE_EXPANDED_BYTES: + raise ValueError("wheel expanded size is too large") by_name[normalized] = member + casefolded.add(folded) return by_name, set(by_name) @@ -1271,8 +1520,14 @@ def validate_sdist_license_boundary( signature_hits: set[str] = set() private_signature_hits: set[str] = set() payloads: dict[str, bytes] = {} + casefolded_members: set[str] = set() + member_count = 0 + expanded_size = 0 with tarfile.open(sdist, mode="r:gz") as archive: for member in archive: + member_count += 1 + if member_count > MAX_ARCHIVE_MEMBERS: + raise ValueError("source distribution contains too many members") parts = _archive_parts(member.name, source="source distribution") if parts[0] != expected_root: raise ValueError( @@ -1292,19 +1547,32 @@ def validate_sdist_license_boundary( f"{member.name!r}" ) relative = "/".join(parts[1:]) - if relative in members: + folded = relative.casefold() + if relative in members or folded in casefolded_members: raise ValueError( f"source distribution contains duplicate member: {relative!r}" ) members.add(relative) + casefolded_members.add(folded) if not member.isfile(): continue + if member.size > MAX_ARCHIVE_MEMBER_BYTES: + raise ValueError( + f"source distribution member is too large: {relative!r}" + ) + expanded_size += member.size + if expanded_size > MAX_ARCHIVE_EXPANDED_BYTES: + raise ValueError("source distribution expanded size is too large") extracted = archive.extractfile(member) if extracted is None: raise ValueError( f"source distribution member could not be read: {relative!r}" ) payload = extracted.read() + if len(payload) != member.size: + raise ValueError( + f"source distribution member size differs: {relative!r}" + ) payloads[relative] = payload if relative == "LICENSE": archived_license = payload @@ -1317,6 +1585,7 @@ def validate_sdist_license_boundary( ): private_signature_hits.add(relative) private = _private_distribution_hits(members, private_signature_hits) + private.update(_private_content_pattern_hits(payloads)) if private: raise ValueError( "source distribution contains private source-policy " @@ -1404,6 +1673,8 @@ def validate_wheel_license_boundary( if info.is_dir(): continue payload = archive.read(info) + if len(payload) != info.file_size: + raise ValueError(f"wheel member size differs: {name!r}") payloads[name] = payload if name in license_members: archived_license = payload @@ -1432,6 +1703,7 @@ def validate_wheel_license_boundary( expected_version=expected_version, ) private = _private_distribution_hits(members, private_signature_hits) + private.update(_private_content_pattern_hits(payloads)) if private: raise ValueError( "wheel contains private source-policy material " diff --git a/scripts/quickstart_lifecycle.py b/scripts/quickstart_lifecycle.py index 7ef099ac..43a3a9dd 100644 --- a/scripts/quickstart_lifecycle.py +++ b/scripts/quickstart_lifecycle.py @@ -100,6 +100,64 @@ def _console_script(root: Path) -> Path: ) +def _browser_presence( + python: Path, + *, + cwd: Path, + env: dict[str, str], + log: Path, +) -> dict[str, object]: + """Read Playwright's expected Chromium path without downloading it.""" + + probe = _run( + [ + str(python), + "-c", + ( + "import json; from pathlib import Path; " + "from playwright.sync_api import sync_playwright; " + "p=sync_playwright().start(); " + "path=Path(p.chromium.executable_path); " + "print(json.dumps({'executable':str(path),'present':path.is_file()})); " + "p.stop()" + ), + ], + cwd=cwd, + env=env, + log=log, + ) + payload = json.loads(probe.stdout.splitlines()[-1]) + if not isinstance(payload, dict) or not isinstance(payload.get("present"), bool): + raise AssertionError("Chromium presence probe returned invalid JSON") + return payload + + +def _verify_browser_launch( + python: Path, + *, + cwd: Path, + env: dict[str, str], + log: Path, +) -> None: + """Launch and close the Chromium that the first Flow command installed.""" + + _run( + [ + str(python), + "-c", + ( + "from playwright.sync_api import sync_playwright; " + "p=sync_playwright().start(); " + "browser=p.chromium.launch(headless=True); " + "browser.close(); p.stop(); print('Chromium launch verified')" + ), + ], + cwd=cwd, + env=env, + log=log, + ) + + def _inspect_opencv_provider( python: Path, *, @@ -277,7 +335,7 @@ def run_lifecycle( work_dir: Path, *, install_browser: bool, - browser_with_deps: bool, + browser_system_deps: bool, source_revision: str | None = None, ) -> dict[str, object]: """Run install through uninstall, returning the evidence summary.""" @@ -300,6 +358,10 @@ def run_lifecycle( # MockMed contains synthetic identities. Disable the optional PHI warning so # lifecycle output stays actionable; real regulated runs must use SCRUB=on. env["OPENADAPT_FLOW_SCRUB"] = "off" + if install_browser: + # Hosted runners can carry a global Playwright cache. An isolated empty + # cache makes the before/after result prove this wheel's lazy download. + env["PLAYWRIGHT_BROWSERS_PATH"] = str(work_dir / "playwright-browsers") installed = False summary: dict[str, object] = { "wheel": wheel.name, @@ -338,29 +400,73 @@ def run_lifecycle( log=logs / "03-cli-help.log", ) + if install_browser: + before = _browser_presence( + python, + cwd=artifacts, + env=env, + log=logs / "04-browser-before-flow.log", + ) + if before["present"]: + raise AssertionError( + "Chromium was present before the first Flow command" + ) + summary["browser_present_before_flow"] = False + # Linux needs host libraries that the ordinary unprivileged first-run - # download cannot install. Pre-provision them only in that lane. The - # macOS and Windows lanes leave Chromium absent here so the first Flow - # command proves the public lazy auto-install contract. - if browser_with_deps: - browser_command = [str(python), "-m", "playwright", "install"] - browser_command.append("--with-deps") - browser_command.append("chromium") + # download cannot install. Install only those libraries. Chromium must + # stay absent so the first Flow browser command exercises lazy delivery. + if browser_system_deps: _run( - browser_command, + [str(python), "-m", "playwright", "install-deps", "chromium"], + cwd=artifacts, + env=env, + log=logs / "04-browser-system-deps.log", + ) + after_deps = _browser_presence( + python, cwd=artifacts, env=env, - log=logs / "04-browser-install.log", + log=logs / "04-browser-after-system-deps.log", ) + if after_deps["present"]: + raise AssertionError("install-deps downloaded Chromium") + summary["browser_present_after_system_deps"] = False cli = [str(python), "-m", "openadapt_flow"] + # Exercise the advertised one-command path before any other Flow command + # can install Chromium for it. + _run( + [*cli, "tutorial", "--out", str(artifacts / "tutorial")], + cwd=artifacts, + env=env, + log=logs / "05-tutorial-verified.log", + ) + if install_browser: + after = _browser_presence( + python, + cwd=artifacts, + env=env, + log=logs / "05-browser-after-tutorial.log", + ) + if not after["present"]: + raise AssertionError("the tutorial did not install Chromium") + _verify_browser_launch( + python, + cwd=artifacts, + env=env, + log=logs / "05-browser-launch.log", + ) + summary["browser_present_after_tutorial"] = True + summary["browser_launch_verified"] = True + recording = artifacts / "recording" bundle = artifacts / "bundle" _run( [*cli, "demo-record", "--out", str(recording)], cwd=artifacts, env=env, - log=logs / "05-record.log", + log=logs / "06-record.log", ) _run( [ @@ -374,7 +480,7 @@ def run_lifecycle( ], cwd=artifacts, env=env, - log=logs / "06-compile.log", + log=logs / "07-compile.log", ) # The bundled tutorial is deliberately not production-certified. The @@ -387,20 +493,20 @@ def run_lifecycle( [*cli, "lint", str(bundle), "--strict"], cwd=artifacts, env=env, - log=logs / "07-strict-lint-expected-refusal.log", + log=logs / "08-strict-lint-expected-refusal.log", expected=1, ) _run( [*cli, "certify", str(bundle), "--policy", "permissive"], cwd=artifacts, env=env, - log=logs / "08-certify-permissive.log", + log=logs / "09-certify-permissive.log", ) _run( [*cli, "certify", str(bundle), "--policy", "clinical-write"], cwd=artifacts, env=env, - log=logs / "09-certify-clinical-expected-refusal.log", + log=logs / "10-certify-clinical-expected-refusal.log", expected=2, ) _run( @@ -413,7 +519,7 @@ def run_lifecycle( ], cwd=artifacts, env=env, - log=logs / "10-replay-baseline.log", + log=logs / "11-replay-baseline.log", ) _run( [ @@ -429,15 +535,7 @@ def run_lifecycle( ], cwd=artifacts, env=env, - log=logs / "11-replay-drift.log", - ) - # The COMPOSED free path. Every command above passed while this loop - # was broken; only running it end to end catches that. - _run( - [*cli, "tutorial", "--out", str(artifacts / "tutorial")], - cwd=artifacts, - env=env, - log=logs / "12-tutorial-verified.log", + log=logs / "12-replay-drift.log", ) summary.update(_inspect_artifacts(artifacts)) finally: @@ -482,16 +580,16 @@ def build_parser() -> argparse.ArgumentParser: "--install-browser", action="store_true", help=( - "Install the wheel's browser extra; Chromium remains lazy unless " - "--browser-with-deps pre-provisions it" + "Install the wheel's browser extra; Chromium remains absent until " + "the first Flow browser command downloads it" ), ) parser.add_argument( - "--browser-with-deps", + "--browser-system-deps", action="store_true", help=( - "Pre-provision Chromium and its Linux host dependencies; without " - "this flag the first Flow command must auto-install Chromium" + "Install Linux Chromium host libraries without downloading the " + "browser; the first Flow command must auto-install Chromium" ), ) parser.add_argument( @@ -504,14 +602,14 @@ def build_parser() -> argparse.ArgumentParser: def main(argv: Sequence[str] | None = None) -> int: args = build_parser().parse_args(argv) - if args.browser_with_deps and not args.install_browser: - raise SystemExit("--browser-with-deps requires --install-browser") + if args.browser_system_deps and not args.install_browser: + raise SystemExit("--browser-system-deps requires --install-browser") wheel = _resolve_wheel(args.wheel) run_lifecycle( wheel, Path(args.work_dir).resolve(), install_browser=args.install_browser, - browser_with_deps=args.browser_with_deps, + browser_system_deps=args.browser_system_deps, source_revision=args.source_revision, ) return 0 diff --git a/source-policy.public.json b/source-policy.public.json index 368aa6e9..b327c851 100644 --- a/source-policy.public.json +++ b/source-policy.public.json @@ -11,6 +11,10 @@ ], "enforcement": { "built_artifacts": { + "content_patterns": [ + "deployment[_-]derived\\s+threshold\\s*=", + "oracle[_-]recipe[_-]id\\s*[:=]" + ], "path_prefixes": [ "docs/validation/adversary_corpus", "openadapt_flow/validation/adversary_corpus" @@ -70,8 +74,8 @@ } }, "generated_from": "OpenAdaptAI/openadapt-internal:source-policy.yaml", - "policy_digest": "sha256:d626f2ad18d2111c1b0cc0f715b1aa8b3a9d73dbb4723a2b2776cce0a9d47199", - "policy_last_updated": "2026-08-27", + "policy_digest": "sha256:183f653281a0f9792f7c54c3831f1a2ea8338a5a8a0af812ba47526d542c4843", + "policy_last_updated": "2026-09-03", "policy_version": 1, "public_repositories": { ".github": { @@ -139,6 +143,19 @@ ], "slug": "OpenAdaptAI/PydanticPrompt" }, + "ehr-integration-directory": { + "classification": "public", + "must_not_contain": [ + "control_plane", + "deployment_thresholds", + "enterprise_productionized", + "grown_corpus", + "oracle_recipes", + "real_emr_datasets", + "tuned_adversary_params" + ], + "slug": "OpenAdaptAI/ehr-integration-directory" + }, "openadapt-agent": { "classification": "public", "must_not_contain": [ diff --git a/tests/test_browser_setup.py b/tests/test_browser_setup.py index 7e493a16..109ad601 100644 --- a/tests/test_browser_setup.py +++ b/tests/test_browser_setup.py @@ -285,12 +285,42 @@ def test_missing_system_libs_abort_before_any_download(monkeypatch): msg = str(exc.value) assert "nss3" in msg - assert "playwright install-deps chromium" in msg # exact primary remedy + assert "-m playwright install-deps chromium" in msg + assert "sudo python -m playwright" not in msg + assert "requests administrator access" in msg assert "apt-get install" in msg # apt alternative line assert "Nothing was downloaded" in msg assert calls == [] +def test_linux_remedy_quotes_the_exact_python_environment(monkeypatch): + """The remedy survives spaces and does not depend on a PATH entry.""" + monkeypatch.setattr(bs.sys, "executable", "/opt/OpenAdapt Tool/bin/python3") + monkeypatch.setattr(bs, "_missing_chromium_system_libs", lambda: ["nss3"]) + + with pytest.raises(RuntimeError) as exc: + bs._require_linux_system_libs() + + msg = str(exc.value) + assert ( + "'/opt/OpenAdapt Tool/bin/python3' -m playwright install-deps chromium" in msg + ) + assert "sudo python" not in msg + + +def test_public_setup_copy_states_the_linux_dependency_boundary(): + """README and tutorial do not promise an unconditional first download.""" + root = Path(__file__).parents[1] + readme = (root / "README.md").read_text() + tutorial = (root / "docs" / "TUTORIAL.md").read_text() + + assert "checks the Chromium host libraries before" in readme + assert "A minimal Linux host may need" in tutorial + assert "exact Python environment" in readme + assert "exact Python environment" in tutorial + assert "sudo python -m playwright" not in readme + tutorial + + def test_present_system_libs_do_not_block_install(monkeypatch): """Empty probe result -> the normal download path proceeds unchanged.""" monkeypatch.setattr(bs, "_chromium_present", lambda: False) diff --git a/tests/test_ci_workflow_contract.py b/tests/test_ci_workflow_contract.py index 8408a1f7..be5ccaea 100644 --- a/tests/test_ci_workflow_contract.py +++ b/tests/test_ci_workflow_contract.py @@ -99,6 +99,17 @@ def test_playwright_installs_and_enclosing_jobs_are_bounded() -> None: assert "pytest -q --basetemp=runs/ci" in matrix_job +def test_clean_linux_lifecycle_keeps_chromium_lazy() -> None: + workflow = QUICKSTART.read_text(encoding="utf-8") + linux_start = workflow.index("- name: Full lifecycle (Linux)") + other_start = workflow.index("- name: Full lifecycle (macOS / Windows)") + linux_step = workflow[linux_start:other_start] + + assert "--browser-system-deps" in linux_step + assert "--browser-with-deps" not in workflow + assert "playwright install --with-deps" not in workflow + + def test_standard_browser_step_covers_cleanup_and_launch_worst_cases() -> None: attempts = 2 attempt_timeout_seconds = 270 diff --git a/tests/test_quickstart_lifecycle.py b/tests/test_quickstart_lifecycle.py index 9dfdcd21..033223df 100644 --- a/tests/test_quickstart_lifecycle.py +++ b/tests/test_quickstart_lifecycle.py @@ -83,6 +83,44 @@ def test_clean_browser_lifecycle_installs_the_browser_extra(tmp_path): ) +def test_browser_presence_reads_the_venv_playwright_path(tmp_path, monkeypatch): + lifecycle = _module() + captured = {} + + def fake_run(command, **kwargs): + captured["command"] = command + return subprocess.CompletedProcess( + command, + 0, + stdout='{"executable": "/isolated/chromium", "present": false}\n', + ) + + monkeypatch.setattr(lifecycle, "_run", fake_run) + + result = lifecycle._browser_presence( + Path("/venv/bin/python"), cwd=tmp_path, env={}, log=tmp_path / "probe.log" + ) + + assert result["present"] is False + assert captured["command"][0] == "/venv/bin/python" + assert "chromium.executable_path" in captured["command"][2] + + +def test_browser_system_deps_requires_browser_extra(): + lifecycle = _module() + + with pytest.raises(SystemExit, match="requires --install-browser"): + lifecycle.main( + [ + "--wheel", + "unused.whl", + "--work-dir", + "unused", + "--browser-system-deps", + ] + ) + + def test_run_forces_utf8_for_child_cli_and_log(tmp_path, monkeypatch): lifecycle = _module() captured = {} diff --git a/tests/test_release_contract.py b/tests/test_release_contract.py index 82017364..d2ebf6f5 100644 --- a/tests/test_release_contract.py +++ b/tests/test_release_contract.py @@ -5,12 +5,16 @@ import json import os import re +import stat +import subprocess import tarfile import zipfile from pathlib import Path import pytest +import scripts.check_release_consistency as release_guard + try: import tomllib except ModuleNotFoundError: # pragma: no cover - exercised on Python 3.10 CI @@ -315,6 +319,10 @@ def test_public_source_tree_rejects_private_segments_tokens_and_signatures( with pytest.raises(ValueError, match="public source tree contains private"): validate_public_source_tree(tmp_path) + renamed.write_bytes(b"deployment" + b"-derived threshold = 0.731") + with pytest.raises(ValueError, match="public source tree contains private"): + validate_public_source_tree(tmp_path) + def test_public_source_tree_rejects_symlinks_and_changed_inventory( tmp_path: Path, @@ -340,6 +348,30 @@ def test_public_source_tree_rejects_symlinks_and_changed_inventory( validate_public_source_tree(tmp_path) +def test_public_source_tree_rejects_special_files(tmp_path: Path) -> None: + if not hasattr(os, "mkfifo"): + pytest.skip("special files are unavailable on this platform") + write_public_artifact_inventory(tmp_path) + fifo = tmp_path / "openadapt_flow/pipe" + fifo.parent.mkdir() + os.mkfifo(fifo) + with pytest.raises(ValueError, match="symlink/special"): + validate_public_source_tree(tmp_path) + + +def test_public_source_tree_scans_tracked_files_under_ignored_build_roots( + tmp_path: Path, +) -> None: + subprocess.run(["git", "init", "-q", str(tmp_path)], check=True) + hidden = tmp_path / "dist/neutral.py" + hidden.parent.mkdir() + hidden.write_bytes(b"deployment" + b"-derived threshold = 0.731") + write_public_artifact_inventory(tmp_path) + subprocess.run(["git", "-C", str(tmp_path), "add", "."], check=True) + with pytest.raises(ValueError, match="public source tree contains private"): + validate_public_source_tree(tmp_path) + + def test_public_artifact_inventory_covers_data_and_configuration_suffixes() -> None: assert {".json", ".jsonl", ".csv", ".yaml", ".yml", ".toml"} <= ( PUBLIC_ARTIFACT_SUFFIXES @@ -484,6 +516,26 @@ def test_release_tag_requires_reviewed_exact_main_and_release_app() -> None: assert "quickstart-lifecycle.yml" in gate +def test_release_qualification_runs_cannot_be_canceled_by_main_activity() -> None: + """Exact-SHA matrices must survive unrelated work on the main ref.""" + for workflow_name in ("ci.yml", "quickstart-lifecycle.yml"): + workflow = (ROOT / ".github" / "workflows" / workflow_name).read_text() + concurrency = workflow[workflow.index("\nconcurrency:") :] + group = next( + line for line in concurrency.splitlines() if line.startswith(" group:") + ) + cancel = next( + line + for line in concurrency.splitlines() + if line.startswith(" cancel-in-progress:") + ) + + assert "github.event_name == 'workflow_dispatch'" in group + assert "github.sha" in group + assert "github.ref" in group + assert "github.event_name != 'workflow_dispatch'" in cancel + + def test_tag_publication_requires_exact_tag_oidc_and_digest_verification() -> None: workflow = (ROOT / ".github/workflows/release.yml").read_text() validate = workflow[ @@ -883,6 +935,101 @@ def test_sdist_refuses_private_corpus_material(tmp_path: Path) -> None: validate_sdist_license_boundary(mixed) +def test_archives_apply_canonical_private_content_patterns(tmp_path: Path) -> None: + """A renamed recipe must not bypass the canonical source policy.""" + license_path = "openadapt_flow-1.0.dist-info/licenses/LICENSE" + metadata_path = "openadapt_flow-1.0.dist-info/METADATA" + renamed = "openadapt_flow/neutral/settings.txt" + # Assemble the sentinel so this release-contract test remains subject to + # the same archive scan. A literal copy here would force an exemption. + private_payload = b"deployment" + b"-derived threshold = 0.731" + + wheel = tmp_path / "renamed-private.whl" + _write_wheel( + wheel, + {license_path, metadata_path, renamed}, + payloads={renamed: private_payload}, + ) + with pytest.raises(ValueError, match="private source-policy"): + validate_wheel_license_boundary(wheel) + + sdist = tmp_path / "renamed-private.tar.gz" + _write_sdist( + sdist, + {*REQUIRED_SDIST_PATHS, "PKG-INFO", renamed}, + payloads={renamed: private_payload}, + ) + with pytest.raises(ValueError, match="private source-policy"): + validate_sdist_license_boundary(sdist) + + +def test_archives_reject_casefold_collisions(tmp_path: Path) -> None: + wheel = tmp_path / "casefold.whl" + _write_wheel( + wheel, + { + "openadapt_flow-1.0.dist-info/licenses/LICENSE", + "openadapt_flow-1.0.dist-info/METADATA", + "openadapt_flow/name.py", + "openadapt_flow/NAME.py", + }, + ) + with pytest.raises(ValueError, match="duplicate member"): + validate_wheel_license_boundary(wheel) + + sdist = tmp_path / "casefold.tar.gz" + _write_sdist( + sdist, + { + *REQUIRED_SDIST_PATHS, + "PKG-INFO", + "openadapt_flow/name.py", + "openadapt_flow/NAME.py", + }, + ) + with pytest.raises(ValueError, match="duplicate member"): + validate_sdist_license_boundary(sdist) + + +def test_wheel_rejects_device_members(tmp_path: Path) -> None: + wheel = tmp_path / "device.whl" + with zipfile.ZipFile(wheel, "w") as archive: + info = zipfile.ZipInfo("openadapt_flow/device") + info.create_system = 3 + info.external_attr = stat.S_IFCHR << 16 + archive.writestr(info, b"") + with pytest.raises(ValueError, match="link/device/special"): + validate_wheel_license_boundary(wheel) + + +def test_archives_enforce_member_and_expanded_size_bounds( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + wheel = tmp_path / "bounded.whl" + _write_wheel( + wheel, + { + "openadapt_flow-1.0.dist-info/licenses/LICENSE", + "openadapt_flow-1.0.dist-info/METADATA", + "openadapt_flow/a.py", + }, + ) + monkeypatch.setattr(release_guard, "MAX_ARCHIVE_MEMBERS", 2) + with pytest.raises(ValueError, match="too many members"): + validate_wheel_license_boundary(wheel) + sdist = tmp_path / "bounded.tar.gz" + _write_sdist(sdist, {*REQUIRED_SDIST_PATHS, "PKG-INFO"}) + with pytest.raises(ValueError, match="too many members"): + validate_sdist_license_boundary(sdist) + + monkeypatch.setattr(release_guard, "MAX_ARCHIVE_MEMBERS", 20_000) + monkeypatch.setattr(release_guard, "MAX_ARCHIVE_EXPANDED_BYTES", 1) + with pytest.raises(ValueError, match="expanded size"): + validate_wheel_license_boundary(wheel) + with pytest.raises(ValueError, match="expanded size"): + validate_sdist_license_boundary(sdist) + + def test_archives_refuse_neutral_unregistered_and_modified_artifacts( tmp_path: Path, ) -> None: diff --git a/tests/test_source_policy_binding.py b/tests/test_source_policy_binding.py index 463f6f0e..7937e948 100644 --- a/tests/test_source_policy_binding.py +++ b/tests/test_source_policy_binding.py @@ -16,6 +16,7 @@ from __future__ import annotations import copy +import hashlib import importlib.util import json import shutil @@ -75,8 +76,19 @@ def _policy_document() -> dict: return json.loads(guard.SOURCE_POLICY_PATH.read_text(encoding="utf-8")) -def _write_policy(tmp_path: Path, document: object) -> Path: +def _write_policy( + tmp_path: Path, document: object, *, refresh_digest: bool = True +) -> Path: path = tmp_path / "source-policy.public.json" + if refresh_digest and isinstance(document, dict): + document = copy.deepcopy(document) + document.pop("policy_digest", None) + canonical = ( + json.dumps(document, indent=2, sort_keys=True, ensure_ascii=True) + "\n" + ) + document["policy_digest"] = ( + "sha256:" + hashlib.sha256(canonical.encode("utf-8")).hexdigest() + ) path.write_text(json.dumps(document), encoding="utf-8") return path @@ -153,6 +165,28 @@ def test_unparseable_policy_raises(tmp_path: Path) -> None: guard.load_source_policy(path) +def test_policy_path_must_be_a_regular_file(tmp_path: Path) -> None: + target = _write_policy(tmp_path, _policy_document()) + link = tmp_path / "policy-link.json" + try: + link.symlink_to(target) + except (OSError, NotImplementedError): + pytest.skip("symlinks are unavailable on this platform") + with pytest.raises(guard.SourcePolicyError, match="symlink|cannot read"): + guard.load_source_policy(link) + + +def test_policy_digest_rejects_a_rule_change_without_a_rerender( + tmp_path: Path, +) -> None: + document = _policy_document() + document["enforcement"]["path_tokens"].append("renamed-private-rule") + with pytest.raises(guard.SourcePolicyError, match="policy_digest"): + guard.load_source_policy( + _write_policy(tmp_path, document, refresh_digest=False) + ) + + def test_unknown_schema_raises(tmp_path: Path) -> None: document = _policy_document() document["schema_version"] = 99 @@ -181,6 +215,18 @@ def test_missing_categories_raise(tmp_path: Path) -> None: guard.load_source_policy(_write_policy(tmp_path, document)) +def test_flow_policy_must_cover_every_crown_jewel_category(tmp_path: Path) -> None: + document = _policy_document() + document["public_repositories"]["openadapt-flow"]["must_not_contain"].pop() + with pytest.raises(guard.SourcePolicyError, match="omits a crown-jewel"): + guard.load_source_policy(_write_policy(tmp_path, document)) + + document = _policy_document() + document["public_repositories"]["openadapt-flow"]["classification"] = "private" + with pytest.raises(guard.SourcePolicyError, match="classified as a public"): + guard.load_source_policy(_write_policy(tmp_path, document)) + + def test_signature_parts_must_be_present(tmp_path: Path) -> None: document = _policy_document() document["enforcement"]["content_signature_parts"] = [] @@ -188,6 +234,23 @@ def test_signature_parts_must_be_present(tmp_path: Path) -> None: guard.load_source_policy(_write_policy(tmp_path, document)) +def test_content_patterns_must_be_present_and_valid(tmp_path: Path) -> None: + document = _policy_document() + document["enforcement"]["built_artifacts"]["content_patterns"] = [] + with pytest.raises(guard.SourcePolicyError, match="content_patterns"): + guard.load_source_policy(_write_policy(tmp_path, document)) + + document = _policy_document() + document["enforcement"]["repository_tree"]["content_patterns"] = [] + with pytest.raises(guard.SourcePolicyError, match="content_patterns"): + guard.load_source_policy(_write_policy(tmp_path, document)) + + document = _policy_document() + document["enforcement"]["built_artifacts"]["content_patterns"] = ["["] + with pytest.raises(guard.SourcePolicyError, match="content_patterns is invalid"): + guard.load_source_policy(_write_policy(tmp_path, document)) + + def test_the_script_refuses_to_run_without_the_policy(tmp_path: Path) -> None: """A checkout with no rendered policy must stop, not validate silently.""" (tmp_path / "scripts").mkdir()