From 96856a008c82ec1a6eb184d4b4ffc6f4f41978ff Mon Sep 17 00:00:00 2001 From: Matthew Wesney Date: Tue, 21 Jul 2026 20:51:26 -0400 Subject: [PATCH] Bind signed bundles to the image worker role --- backend/cortex_backend/execution/__init__.py | 12 ++ .../execution/worker_provenance.py | 202 ++++++++++++++++++ ...bility-tiered-agentic-execution-harness.md | 17 +- docs/adr/0001-phase2-evidence.md | 10 +- docs/adr/0001-phase2-recipe-provider.md | 7 +- docs/adr/0001-phase2-sandbox-qualification.md | 2 +- docs/adr/0001-phase2-worker-provenance.md | 66 ++++++ tests/test_phase2_worker_provenance.py | 159 ++++++++++++++ 8 files changed, 462 insertions(+), 13 deletions(-) create mode 100644 backend/cortex_backend/execution/worker_provenance.py create mode 100644 docs/adr/0001-phase2-worker-provenance.md create mode 100644 tests/test_phase2_worker_provenance.py diff --git a/backend/cortex_backend/execution/__init__.py b/backend/cortex_backend/execution/__init__.py index dc53e53..3171c98 100644 --- a/backend/cortex_backend/execution/__init__.py +++ b/backend/cortex_backend/execution/__init__.py @@ -67,6 +67,13 @@ NativeBrokerServerConfig, build_pipe_sddl, ) +from .worker_provenance import ( + EXPECTED_WORKER_PATH, + VerifiedRecipeWorker, + WorkerProvenanceError, + verify_active_worker, + verify_installed_worker, +) from .recipes import ( CalculatorPlan, CheckPlan, @@ -155,6 +162,11 @@ "PeerIdentity", "authorize_message", "build_pipe_sddl", + "EXPECTED_WORKER_PATH", + "VerifiedRecipeWorker", + "WorkerProvenanceError", + "verify_active_worker", + "verify_installed_worker", "decode_frame", "decode_message", "encode_frame", diff --git a/backend/cortex_backend/execution/worker_provenance.py b/backend/cortex_backend/execution/worker_provenance.py new file mode 100644 index 0000000..d5161a5 --- /dev/null +++ b/backend/cortex_backend/execution/worker_provenance.py @@ -0,0 +1,202 @@ +"""Storage-only binding of an installed signed bundle to the image worker role. + +This module is intentionally not a launcher. It accepts only an +``InstalledBundle`` returned by the signed installer, revalidates the immutable +generation, and proves that exactly one declared ``image_transform`` entry is the +fixed ``recipe_worker.exe`` file. It never imports, loads, decodes, or executes +the worker. Process isolation, native broker identity, resource limits, and +lifecycle enablement remain separate release gates. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from hashlib import sha256 +import json +from pathlib import Path +import re +from typing import Final + +from .bundle_installer import ( + BundleInstallError, + InstalledBundle, + SignedBundleInstaller, +) +from .manifest import ( + MAX_BUNDLE_ENTRY_BYTES, + MAX_MANIFEST_BYTES, + ManifestVerificationError, + parse_signed_manifest, + verify_bundle_files, +) + + +EXPECTED_WORKER_PATH: Final[str] = "recipe_worker.exe" +_SAFE_CODE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") + + +class WorkerProvenanceError(ValueError): + """Stable worker provenance failure without paths or filesystem details.""" + + def __init__(self, code: str) -> None: + if _SAFE_CODE.fullmatch(code) is None: + raise ValueError("invalid worker provenance code") + self.code = code + super().__init__("The installed recipe worker failed provenance verification safely.") + + +@dataclass(frozen=True, slots=True) +class VerifiedRecipeWorker: + """Verified metadata for a worker file; no executable handle is retained.""" + + bundle_root: Path + bundle_digest: str + key_id: str + worker_path: str + worker_sha256: str + worker_size: int + recipe_version: str + + +def _is_reparse_point(path: Path) -> bool: + if path.is_symlink(): + return True + is_junction = getattr(path, "is_junction", None) + return bool(is_junction is not None and is_junction()) + + +def _read_manifest(root: Path): + manifest_path = root / "manifest.json" + if _is_reparse_point(manifest_path): + raise WorkerProvenanceError("worker_manifest_reparse") + try: + with manifest_path.open("rb") as stream: + payload = stream.read(MAX_MANIFEST_BYTES + 1) + except OSError: + raise WorkerProvenanceError("worker_manifest_unavailable") from None + if len(payload) > MAX_MANIFEST_BYTES: + raise WorkerProvenanceError("worker_manifest_too_large") + try: + raw = json.loads(payload.decode("ascii")) + return parse_signed_manifest(raw) + except (UnicodeDecodeError, json.JSONDecodeError, ManifestVerificationError, ValueError): + raise WorkerProvenanceError("worker_manifest_invalid") from None + + +def _file_identity(path: Path) -> tuple[int, int, int, int]: + try: + stat = path.stat() + except OSError: + raise WorkerProvenanceError("worker_entrypoint_unavailable") from None + if _is_reparse_point(path): + raise WorkerProvenanceError("worker_entrypoint_reparse") + if not path.is_file() or getattr(stat, "st_nlink", 1) != 1: + raise WorkerProvenanceError("worker_entrypoint_invalid") + return ( + int(stat.st_size), + int(stat.st_mtime_ns), + int(stat.st_ctime_ns), + int(getattr(stat, "st_ino", 0)), + ) + + +def _read_worker(path: Path, expected_size: int, expected_digest: str) -> None: + before = _file_identity(path) + if before[0] != expected_size or expected_size > MAX_BUNDLE_ENTRY_BYTES: + raise WorkerProvenanceError("worker_entrypoint_size_mismatch") + digest = sha256() + total = 0 + try: + with path.open("rb") as stream: + while True: + chunk = stream.read(min(1024 * 1024, MAX_BUNDLE_ENTRY_BYTES + 1 - total)) + if not chunk: + break + total += len(chunk) + if total > expected_size: + raise WorkerProvenanceError("worker_entrypoint_size_mismatch") + digest.update(chunk) + except WorkerProvenanceError: + raise + except OSError: + raise WorkerProvenanceError("worker_entrypoint_unavailable") from None + after = _file_identity(path) + if before != after or total != expected_size: + raise WorkerProvenanceError("worker_entrypoint_changed") + if digest.hexdigest() != expected_digest: + raise WorkerProvenanceError("worker_entrypoint_hash_mismatch") + + +def verify_installed_worker(installed: InstalledBundle) -> VerifiedRecipeWorker: + """Verify the exact image-worker role in an installer-returned generation.""" + + if not isinstance(installed, InstalledBundle): + raise TypeError("installed must be an InstalledBundle") + root = installed.bundle_root + if _is_reparse_point(root): + raise WorkerProvenanceError("worker_bundle_reparse") + try: + root = root.resolve(strict=True) + except (OSError, RuntimeError): + raise WorkerProvenanceError("worker_bundle_unavailable") from None + if not root.is_dir(): + raise WorkerProvenanceError("worker_bundle_unavailable") + + manifest = _read_manifest(root) + if manifest.manifest_digest() != installed.state.digest: + raise WorkerProvenanceError("worker_manifest_mismatch") + try: + verify_bundle_files(manifest, root) + except ManifestVerificationError: + raise WorkerProvenanceError("worker_bundle_integrity_failed") from None + + worker_entries = [ + entry for entry in manifest.entries if entry.entrypoint == "image_transform" + ] + if len(worker_entries) == 0: + raise WorkerProvenanceError("worker_role_missing") + if len(worker_entries) != 1: + raise WorkerProvenanceError("worker_role_ambiguous") + entry = worker_entries[0] + if entry.bundle_path != EXPECTED_WORKER_PATH: + raise WorkerProvenanceError("worker_entrypoint_mismatch") + + worker_path = root / EXPECTED_WORKER_PATH + try: + if not worker_path.resolve(strict=True).is_relative_to(root): + raise WorkerProvenanceError("worker_entrypoint_invalid") + except (OSError, RuntimeError): + raise WorkerProvenanceError("worker_entrypoint_unavailable") from None + _read_worker(worker_path, entry.size, entry.sha256) + return VerifiedRecipeWorker( + bundle_root=root, + bundle_digest=installed.state.digest, + key_id=manifest.key_id, + worker_path=EXPECTED_WORKER_PATH, + worker_sha256=entry.sha256, + worker_size=entry.size, + recipe_version=entry.version, + ) + + +def verify_active_worker(installer: SignedBundleInstaller) -> VerifiedRecipeWorker: + """Verify the active generation after the installer rechecks its signature.""" + + if not isinstance(installer, SignedBundleInstaller): + raise TypeError("installer must be a SignedBundleInstaller") + try: + installed = installer.status() + except BundleInstallError: + raise WorkerProvenanceError("worker_bundle_integrity_failed") from None + if installed is None: + raise WorkerProvenanceError("worker_bundle_unavailable") + return verify_installed_worker(installed) + + +__all__ = [ + "EXPECTED_WORKER_PATH", + "VerifiedRecipeWorker", + "WorkerProvenanceError", + "verify_active_worker", + "verify_installed_worker", +] diff --git a/docs/adr/0001-capability-tiered-agentic-execution-harness.md b/docs/adr/0001-capability-tiered-agentic-execution-harness.md index e8ecad9..ee73e69 100644 --- a/docs/adr/0001-capability-tiered-agentic-execution-harness.md +++ b/docs/adr/0001-capability-tiered-agentic-execution-harness.md @@ -944,13 +944,16 @@ pass without executing code. - The owner-bound copy-in, exact-claim output validation, quarantine, hashing, and atomic publication boundary is implemented. The fixed-function provider core is qualification-only. The disposable Windows sandbox qualification harness now - composes AppContainer/Job Object controls and a fixed decoder corpus, but the - signed worker provenance gate remains blocked; collect opt-in aggregate reliability - metrics, never content, only after the provider is sandbox-qualified. - -**Implementation gate:** parser, artifact-boundary, qualification-provider, and -sandbox-qualification regression suites pass; no source overwrite is possible. The -qualification harness must remain fail-closed when the signed worker is absent. + composes AppContainer/Job Object controls and a fixed decoder corpus. Storage-only + signed worker provenance now binds exactly one image-transform role to the fixed + worker entrypoint, but native launch/resource/broker gates remain blocked; collect + opt-in aggregate reliability metrics, never content, only after the provider is + sandbox-qualified. + +**Implementation gate:** parser, artifact-boundary, qualification-provider, +worker-provenance, and sandbox-qualification regression suites pass; no source +overwrite is possible. The qualification harness must remain fail-closed when the +signed worker is absent. **Release gate:** parser fuzzing, artifact security review, hostile decoder corpus executed inside the worker, OS sandbox qualification, signed provenance, broker identity, resource/watchdog accounting, external review, and fixed-function provider diff --git a/docs/adr/0001-phase2-evidence.md b/docs/adr/0001-phase2-evidence.md index 9f65092..2f6a29a 100644 --- a/docs/adr/0001-phase2-evidence.md +++ b/docs/adr/0001-phase2-evidence.md @@ -16,6 +16,7 @@ | Canonical plan identity | **Complete** | Validated plans expose stable canonical JSON and SHA-256 digests for future idempotency/signature binding. | | Signed recipe manifest | **Complete (verification only)** | Ed25519 signature verification uses a pinned key-id allowlist; every declared bundle entry is path-, size-, and SHA-256-verified; monotonic updates and explicit rollback authorization are enforced. | | Signed bundle installation/update | **Complete (storage-only)** | Digest-named immutable generations, exclusive staging, atomic activation state, chained keyring rotation, explicit rollback authorization, and previous-generation recovery are covered by installer tests. No provider is loaded. | +| Signed worker provenance binding | **Complete (storage-only)** | `verify_active_worker()` rechecks the active signed generation, binds exactly one `image_transform` role to `recipe_worker.exe`, revalidates byte identity, and rejects missing/ambiguous/mismatched/tampered/reparse entries without launching. | | Authenticated broker contract | **Complete (transport-neutral)** | Bounded versioned frames, direction-specific HMAC keys, canonical messages, peer ACL/integrity policy, and owner-scoped authorization are covered by adversarial tests. | | Native named-pipe adapter/DACL/peer-token binding | **Complete (transport-only)** | Protected local pipe, expected PID, OS token identity, X25519/HKDF handshake, direction keys, and close-on-error lifecycle are covered by native broker tests. | | User-artifact copy-in, output validation, and publication | **Complete (boundary only)** | Explicit owner/turn grants, bounded stable snapshots, link/reparse/hardlink/sparse/ADS rejection, byte-derived MIME policy, exact output claims, quarantine, hash/size limits, atomic repository publication, rollback, and cleanup categories are covered by `tests/test_phase2_artifact_boundary.py`. | @@ -76,6 +77,9 @@ 21. The sandbox qualification harness never authorizes a provider launch from a missing, unsigned, or merely present worker directory; it reports `blocked` and never falls back to host-process decoding. +22. Worker provenance is storage-only: only an installer-validated immutable + generation with one exact `image_transform`/`recipe_worker.exe` role and stable + byte identity can proceed to a future launcher; no executable is loaded here. ## Re-run target @@ -87,6 +91,7 @@ python -m pytest tests/test_phase2_native_broker.py -q python -m pytest tests/test_phase2_bundle_installer.py -q python -m pytest tests/test_phase2_artifact_boundary.py -q python -m pytest tests/test_phase2_recipe_provider.py -q +python -m pytest tests/test_phase2_worker_provenance.py -q python -m pytest tests/test_recipe_sandbox_qualification.py -q python tools/execution_spikes/recipe_sandbox_qualification.py --json --strict python -m compileall -q backend\cortex_backend\execution tests @@ -100,9 +105,10 @@ npm.cmd test --prefix frontend -- --run **Validation result (2026-07-21):** 16 Phase 2 contract tests, 9 signed-manifest tests, 7 broker-contract tests, 9 native-broker tests, 7 bundle-installer tests, 16 -artifact-boundary tests, 17 recipe-provider tests, and 5 sandbox-qualification tests +artifact-boundary tests, 17 recipe-provider tests, 6 worker-provenance tests, and +5 sandbox-qualification tests passed; the full Python suite -passed (209 tests total) with one +passed (215 tests total) with one native-platform skip and one pre-existing `pytest-asyncio` deprecation warning. Frontend lint, typecheck, production build, and all 39 frontend tests passed. Contract generation, compileall, and `git diff --check` passed. No production execution diff --git a/docs/adr/0001-phase2-recipe-provider.md b/docs/adr/0001-phase2-recipe-provider.md index bb8164a..682e35b 100644 --- a/docs/adr/0001-phase2-recipe-provider.md +++ b/docs/adr/0001-phase2-recipe-provider.md @@ -106,9 +106,10 @@ allowlisted transforms, deterministic output hashes, metadata stripping, malform active inputs, malformed JPEGs, animated WebP rejection, pixel/decoded-memory/output limits, crop bounds, cancellation, stop behavior, and non-raiseable configuration caps. -The next gate is the disposable [Windows sandbox qualification harness](0001-phase2-sandbox-qualification.md), -which currently proves only the existing native isolation/cancellation controls and -fixed decoder corpus. The actual provider worker must still launch out of process +The next gate is the [signed worker provenance binding](0001-phase2-worker-provenance.md) +followed by the disposable [Windows sandbox qualification harness](0001-phase2-sandbox-qualification.md). +The storage boundary now proves only the exact role and immutable bytes; the actual +provider worker must still launch out of process under a signed bundle, AppContainer/LPAC and Job Object controls, restricted handles/environment, native broker identity, watchdog, and accounting; run the hostile decoder corpus there; then wire it to `ExecutionLifecycle` only after external review. diff --git a/docs/adr/0001-phase2-sandbox-qualification.md b/docs/adr/0001-phase2-sandbox-qualification.md index fb1c62a..f09da33 100644 --- a/docs/adr/0001-phase2-sandbox-qualification.md +++ b/docs/adr/0001-phase2-sandbox-qualification.md @@ -43,7 +43,7 @@ fallback. | AppContainer token and zero-capability denials | `appcontainer_smoke.py`, child report `recipe_appcontainer_control` | Required prerequisite; does not prove LPAC policy or provider launch identity | | Job Object kill-on-close and tree cancellation | `cancellation_corpus.py`, child report `recipe_cancellation_control` | Required prerequisite; resource limits and accounting remain open | | Decoder hostile corpus | Fixed one-pixel PNG, truncated PNG, and active SVG against the core | Qualification-only evidence; not OS-sandbox evidence | -| Signed worker provenance | Fixed `packaging/recipe-runtime` location and exact `recipe_worker.exe` entrypoint | **Blocked** until packaged trust-root/signature/hash verification exists | +| Signed worker provenance | Storage-only `verify_active_worker()` role binding plus fixed package precondition | **Storage gate complete; launch remains blocked** until a packaged executable and native launcher exist | | Broker identity and framed IPC | Native broker transport tests and ADR | Must be bound to the actual worker PID/token before launch | | Lifecycle enablement | `ExecutionLifecycle` remains disabled by default; provider is not exported | No provider can become reachable from the application | diff --git a/docs/adr/0001-phase2-worker-provenance.md b/docs/adr/0001-phase2-worker-provenance.md new file mode 100644 index 0000000..cea8806 --- /dev/null +++ b/docs/adr/0001-phase2-worker-provenance.md @@ -0,0 +1,66 @@ +# ADR-0001 Phase 2 signed worker provenance binding + +- **Status:** Storage-only worker-role verifier complete; native launch remains blocked +- **Phase:** 2 - fixed-function image provider +- **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) +- **Depends on:** [signed bundle installation](0001-phase2-bundle-installation.md), [signed recipe manifest](0001-phase2-signed-manifest.md), and [Windows sandbox qualification](0001-phase2-sandbox-qualification.md) +- **Scope:** Bind an installed signed generation to the fixed image-transform worker role without executing it. + +## Decision + +`backend/cortex_backend/execution/worker_provenance.py` adds a storage-only +verification boundary: + +1. `verify_active_worker()` first asks `SignedBundleInstaller.status()` to + revalidate the active signed generation and exact immutable tree; +2. the manifest is read with a bounded ASCII read and its digest must match the + installer state; +3. every declared bundle byte is revalidated through `verify_bundle_files()`; +4. exactly one `image_transform` entry must exist and its path must be the fixed + `recipe_worker.exe` name; +5. the worker path must be an ordinary single-link file inside the generation, + and its size, timestamps, identity, and SHA-256 must remain stable across the + bounded read; and +6. only immutable metadata is returned. No executable handle, import, decode, + process creation, or provider instance is returned or performed. + +The verifier is deliberately a second role-binding boundary after signature and +bundle installation. A valid signed bundle containing a different image entry, +multiple image roles, a changed worker, or a reparse/hardlink entry cannot be +treated as the worker. Stable `WorkerProvenanceError.code` values cross the +boundary; paths, bytes, OS errors, and signatures never do. + +## Failure categories + +The verifier uses bounded categories including `worker_bundle_unavailable`, +`worker_bundle_integrity_failed`, `worker_manifest_invalid`, +`worker_manifest_mismatch`, `worker_role_missing`, `worker_role_ambiguous`, +`worker_entrypoint_mismatch`, `worker_entrypoint_invalid`, +`worker_entrypoint_changed`, `worker_entrypoint_hash_mismatch`, and +`worker_entrypoint_reparse`/`worker_manifest_reparse` where applicable. + +## Explicitly not implemented here + +This boundary does not launch the worker or claim any OS isolation. The following +remain required before the provider can be enabled: + +- a signed packaged executable at the fixed role path; +- a native suspended launcher with private staging and exact ACLs; +- AppContainer/LPAC policy, Job Object CPU/memory/breakaway limits, accounting, + watchdog, and full-tree cancellation; +- protected broker PID/token binding and bounded framed IPC; +- hostile decoder execution inside that worker; and +- external review and `ExecutionLifecycle` health-gated wiring. + +Any missing control must leave the provider unavailable. The host process must +never decode or execute as a fallback. + +## Verification + +`tests/test_phase2_worker_provenance.py` proves a valid signed fixture binds to +the exact worker role and that no active generation, wrong path, ambiguous role, +or post-install tamper is accepted. Existing bundle-installer tests continue to +cover signature, byte, staging, hardlink, rollback, and recovery boundaries. + +This stage is therefore **complete as a storage-only provenance substage**, not a +provider release approval. diff --git a/tests/test_phase2_worker_provenance.py b/tests/test_phase2_worker_provenance.py new file mode 100644 index 0000000..f294a1b --- /dev/null +++ b/tests/test_phase2_worker_provenance.py @@ -0,0 +1,159 @@ +"""Signed image-worker role binding remains storage-only and fail-closed.""" + +from __future__ import annotations + +import base64 +from hashlib import sha256 +import json +from pathlib import Path + +import pytest +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey + +from cortex_backend.execution.bundle_installer import SignedBundleInstaller +from cortex_backend.execution.manifest import TrustedRecipeKeys +from cortex_backend.execution.worker_provenance import ( + WorkerProvenanceError, + verify_active_worker, +) + + +def _canonical(payload: dict) -> bytes: + return json.dumps( + payload, + ensure_ascii=True, + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + + +def _keyring(private_key: Ed25519PrivateKey) -> TrustedRecipeKeys: + public = private_key.public_key().public_bytes( + serialization.Encoding.Raw, + serialization.PublicFormat.Raw, + ) + return TrustedRecipeKeys({"release-1": public}) + + +def _payload( + private_key: Ed25519PrivateKey, + entries: list[dict], +) -> dict: + unsigned = { + "schema_version": "recipe.manifest.v1", + "key_id": "release-1", + "sequence": 1, + "bundle_version": "1.0.0", + "rollback_of": None, + "entries": entries, + } + signature = base64.urlsafe_b64encode(private_key.sign(_canonical(unsigned))).decode("ascii") + return {**unsigned, "signature": signature.rstrip("=")} + + +def _entry(path: str, content: bytes, *, recipe_id: str = "image-transform") -> dict: + return { + "recipe_id": recipe_id, + "bundle_path": path, + "entrypoint": "image_transform", + "version": "1.0.0", + "size": len(content), + "sha256": sha256(content).hexdigest(), + } + + +def _install( + tmp_path: Path, + entries: list[tuple[str, bytes, str]], +): + signer = Ed25519PrivateKey.generate() + source = tmp_path / "incoming" + for path, content, _recipe_id in entries: + target = source / path + target.parent.mkdir(parents=True, exist_ok=True) + target.write_bytes(content) + manifest_entries = [ + _entry(path, content, recipe_id=recipe_id) + for path, content, recipe_id in entries + ] + installer = SignedBundleInstaller(tmp_path / "store", _keyring(signer)) + installer.install(_payload(signer, manifest_entries), source) + return installer + + +def test_active_signed_generation_binds_exact_worker_role(tmp_path: Path): + content = b"fixed recipe worker fixture" + installer = _install(tmp_path, [("recipe_worker.exe", content, "image-transform")]) + + worker = verify_active_worker(installer) + + assert worker.worker_path == "recipe_worker.exe" + assert worker.worker_size == len(content) + assert worker.worker_sha256 == sha256(content).hexdigest() + assert worker.recipe_version == "1.0.0" + + +def test_no_active_generation_is_unavailable(tmp_path: Path): + signer = Ed25519PrivateKey.generate() + installer = SignedBundleInstaller(tmp_path / "store", _keyring(signer)) + + with pytest.raises(WorkerProvenanceError) as error: + verify_active_worker(installer) + + assert error.value.code == "worker_bundle_unavailable" + + +def test_worker_role_must_use_fixed_entrypoint_path(tmp_path: Path): + installer = _install(tmp_path, [("recipes/image.bin", b"worker", "image-transform")]) + + with pytest.raises(WorkerProvenanceError) as error: + verify_active_worker(installer) + + assert error.value.code == "worker_entrypoint_mismatch" + + +def test_multiple_image_roles_are_ambiguous(tmp_path: Path): + installer = _install( + tmp_path, + [ + ("recipe_worker.exe", b"worker-one", "image-transform"), + ("recipe_worker_two.exe", b"worker-two", "image-transform-two"), + ], + ) + + with pytest.raises(WorkerProvenanceError) as error: + verify_active_worker(installer) + + assert error.value.code == "worker_role_ambiguous" + + +def test_tampered_installed_worker_fails_before_role_binding(tmp_path: Path): + installer = _install(tmp_path, [("recipe_worker.exe", b"worker", "image-transform")]) + installed = installer.status() + assert installed is not None + (installed.bundle_root / "recipe_worker.exe").write_bytes(b"tampered") + + with pytest.raises(WorkerProvenanceError) as error: + verify_active_worker(installer) + + assert error.value.code == "worker_bundle_integrity_failed" + + +def test_reparse_worker_fails_closed_before_provenance_binding(tmp_path: Path): + installer = _install(tmp_path, [("recipe_worker.exe", b"worker", "image-transform")]) + installed = installer.status() + assert installed is not None + target = installed.bundle_root / "recipe_worker.exe" + external = tmp_path / "external-worker.exe" + external.write_bytes(b"outside") + target.unlink() + try: + target.symlink_to(external) + except OSError: + pytest.skip("symlinks are unavailable on this Windows host") + + with pytest.raises(WorkerProvenanceError) as error: + verify_active_worker(installer) + + assert error.value.code == "worker_bundle_integrity_failed"