From 2bcdf5a9f7973ef9aeda05e737271f6d92467ef1 Mon Sep 17 00:00:00 2001 From: Matthew Wesney Date: Tue, 21 Jul 2026 19:40:26 -0400 Subject: [PATCH 1/2] Add qualification-only image recipe provider --- .../execution/recipe_provider.py | 461 ++++++++++++++++++ ...bility-tiered-agentic-execution-harness.md | 14 +- docs/adr/0001-phase2-artifact-boundary.md | 3 +- docs/adr/0001-phase2-evidence.md | 20 +- docs/adr/0001-phase2-native-broker.md | 6 +- docs/adr/0001-phase2-recipe-contract.md | 12 +- docs/adr/0001-phase2-recipe-provider.md | 109 +++++ requirements.txt | 1 + tests/test_phase2_recipe_provider.py | 219 +++++++++ 9 files changed, 825 insertions(+), 20 deletions(-) create mode 100644 backend/cortex_backend/execution/recipe_provider.py create mode 100644 docs/adr/0001-phase2-recipe-provider.md create mode 100644 tests/test_phase2_recipe_provider.py diff --git a/backend/cortex_backend/execution/recipe_provider.py b/backend/cortex_backend/execution/recipe_provider.py new file mode 100644 index 0000000..7e2574a --- /dev/null +++ b/backend/cortex_backend/execution/recipe_provider.py @@ -0,0 +1,461 @@ +"""Qualification-only fixed-function image recipe provider. + +This module is a bounded transform core, not an execution route. It accepts +validated ``ImageTransformPlan`` objects and immutable bytes, never paths or +model source, and returns a new encoded image only after decoding and +re-validating the result. The provider starts only after an external sandbox +health result is available; no application lifecycle imports this module yet. +""" + +from __future__ import annotations + +from collections.abc import Callable +from contextlib import contextmanager +from dataclasses import dataclass +from hashlib import sha256 +from io import BytesIO +import math +import re +from threading import RLock +from typing import Any, Iterator +import warnings + +from .artifact_boundary import ArtifactBoundaryError, sniff_artifact_mime +from .lifecycle import RuntimeHealth +from .recipes import ImageTransformPlan + +try: # Keep the application importable when the optional qualification wheel is absent. + import PIL + from PIL import Image, ImageEnhance, ImageFile + from PIL import UnidentifiedImageError +except ImportError: # pragma: no cover - exercised by packaging/health probes. + PIL = None # type: ignore[assignment] + Image = None # type: ignore[assignment] + ImageEnhance = None # type: ignore[assignment] + ImageFile = None # type: ignore[assignment] + UnidentifiedImageError = Exception # type: ignore[assignment,misc] + + +MAX_INPUT_BYTES = 100 * 1024 * 1024 +MAX_OUTPUT_BYTES = 128 * 1024 * 1024 +MAX_PIXELS = 64 * 1024 * 1024 +MAX_DIMENSION = 16_384 +MAX_DECODED_BYTES = 256 * 1024 * 1024 +_SAFE_CODE = re.compile(r"^[a-z][a-z0-9_]{0,63}$") +_FORMAT_BY_PLAN = {"png": "PNG", "jpeg": "JPEG", "webp": "WEBP"} +_MIME_BY_FORMAT = { + "PNG": "image/png", + "JPEG": "image/jpeg", + "WEBP": "image/webp", +} +_PIL_LOCK = RLock() + + +class RecipeProviderError(ValueError): + """Stable provider failure without paths, payloads, or decoder details.""" + + def __init__(self, code: str) -> None: + if _SAFE_CODE.fullmatch(code) is None: + raise ValueError("invalid recipe provider error code") + self.code = code + super().__init__("The image recipe could not be completed safely.") + + +@dataclass(frozen=True, slots=True) +class RecipeProviderLimits: + """Per-attempt byte, pixel, dimension, and decoded-memory ceilings.""" + + max_input_bytes: int = MAX_INPUT_BYTES + max_output_bytes: int = MAX_OUTPUT_BYTES + max_pixels: int = MAX_PIXELS + max_dimension: int = MAX_DIMENSION + max_decoded_bytes: int = MAX_DECODED_BYTES + max_steps: int = 8 + + def __post_init__(self) -> None: + values = ( + self.max_input_bytes, + self.max_output_bytes, + self.max_pixels, + self.max_dimension, + self.max_decoded_bytes, + self.max_steps, + ) + if any(isinstance(value, bool) or not isinstance(value, int) or value <= 0 for value in values): + raise ValueError("recipe provider limits must be positive integers") + if self.max_dimension > MAX_DIMENSION: + raise ValueError("recipe provider dimension ceiling is too high") + if self.max_steps > 8: + raise ValueError("recipe provider step ceiling is too high") + if ( + self.max_input_bytes > MAX_INPUT_BYTES + or self.max_output_bytes > MAX_OUTPUT_BYTES + or self.max_pixels > MAX_PIXELS + or self.max_decoded_bytes > MAX_DECODED_BYTES + ): + raise ValueError("recipe provider resource ceiling is too high") + + +@dataclass(frozen=True, slots=True) +class RecipeProviderResult: + """Validated output bytes and safe metadata returned to a future coordinator.""" + + content: bytes + mime_type: str + width: int + height: int + format: str + sha256: str + + def __post_init__(self) -> None: + if not isinstance(self.content, bytes) or not self.content: + raise ValueError("provider result content must be non-empty bytes") + if self.mime_type not in set(_MIME_BY_FORMAT.values()): + raise ValueError("provider result MIME is invalid") + if self.format not in set(_MIME_BY_FORMAT): + raise ValueError("provider result format is invalid") + if _MIME_BY_FORMAT[self.format] != self.mime_type: + raise ValueError("provider result format/MIME mismatch") + if self.width <= 0 or self.height <= 0: + raise ValueError("provider result dimensions are invalid") + if not re.fullmatch(r"[0-9a-f]{64}", self.sha256): + raise ValueError("provider result digest is invalid") + + +CancellationCheck = Callable[[], bool] + + +def _check_cancel(cancel_check: CancellationCheck | None) -> None: + if cancel_check is None: + return + try: + cancelled = bool(cancel_check()) + except Exception: + raise RecipeProviderError("cancellation_check_failed") from None + if cancelled: + raise RecipeProviderError("cancelled") + + +def _pillow_health() -> tuple[bool, str, str]: + if Image is None or ImageEnhance is None or ImageFile is None or PIL is None: + return False, "recipe_dependency_missing", "The image recipe dependency is unavailable." + try: + Image.init() + supported = set(Image.registered_extensions().values()) + except Exception: + return False, "recipe_codec_unavailable", "The required image codecs are unavailable." + if not {"PNG", "JPEG", "WEBP"}.issubset(supported): + return False, "recipe_codec_unavailable", "The required image codecs are unavailable." + return True, "ready", f"Fixed-function image provider is ready (Pillow {PIL.__version__})." + + +@contextmanager +def _decoder_limits(max_pixels: int) -> Iterator[None]: + """Serialize Pillow's process-global bomb/truncation controls and restore them.""" + + if Image is None or ImageFile is None: + raise RecipeProviderError("recipe_dependency_missing") + with _PIL_LOCK: + previous_pixels = Image.MAX_IMAGE_PIXELS + previous_truncated = ImageFile.LOAD_TRUNCATED_IMAGES + Image.MAX_IMAGE_PIXELS = max_pixels + ImageFile.LOAD_TRUNCATED_IMAGES = False + with warnings.catch_warnings(): + warnings.simplefilter("error", Image.DecompressionBombWarning) + try: + yield + finally: + Image.MAX_IMAGE_PIXELS = previous_pixels + ImageFile.LOAD_TRUNCATED_IMAGES = previous_truncated + + +def _format_for_mime(mime_type: str) -> str: + for image_format, image_mime in _MIME_BY_FORMAT.items(): + if image_mime == mime_type: + return image_format + raise RecipeProviderError("unsupported_format") + + +def _image_dimensions(image: Any, limits: RecipeProviderLimits) -> tuple[int, int]: + try: + width, height = int(image.width), int(image.height) + frames = int(getattr(image, "n_frames", 1)) + except Exception: + raise RecipeProviderError("decode_failed") from None + if width <= 0 or height <= 0: + raise RecipeProviderError("decode_failed") + if frames != 1: + raise RecipeProviderError("unsupported_frames") + pixels = width * height + if width > limits.max_dimension or height > limits.max_dimension or pixels > limits.max_pixels: + raise RecipeProviderError("resource_limit") + try: + channels = {"1": 1, "L": 1, "P": 1, "LA": 2, "RGB": 3, "RGBA": 4}.get(image.mode, 4) + except Exception: + channels = 4 + if pixels * channels > limits.max_decoded_bytes: + raise RecipeProviderError("resource_limit") + return width, height + + +def _decode_verified( + content: bytes, + image_format: str, + limits: RecipeProviderLimits, + cancel_check: CancellationCheck | None, +) -> Any: + if not isinstance(content, bytes) or not content: + raise RecipeProviderError("invalid_input") + if len(content) > limits.max_input_bytes: + raise RecipeProviderError("input_too_large") + try: + detected = sniff_artifact_mime(content) + except ArtifactBoundaryError: + raise RecipeProviderError("invalid_input") from None + if detected != _MIME_BY_FORMAT[image_format]: + raise RecipeProviderError("input_format_mismatch") + _check_cancel(cancel_check) + try: + with BytesIO(content) as stream: + with Image.open(stream, formats=(image_format,)) as candidate: + _image_dimensions(candidate, limits) + candidate.verify() + _check_cancel(cancel_check) + with BytesIO(content) as stream: + with Image.open(stream, formats=(image_format,)) as decoded: + _image_dimensions(decoded, limits) + decoded.load() + _image_dimensions(decoded, limits) + normalized = decoded.convert("RGBA") + normalized.info.clear() + _image_dimensions(normalized, limits) + return normalized + except RecipeProviderError: + raise + except (Image.DecompressionBombError, Image.DecompressionBombWarning): + raise RecipeProviderError("resource_limit") from None + except (UnidentifiedImageError, OSError, ValueError, SyntaxError, MemoryError): + raise RecipeProviderError("decode_failed") from None + + +def _replace_image(current: Any, replacement: Any) -> Any: + current.close() + return replacement + + +def _apply_plan( + image: Any, + plan: ImageTransformPlan, + limits: RecipeProviderLimits, + cancel_check: CancellationCheck | None, +) -> Any: + if len(plan.steps) > limits.max_steps: + raise RecipeProviderError("resource_limit") + current = image + try: + for step in plan.steps: + _check_cancel(cancel_check) + if step.op == "grayscale": + current = _replace_image(current, current.convert("L")) + elif step.op == "contrast": + factor = float(step.factor) + if not math.isfinite(factor): + raise RecipeProviderError("invalid_plan") + current = _replace_image(current, ImageEnhance.Contrast(current).enhance(factor)) + elif step.op == "brightness": + factor = float(step.factor) + if not math.isfinite(factor): + raise RecipeProviderError("invalid_plan") + current = _replace_image(current, ImageEnhance.Brightness(current).enhance(factor)) + elif step.op == "crop": + right = step.x + step.width + bottom = step.y + step.height + if right > current.width or bottom > current.height: + raise RecipeProviderError("invalid_plan") + current = _replace_image(current, current.crop((step.x, step.y, right, bottom))) + elif step.op == "resize": + current = _replace_image( + current, + current.resize((step.width, step.height), resample=Image.Resampling.LANCZOS), + ) + elif step.op == "rotate": + current = _replace_image( + current, + current.rotate(step.degrees, resample=Image.Resampling.BICUBIC, expand=True), + ) + else: # Defensive if a future plan type bypasses the parser. + raise RecipeProviderError("invalid_plan") + _image_dimensions(current, limits) + _check_cancel(cancel_check) + return current + except Exception: + if current is not image: + current.close() + raise + + +def _encode_verified( + image: Any, + output_format: str, + limits: RecipeProviderLimits, + cancel_check: CancellationCheck | None, +) -> tuple[bytes, int, int]: + _check_cancel(cancel_check) + width, height = _image_dimensions(image, limits) + target = image + converted = None + if output_format == "JPEG" and image.mode not in {"L", "RGB"}: + converted = image.convert("RGB") + target = converted + try: + target.info.clear() + with BytesIO() as stream: + kwargs: dict[str, Any] = {} + if output_format == "PNG": + kwargs.update(compress_level=9, optimize=False, exif=b"", icc_profile=None) + elif output_format == "JPEG": + kwargs.update( + quality=95, + optimize=False, + progressive=False, + subsampling=0, + exif=b"", + icc_profile=None, + ) + elif output_format == "WEBP": + kwargs.update(lossless=True, method=6, exif=b"", icc_profile=None) + target.save(stream, format=output_format, **kwargs) + encoded = stream.getvalue() + except (OSError, ValueError, MemoryError): + raise RecipeProviderError("encode_failed") from None + finally: + if converted is not None: + converted.close() + if len(encoded) == 0 or len(encoded) > limits.max_output_bytes: + raise RecipeProviderError("output_too_large") + _check_cancel(cancel_check) + try: + if sniff_artifact_mime(encoded) != _MIME_BY_FORMAT[output_format]: + raise RecipeProviderError("output_invalid") + with BytesIO(encoded) as stream: + with Image.open(stream, formats=(output_format,)) as candidate: + out_width, out_height = _image_dimensions(candidate, limits) + candidate.verify() + with BytesIO(encoded) as stream: + with Image.open(stream, formats=(output_format,)) as candidate: + candidate.load() + out_width, out_height = _image_dimensions(candidate, limits) + if out_width != width or out_height != height: + raise RecipeProviderError("output_invalid") + if any(key in candidate.info for key in ("exif", "icc_profile", "xmp", "comment", "text")): + raise RecipeProviderError("output_metadata_present") + except RecipeProviderError: + raise + except (Image.DecompressionBombError, Image.DecompressionBombWarning): + raise RecipeProviderError("resource_limit") from None + except (UnidentifiedImageError, OSError, ValueError, SyntaxError, MemoryError): + raise RecipeProviderError("output_invalid") from None + return encoded, out_width, out_height + + +class RecipeImageProvider: + """Fixed-function image provider that remains disabled until sandbox health passes.""" + + def __init__(self, limits: RecipeProviderLimits | None = None) -> None: + self.limits = limits or RecipeProviderLimits() + self._enabled = False + self._health = RuntimeHealth.blocked( + code="recipe_provider_disabled", + message="The fixed-function image provider is disabled in this build.", + ) + + @property + def enabled(self) -> bool: + return self._enabled + + @property + def health_snapshot(self) -> RuntimeHealth: + return self._health + + def health(self, sandbox_health: RuntimeHealth | None = None) -> RuntimeHealth: + if sandbox_health is None: + return RuntimeHealth.blocked( + code="sandbox_unverified", + message="The image provider sandbox has not been verified.", + ) + if not sandbox_health.available: + return RuntimeHealth.blocked( + code="sandbox_unavailable", + message="The image provider sandbox is unavailable.", + ) + available, code, message = _pillow_health() + return RuntimeHealth(available=available, code=code, message=message) + + def start(self, sandbox_health: RuntimeHealth) -> RuntimeHealth: + """Enable only after the caller supplies a passing external sandbox probe.""" + + health = self.health(sandbox_health) + self._health = health + self._enabled = health.available + return health + + def stop(self) -> RuntimeHealth: + self._enabled = False + self._health = RuntimeHealth.blocked( + code="recipe_provider_stopped", + message="The fixed-function image provider is stopped.", + ) + return self._health + + def transform( + self, + plan: ImageTransformPlan, + content: bytes, + *, + cancel_check: CancellationCheck | None = None, + ) -> RecipeProviderResult: + """Transform one immutable image in memory; no paths or host capabilities are accepted.""" + + if not self._enabled: + raise RecipeProviderError("provider_disabled") + if not isinstance(plan, ImageTransformPlan): + raise RecipeProviderError("invalid_plan") + try: + image_format = _format_for_mime(sniff_artifact_mime(content)) + except ArtifactBoundaryError: + raise RecipeProviderError("invalid_input") from None + expected_format = _FORMAT_BY_PLAN[plan.output_format] + with _decoder_limits(self.limits.max_pixels): + image = _decode_verified(content, image_format, self.limits, cancel_check) + try: + image = _apply_plan(image, plan, self.limits, cancel_check) + encoded, width, height = _encode_verified( + image, + expected_format, + self.limits, + cancel_check, + ) + finally: + image.close() + return RecipeProviderResult( + content=encoded, + mime_type=_MIME_BY_FORMAT[expected_format], + width=width, + height=height, + format=expected_format, + sha256=sha256(encoded).hexdigest(), + ) + + +__all__ = [ + "CancellationCheck", + "MAX_DECODED_BYTES", + "MAX_DIMENSION", + "MAX_INPUT_BYTES", + "MAX_OUTPUT_BYTES", + "MAX_PIXELS", + "RecipeImageProvider", + "RecipeProviderError", + "RecipeProviderLimits", + "RecipeProviderResult", +] diff --git a/docs/adr/0001-capability-tiered-agentic-execution-harness.md b/docs/adr/0001-capability-tiered-agentic-execution-harness.md index c6c5a58..c32b8ce 100644 --- a/docs/adr/0001-capability-tiered-agentic-execution-harness.md +++ b/docs/adr/0001-capability-tiered-agentic-execution-harness.md @@ -942,12 +942,14 @@ pass without executing code. - Keep the typed recipe, signed bundle, broker transport, and trusted artifact boundary provider-independent until the sandbox qualification gate passes. - The owner-bound copy-in, exact-claim output validation, quarantine, hashing, and - atomic publication boundary is implemented; collect opt-in aggregate reliability - metrics, never content, after the provider is qualified. - -**Implementation gate:** parser and artifact-boundary adversarial suites pass; no source -overwrite is possible. **Release gate:** parser fuzzing, artifact security review, and -fixed-function provider qualification must pass before any provider is enabled. + atomic publication boundary is implemented. The fixed-function provider core is + qualification-only; collect opt-in aggregate reliability metrics, never content, + after the provider is sandbox-qualified. + +**Implementation gate:** parser, artifact-boundary, and qualification-provider adversarial +suites pass; no source overwrite is possible. **Release gate:** parser fuzzing, artifact +security review, hostile decoder corpus, OS sandbox qualification, and fixed-function +provider lifecycle health must pass before any provider is enabled. ### Phase 3 — `scratch.auto.v1` arbitrary WebAssembly code diff --git a/docs/adr/0001-phase2-artifact-boundary.md b/docs/adr/0001-phase2-artifact-boundary.md index e6d9be8..bcf44f6 100644 --- a/docs/adr/0001-phase2-artifact-boundary.md +++ b/docs/adr/0001-phase2-artifact-boundary.md @@ -50,7 +50,8 @@ WebP, finite JSON, and ordinary UTF-8 text are recognized. Portable executables, ELF/OLE files, shortcuts, shebang scripts, archives, active HTML/SVG/JavaScript, and common shell/PowerShell launchers are rejected. Unknown non-active bytes may be stored as `application/octet-stream`; this is metadata safety, not permission to decode -or execute them. Image decoding remains a later provider qualification gate. +or execute them. Production image decoding remains behind the separate sandboxed +[recipe provider qualification gate](0001-phase2-recipe-provider.md). ### 3. Private output staging and exact claims diff --git a/docs/adr/0001-phase2-evidence.md b/docs/adr/0001-phase2-evidence.md index 7288d7d..f7c42cc 100644 --- a/docs/adr/0001-phase2-evidence.md +++ b/docs/adr/0001-phase2-evidence.md @@ -1,8 +1,8 @@ # ADR-0001 Phase 2 evidence log - **Phase:** 2 — signed image recipes and calculator/check primitives -- **Status:** Typed contract, signed-manifest verification, native broker transport, signed bundle installation, and trusted artifact boundary complete; provider and release gates remain open -- **Scope:** Provider-independent validation and deterministic trusted primitives only +- **Status:** Typed contract, signed-manifest verification, native broker transport, signed bundle installation, trusted artifact boundary, and qualification-only provider core complete; OS sandbox/provider and release gates remain open +- **Scope:** Provider-independent contracts plus a qualification-only fixed-function core - **Source decision:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Contract ADR:** [Phase 2 typed recipe and primitive contract](0001-phase2-recipe-contract.md) @@ -19,8 +19,8 @@ | 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`. | -| Image decoding and provider-produced image outputs | **Blocked / next gate** | The boundary records safe bytes and metadata only; codecs, decompression, thumbnails, and provider execution remain intentionally unimplemented. | -| Production sandbox provider and execution route | **Blocked / next gate** | The native pipe is transport-only; no Wasmtime, AppContainer, Job Object, subprocess, lifecycle provider, or production execution route was added. | +| Fixed-function image provider core | **Complete (qualification-only)** | `RecipeImageProvider` validates allowlisted PNG/JPEG/WebP bytes, verifies/loads one frame with Pillow bomb/resource limits, applies only parsed steps, strips metadata, revalidates encoded output, checks cancellation, and remains disabled until external sandbox health passes. | +| OS sandbox provider and provider-produced image outputs | **Blocked / next gate** | The core has no process, AppContainer/LPAC, Job Object, broker, watchdog, or lifecycle route; hostile decoder qualification and external review remain required. | ## Security invariants @@ -66,6 +66,12 @@ while quarantine/cleanup failures surface for supervisor recovery. 18. Artifact records are opaque IDs; repository read/delete/purge operations remain confined to the configured artifact root and verify the stored SHA-256. +19. The fixed-function provider accepts only immutable bytes and parsed plans, uses an + independent format allowlist, treats decoder warnings as errors, rejects multiple + frames, enforces hard byte/pixel/dimension/memory/step caps, and revalidates output. +20. Provider startup requires an external available sandbox health result; dependency + or codec failure, cancellation, decoder failure, and output metadata/size failure + leave the provider disabled and return stable categories only. ## Re-run target @@ -76,6 +82,7 @@ python -m pytest tests/test_phase2_broker.py -q 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 compileall -q backend\cortex_backend\execution tests python -m pytest -q python tools/generate_contracts.py @@ -86,8 +93,9 @@ 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, and 16 -artifact-boundary tests passed; the full Python suite passed (187 tests total) with one +7 broker-contract tests, 9 native-broker tests, 7 bundle-installer tests, 16 +artifact-boundary tests, and 17 recipe-provider tests passed; the full Python suite +passed (204 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-native-broker.md b/docs/adr/0001-phase2-native-broker.md index 34480d7..384b87b 100644 --- a/docs/adr/0001-phase2-native-broker.md +++ b/docs/adr/0001-phase2-native-broker.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 native broker adapter -- **Status:** Implemented and verified; user-artifact staging and provider enablement remain blocked +- **Status:** Implemented and verified; provider enablement remains blocked - **Parent:** [Phase 2 authenticated broker contract](0001-phase2-broker-contract.md) - **Scope:** Windows named-pipe transport, protected DACL, OS peer identity, and authenticated session-key establishment @@ -56,7 +56,9 @@ and payloads are not exposed. This ADR does not launch or supervise a process, create an AppContainer or Job Object, install a signed bundle, copy files, decode images, publish artifacts, -enable a lifecycle provider, or expose a model tool. Those remain separate gates. +enable a lifecycle provider, or expose a model tool. Artifact copy-in/publication is +covered by the separate [trusted artifact boundary](0001-phase2-artifact-boundary.md); +decoding and lifecycle enablement remain separate gates. ## Verification diff --git a/docs/adr/0001-phase2-recipe-contract.md b/docs/adr/0001-phase2-recipe-contract.md index 79b76ca..0562bfe 100644 --- a/docs/adr/0001-phase2-recipe-contract.md +++ b/docs/adr/0001-phase2-recipe-contract.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 typed recipe and primitive contract -- **Status:** Typed contract, signed-manifest verification, native broker transport, signed bundle installation, and trusted artifact boundary implemented and verified; provider enablement remains blocked +- **Status:** Typed contract, signed-manifest verification, native broker transport, signed bundle installation, trusted artifact boundary, and qualification-only provider core implemented and verified; sandbox/provider enablement remains blocked - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [Phase 1 production lifecycle gate](0001-phase1-production-lifecycle.md) - **Scope:** Typed fixed-function image plans, calculator/check primitives, canonical @@ -54,9 +54,10 @@ This ADR does not authorize: verification plus storage installation are implemented separately in [the signed-manifest ADR](0001-phase2-signed-manifest.md) and [the bundle installation ADR](0001-phase2-bundle-installation.md); -- image decoding, codecs, thumbnails, archive extraction, or provider-produced content - handling beyond the - trusted [artifact boundary](0001-phase2-artifact-boundary.md); +- production image decoding, sandbox execution, thumbnails, archive extraction, or + provider-produced content handling beyond the qualification-only + [recipe provider core](0001-phase2-recipe-provider.md) and trusted + [artifact boundary](0001-phase2-artifact-boundary.md); - production execution beyond the transport-only [native broker adapter](0001-phase2-native-broker.md); - Wasmtime/WASI, AppContainer/LPAC, Job Object, host process, or any other provider; @@ -68,7 +69,8 @@ packaged application remains on the explicitly disabled lifecycle from Phase 1. ## Required next gates 1. Qualify the fixed-function provider inside the OS sandbox and wire it only through - a passing lifecycle health check after external review. + a passing lifecycle health check after external review; the current provider core + intentionally does not satisfy this gate by itself. ## Verification diff --git a/docs/adr/0001-phase2-recipe-provider.md b/docs/adr/0001-phase2-recipe-provider.md new file mode 100644 index 0000000..b6efd52 --- /dev/null +++ b/docs/adr/0001-phase2-recipe-provider.md @@ -0,0 +1,109 @@ +# ADR-0001 Phase 2 fixed-function recipe provider qualification + +- **Status:** Qualification core implemented and verified; OS sandbox and lifecycle enablement remain blocked +- **Phase:** 2 - fixed-function image provider +- **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) +- **Depends on:** [Phase 2 typed recipe contract](0001-phase2-recipe-contract.md) and [trusted artifact boundary](0001-phase2-artifact-boundary.md) +- **Scope:** Qualification-only decode/transform/re-encode core. It is not a production provider route. + +## Context and research basis + +The typed recipe and artifact boundary are now complete, but image bytes still need +format-aware decoding before a fixed-function transform can be considered safe. Image +decoders process attacker-controlled bytes in native libraries and can encounter +decompression bombs, malformed metadata, multi-frame inputs, truncated data, and +resource exhaustion. Pillow's current security guidance recommends independent byte +and format validation, an explicit format allowlist, treating decompression warnings as +errors, strict pixel/resource limits, metadata stripping, pinned dependencies, and an +isolated subprocess for the decoder. See the official [Pillow security guidance](https://pillow.readthedocs.io/en/stable/handbook/security.html), +[Image.open/verify/load reference](https://pillow.readthedocs.io/en/stable/reference/Image.html), +and [Python support matrix](https://pillow.readthedocs.io/en/stable/installation/python-support.html). + +## Decision + +`RecipeImageProvider` is a qualification-only core. It is deliberately not imported +by the application lifecycle or exported as an execution API. A future `RecipeExecutor` +may call this core only after the Windows sandbox has been independently constructed +and attested. + +### Input and decoder boundary + +1. The input is immutable bytes plus an already parsed `ImageTransformPlan`; no path, + filename, MIME claim, source text, network target, or plugin name is accepted. +2. Bytes are first checked by the trusted artifact MIME sniffer. Only PNG, JPEG, and + WebP magic types are passed to Pillow, and Pillow receives the corresponding + `formats` allowlist. TIFF, GIF, SVG, PDF, RAW, archives, executables, and active + content are not accepted by this provider. +3. Pillow's `verify()` pass is followed by a fresh `load()` pass. Truncated images are + disabled, decompression-bomb warnings are promoted to errors, and bomb/resource + errors map to stable `resource_limit` failures. +4. Only one frame is accepted. Width, height, total pixels, encoded input bytes, and + estimated decoded bytes are checked before and after pixel loading and after every + transform step. + +### Fixed-function transform and output boundary + +The only operations are the schema's grayscale, contrast, brightness, crop, resize, +and rotate steps. Crop bounds are rechecked against the current image dimensions; +the provider never trusts the parser alone. Each step checks cancellation and produces +a fresh image object. There is no expression evaluation, dynamic import, native +extension selection, filesystem access, or network capability. + +Outputs are encoded only as PNG, JPEG, or lossless WebP with fixed encoder settings. +Metadata containers are cleared and EXIF, ICC, XMP, comments, and PNG text are not +carried forward. The encoded bytes are then independently sniffed, reopened, fully +loaded, verified for format/dimensions/one-frame status, checked for metadata and size, +and hashed before a `RecipeProviderResult` is returned. The provider never writes a +file or publishes an artifact; the existing artifact boundary remains the only +publication path. + +### Health and enablement gate + +The provider starts disabled. `start()` requires an externally supplied available +`RuntimeHealth` from the future sandbox probe and a local Pillow capability check for +PNG/JPEG/WebP. Missing sandbox health, unavailable codecs, or any failed probe leaves +the provider disabled. There is no host-process, in-process `exec`, virtual-environment, +or weaker-provider fallback. `stop()` is monotonic and clears the enabled state. + +The current `RuntimeHealth.ready()` test fixture is only a contract test. It is not an +attestation of an AppContainer/LPAC, Job Object, restricted handle set, signed bundle, +named-pipe identity, CPU watchdog, or memory limit. Those controls remain required +before this provider can be wired into `ExecutionLifecycle`. + +## Qualification ceilings + +| Resource | Ceiling | +| --- | ---: | +| Encoded input | 100 MiB | +| Encoded output | 128 MiB | +| Pixels | 64 megapixels | +| Width or height | 16,384 pixels | +| Estimated decoded memory | 256 MiB | +| Transform steps | 8 | +| Frames | 1 | + +The ceilings are hard caps in `RecipeProviderLimits`; callers cannot raise them by +configuration. CPU time, process-tree lifetime, and OS memory enforcement are not +implemented by this core and therefore cannot satisfy the release gate. + +## Failure contract + +Only stable categories cross the provider boundary: `provider_disabled`, +`sandbox_unverified`, `sandbox_unavailable`, `recipe_dependency_missing`, +`recipe_codec_unavailable`, `invalid_input`, `unsupported_format`, +`unsupported_frames`, `decode_failed`, `invalid_plan`, `resource_limit`, +`input_too_large`, `output_too_large`, `encode_failed`, `output_invalid`, +`output_metadata_present`, `cancelled`, and `cancellation_check_failed`. +Decoder messages, paths, bytes, OS errors, and stack traces are not returned. + +## Verification and next gate + +`tests/test_phase2_recipe_provider.py` covers disabled/health-gated startup, +allowlisted transforms, deterministic output hashes, metadata stripping, malformed and +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 a separate Windows sandbox qualification: launch the provider out of +process under the 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/requirements.txt b/requirements.txt index 1c62680..2fcdd3e 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,3 +6,4 @@ uvicorn>=0.51,<0.52 httpx>=0.27,<1 pywebview>=6.2,<6.3 cryptography>=49,<50 +Pillow>=12.3,<12.4 diff --git a/tests/test_phase2_recipe_provider.py b/tests/test_phase2_recipe_provider.py new file mode 100644 index 0000000..a9d9bfc --- /dev/null +++ b/tests/test_phase2_recipe_provider.py @@ -0,0 +1,219 @@ +"""Qualification tests for the disabled fixed-function image provider.""" + +from __future__ import annotations + +from io import BytesIO +from threading import Event + +import pytest +from PIL import Image + +from cortex_backend.execution.lifecycle import RuntimeHealth +import cortex_backend.execution.recipe_provider as provider_module +from cortex_backend.execution.recipe_provider import ( + MAX_DECODED_BYTES, + MAX_DIMENSION, + MAX_INPUT_BYTES, + MAX_OUTPUT_BYTES, + MAX_PIXELS, + RecipeImageProvider, + RecipeProviderError, + RecipeProviderLimits, +) +from cortex_backend.execution.recipes import parse_image_transform + + +def _plan(*steps: dict, output_format: str = "png"): + return parse_image_transform( + { + "schema_version": "artifact.transform.v1", + "input_artifact_id": "artifact-1", + "steps": list(steps), + "output_format": output_format, + } + ) + + +def _image_bytes( + image_format: str = "PNG", + *, + size: tuple[int, int] = (4, 3), + mode: str = "RGBA", + exif: bytes | None = None, +) -> bytes: + image = Image.new(mode, size, (120, 80, 40, 255) if "A" in mode else 120) + try: + with BytesIO() as stream: + kwargs = {"format": image_format} + if exif is not None: + kwargs["exif"] = exif + image.save(stream, **kwargs) + return stream.getvalue() + finally: + image.close() + + +def _started_provider(limits: RecipeProviderLimits | None = None) -> RecipeImageProvider: + provider = RecipeImageProvider(limits) + health = provider.start(RuntimeHealth.ready("test sandbox attestation")) + assert health.available + return provider + + +def test_provider_is_disabled_until_external_sandbox_health_passes(): + provider = RecipeImageProvider() + assert provider.health_snapshot.code == "recipe_provider_disabled" + assert provider.health().code == "sandbox_unverified" + blocked = provider.start(RuntimeHealth.blocked("sandbox_unavailable", "sandbox is unavailable")) + assert not blocked.available + assert not provider.enabled + with pytest.raises(RecipeProviderError) as error: + provider.transform(_plan({"op": "grayscale"}), _image_bytes()) + assert error.value.code == "provider_disabled" + + +def test_provider_transforms_and_reencodes_without_metadata(): + provider = _started_provider() + source = _image_bytes("JPEG", mode="RGB", exif=b"Exif\x00\x00untrusted metadata") + plan = _plan( + {"op": "grayscale"}, + {"op": "contrast", "factor": "1.2"}, + {"op": "resize", "width": 2, "height": 2}, + output_format="png", + ) + + result = provider.transform(plan, source) + + assert result.mime_type == "image/png" + assert result.format == "PNG" + assert (result.width, result.height) == (2, 2) + assert result.sha256 + with Image.open(BytesIO(result.content)) as output: + assert output.format == "PNG" + assert output.mode == "L" + assert "exif" not in output.info + assert "text" not in output.info + assert provider.transform(plan, source).sha256 == result.sha256 + + +@pytest.mark.parametrize( + ("content", "expected"), + [ + (b"not an image", "unsupported_format"), + (b"", "invalid_input"), + (b"MZ\x90\x00not an image", "invalid_input"), + ], +) +def test_provider_rejects_non_allowlisted_or_active_input(content: bytes, expected: str): + provider = _started_provider() + with pytest.raises(RecipeProviderError) as error: + provider.transform(_plan({"op": "grayscale"}), content) + assert error.value.code == expected + + +def test_provider_rejects_malformed_and_multipage_input(): + provider = _started_provider() + with pytest.raises(RecipeProviderError) as malformed: + provider.transform(_plan({"op": "grayscale"}), b"\xff\xd8\xffmalformed jpeg") + assert malformed.value.code == "decode_failed" + + first = Image.new("RGB", (2, 2), (1, 2, 3)) + second = Image.new("RGB", (2, 2), (4, 5, 6)) + try: + with BytesIO() as stream: + first.save( + stream, + format="WEBP", + save_all=True, + append_images=[second], + duration=100, + loop=0, + lossless=True, + ) + animated = stream.getvalue() + finally: + first.close() + second.close() + with pytest.raises(RecipeProviderError) as frames: + provider.transform(_plan({"op": "grayscale"}), animated) + assert frames.value.code == "unsupported_frames" + + +def test_provider_enforces_pixel_decoded_memory_and_output_limits(): + image = _image_bytes("PNG", size=(8, 8)) + provider = _started_provider(RecipeProviderLimits(max_pixels=16, max_decoded_bytes=64)) + with pytest.raises(RecipeProviderError) as pixels: + provider.transform(_plan({"op": "grayscale"}), image) + assert pixels.value.code == "resource_limit" + + tiny_output_limit = _started_provider(RecipeProviderLimits(max_output_bytes=8)) + with pytest.raises(RecipeProviderError) as output: + tiny_output_limit.transform(_plan({"op": "grayscale"}), _image_bytes()) + assert output.value.code == "output_too_large" + + +def test_provider_rejects_out_of_bounds_crop_and_honors_cancellation(): + provider = _started_provider() + with pytest.raises(RecipeProviderError) as crop: + provider.transform( + _plan({"op": "crop", "x": 3, "y": 0, "width": 2, "height": 2}), + _image_bytes(), + ) + assert crop.value.code == "invalid_plan" + + cancelled = Event() + cancelled.set() + with pytest.raises(RecipeProviderError) as cancel_error: + provider.transform(_plan({"op": "grayscale"}), _image_bytes(), cancel_check=cancelled.is_set) + assert cancel_error.value.code == "cancelled" + + with pytest.raises(RecipeProviderError) as callback_error: + provider.transform(_plan({"op": "grayscale"}), _image_bytes(), cancel_check=lambda: 1 / 0) + assert callback_error.value.code == "cancellation_check_failed" + + +def test_provider_stop_is_monotonic_and_redacts_runtime_failures(): + provider = _started_provider() + stopped = provider.stop() + assert not stopped.available + assert stopped.code == "recipe_provider_stopped" + assert not provider.enabled + + +@pytest.mark.parametrize( + "field, value", + [ + ("max_input_bytes", MAX_INPUT_BYTES + 1), + ("max_output_bytes", MAX_OUTPUT_BYTES + 1), + ("max_pixels", MAX_PIXELS + 1), + ("max_dimension", MAX_DIMENSION + 1), + ("max_decoded_bytes", MAX_DECODED_BYTES + 1), + ("max_steps", 9), + ], +) +def test_provider_limits_cannot_raise_the_qualification_ceiling(field: str, value: int): + with pytest.raises(ValueError): + RecipeProviderLimits(**{field: value}) + + +def test_provider_health_blocks_missing_dependency_or_codec(monkeypatch): + provider = RecipeImageProvider() + monkeypatch.setattr(provider_module, "Image", None) + missing = provider.start(RuntimeHealth.ready("test sandbox attestation")) + assert not missing.available + assert missing.code == "recipe_dependency_missing" + assert not provider.enabled + + monkeypatch.undo() + monkeypatch.setattr(provider_module.Image, "registered_extensions", lambda: {".png": "PNG"}) + unavailable = provider.start(RuntimeHealth.ready("test sandbox attestation")) + assert not unavailable.available + assert unavailable.code == "recipe_codec_unavailable" + assert not provider.enabled + + +def test_provider_rejects_input_before_decoder_when_encoded_limit_is_exceeded(): + provider = _started_provider(RecipeProviderLimits(max_input_bytes=8)) + with pytest.raises(RecipeProviderError) as error: + provider.transform(_plan({"op": "grayscale"}), _image_bytes()) + assert error.value.code == "input_too_large" From 99e9ab880542f35f4038dd2714a948a3bfd6f585 Mon Sep 17 00:00:00 2001 From: Matthew Wesney Date: Tue, 21 Jul 2026 19:42:14 -0400 Subject: [PATCH 2/2] Document provider dependency qualification boundary --- docs/adr/0001-phase2-recipe-provider.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/docs/adr/0001-phase2-recipe-provider.md b/docs/adr/0001-phase2-recipe-provider.md index b6efd52..b8520d2 100644 --- a/docs/adr/0001-phase2-recipe-provider.md +++ b/docs/adr/0001-phase2-recipe-provider.md @@ -18,6 +18,9 @@ errors, strict pixel/resource limits, metadata stripping, pinned dependencies, a isolated subprocess for the decoder. See the official [Pillow security guidance](https://pillow.readthedocs.io/en/stable/handbook/security.html), [Image.open/verify/load reference](https://pillow.readthedocs.io/en/stable/reference/Image.html), and [Python support matrix](https://pillow.readthedocs.io/en/stable/installation/python-support.html). +The repository pins the supported Pillow line as `>=12.3,<12.4`; signed bundle +hashing and runtime packaging remain a later sandbox gate and are not implied by this +Python dependency declaration. ## Decision