diff --git a/Cortex_Preview.py b/Cortex_Preview.py index a7146cb..80f1154 100644 --- a/Cortex_Preview.py +++ b/Cortex_Preview.py @@ -17,8 +17,11 @@ from cortex_backend.api import BackendDependencies, create_app # noqa: E402 from cortex_backend.api.security import SessionManager # noqa: E402 from cortex_backend.core.paths import AppPaths # noqa: E402 -from cortex_backend.execution.coordinator import DurableFakeCoordinator # noqa: E402 -from cortex_backend.execution.lifecycle import ExecutionLifecycle, RuntimeHealth # noqa: E402 +from cortex_backend.execution.lifecycle import ExecutionLifecycle # noqa: E402 +from cortex_backend.execution.qualification import ( # noqa: E402 + QualificationLifecycleConfig, + build_execution_lifecycle, +) from cortex_backend.execution.repository import ExecutionRepository # noqa: E402 from cortex_backend.repositories.chats import LegacyDatabaseChatRepository # noqa: E402 from cortex_backend.repositories.legacy_settings import LegacySettingsReader # noqa: E402 @@ -39,6 +42,9 @@ def build_preview_app( frontend_dist: Path | None = None, serve_frontend: bool = True, handoff_secret: str | None = None, + execution_profile: str | None = None, + qualification: QualificationLifecycleConfig | None = None, + execution_lifecycle: ExecutionLifecycle | None = None, ): """Build the local web application without starting a server.""" paths = AppPaths.from_data_dir(data_dir) if data_dir else AppPaths.for_current_user() @@ -46,17 +52,18 @@ def build_preview_app( paths.execution_database, paths.execution_artifacts, ) - execution_lifecycle = ExecutionLifecycle( - execution_repository, - coordinator_factory=lambda repository: DurableFakeCoordinator( - repository, auto_recover=False - ), - health_check=lambda: RuntimeHealth.blocked( - code="runtime_unavailable", - message="A qualified execution runtime is not enabled in this build.", - ), - enabled=False, - ) + if execution_lifecycle is not None and ( + execution_profile is not None or qualification is not None + ): + raise ValueError( + "execution lifecycle cannot be combined with an execution profile" + ) + if execution_lifecycle is None: + execution_lifecycle = build_execution_lifecycle( + execution_repository, + profile=execution_profile, + qualification=qualification, + ) database = DatabaseManager(app_paths=paths) database.migrate_from_json_if_needed() permanent_memory = PermanentMemoryManager(app_paths=paths) diff --git a/backend/cortex_backend/execution/__init__.py b/backend/cortex_backend/execution/__init__.py index 720022a..7399cd0 100644 --- a/backend/cortex_backend/execution/__init__.py +++ b/backend/cortex_backend/execution/__init__.py @@ -1,9 +1,10 @@ """Durable execution primitives and provider-independent safety contracts. -Only deterministic contracts, the storage-only signed bundle installer, and the -reviewed broker transports are exposed in this phase. Native transport and bundle -storage never load a provider; real runtime providers stay absent until later ADR -gates are approved. +Only deterministic contracts, the storage-only signed bundle installer, the +reviewed broker transports, and the explicit local qualification composition are +exposed in this phase. Native transport and bundle storage never load a provider; +the normal application remains default-off and a recipe request coordinator is a +later ADR slice. """ from .broker import ( @@ -43,6 +44,15 @@ verify_keyring_update, ) from .lifecycle import ExecutionLifecycle, LifecycleSnapshot, RuntimeHealth +from .qualification import ( + CoordinatorFactory, + ExecutionProfile, + ProviderHealthProbe, + QualificationLifecycleConfig, + QualificationProfileError, + build_execution_lifecycle, + parse_execution_profile, +) from .manifest import ( ManifestEntry, ManifestState, @@ -174,6 +184,8 @@ "ExecutionRepository", "ExecutionRepositoryError", "ExecutionLifecycle", + "ExecutionProfile", + "CoordinatorFactory", "CalculatorPlan", "CheckPlan", "FakeExecutionPlan", @@ -212,6 +224,11 @@ "PublishedArtifact", "RecipeValidationError", "RuntimeHealth", + "ProviderHealthProbe", + "QualificationLifecycleConfig", + "QualificationProfileError", + "build_execution_lifecycle", + "parse_execution_profile", "RollbackAuthorizer", "SignedBundleInstaller", "SignedRecipeManifest", diff --git a/backend/cortex_backend/execution/lifecycle.py b/backend/cortex_backend/execution/lifecycle.py index 3ef8a48..335fd01 100644 --- a/backend/cortex_backend/execution/lifecycle.py +++ b/backend/cortex_backend/execution/lifecycle.py @@ -67,6 +67,7 @@ class LifecycleSnapshot: state: LifecycleState health: RuntimeHealth recovered_job_ids: tuple[str, ...] = () + profile: str = "disabled" @property def available(self) -> bool: @@ -83,11 +84,21 @@ def __init__( coordinator_factory: Callable[[ExecutionRepository], LifecycleCoordinator], health_check: Callable[[], RuntimeHealth], enabled: bool = False, + profile: str | None = None, ) -> None: + selected_profile = ( + profile if profile is not None else ("disabled" if not enabled else "custom") + ) + if ( + not isinstance(selected_profile, str) + or _SAFE_HEALTH_CODE.fullmatch(selected_profile) is None + ): + raise ValueError("execution lifecycle profile must be a bounded identifier") self.repository = repository self._coordinator_factory = coordinator_factory self._health_check = health_check self._enabled = enabled + self._profile = selected_profile self._coordinator: LifecycleCoordinator | None = None self._recovered_job_ids: tuple[str, ...] = () self._health = ( @@ -108,9 +119,16 @@ def snapshot(self) -> LifecycleSnapshot: return LifecycleSnapshot( state=self._state, health=self._health, + profile=self._profile, recovered_job_ids=self._recovered_job_ids, ) + @property + def profile(self) -> str: + """Return the bounded profile label selected at construction time.""" + + return self._profile + @property def coordinator(self) -> LifecycleCoordinator | None: """Expose a coordinator only while the lifecycle is fully ready.""" diff --git a/backend/cortex_backend/execution/qualification.py b/backend/cortex_backend/execution/qualification.py new file mode 100644 index 0000000..fd35c2b --- /dev/null +++ b/backend/cortex_backend/execution/qualification.py @@ -0,0 +1,184 @@ +"""Explicit local qualification-profile lifecycle composition. + +The normal Cortex application does not construct a runnable recipe lifecycle. +This module provides the deliberate development/CI seam for callers that have +already built the fixed worker, installed it with disposable qualification +trust, and supplied a coordinator that owns the worker boundary. + +The profile is intentionally narrower than an official release profile: + +* ``disabled`` is the default and never calls a health probe or coordinator; +* ``qualification`` requires the qualification release gate, an injected + coordinator factory, and a provider-health probe; and +* missing or malformed qualification wiring becomes a blocked lifecycle, not a + host-process fallback or an implicitly enabled provider. + +This module does not create a process, bind a broker, load a provider, or read a +worker path. Those actions remain responsibilities of the injected controls. +""" + +from __future__ import annotations + +from dataclasses import dataclass +import re +from collections.abc import Callable +from typing import Literal + +from .lifecycle import ExecutionLifecycle, LifecycleCoordinator, RuntimeHealth +from .release_gate import RecipeRuntimeReleaseGate +from .repository import ExecutionRepository + + +ExecutionProfile = Literal["disabled", "qualification"] +_SAFE_CODE = re.compile(r"^[a-z][a-z0-9._-]{0,63}$") + + +class QualificationProfileError(ValueError): + """Stable configuration error for an explicit local profile selection.""" + + def __init__(self, code: str) -> None: + if _SAFE_CODE.fullmatch(code) is None: + raise ValueError("invalid qualification profile code") + self.code = code + super().__init__("The local qualification profile configuration is invalid.") + + +ProviderHealthProbe = Callable[[RuntimeHealth], RuntimeHealth] +CoordinatorFactory = Callable[[ExecutionRepository], LifecycleCoordinator] + + +@dataclass(frozen=True, slots=True) +class QualificationLifecycleConfig: + """Controls required before the local qualification lifecycle can start.""" + + release_gate: RecipeRuntimeReleaseGate + coordinator_factory: CoordinatorFactory + provider_health_check: ProviderHealthProbe + + def __post_init__(self) -> None: + if not isinstance(self.release_gate, RecipeRuntimeReleaseGate): + raise TypeError("qualification release gate is required") + if self.release_gate.release_profile != "qualification": + raise QualificationProfileError("qualification_gate_required") + if not callable(self.coordinator_factory): + raise TypeError("qualification coordinator factory must be callable") + if not callable(self.provider_health_check): + raise TypeError("qualification provider health check must be callable") + + +def parse_execution_profile(value: str | None) -> ExecutionProfile: + """Parse an explicit profile without accepting aliases or whitespace.""" + + if value is None: + return "disabled" + if value == "disabled": + return "disabled" + if value == "qualification": + return "qualification" + raise QualificationProfileError("execution_profile_invalid") + + +def _unconfigured_factory(_repository: ExecutionRepository) -> LifecycleCoordinator: + raise RuntimeError("qualification coordinator is not configured") + + +def _disabled_health() -> RuntimeHealth: + return RuntimeHealth.blocked( + code="runtime_disabled", + message="Execution runtime is disabled in this build.", + ) + + +def _qualification_configuration_health() -> RuntimeHealth: + return RuntimeHealth.blocked( + code="qualification_configuration_missing", + message="The local qualification runtime is not configured.", + ) + + +def _qualification_health(config: QualificationLifecycleConfig) -> RuntimeHealth: + """Compose release and provider health without leaking internal details.""" + + try: + release_health = config.release_gate.health() + except Exception: + return RuntimeHealth.blocked( + code="qualification_gate_failed", + message="The local qualification controls could not be verified safely.", + ) + if not isinstance(release_health, RuntimeHealth): + return RuntimeHealth.blocked( + code="qualification_gate_result_invalid", + message="The local qualification controls returned an invalid result.", + ) + if not release_health.available: + return release_health + try: + provider_health = config.provider_health_check(release_health) + except Exception: + return RuntimeHealth.blocked( + code="qualification_provider_health_failed", + message="The fixed-function provider could not be verified safely.", + ) + if not isinstance(provider_health, RuntimeHealth): + return RuntimeHealth.blocked( + code="qualification_provider_health_invalid", + message="The fixed-function provider returned an invalid health result.", + ) + if not provider_health.available: + return provider_health + return RuntimeHealth.ready("Local recipe qualification controls are ready.") + + +def build_execution_lifecycle( + repository: ExecutionRepository, + *, + profile: str | None = None, + qualification: QualificationLifecycleConfig | None = None, +) -> ExecutionLifecycle: + """Build the only supported local profile boundary. + + ``profile=None`` and ``profile="disabled"`` construct the same inert + lifecycle. Selecting ``qualification`` is explicit and still fails closed + when its controls are absent or unhealthy. The returned lifecycle carries a + safe profile label for diagnostics. + """ + + selected = parse_execution_profile(profile) + if selected == "disabled": + if qualification is not None: + raise QualificationProfileError("qualification_config_with_disabled_profile") + return ExecutionLifecycle( + repository, + coordinator_factory=_unconfigured_factory, + health_check=_disabled_health, + enabled=False, + profile="disabled", + ) + + if qualification is None: + return ExecutionLifecycle( + repository, + coordinator_factory=_unconfigured_factory, + health_check=_qualification_configuration_health, + enabled=True, + profile="qualification", + ) + return ExecutionLifecycle( + repository, + coordinator_factory=qualification.coordinator_factory, + health_check=lambda: _qualification_health(qualification), + enabled=True, + profile="qualification", + ) + + +__all__ = [ + "CoordinatorFactory", + "ExecutionProfile", + "ProviderHealthProbe", + "QualificationLifecycleConfig", + "QualificationProfileError", + "build_execution_lifecycle", + "parse_execution_profile", +] diff --git a/backend/cortex_backend/execution/release_gate.py b/backend/cortex_backend/execution/release_gate.py index 85ef4c4..c1b0b20 100644 --- a/backend/cortex_backend/execution/release_gate.py +++ b/backend/cortex_backend/execution/release_gate.py @@ -105,6 +105,12 @@ def __init__( self._platform_name = platform_name or os.name self._release_profile = release_profile + @property + def release_profile(self) -> Literal["official", "qualification"]: + """Return the immutable profile selected for this preflight.""" + + return self._release_profile + @staticmethod def _blocked( checks: list[ReleaseGateCheck], diff --git a/docs/adr/0001-capability-tiered-agentic-execution-harness.md b/docs/adr/0001-capability-tiered-agentic-execution-harness.md index 76e6ab7..11e10e0 100644 --- a/docs/adr/0001-capability-tiered-agentic-execution-harness.md +++ b/docs/adr/0001-capability-tiered-agentic-execution-harness.md @@ -997,11 +997,14 @@ presence, and (when maintainers choose to use it) explicit external-review evide Quality CI now builds the fixed worker and runs the disposable signed/AppContainer/broker, hostile-decoder, and cancellation corpus with bounded timeouts; this is the open-source qualification evidence and does not need -production trust material. The next core slice is explicit lifecycle wiring for -`release_profile="qualification"`, with the application still default-off and the -profile documented as local/CI-only. The separate `recipe.release-review.v1` -verifier is available as optional release hardening; it remains observation-only -and no approval record or production trust material is committed. +production trust material. Explicit lifecycle composition for +`release_profile="qualification"` is now available through the documented +local/CI-only builder; the application remains default-off and no recipe request +route is enabled by this slice. The next core slice is the recipe-specific +coordinator/request and artifact-publication path behind that lifecycle. The +separate `recipe.release-review.v1` verifier is available as optional release +hardening; it remains observation-only and no approval record or production +trust material is committed. ### Phase 3 — `scratch.auto.v1` arbitrary WebAssembly code diff --git a/docs/adr/0001-phase2-evidence.md b/docs/adr/0001-phase2-evidence.md index f87c731..451e8ec 100644 --- a/docs/adr/0001-phase2-evidence.md +++ b/docs/adr/0001-phase2-evidence.md @@ -28,14 +28,15 @@ | 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`. | | Deterministic artifact security review | **Complete (qualification-only)** | `artifact_security_review.py --json --strict` passed the fixed 12-case disposable corpus (`artifact-boundary-review.v1`, digest `a748cc9f0a514c8d`): owner/path binding, link/hardlink rejection, active/non-finite content, exact claims/quarantine, rollback, and repository size integrity. Missing link primitives remain blocked. | -| Deterministic resource/watchdog accounting | **Complete (qualification-only)** | `resource_watchdog_qualification.py --json --strict` passed the fixed `resource-watchdog.v1` corpus (digest `5eac03e2b4981543`): immutable ADR budgets, wall/idle watchdogs, clock and cumulative-sample regression, stable CPU/memory/input/output/console/observation/message limit precedence, missing-memory fail-closed behavior, actual Windows Job Object accounting, and kill-on-close process-tree reaping. The explicit local/qualification profile is the next lifecycle-wiring slice; external review is not required for this open-source path. | +| Deterministic resource/watchdog accounting | **Complete (qualification-only)** | `resource_watchdog_qualification.py --json --strict` passed the fixed `resource-watchdog.v1` corpus (digest `5eac03e2b4981543`): immutable ADR budgets, wall/idle watchdogs, clock and cumulative-sample regression, stable CPU/memory/input/output/console/observation/message limit precedence, missing-memory fail-closed behavior, actual Windows Job Object accounting, and kill-on-close process-tree reaping. The explicit local/qualification lifecycle composition now consumes this evidence; external review is not required for this open-source path. | | Release/lifecycle health preflight | **Complete (official + qualification profiles)** | `RecipeRuntimeReleaseGate` defaults to `release_profile="official"` and requires the reviewed native process factory, live broker binder, and external-review result. An explicit `release_profile="qualification"` records `qualification_profile` and omits only the optional outside-review requirement for local/CI development; it performs no launch, broker bind, provider load, or lifecycle mutation. | | Packaged worker release qualification | **Complete (CI qualification-only)** | Quality CI builds the unsigned fixed one-folder worker, signs it only with an in-memory ephemeral key, installs/reverifies the immutable generation, and runs the live AppContainer/Job Object/broker/hostile/cancellation corpus with bounded 15/20-minute steps. Production trust material and provider enablement remain excluded. | | External release-review attestation | **Complete (optional official-release hardening)** | `recipe.release-review.v1`, an independent pinned review key root, exact release/bundle/worker-key/launcher/threat-model binding, bounded freshness, and redacted `RuntimeHealth` adaptation are implemented and adversarially tested. It is not required to build, test, or use the open-source qualification profile. | -| 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 default-off until explicit qualification-profile lifecycle wiring. | +| 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, and checks cancellation. The provider request route remains separate and default-off. | | Windows recipe sandbox qualification harness | **Complete (signed launch/broker/hostile/cancellation/resource/watchdog)** | `recipe_worker_e2e_qualification.py` signs a disposable package with an in-memory key, installs/verifies one immutable generation, binds the live AppContainer identity to the broker, and exercises the fixed PNG transform, truncated-PNG decoder rejection, active-SVG decoder rejection, and in-flight cancellation corpus. `resource_watchdog_qualification.py` separately proves immutable budgets, actual Job Object accounting, and kill-on-close tree reaping. The native broker uses bounded availability polling before reads so the cancellation reader remains live while the packaged provider transforms. | | Suspended native launcher/resource policy | **Complete (factory + binder + ACL cleanup + qualification evidence)** | `NativeWin32ProcessFactory` grants only inherited read/execute access to the fresh AppContainer SID on the verified package root, applies and verifies Job Object policy before resume, and removes the per-launch ACE during cleanup. `NativeBrokerIdentityBinder` pins the live server to the worker PID/AppContainer SID and launcher cleanup closes it on failure. | -| OS sandbox provider and provider-produced image outputs | **Complete (qualification; default-off in the app)** | Signed installation, provenance, AppContainer/job identity, broker handshake, `prepare`, `input_chunk`, `input_complete`, `collect_output`, hostile decoder rejection, in-flight cancellation acknowledgement, artifact security review, resource accounting, and watchdog tree reaping are qualified. The next core slice is explicit local/qualification lifecycle wiring; official-release review/signing remains optional hardening. | +| OS sandbox provider and provider-produced image outputs | **Complete (qualification; default-off in the app)** | Signed installation, provenance, AppContainer/job identity, broker handshake, `prepare`, `input_chunk`, `input_complete`, `collect_output`, hostile decoder rejection, in-flight cancellation acknowledgement, artifact security review, resource accounting, and watchdog tree reaping are qualified. The explicit lifecycle composition is now wired; the next core slice is the recipe coordinator/request path. Official-release review/signing remains optional hardening. | +| Explicit qualification-profile lifecycle wiring | **Complete (local/CI composition; default-off in the app)** | `build_execution_lifecycle()` accepts only exact `disabled`/`qualification` selection, requires a qualification release gate, coordinator factory, and provider-health probe, and composes them in a fail-closed order. `Cortex_Preview.build_preview_app` exposes this only as an explicit injection; the normal app supplies no profile or controls. | ## Security invariants @@ -287,9 +288,10 @@ attestation tests, packaged-worker build, and disposable Windows qualification. **Roadmap correction (2026-07-29):** External review, production signing, and trusted release roots are now explicitly classified as optional official-release -hardening. They do not block the open-source source checkout, local qualification, -or a future explicit development profile. The release gate now exposes an explicit -`release_profile="qualification"` for that path, while the application remains -default-off. The next core implementation slice is wiring that profile into the -local lifecycle with clear default-off controls; no outside reviewer or production -key is required for that work. +hardening. They do not block the open-source source checkout or local +qualification. The release gate exposes an explicit +`release_profile="qualification"`, and the new lifecycle builder composes that +profile only when a caller injects the qualification gate, coordinator, and +provider-health probe. The normal application remains default-off. The next core +implementation slice is the recipe-specific coordinator/request and artifact +publication path; no outside reviewer or production key is required for it. diff --git a/docs/adr/0001-phase2-qualification-lifecycle.md b/docs/adr/0001-phase2-qualification-lifecycle.md new file mode 100644 index 0000000..2f4746c --- /dev/null +++ b/docs/adr/0001-phase2-qualification-lifecycle.md @@ -0,0 +1,66 @@ +# ADR-0001 Phase 2 explicit qualification-profile lifecycle + +- **Status:** Implemented as a local/CI lifecycle composition boundary; recipe request execution remains a separate slice +- **Phase:** 2 - fixed-function image provider +- **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) +- **Depends on:** [Phase 2 release/lifecycle preflight](0001-phase2-release-lifecycle-gate.md), [recipe provider](0001-phase2-recipe-provider.md), and [Phase 1 lifecycle](0001-phase1-production-lifecycle.md) +- **Scope:** Explicit local/CI qualification-profile selection and health-gated lifecycle construction + +## Decision + +The source checkout now has one deliberate composition path for the fixed +qualification profile: `build_execution_lifecycle(...)` in +`cortex_backend.execution.qualification`. The profile parser accepts exactly +`disabled` and `qualification`; an omitted value is `disabled`. + +The qualification path requires all of the following injected controls: + +1. a `RecipeRuntimeReleaseGate` constructed with + `release_profile="qualification"`; +2. a coordinator factory that owns the already-qualified worker boundary; and +3. a provider-health probe that receives the passing release health result. + +The lifecycle evaluates those controls in order. A failed or malformed release +gate, provider probe, or coordinator startup returns a bounded blocked state and +keeps the coordinator unavailable. No path falls back to a host process, +in-process image decoding, or an unverified worker. The lifecycle records the +safe profile label `qualification` for diagnostics. + +`Cortex_Preview.build_preview_app` accepts the explicit profile/configuration as +an injection point. Its normal call site supplies neither, so the packaged and +source application remain `disabled` by default. This stage does not add a +recipe API route, automatic model tool selection, worker-package discovery, or +persistent signing/trust material. A caller must deliberately provide those +qualified controls in local/CI code. + +## Failure and recovery behavior + +- Missing qualification configuration is `qualification_configuration_missing`. +- Gate, provider, and result-shape exceptions are reduced to stable health codes; + internal paths, exception text, process IDs, tokens, and signatures do not + cross the lifecycle boundary. +- A coordinator is constructed only after every health check passes. If startup + recovery fails, the existing lifecycle cleanup path closes the partial + coordinator and leaves execution blocked. +- `disabled` never calls a health probe or coordinator factory. +- `official` release review remains inside `RecipeRuntimeReleaseGate` and is not + selected by this local profile helper. + +## Consequences + +Open-source contributors can exercise the fixed, bounded runtime in local/CI +qualification code without an outside reviewer, production signing key, or +trusted release root. The normal application cannot be enabled accidentally by +importing the provider or setting an implicit default. + +The next Phase 2 slice is the recipe-specific coordinator/request and artifact +publication path that can be injected behind this lifecycle. It must preserve +the same release gate, native launcher, broker identity, resource/watchdog, and +trusted artifact controls. + +## Verification + +`tests/test_phase2_qualification_lifecycle.py` covers exact profile parsing, +default-off behavior, missing configuration, official-gate rejection, blocked +health ordering, provider-health composition, coordinator startup, profile +diagnostics, and clean stop. diff --git a/docs/adr/0001-phase2-recipe-contract.md b/docs/adr/0001-phase2-recipe-contract.md index 8ce9be1..9cb094a 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, trusted artifact boundary, and qualification-only provider core implemented and verified; explicit qualification-profile lifecycle wiring remains next +- **Status:** Typed contract, signed-manifest verification, native broker transport, signed bundle installation, trusted artifact boundary, qualification-only provider core, and explicit qualification-profile lifecycle composition implemented and verified; recipe request execution remains next - **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 @@ -64,17 +64,18 @@ This ADR does not authorize: - model prompt/tool exposure, automatic execution, or application lifecycle enablement. Those gates require their own implementation evidence. The packaged application -remains on the explicitly disabled lifecycle from Phase 1 until the local -qualification profile is deliberately wired; outside review and production trust -are optional official-release hardening, not open-source prerequisites. +remains on the explicitly disabled lifecycle from Phase 1; the local qualification +profile is now deliberately composable through an explicit injected lifecycle +builder. Outside review and production trust are optional official-release +hardening, not open-source prerequisites. ## Required next gates -1. Wire the fixed-function provider inside the OS sandbox through a passing lifecycle - health check for the explicit `release_profile="qualification"`; the current - provider core intentionally does not satisfy this gate by itself. Official - prebuilt releases may add external review and production trust as optional - hardening. +1. Add the recipe-specific coordinator/request and artifact-publication path behind + the passing lifecycle health check for the explicit + `release_profile="qualification"`. The lifecycle builder intentionally does not + create processes or expose a recipe route by itself. Official prebuilt releases + may add external review and production trust as optional hardening. ## Verification diff --git a/docs/adr/0001-phase2-recipe-provider.md b/docs/adr/0001-phase2-recipe-provider.md index a41fc03..ef721dc 100644 --- a/docs/adr/0001-phase2-recipe-provider.md +++ b/docs/adr/0001-phase2-recipe-provider.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 fixed-function recipe provider qualification -- **Status:** Qualification core implemented and verified; explicit qualification-profile lifecycle wiring remains next +- **Status:** Qualification core and explicit qualification-profile lifecycle composition implemented and verified; recipe request execution remains next - **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) @@ -110,9 +110,11 @@ 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 explicit qualification-profile lifecycle wiring after the signed +The explicit qualification-profile lifecycle composition now consumes the signed worker provenance, disposable [Windows sandbox qualification harness](0001-phase2-sandbox-qualification.md), -native broker identity, watchdog, and accounting controls pass. The provider must -still launch out of process under the bounded worker contract; it must never fall -back to a host process or in-process execution. External review and production -signing remain optional official-release hardening. +native broker identity, watchdog, and accounting controls through an injected +health-gated builder. The next gate is the recipe-specific coordinator/request +and artifact-publication path. The provider must still launch out of process +under the bounded worker contract; it must never fall back to a host process or +in-process execution. External review and production signing remain optional +official-release hardening. diff --git a/docs/adr/0001-phase2-release-lifecycle-gate.md b/docs/adr/0001-phase2-release-lifecycle-gate.md index b4863be..195c3a2 100644 --- a/docs/adr/0001-phase2-release-lifecycle-gate.md +++ b/docs/adr/0001-phase2-release-lifecycle-gate.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 release and lifecycle health preflight -- **Status:** Optional official-release preflight implemented and verified; open-source qualification is a separate track +- **Status:** Optional official-release preflight and explicit local qualification composition implemented and verified; recipe request execution remains separate - **Phase:** 2 - fixed-function image provider - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [signed worker provenance](0001-phase2-worker-provenance.md), [native launcher](0001-phase2-native-launcher.md), [native broker](0001-phase2-native-broker.md), and [Phase 1 lifecycle](0001-phase1-production-lifecycle.md) @@ -8,9 +8,9 @@ This ADR governs a maintainer-controlled official release profile. It is not a requirement for cloning the repository, running the tests, building the worker, or -using a future explicit local/qualification profile with disposable signing material. -The normal source checkout remains default-off until that profile is deliberately -wired and documented. +using the explicit local/qualification profile with disposable signing material. +The normal source checkout remains default-off; the composition boundary is +documented separately in [the qualification lifecycle ADR](0001-phase2-qualification-lifecycle.md). ## Decision @@ -32,8 +32,8 @@ review callback. An explicit `release_profile="qualification"` is available for local/CI development after the sandbox controls pass; it records `qualification_profile` and intentionally does not require outside review or production trust material. The qualification profile is not an implicit provider -enablement and the application remains default-off until a separate explicit -lifecycle wiring change. +enablement; the application remains default-off and only the separate injected +lifecycle builder may compose it. The result is a `ReleaseGateSnapshot` containing only safe check names, stable codes, and a `RuntimeHealth`. Missing or invalid controls return a blocked result @@ -77,10 +77,10 @@ The fixed failure order is: | Broker identity | `native_broker_binding_required` | The live PID/AppContainer binder is not configured. | | External review | `external_review_required`, `external_review_unavailable`, `external_review_result_invalid`, or `external_review_check_failed` | Release approval is absent, failed, or malformed. | -The snapshot is suitable for an `ExecutionLifecycle` health callback, but the -application must still construct an explicitly enabled lifecycle and a reviewed -coordinator as a separate release change. The current build remains disabled by -default, so ordinary chat readiness is unaffected. +The snapshot is suitable for an `ExecutionLifecycle` health callback. The local +qualification builder additionally requires a provider-health probe and an +injected coordinator; the application remains disabled by default, so ordinary +chat readiness is unaffected. ## Invariants diff --git a/docs/adr/0001-phase2-sandbox-qualification.md b/docs/adr/0001-phase2-sandbox-qualification.md index a8a6d5e..10aea5c 100644 --- a/docs/adr/0001-phase2-sandbox-qualification.md +++ b/docs/adr/0001-phase2-sandbox-qualification.md @@ -1,6 +1,6 @@ # ADR-0001 Phase 2 Windows recipe sandbox qualification -- **Status:** Qualification harness and packaged worker/release preflight implemented; explicit qualification-profile lifecycle wiring remains next +- **Status:** Qualification harness, packaged worker/release preflight, and explicit qualification-profile lifecycle composition implemented; recipe request execution remains next - **Phase:** 2 - fixed-function image provider - **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Depends on:** [Phase 2 recipe provider core](0001-phase2-recipe-provider.md), [signed bundle installation](0001-phase2-bundle-installation.md), [native broker adapter](0001-phase2-native-broker.md), and [trusted artifact boundary](0001-phase2-artifact-boundary.md) @@ -8,8 +8,9 @@ ## Decision -The next gate is represented by `tools/execution_spikes/recipe_sandbox_qualification.py`. -The harness is deliberately fail-closed and has independent checks for: +The qualification evidence is represented by `tools/execution_spikes/recipe_sandbox_qualification.py` +and the strict packaged-worker harness. These helpers are deliberately +fail-closed and have independent checks for: 1. it runs the reviewed zero-capability AppContainer isolation helper in a child process and requires token identity, parent-file denial, loopback denial, and @@ -18,14 +19,14 @@ The harness is deliberately fail-closed and has independent checks for: process and requires full process-tree reaping after watchdog cancellation; 3. it exercises a fixed allowlisted/hostile decoder corpus against the qualification-only Pillow core, while recording `sandboxed=false`; and -4. it requires the future fixed recipe worker package at the repository's fixed +4. it requires the fixed recipe worker package at the repository's fixed packaging location; 5. it runs the deterministic resource/watchdog corpus, requiring immutable budgets, cumulative accounting, actual Job Object CPU/memory/process/I/O accounting, and kill-on-close tree reaping; and -6. it reports end-to-end broker execution as blocked until the launcher binds the - qualified transport to a signed worker's actual PID/AppContainer token and the - packaged worker completes the authenticated client handshake and hostile corpus. +6. it binds the qualified transport to a signed worker's actual PID/AppContainer + token and requires the packaged worker to complete the authenticated client + handshake and hostile corpus. The worker package boundary is now qualified by the strict disposable packaged-worker corpus. A directory, executable, self-reported digest, or unverified manifest still @@ -45,20 +46,20 @@ 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; the watchdog corpus proves full-tree reaping | -| Suspended launch/resource policy | `native_launcher_qualification.py`, child report `recipe_native_launcher_policy` | Policy application/query passes for a fixed benign child; real worker enforcement/review remains open | +| Suspended launch/resource policy | `native_launcher_qualification.py`, child report `recipe_native_launcher_policy` | Policy application/query and disposable worker enforcement qualify; official-release review/signing remains optional hardening | | Resource/watchdog accounting | `resource_watchdog_qualification.py`, child report `recipe_resource_controls` | Immutable budgets, actual Job Object accounting, and kill-on-close reaping qualify; official-release review/signing remains optional hardening | | 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 | 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; `RecipeRuntimeReleaseGate` supports an explicit `release_profile="qualification"` without external review and an `official` profile with optional release hardening | No provider can become reachable accidentally; the next core slice is explicit qualification-profile wiring | +| Signed worker provenance | Storage-only `verify_active_worker()` role binding plus strict packaged-worker corpus | **Qualification complete** for the fixed worker role; production trust remains separate | +| Broker identity and framed IPC | Native broker transport tests and strict packaged-worker corpus | **Qualification complete** when bound to the actual worker PID/token; no host fallback | +| Lifecycle enablement | `ExecutionLifecycle` remains disabled by default; `build_execution_lifecycle()` composes an explicit `release_profile="qualification"` only with an injected gate, coordinator, and provider-health probe | No provider can become reachable accidentally; the next core slice is the recipe coordinator/request path | No single green smoke result closes the gate. A missing, failed, or unverified control produces `blocked` or `fail`, and no weaker host-process path is attempted. -## Required future worker qualification +## Qualification worker sequence -The explicit qualification profile must install a disposable signed worker bundle and -run the existing native launcher/worker loop per attempt: +The explicit qualification profile installs a disposable signed worker bundle and +runs the existing native launcher/worker loop per attempt: 1. verifies the installed immutable generation and image-worker entrypoint; 2. creates private staging and grants only the sandbox identity and required @@ -102,7 +103,8 @@ preflight never creates a process, binds a broker, or enables a provider. This stage provides reproducible evidence for the controls that already exist and prevents accidental false-green qualification. The source checkout remains -default-off; the next implementation slice wires the explicit qualification profile -to lifecycle. Official production readiness, signing, and external review are -separate optional maintainer concerns and are not prerequisites for open-source -development. +default-off; the explicit qualification profile is now a deliberate, injected +lifecycle composition rather than an application default. Official production +readiness, signing, and external review are separate optional maintainer concerns +and are not prerequisites for open-source development. The next implementation +slice is the recipe coordinator/request path behind this boundary. diff --git a/tests/test_phase2_qualification_lifecycle.py b/tests/test_phase2_qualification_lifecycle.py new file mode 100644 index 0000000..cc5a040 --- /dev/null +++ b/tests/test_phase2_qualification_lifecycle.py @@ -0,0 +1,143 @@ +"""Explicit local qualification lifecycle wiring remains fail-closed.""" + +from __future__ import annotations + +import pytest + +from cortex_backend.execution.coordinator import DurableFakeCoordinator +from cortex_backend.execution.lifecycle import RuntimeHealth +from cortex_backend.execution.qualification import ( + QualificationLifecycleConfig, + QualificationProfileError, + build_execution_lifecycle, + parse_execution_profile, +) +from cortex_backend.execution.bundle_installer import SignedBundleInstaller +from cortex_backend.execution.manifest import TrustedRecipeKeys +from cortex_backend.execution.release_gate import RecipeRuntimeReleaseGate +from cortex_backend.execution.repository import ExecutionRepository + + +def _gate(tmp_path, *, profile: str = "qualification") -> RecipeRuntimeReleaseGate: + installer = SignedBundleInstaller( + tmp_path / "bundle-store", + TrustedRecipeKeys({"qualification": b"q" * 32}), + ) + return RecipeRuntimeReleaseGate( + installer, + platform_name="nt", + process_factory=object(), + broker_binder=object(), + release_profile=profile, + ) + + +def test_profile_parser_is_exact_and_defaults_to_disabled(): + assert parse_execution_profile(None) == "disabled" + assert parse_execution_profile("disabled") == "disabled" + assert parse_execution_profile("qualification") == "qualification" + with pytest.raises(QualificationProfileError) as error: + parse_execution_profile(" Qualification ") + assert error.value.code == "execution_profile_invalid" + + +def test_disabled_profile_never_calls_health_or_coordinator(tmp_path): + repository = ExecutionRepository(tmp_path / "execution.sqlite", tmp_path / "artifacts") + calls: list[str] = [] + lifecycle = build_execution_lifecycle( + repository, + profile=None, + ) + assert lifecycle.snapshot.profile == "disabled" + assert lifecycle.snapshot.state == "disabled" + assert lifecycle.start().health.code == "runtime_disabled" + assert calls == [] + + +def test_preview_builder_remains_disabled_without_explicit_profile(tmp_path): + from Cortex_Preview import build_preview_app + + app = build_preview_app(data_dir=tmp_path / "app-data", serve_frontend=False) + + assert app.state.execution_lifecycle.profile == "disabled" + assert app.state.execution_lifecycle.snapshot.state == "disabled" + + +def test_qualification_without_controls_is_blocked_without_factory_call(tmp_path): + repository = ExecutionRepository(tmp_path / "execution.sqlite", tmp_path / "artifacts") + lifecycle = build_execution_lifecycle(repository, profile="qualification") + + snapshot = lifecycle.start() + + assert snapshot.profile == "qualification" + assert snapshot.state == "blocked" + assert snapshot.health.code == "qualification_configuration_missing" + assert lifecycle.coordinator is None + + +def test_qualification_config_rejects_official_release_gate(tmp_path): + with pytest.raises(QualificationProfileError) as error: + QualificationLifecycleConfig( + release_gate=_gate(tmp_path, profile="official"), + coordinator_factory=lambda repository: DurableFakeCoordinator(repository), + provider_health_check=lambda _health: RuntimeHealth.ready(), + ) + assert error.value.code == "qualification_gate_required" + + +def test_qualification_health_failure_stays_blocked_before_coordinator(tmp_path, monkeypatch): + repository = ExecutionRepository(tmp_path / "execution.sqlite", tmp_path / "artifacts") + gate = _gate(tmp_path) + monkeypatch.setattr( + gate, + "health", + lambda: RuntimeHealth.blocked( + "worker_bundle_unavailable", "The signed recipe worker is unavailable." + ), + ) + factory_calls: list[bool] = [] + config = QualificationLifecycleConfig( + release_gate=gate, + coordinator_factory=lambda repo: factory_calls.append(True) + or DurableFakeCoordinator(repo, auto_recover=False), + provider_health_check=lambda _health: RuntimeHealth.ready(), + ) + + snapshot = build_execution_lifecycle( + repository, + profile="qualification", + qualification=config, + ).start() + + assert snapshot.state == "blocked" + assert snapshot.health.code == "worker_bundle_unavailable" + assert factory_calls == [] + + +def test_qualification_profile_composes_gate_provider_health_and_lifecycle(tmp_path, monkeypatch): + repository = ExecutionRepository(tmp_path / "execution.sqlite", tmp_path / "artifacts") + gate = _gate(tmp_path) + monkeypatch.setattr(gate, "health", lambda: RuntimeHealth.ready("gate passed")) + provider_inputs: list[str] = [] + factory_calls: list[bool] = [] + config = QualificationLifecycleConfig( + release_gate=gate, + coordinator_factory=lambda repo: factory_calls.append(True) + or DurableFakeCoordinator(repo, auto_recover=False), + provider_health_check=lambda health: provider_inputs.append(health.code) + or RuntimeHealth.ready("provider passed"), + ) + + lifecycle = build_execution_lifecycle( + repository, + profile="qualification", + qualification=config, + ) + snapshot = lifecycle.start() + + assert snapshot.available is True + assert snapshot.profile == "qualification" + assert snapshot.health.message == "Local recipe qualification controls are ready." + assert provider_inputs == ["ready"] + assert factory_calls == [True] + assert lifecycle.stop().state == "stopped" diff --git a/tools/execution_spikes/README.md b/tools/execution_spikes/README.md index 008029d..dd246b7 100644 --- a/tools/execution_spikes/README.md +++ b/tools/execution_spikes/README.md @@ -51,7 +51,7 @@ limits with no breakaway flags. Its overall exit remains blocked for this isolat probe until the signed worker package is installed and the worker completes the live authenticated broker handshake; launcher-side PID/AppContainer binding is now implemented separately. This does not block the dedicated packaged qualification -workflow or the planned explicit local qualification profile. +workflow or the explicit local qualification profile builder. The fixed worker protocol/package boundary can be qualified separately on Windows: @@ -122,10 +122,11 @@ The current `resource-watchdog.v1` evidence passes all 15 cases (digest `5eac03e2b4981543`): budget matrix, wall/idle watchdogs, clock and sample regression, stable CPU/memory/input/output/console/observation/message limits, missing-memory fail-closed behavior, actual Job Object accounting, and full-tree -reaping. This is -qualification-only evidence; explicit qualification-profile lifecycle wiring is -the next core implementation slice. External review and production signing are -optional official-release hardening. +reaping. This is qualification-only evidence; the explicit qualification-profile +lifecycle builder now consumes this evidence when a caller injects its controls. +The recipe-specific coordinator/request path is the next core implementation +slice. External review and production signing are optional official-release +hardening. The release/lifecycle composition preflight is covered by the backend boundary `RecipeRuntimeReleaseGate` and `tests/test_phase2_release_gate.py`. It is a @@ -160,8 +161,9 @@ control/unicode text, and invalid optional values. A green result requires only typed models or stable redacted `RecipeValidationError` categories; unexpected exceptions, unbounded budgets, or canonicalization failures return exit code 2. The current evidence is 158 accepted payloads, 1,842 rejections, and zero -unexpected exceptions. This closes parser-fuzz evidence only; explicit -qualification-profile lifecycle wiring remains the next core slice. +unexpected exceptions. This closes parser-fuzz evidence; the lifecycle builder +keeps the profile blocked unless the release gate, coordinator, and provider +health controls are explicitly supplied. ## Run the artifact security review qualification