diff --git a/docs/adr/0001-capability-tiered-agentic-execution-harness.md b/docs/adr/0001-capability-tiered-agentic-execution-harness.md index c32b8ce..e8ecad9 100644 --- a/docs/adr/0001-capability-tiered-agentic-execution-harness.md +++ b/docs/adr/0001-capability-tiered-agentic-execution-harness.md @@ -943,13 +943,18 @@ pass without executing code. 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. 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. + qualification-only. The disposable Windows sandbox qualification harness now + composes AppContainer/Job Object controls and a fixed decoder corpus, but the + signed worker provenance gate remains blocked; collect opt-in aggregate reliability + metrics, never content, only after the provider is sandbox-qualified. + +**Implementation gate:** parser, artifact-boundary, qualification-provider, and +sandbox-qualification regression suites pass; no source overwrite is possible. The +qualification harness must remain fail-closed when the signed worker is absent. +**Release gate:** parser fuzzing, artifact security review, hostile decoder corpus +executed inside the worker, OS sandbox qualification, signed provenance, broker +identity, resource/watchdog accounting, external review, and fixed-function provider +lifecycle health must pass before any provider is enabled. ### 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 f7c42cc..9f65092 100644 --- a/docs/adr/0001-phase2-evidence.md +++ b/docs/adr/0001-phase2-evidence.md @@ -20,7 +20,8 @@ | 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`. | | 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. | +| Windows recipe sandbox qualification harness | **Complete (qualification harness; worker gate blocked)** | `recipe_sandbox_qualification.py` composes out-of-process AppContainer isolation and Job Object cancellation with a fixed decoder corpus, then fails closed because the signed `recipe_worker.exe` bundle and trust-root launch verification are not shipped. | +| OS sandbox provider and provider-produced image outputs | **Blocked / release gate** | The actual provider worker still needs signed provenance, LPAC/AppContainer policy, Job Object resource limits/accounting, broker PID/token binding, watchdog, hostile decoder execution inside the sandbox, external review, and lifecycle wiring. | ## Security invariants @@ -72,6 +73,9 @@ 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. +21. The sandbox qualification harness never authorizes a provider launch from a + missing, unsigned, or merely present worker directory; it reports `blocked` and + never falls back to host-process decoding. ## Re-run target @@ -83,6 +87,8 @@ python -m pytest tests/test_phase2_native_broker.py -q python -m pytest tests/test_phase2_bundle_installer.py -q python -m pytest tests/test_phase2_artifact_boundary.py -q python -m pytest tests/test_phase2_recipe_provider.py -q +python -m pytest tests/test_recipe_sandbox_qualification.py -q +python tools/execution_spikes/recipe_sandbox_qualification.py --json --strict python -m compileall -q backend\cortex_backend\execution tests python -m pytest -q python tools/generate_contracts.py @@ -94,9 +100,12 @@ npm.cmd test --prefix frontend -- --run **Validation result (2026-07-21):** 16 Phase 2 contract tests, 9 signed-manifest tests, 7 broker-contract tests, 9 native-broker tests, 7 bundle-installer tests, 16 -artifact-boundary tests, and 17 recipe-provider tests passed; the full Python suite -passed (204 tests total) with one +artifact-boundary tests, 17 recipe-provider tests, and 5 sandbox-qualification tests +passed; the full Python suite +passed (209 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 -provider is enabled. +provider is enabled. The sandbox qualification command passed its AppContainer, +Job Object, cancellation, and fixed decoder checks but returned the expected +fail-closed `blocked` status because the signed worker bundle is not shipped. diff --git a/docs/adr/0001-phase2-recipe-provider.md b/docs/adr/0001-phase2-recipe-provider.md index b8520d2..bb8164a 100644 --- a/docs/adr/0001-phase2-recipe-provider.md +++ b/docs/adr/0001-phase2-recipe-provider.md @@ -106,7 +106,9 @@ 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 a separate Windows sandbox qualification: launch the provider out of -process under the signed bundle, AppContainer/LPAC and Job Object controls, restricted +The next gate is the disposable [Windows sandbox qualification harness](0001-phase2-sandbox-qualification.md), +which currently proves only the existing native isolation/cancellation controls and +fixed decoder corpus. The actual provider worker must still launch out of process +under a signed bundle, AppContainer/LPAC and Job Object controls, restricted handles/environment, native broker identity, watchdog, and accounting; run the hostile decoder corpus there; then wire it to `ExecutionLifecycle` only after external review. diff --git a/docs/adr/0001-phase2-sandbox-qualification.md b/docs/adr/0001-phase2-sandbox-qualification.md new file mode 100644 index 0000000..fb1c62a --- /dev/null +++ b/docs/adr/0001-phase2-sandbox-qualification.md @@ -0,0 +1,95 @@ +# ADR-0001 Phase 2 Windows recipe sandbox qualification + +- **Status:** Qualification harness implemented; signed provider worker and release gate blocked +- **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) +- **Scope:** Disposable Windows control qualification only. No production provider or lifecycle route is enabled. + +## 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: + +1. it runs the reviewed zero-capability AppContainer isolation helper in a child + process and requires token identity, parent-file denial, loopback denial, and + bounded completion; +2. it runs the reviewed AppContainer/Job Object cancellation corpus in a child + 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 + packaging location; +5. it reports per-worker CPU/memory/breakaway/accounting controls as blocked until + the native launcher applies and verifies them; and +6. it reports broker PID/token binding as blocked until the launcher binds the + qualified transport to the actual worker. + +The fourth check is intentionally blocked in this stage. A directory, executable, +self-reported digest, or unverified manifest cannot authorize a launch. The future +implementation must verify the packaged trust root, signed manifest, every declared +byte, worker identity, and runtime version before the native launcher is allowed to +start the worker. Until that exists, `provider_launch_authorized` is always false. + +The harness accepts no command, source text, uploaded path, network target, or model +input. It invokes only fixed repository helpers and fixed bytes. It is not imported +by `backend/cortex_backend`, not a PyInstaller hidden import, and not an execution +fallback. + +## Control matrix + +| Control | Evidence in this stage | Release interpretation | +| --- | --- | --- | +| AppContainer token and zero-capability denials | `appcontainer_smoke.py`, child report `recipe_appcontainer_control` | Required prerequisite; does not prove LPAC policy or provider launch identity | +| Job Object kill-on-close and tree cancellation | `cancellation_corpus.py`, child report `recipe_cancellation_control` | Required prerequisite; resource limits and accounting remain open | +| Decoder hostile corpus | Fixed one-pixel PNG, truncated PNG, and active SVG against the core | Qualification-only evidence; not OS-sandbox evidence | +| Signed worker provenance | Fixed `packaging/recipe-runtime` location and exact `recipe_worker.exe` entrypoint | **Blocked** until packaged trust-root/signature/hash verification exists | +| Broker identity and framed IPC | Native broker transport tests and ADR | Must be bound to the actual worker PID/token before launch | +| Lifecycle enablement | `ExecutionLifecycle` remains disabled by default; provider is not exported | No provider can become reachable from the application | + +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 + +The next implementation must add a signed, pinned worker bundle and a native +launcher that, per attempt: + +1. verifies the installed immutable generation and image-worker entrypoint; +2. creates private staging and grants only the sandbox identity and required + system read/write handles; +3. starts the worker suspended under the intended LPAC/AppContainer policy; +4. applies Job Object kill-on-close, active-process, CPU-time, memory, and + breakaway restrictions and records accounting; +5. binds the protected broker pipe to the expected PID and OS token identity; +6. resumes only after all checks pass, enforces wall-clock/progress watchdogs, + and bounds every IPC frame; and +7. closes the job on completion or cancellation, validates output through the + trusted artifact boundary, and removes staging with recoverable cleanup state. + +If any step cannot be applied or verified, the provider remains unavailable. The +host process must never decode the input as a fallback. + +## Verification performed + +On the controlled Windows host (2026-07-21): + +```powershell +python tools/execution_spikes/recipe_sandbox_qualification.py --strict --json +``` + +The AppContainer and cancellation controls passed, and the fixed decoder corpus +passed. The overall result was intentionally `blocked` because the signed worker +package is not shipped. This is the expected result for the current stage. + +Regression coverage is in `tests/test_recipe_sandbox_qualification.py`, including +missing/unsigned worker refusal, helper timeout/evidence failure, and the invariant +that a blocked worker gate never authorizes provider launch. + +## Consequences + +This stage provides reproducible evidence for the controls that already exist and +prevents accidental false-green qualification. It does not claim decoder isolation, +LPAC capability policy, resource enforcement, signed runtime provenance, native +worker identity, or production readiness. Those remain explicit blockers before +the provider can be wired to lifecycle or exposed to any model/UI route. diff --git a/tests/test_recipe_sandbox_qualification.py b/tests/test_recipe_sandbox_qualification.py new file mode 100644 index 0000000..141b8c3 --- /dev/null +++ b/tests/test_recipe_sandbox_qualification.py @@ -0,0 +1,98 @@ +"""Tests for the non-production recipe sandbox qualification gate.""" + +from __future__ import annotations + +import json +import subprocess + +import tools.execution_spikes.recipe_sandbox_qualification as qualification + + +def test_missing_signed_worker_blocks_without_authorizing_launch(tmp_path, monkeypatch): + monkeypatch.setattr(qualification, "EXPECTED_WORKER_ROOT", tmp_path / "missing") + + result = qualification._probe_signed_worker_precondition() + + assert result["status"] == "blocked" + assert result["details"]["signature_verified"] is False + + +def test_present_unsigned_worker_still_blocks(tmp_path, monkeypatch): + root = tmp_path / "recipe-runtime" + root.mkdir() + (root / "manifest.json").write_text("{}", encoding="ascii") + (root / qualification.EXPECTED_WORKER_ENTRYPOINT).write_bytes(b"not-a-signed-worker") + monkeypatch.setattr(qualification, "EXPECTED_WORKER_ROOT", root) + + result = qualification._probe_signed_worker_precondition() + + assert result["status"] == "blocked" + assert result["details"]["signature_verified"] is False + assert result["details"]["launch_refused"] is True + + +def test_fixed_helper_timeout_fails_closed(tmp_path): + helper = tmp_path / "helper.py" + helper.write_text("", encoding="ascii") + + def timeout_runner(*args, **kwargs): + raise subprocess.TimeoutExpired("fixed-helper", 1) + + result = qualification._run_fixed_helper( + helper, + "fixed_check", + 1, + runner=timeout_runner, + ) + + assert result["name"] == "fixed_check" + assert result["status"] == "fail" + + +def test_fixed_helper_rejects_unexpected_evidence(tmp_path): + helper = tmp_path / "helper.py" + helper.write_text("", encoding="ascii") + + def invalid_runner(*args, **kwargs): + return subprocess.CompletedProcess( + args=["fixed-helper"], + returncode=0, + stdout=json.dumps({"name": "wrong-check"}), + stderr="", + ) + + result = qualification._run_fixed_helper( + helper, + "fixed_check", + 1, + runner=invalid_runner, + ) + + assert result["name"] == "fixed_check" + assert result["status"] == "fail" + + +def test_report_remains_blocked_when_worker_gate_is_blocked(monkeypatch): + monkeypatch.setattr( + qualification, + "_probe_os_controls", + lambda: [ + {"name": "recipe_appcontainer_control", "status": "pass"}, + {"name": "recipe_cancellation_control", "status": "pass"}, + ], + ) + monkeypatch.setattr( + qualification, + "_probe_provider_core", + lambda: {"name": "recipe_decoder_corpus", "status": "pass"}, + ) + monkeypatch.setattr( + qualification, + "_probe_signed_worker_precondition", + lambda: {"name": "recipe_signed_worker_provenance", "status": "blocked"}, + ) + + report = qualification.build_report() + + assert report["qualification_status"] == "blocked" + assert report["provider_launch_authorized"] is False diff --git a/tools/execution_spikes/README.md b/tools/execution_spikes/README.md index c5c807b..a1b0656 100644 --- a/tools/execution_spikes/README.md +++ b/tools/execution_spikes/README.md @@ -26,6 +26,22 @@ required check that is blocked or fails: python tools/execution_spikes/phase0_probe.py --json --job-smoke --ipc-smoke --appcontainer-smoke --guest-language-smoke --cancellation-smoke --wasi-smoke --strict ``` +## Run the Phase 2 recipe sandbox qualification gate + +The recipe harness runs only fixed repository helpers and fixed decoder bytes. It +does not accept model input and never falls back to host-process decoding: + +```powershell +python tools/execution_spikes/recipe_sandbox_qualification.py --json +python tools/execution_spikes/recipe_sandbox_qualification.py --json --strict +``` + +The expected result at this stage is `qualification_status=blocked`: the native +AppContainer and Job Object controls may pass, but the signed `recipe_worker.exe` +bundle is not shipped yet. A blocked worker-provenance check is intentional and +must remain blocking until trust-root verification, native worker identity, and +resource/watchdog enforcement are implemented. + ## What the probes prove - `environment`: supported Windows host and interpreter metadata. @@ -50,6 +66,9 @@ python tools/execution_spikes/phase0_probe.py --json --job-smoke --ipc-smoke --a - `containment_cancellation_corpus`: starts a fixed AppContainer launcher that creates a native descendant, closes the kill-on-close Job Object, and verifies every observed process ID is reaped. +- `recipe_sandbox_qualification`: composes the native isolation/cancellation + controls with the qualification-only decoder corpus and a mandatory signed + worker provenance gate; it never authorizes a host-process fallback. - `security_review`: records the conditional Phase 0 spike review and residual blockers. - `pyinstaller_package_preconditions`: all currently known one-folder package @@ -70,3 +89,6 @@ Phase 0 gates. 4. Keep package/runtime experiments in a disposable environment and record exact versions and hashes in the Phase 0 evidence log. 5. A blocked result is safer than a green result produced by a weaker fallback. +6. Do not add the recipe qualification harness or any worker bundle to application + imports, model tools, or PyInstaller hidden imports before its release gate is + explicitly closed. diff --git a/tools/execution_spikes/recipe_sandbox_qualification.py b/tools/execution_spikes/recipe_sandbox_qualification.py new file mode 100644 index 0000000..06a6387 --- /dev/null +++ b/tools/execution_spikes/recipe_sandbox_qualification.py @@ -0,0 +1,355 @@ +"""Disposable qualification harness for the future Windows recipe worker. + +This is a release-gate probe, not a provider or an execution fallback. It runs +the already-reviewed fixed AppContainer and cancellation helpers out of process, +exercises a tiny fixed decoder corpus against the qualification-only core, and +then requires a signed, packaged recipe worker before it can report green. The +worker package is intentionally absent in this stage. A missing package, +signature keyring, or sandbox control therefore produces ``blocked`` rather than +silently running Pillow in the Cortex host process. + +No command, source text, uploaded path, network target, or model input is +accepted. The only filesystem location inspected is the repository's fixed +future package location. This module is never imported by Cortex production +code or included as a PyInstaller hidden import. +""" + +from __future__ import annotations + +import argparse +import base64 +import json +from pathlib import Path +import subprocess +import sys +import time +from typing import Any, Callable + + +ROOT = Path(__file__).resolve().parents[2] +PASS = "pass" +BLOCKED = "blocked" +FAIL = "fail" + +# The location is deliberately fixed. A future packaging change must update +# this constant and its ADR rather than allowing an operator/model-selected path. +EXPECTED_WORKER_ROOT = ROOT / "packaging" / "recipe-runtime" +EXPECTED_WORKER_ENTRYPOINT = "recipe_worker.exe" + +# A one-pixel PNG is sufficient to exercise the complete provider boundary while +# keeping this probe deterministic and tiny. It is not user or model input. +_ONE_PIXEL_PNG = base64.b64decode( + "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNk+A8AAQUBAScY42YAAAAASUVORK5CYII=" +) + + +def _result(name: str, status: str, evidence: str, **details: Any) -> dict[str, Any]: + payload: dict[str, Any] = {"name": name, "status": status, "evidence": evidence} + if details: + payload["details"] = details + return payload + + +def _run_fixed_helper( + helper: Path, + expected_name: str, + timeout_seconds: int, + *, + runner: Callable[..., subprocess.CompletedProcess[str]] = subprocess.run, +) -> dict[str, Any]: + """Run one reviewed helper without inheriting stdin or accepting arguments.""" + + if not helper.is_file(): + return _result( + expected_name, + BLOCKED, + "The reviewed fixed helper is missing; the sandbox gate fails closed.", + helper=str(helper), + ) + try: + completed = runner( + [sys.executable, str(helper)], + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout_seconds, + check=False, + ) + payload = json.loads(completed.stdout) + if payload.get("name") != expected_name: + raise ValueError("fixed helper returned an unexpected check name") + if payload.get("status") == PASS and completed.returncode != 0: + raise ValueError("fixed helper reported pass with a nonzero exit code") + payload.setdefault("details", {})["helper_exit_code"] = completed.returncode + if completed.stderr: + payload["details"]["helper_stderr"] = completed.stderr[-2000:] + return payload + except subprocess.TimeoutExpired as exc: + return _result( + expected_name, + FAIL, + "The fixed sandbox helper exceeded its fail-closed watchdog.", + error_type=type(exc).__name__, + timeout_seconds=timeout_seconds, + ) + except Exception as exc: + return _result( + expected_name, + FAIL, + "The fixed sandbox helper returned invalid evidence and the gate failed closed.", + error_type=type(exc).__name__, + error=str(exc), + ) + + +def _probe_os_controls() -> list[dict[str, Any]]: + """Run the existing native containment and cancellation helpers out of process.""" + + if sys.platform != "win32": + return [ + _result( + "recipe_appcontainer_control", + BLOCKED, + "Recipe sandbox qualification requires Windows AppContainer/LPAC APIs.", + ), + _result( + "recipe_cancellation_control", + BLOCKED, + "Recipe sandbox qualification requires Windows Job Object APIs.", + ), + ] + + appcontainer = ROOT / "tools" / "execution_spikes" / "appcontainer_smoke.py" + cancellation = ROOT / "tools" / "execution_spikes" / "cancellation_corpus.py" + isolation = _run_fixed_helper( + appcontainer, + "appcontainer_process_isolation_smoke", + 20, + ) + cancellation_result = _run_fixed_helper( + cancellation, + "containment_cancellation_corpus", + 30, + ) + # Preserve the stable Phase 0 helper names while exposing recipe-specific + # gate names in this report. No PASS is inferred from a missing detail. + isolation["name"] = "recipe_appcontainer_control" + cancellation_result["name"] = "recipe_cancellation_control" + return [isolation, cancellation_result] + + +def _probe_provider_core() -> dict[str, Any]: + """Exercise fixed decoder cases without claiming OS sandbox attestation.""" + + try: + sys.path.insert(0, str(ROOT / "backend")) + from cortex_backend.execution.lifecycle import RuntimeHealth + from cortex_backend.execution.recipe_provider import ( + RecipeImageProvider, + RecipeProviderError, + ) + from cortex_backend.execution.recipes import parse_image_transform + except Exception as exc: + return _result( + "recipe_decoder_corpus", + BLOCKED, + "The qualification-only decoder dependency is unavailable; no host fallback is permitted.", + error_type=type(exc).__name__, + ) + + provider = RecipeImageProvider() + health = provider.start(RuntimeHealth.ready("qualification-only core; no sandbox attestation")) + if not health.available: + return _result( + "recipe_decoder_corpus", + BLOCKED, + "The qualification-only decoder core did not pass its local capability check.", + health_code=health.code, + ) + + try: + plan = parse_image_transform( + { + "schema_version": "artifact.transform.v1", + "input_artifact_id": "qualification-input", + "steps": [{"op": "grayscale"}], + "output_format": "png", + "strip_metadata": True, + } + ) + except Exception as exc: + provider.stop() + return _result( + "recipe_decoder_corpus", + FAIL, + "The fixed decoder plan could not be parsed; the qualification gate failed closed.", + error_type=type(exc).__name__, + provider_enabled=provider.enabled, + ) + cases: dict[str, str] = {} + try: + output = provider.transform(plan, _ONE_PIXEL_PNG) + cases["valid_allowlisted_png"] = ( + "pass" if output.mime_type == "image/png" else "fail" + ) + except Exception as exc: + cases["valid_allowlisted_png"] = f"fail:{type(exc).__name__}" + + for case_name, payload in { + "truncated_png": b"\x89PNG\r\n\x1a\ntruncated", + "active_svg": b"", + }.items(): + try: + provider.transform(plan, payload) + except RecipeProviderError as exc: + cases[case_name] = ( + "pass" + if exc.code in {"decode_failed", "unsupported_format", "invalid_input"} + else f"fail:{exc.code}" + ) + except Exception as exc: + cases[case_name] = f"fail:{type(exc).__name__}" + else: + cases[case_name] = "fail:accepted" + + provider.stop() + passed = all(value == "pass" for value in cases.values()) + return _result( + "recipe_decoder_corpus", + PASS if passed else FAIL, + "Fixed allowlisted/hostile bytes exercised the qualification-only core; this is not sandbox evidence.", + cases=cases, + sandboxed=False, + sandbox_attestation_required=True, + provider_enabled=provider.enabled, + ) + + +def _is_reparse(path: Path) -> bool: + """Reject symlink/junction-like package entries before any future launch.""" + + if path.is_symlink(): + return True + is_junction = getattr(path, "is_junction", None) + return bool(is_junction is not None and is_junction()) + + +def _probe_signed_worker_precondition() -> dict[str, Any]: + """Require the future signed worker package without accepting a fallback.""" + + root = EXPECTED_WORKER_ROOT + if not root.exists(): + return _result( + "recipe_signed_worker_provenance", + BLOCKED, + "The signed recipe worker package is not shipped yet; no provider process may start.", + expected_root=str(root), + expected_entrypoint=EXPECTED_WORKER_ENTRYPOINT, + signature_verified=False, + ) + if _is_reparse(root) or not root.is_dir(): + return _result( + "recipe_signed_worker_provenance", + FAIL, + "The fixed recipe worker root is not a private, ordinary directory.", + expected_root=str(root), + signature_verified=False, + ) + manifest = root / "manifest.json" + entrypoint = root / EXPECTED_WORKER_ENTRYPOINT + if _is_reparse(manifest) or _is_reparse(entrypoint): + return _result( + "recipe_signed_worker_provenance", + FAIL, + "The fixed recipe worker package contains a reparse-point entry.", + signature_verified=False, + launch_refused=True, + ) + if not manifest.is_file() or not entrypoint.is_file(): + return _result( + "recipe_signed_worker_provenance", + BLOCKED, + "The worker package is incomplete; signature and entrypoint verification are required.", + manifest_present=manifest.is_file(), + entrypoint_present=entrypoint.is_file(), + signature_verified=False, + ) + # A future implementation must verify the manifest with the packaged trust + # root and verify every declared byte before launching. Do not infer trust + # from the presence of files or a self-reported digest today. + return _result( + "recipe_signed_worker_provenance", + BLOCKED, + "A worker directory exists but signed-manifest trust-root verification is not implemented in this qualification stage.", + expected_root=str(root), + signature_verified=False, + launch_refused=True, + ) + + +def _probe_future_worker_controls() -> list[dict[str, Any]]: + """Expose controls that cannot be claimed by the current fixed helpers.""" + + return [ + _result( + "recipe_resource_controls", + BLOCKED, + "Per-worker CPU, memory, breakaway, and accounting enforcement is not implemented; active-process and kill-on-close smoke is only a prerequisite.", + launch_refused=True, + ), + _result( + "recipe_broker_identity", + BLOCKED, + "The native broker transport is qualified separately but is not yet bound to a recipe worker PID and OS token by a launcher.", + launch_refused=True, + ), + ] + + +def build_report() -> dict[str, Any]: + """Build the stable qualification report without modifying Cortex state.""" + + checks = [ + *_probe_os_controls(), + _probe_provider_core(), + _probe_signed_worker_precondition(), + *_probe_future_worker_controls(), + ] + ready = all(check["status"] == PASS for check in checks) + return { + "probe": "cortex-recipe-sandbox-qualification", + "schema_version": 1, + "generated_at_utc": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()), + "repository_root": str(ROOT), + "checks": checks, + "provider_launch_authorized": False, + "qualification_status": PASS if ready else BLOCKED, + } + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--json", action="store_true", help="Emit compact JSON only.") + parser.add_argument( + "--strict", + action="store_true", + help="Exit 2 unless every sandbox qualification check is green.", + ) + return parser.parse_args() + + +def main() -> int: + args = parse_args() + report = build_report() + if args.json: + print(json.dumps(report, separators=(",", ":"), sort_keys=True)) + else: + print(json.dumps(report, indent=2, sort_keys=True)) + if args.strict and report["qualification_status"] != PASS: + return 2 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())