diff --git a/docs/adr/0001-capability-tiered-agentic-execution-harness.md b/docs/adr/0001-capability-tiered-agentic-execution-harness.md index ee73e69..03599c4 100644 --- a/docs/adr/0001-capability-tiered-agentic-execution-harness.md +++ b/docs/adr/0001-capability-tiered-agentic-execution-harness.md @@ -946,14 +946,15 @@ pass without executing code. qualification-only. The disposable Windows sandbox qualification harness now composes AppContainer/Job Object controls and a fixed decoder corpus. Storage-only signed worker provenance now binds exactly one image-transform role to the fixed - worker entrypoint, but native launch/resource/broker gates remain blocked; collect - opt-in aggregate reliability metrics, never content, only after the provider is - sandbox-qualified. + worker entrypoint, and the disposable launcher now qualifies pre-resume Job Object + resource policy. Native worker launch and broker PID/token binding remain blocked; + collect opt-in aggregate reliability metrics, never content, only after the + provider is sandbox-qualified. **Implementation gate:** parser, artifact-boundary, qualification-provider, -worker-provenance, and sandbox-qualification regression suites pass; no source -overwrite is possible. The qualification harness must remain fail-closed when the -signed worker is absent. +worker-provenance, sandbox-qualification, and native-launcher-policy regression +suites pass; no source overwrite is possible. The qualification harness must remain +fail-closed when the signed worker is absent. **Release gate:** parser fuzzing, artifact security review, hostile decoder corpus executed inside the worker, OS sandbox qualification, signed provenance, broker identity, resource/watchdog accounting, external review, and fixed-function provider diff --git a/docs/adr/0001-phase2-evidence.md b/docs/adr/0001-phase2-evidence.md index 2f6a29a..0a7207a 100644 --- a/docs/adr/0001-phase2-evidence.md +++ b/docs/adr/0001-phase2-evidence.md @@ -22,6 +22,7 @@ | 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. | | 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. | +| Suspended native launcher/resource policy | **Complete (disposable control spike)** | `native_launcher_qualification.py` creates a fixed suspended AppContainer child, applies and queries active-process, kill-on-close, CPU-time, memory, and no-breakaway Job Object policy before resume; real worker enforcement and broker binding remain blocked. | | 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 @@ -80,6 +81,9 @@ 22. Worker provenance is storage-only: only an installer-validated immutable generation with one exact `image_transform`/`recipe_worker.exe` role and stable byte identity can proceed to a future launcher; no executable is loaded here. +23. The disposable launcher applies all required Job Object policy before resume, + queries the configured limits/accounting, never grants breakaway, and reports + the absent worker/broker gates as blocking. ## Re-run target @@ -92,7 +96,9 @@ python -m pytest tests/test_phase2_bundle_installer.py -q python -m pytest tests/test_phase2_artifact_boundary.py -q python -m pytest tests/test_phase2_recipe_provider.py -q python -m pytest tests/test_phase2_worker_provenance.py -q +python -m pytest tests/test_native_launcher_qualification.py -q python -m pytest tests/test_recipe_sandbox_qualification.py -q +python tools/execution_spikes/native_launcher_qualification.py python tools/execution_spikes/recipe_sandbox_qualification.py --json --strict python -m compileall -q backend\cortex_backend\execution tests python -m pytest -q @@ -105,10 +111,11 @@ npm.cmd test --prefix frontend -- --run **Validation result (2026-07-21):** 16 Phase 2 contract tests, 9 signed-manifest tests, 7 broker-contract tests, 9 native-broker tests, 7 bundle-installer tests, 16 -artifact-boundary tests, 17 recipe-provider tests, 6 worker-provenance tests, and +artifact-boundary tests, 17 recipe-provider tests, 6 worker-provenance tests, 4 +native-launcher tests, and 5 sandbox-qualification tests passed; the full Python suite -passed (215 tests total) with one +passed (219 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-launcher.md b/docs/adr/0001-phase2-native-launcher.md new file mode 100644 index 0000000..c857c52 --- /dev/null +++ b/docs/adr/0001-phase2-native-launcher.md @@ -0,0 +1,64 @@ +# ADR-0001 Phase 2 disposable native launcher qualification + +- **Status:** Suspended-launch and Job Object policy spike complete; real worker launch blocked +- **Phase:** 2 - fixed-function image provider +- **Parent:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) +- **Depends on:** [Windows sandbox qualification](0001-phase2-sandbox-qualification.md) and [signed worker provenance](0001-phase2-worker-provenance.md) +- **Scope:** Qualify construction order and resource-policy application with fixed benign Windows executables only. + +## Decision + +`tools/execution_spikes/native_launcher_qualification.py` exercises the reviewed +native construction sequence without accepting a worker path or user input: + +1. create a unique zero-capability AppContainer profile; +2. create a fixed `findstr.exe` child suspended with the AppContainer token; +3. assign it to a Job Object before resume; +4. apply kill-on-close, active-process, process/job CPU-time, and process/job + memory limits, with breakaway flags absent; +5. resume only after policy configuration; +6. query the Job Object policy/accounting and require every configured value; and +7. close all handles and remove the disposable profile/marker. + +The lower-level `appcontainer_smoke._run_child()` now exposes bounded optional +resource-limit parameters and returns the queried policy/accounting. Existing +filesystem/network and cancellation probes continue to use the same helper and +remain green after this change. + +This is qualification evidence for control application, not approval to launch a +recipe worker. The fixed probe reports `provider_launch_authorized=false` until +the signed worker package and broker identity gates pass. + +## Evidence and limits + +On the controlled Windows host (2026-07-22), the resource-policy check passed with: + +- AppContainer token confirmed; +- active-process limit `1`; +- process memory `64 MiB`, job memory `128 MiB`; +- process CPU `2 s`, job CPU `4 s`; +- kill-on-close present; and +- breakaway flags absent. + +The policy values are queried before the Job Object handle closes. This does not +yet prove enforcement against a real signed recipe worker, resource exhaustion +behavior across supported Windows versions, or external launcher review. + +## Remaining blockers + +- The fixed signed `recipe_worker.exe` package is not shipped at the immutable + installer generation used by the launcher. +- The native broker transport is not yet bound to the launched worker PID and OS + token by a single reviewed launcher transaction. +- Watchdog progress, output framing, staging ACLs, hostile decoder execution, and + lifecycle health-gated wiring remain separate release gates. + +If any control is missing or cannot be verified, the provider remains unavailable; +there is no host-process or weaker-sandbox fallback. + +## Verification + +`tests/test_native_launcher_qualification.py` covers non-Windows blocking, report +fail-closed behavior, and the no-breakaway policy invariant. The full repository +suite and the existing AppContainer/cancellation corpus are required before this +spike can be merged. diff --git a/docs/adr/0001-phase2-sandbox-qualification.md b/docs/adr/0001-phase2-sandbox-qualification.md index f09da33..38b99f3 100644 --- a/docs/adr/0001-phase2-sandbox-qualification.md +++ b/docs/adr/0001-phase2-sandbox-qualification.md @@ -42,6 +42,7 @@ fallback. | --- | --- | --- | | AppContainer token and zero-capability denials | `appcontainer_smoke.py`, child report `recipe_appcontainer_control` | Required prerequisite; does not prove LPAC policy or provider launch identity | | Job Object kill-on-close and tree cancellation | `cancellation_corpus.py`, child report `recipe_cancellation_control` | Required prerequisite; resource limits and accounting remain open | +| 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 | | 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 | diff --git a/tests/test_native_launcher_qualification.py b/tests/test_native_launcher_qualification.py new file mode 100644 index 0000000..59413f3 --- /dev/null +++ b/tests/test_native_launcher_qualification.py @@ -0,0 +1,82 @@ +"""Regression tests for the non-production suspended-launch qualification.""" + +from __future__ import annotations + +import pytest + +import tools.execution_spikes.appcontainer_smoke as native +import tools.execution_spikes.native_launcher_qualification as qualification + + +def test_non_windows_resource_probe_fails_closed(monkeypatch): + monkeypatch.setattr(qualification.sys, "platform", "linux") + + result = qualification._probe_resource_policy() + + assert result["status"] == "blocked" + + +def test_launcher_report_never_authorizes_when_worker_or_broker_is_blocked(monkeypatch): + monkeypatch.setattr( + qualification, + "_probe_resource_policy", + lambda: {"name": "native_launcher_resource_policy", "status": "pass"}, + ) + monkeypatch.setattr( + qualification, + "_probe_worker_package", + lambda: {"name": "native_launcher_worker_package", "status": "blocked"}, + ) + monkeypatch.setattr( + qualification, + "_probe_broker_binding", + lambda: {"name": "native_launcher_broker_binding", "status": "blocked"}, + ) + + report = qualification.build_report() + + assert report["qualification_status"] == "blocked" + assert report["provider_launch_authorized"] is False + + +def test_resource_policy_requires_no_breakaway_flag(): + breakaway = ( + qualification._JOB_OBJECT_LIMIT_BREAKAWAY_OK + | qualification._JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK + ) + required = ( + qualification._JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + | qualification._JOB_OBJECT_LIMIT_ACTIVE_PROCESS + | qualification._JOB_OBJECT_LIMIT_PROCESS_MEMORY + | qualification._JOB_OBJECT_LIMIT_JOB_MEMORY + | qualification._JOB_OBJECT_LIMIT_PROCESS_TIME + | qualification._JOB_OBJECT_LIMIT_JOB_TIME + ) + + assert required & breakaway == 0 + + +def test_child_policy_limits_reject_unbounded_values_before_process_creation(): + with pytest.raises(ValueError): + native._run_child( + None, + None, + None, + None, + None, + "fixed.exe", + [], + process_memory_limit_bytes=0, + ) + + with pytest.raises(ValueError): + native._run_child( + None, + None, + None, + None, + None, + "fixed.exe", + [], + active_process_limit=65, + ) diff --git a/tests/test_recipe_sandbox_qualification.py b/tests/test_recipe_sandbox_qualification.py index 141b8c3..8971a16 100644 --- a/tests/test_recipe_sandbox_qualification.py +++ b/tests/test_recipe_sandbox_qualification.py @@ -91,6 +91,11 @@ def test_report_remains_blocked_when_worker_gate_is_blocked(monkeypatch): "_probe_signed_worker_precondition", lambda: {"name": "recipe_signed_worker_provenance", "status": "blocked"}, ) + monkeypatch.setattr( + qualification, + "_probe_future_worker_controls", + lambda: [{"name": "recipe_native_launcher_policy", "status": "blocked"}], + ) report = qualification.build_report() diff --git a/tools/execution_spikes/README.md b/tools/execution_spikes/README.md index a1b0656..5cb1f26 100644 --- a/tools/execution_spikes/README.md +++ b/tools/execution_spikes/README.md @@ -34,6 +34,7 @@ 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 +python tools/execution_spikes/native_launcher_qualification.py ``` The expected result at this stage is `qualification_status=blocked`: the native @@ -42,6 +43,11 @@ 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. +The native launcher qualification prints a passing resource-policy subcheck when +the fixed suspended child receives and reports Job Object CPU/memory/active-process +limits with no breakaway flags. Its overall exit remains blocked until the signed +worker package and broker PID/token binding exist. + ## What the probes prove - `environment`: supported Windows host and interpreter metadata. @@ -69,6 +75,9 @@ resource/watchdog enforcement are implemented. - `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. +- `native_launcher_qualification`: creates only a fixed suspended `findstr.exe` + child, applies and queries Job Object resource policy before resume, and reports + the signed-worker and broker-binding blockers without launching either. - `security_review`: records the conditional Phase 0 spike review and residual blockers. - `pyinstaller_package_preconditions`: all currently known one-folder package diff --git a/tools/execution_spikes/appcontainer_smoke.py b/tools/execution_spikes/appcontainer_smoke.py index a0e34a3..fd1fbd8 100644 --- a/tools/execution_spikes/appcontainer_smoke.py +++ b/tools/execution_spikes/appcontainer_smoke.py @@ -33,7 +33,11 @@ _CREATE_NO_WINDOW = 0x08000000 _PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009 _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION = 9 +_JOB_OBJECT_LIMIT_PROCESS_TIME = 0x00000002 +_JOB_OBJECT_LIMIT_JOB_TIME = 0x00000004 _JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008 +_JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x00000100 +_JOB_OBJECT_LIMIT_JOB_MEMORY = 0x00000200 _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 _TOKEN_IS_APPCONTAINER = 29 _WAIT_OBJECT_0 = 0 @@ -270,7 +274,21 @@ def _run_child( arguments: list[str], timeout_ms: int = 5000, active_process_limit: int = 1, + process_memory_limit_bytes: int | None = None, + job_memory_limit_bytes: int | None = None, + process_user_time_100ns: int | None = None, + job_user_time_100ns: int | None = None, ) -> dict[str, Any]: + for name, value in ( + ("process_memory_limit_bytes", process_memory_limit_bytes), + ("job_memory_limit_bytes", job_memory_limit_bytes), + ("process_user_time_100ns", process_user_time_100ns), + ("job_user_time_100ns", job_user_time_100ns), + ): + if value is not None and (type(value) is not int or not 1 <= value <= 1024**3): + raise ValueError(f"{name} is outside the disposable probe bounds") + if type(active_process_limit) is not int or not 1 <= active_process_limit <= 64: + raise ValueError("active_process_limit is outside the disposable probe bounds") stage = "start" command_line = subprocess.list2cmdline([executable, *arguments]) command_buffer = ctypes.create_unicode_buffer(command_line) @@ -343,6 +361,20 @@ def _run_child( _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE | _JOB_OBJECT_LIMIT_ACTIVE_PROCESS ) limits.basic_limit_information.active_process_limit = active_process_limit + if process_memory_limit_bytes is not None: + limits.basic_limit_information.limit_flags |= _JOB_OBJECT_LIMIT_PROCESS_MEMORY + limits.process_memory_limit = process_memory_limit_bytes + if job_memory_limit_bytes is not None: + limits.basic_limit_information.limit_flags |= _JOB_OBJECT_LIMIT_JOB_MEMORY + limits.job_memory_limit = job_memory_limit_bytes + if process_user_time_100ns is not None: + limits.basic_limit_information.limit_flags |= _JOB_OBJECT_LIMIT_PROCESS_TIME + limits.basic_limit_information.per_process_user_time.quad_part = ( + process_user_time_100ns + ) + if job_user_time_100ns is not None: + limits.basic_limit_information.limit_flags |= _JOB_OBJECT_LIMIT_JOB_TIME + limits.basic_limit_information.per_job_user_time.quad_part = job_user_time_100ns stage = "job-configure" if not kernel32.SetInformationJobObject( job, @@ -360,6 +392,7 @@ def _run_child( stage = "wait" wait_result = kernel32.WaitForSingleObject(process_info.process, timeout_ms) timed_out = wait_result == _WAIT_TIMEOUT + job_information = _job_extended_info(kernel32, job) job_process_ids: list[int] = [] all_job_processes_reaped: bool | None = None reap_seconds: float | None = None @@ -383,6 +416,7 @@ def _run_child( "job_process_ids": job_process_ids, "all_job_processes_reaped": all_job_processes_reaped, "reap_seconds": reap_seconds, + "job_information": job_information, } except Exception as exc: raise RuntimeError(f"{stage}: {exc}") from exc @@ -425,6 +459,44 @@ def _job_process_ids(kernel32: Any, job: wintypes.HANDLE) -> list[int]: return [int(ids[index]) for index in range(count)] +def _job_extended_info(kernel32: Any, job: wintypes.HANDLE) -> dict[str, int]: + """Read bounded Job Object policy/accounting before the handle is closed.""" + + query = kernel32.QueryInformationJobObject + query.argtypes = [ + wintypes.HANDLE, + wintypes.INT, + wintypes.LPVOID, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ] + query.restype = wintypes.BOOL + info = _ExtendedLimitInformation() + returned = wintypes.DWORD() + if not query( + job, + _JOB_OBJECT_EXTENDED_LIMIT_INFORMATION, + ctypes.byref(info), + ctypes.sizeof(info), + ctypes.byref(returned), + ): + raise ctypes.WinError(ctypes.get_last_error()) + return { + "limit_flags": int(info.basic_limit_information.limit_flags), + "active_process_limit": int(info.basic_limit_information.active_process_limit), + "process_memory_limit": int(info.process_memory_limit), + "job_memory_limit": int(info.job_memory_limit), + "process_user_time_limit_100ns": int( + info.basic_limit_information.per_process_user_time.quad_part + ), + "job_user_time_limit_100ns": int( + info.basic_limit_information.per_job_user_time.quad_part + ), + "peak_process_memory_used": int(info.peak_process_memory_used), + "peak_job_memory_used": int(info.peak_job_memory_used), + } + + def _all_processes_reaped(kernel32: Any, process_ids: list[int]) -> bool: """Confirm every process observed in the Job is terminated after close.""" diff --git a/tools/execution_spikes/native_launcher_qualification.py b/tools/execution_spikes/native_launcher_qualification.py new file mode 100644 index 0000000..bacf0a9 --- /dev/null +++ b/tools/execution_spikes/native_launcher_qualification.py @@ -0,0 +1,234 @@ +"""Disposable suspended-launch and Job Object policy qualification. + +This helper exercises the native construction order with fixed Windows tools: +create an AppContainer child suspended, apply explicit Job Object limits, verify +the queried policy/accounting, then resume and close all handles. It never +accepts a worker path, model input, command, or bundle and never claims that the +real recipe worker is launchable. The signed worker package and live broker +PID/token binding remain blocking checks until a reviewed native launcher exists. +""" + +from __future__ import annotations + +import argparse +import ctypes +from ctypes import wintypes +import json +import os +from pathlib import Path +import sys +import tempfile +from typing import Any +from uuid import uuid4 + +try: + import appcontainer_smoke as native +except ModuleNotFoundError: # pragma: no cover - package import path + from . import appcontainer_smoke as native + + +ROOT = Path(__file__).resolve().parents[2] +PASS = "pass" +BLOCKED = "blocked" +FAIL = "fail" +_JOB_OBJECT_LIMIT_PROCESS_TIME = 0x00000002 +_JOB_OBJECT_LIMIT_JOB_TIME = 0x00000004 +_JOB_OBJECT_LIMIT_ACTIVE_PROCESS = 0x00000008 +_JOB_OBJECT_LIMIT_PROCESS_MEMORY = 0x00000100 +_JOB_OBJECT_LIMIT_JOB_MEMORY = 0x00000200 +_JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE = 0x00002000 +_JOB_OBJECT_LIMIT_BREAKAWAY_OK = 0x00000800 +_JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK = 0x00001000 + + +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 _find_system_tool(name: str) -> str | None: + system_root = os.environ.get("WINDIR", r"C:\Windows") + path = Path(system_root) / "System32" / name + return str(path) if path.is_file() else None + + +def _probe_resource_policy() -> dict[str, Any]: + if sys.platform != "win32": + return _result( + "native_launcher_resource_policy", + BLOCKED, + "The suspended native launcher policy probe requires Windows Job Objects.", + ) + findstr = _find_system_tool("findstr.exe") + if not findstr: + return _result( + "native_launcher_resource_policy", + BLOCKED, + "The fixed Windows executable used for policy qualification is unavailable.", + ) + + kernel32, userenv, advapi32, *_ = native._configure_apis() + profile_name = f"CortexLauncherPolicy-{uuid4().hex}" + profile_sid = wintypes.LPVOID() + marker_dir: Path | None = None + marker_path: Path | None = None + try: + hr = userenv.CreateAppContainerProfile( + profile_name, + "Cortex launcher policy", + "Disposable fixed Job Object resource policy probe", + None, + 0, + ctypes.byref(profile_sid), + ) + if hr != 0: + raise ctypes.WinError(hr & 0xFFFFFFFF) + + marker_dir = Path(tempfile.mkdtemp(prefix="cortex-launcher-policy-")) + marker_path = marker_dir / "private-marker.txt" + marker_path.write_text("launcher-policy-marker\n", encoding="ascii") + details = native._run_child( + kernel32, + userenv, + advapi32, + profile_name, + profile_sid, + findstr, + ["/C:launcher-policy-marker", str(marker_path)], + timeout_ms=5000, + active_process_limit=1, + process_memory_limit_bytes=64 * 1024 * 1024, + job_memory_limit_bytes=128 * 1024 * 1024, + process_user_time_100ns=20_000_000, + job_user_time_100ns=40_000_000, + ) + info = details.get("job_information") + if not isinstance(info, dict): + raise RuntimeError("Job Object accounting was not returned") + flags = int(info.get("limit_flags", 0)) + expected_flags = ( + _JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE + | _JOB_OBJECT_LIMIT_ACTIVE_PROCESS + | _JOB_OBJECT_LIMIT_PROCESS_MEMORY + | _JOB_OBJECT_LIMIT_JOB_MEMORY + | _JOB_OBJECT_LIMIT_PROCESS_TIME + | _JOB_OBJECT_LIMIT_JOB_TIME + ) + checks = { + "appcontainer_token": details.get("is_appcontainer") is True, + "suspended_policy_applied_before_resume": True, + "bounded_completion": details.get("timed_out") is False, + "required_limit_flags": flags & expected_flags == expected_flags, + "active_process_limit": info.get("active_process_limit") == 1, + "process_memory_limit": info.get("process_memory_limit") == 64 * 1024 * 1024, + "job_memory_limit": info.get("job_memory_limit") == 128 * 1024 * 1024, + "process_cpu_limit": info.get("process_user_time_limit_100ns") == 20_000_000, + "job_cpu_limit": info.get("job_user_time_limit_100ns") == 40_000_000, + "breakaway_not_granted": flags + & (_JOB_OBJECT_LIMIT_BREAKAWAY_OK | _JOB_OBJECT_LIMIT_SILENT_BREAKAWAY_OK) + == 0, + } + status = PASS if all(checks.values()) else FAIL + return _result( + "native_launcher_resource_policy", + status, + "A fixed AppContainer child was created suspended, assigned explicit Job Object resource policy before resume, and its policy/accounting was queried." + if status == PASS + else "The fixed suspended-launch policy did not prove every required Job Object limit.", + profile_name=profile_name, + checks=checks, + job_information=info, + child_exit_code=details.get("exit_code"), + ) + except Exception as exc: + return _result( + "native_launcher_resource_policy", + FAIL, + "The fixed native launcher policy probe failed closed before policy qualification completed.", + error_type=type(exc).__name__, + error=str(exc), + ) + finally: + if marker_path is not None: + try: + marker_path.unlink(missing_ok=True) + marker_path.parent.rmdir() + except OSError: + pass + if profile_sid and profile_sid.value: + try: + advapi32.FreeSid(profile_sid) + except OSError: + pass + try: + userenv.DeleteAppContainerProfile(profile_name) + except OSError: + pass + + +def _probe_worker_package() -> dict[str, Any]: + root = ROOT / "packaging" / "recipe-runtime" + entrypoint = root / "recipe_worker.exe" + if not root.is_dir() or not entrypoint.is_file(): + return _result( + "native_launcher_worker_package", + BLOCKED, + "The fixed signed recipe worker package is not shipped; launch remains refused.", + expected_root=str(root), + expected_entrypoint="recipe_worker.exe", + launch_refused=True, + ) + return _result( + "native_launcher_worker_package", + BLOCKED, + "A package is present but this disposable spike has no trust-root-to-launcher binding yet.", + launch_refused=True, + ) + + +def _probe_broker_binding() -> dict[str, Any]: + return _result( + "native_launcher_broker_binding", + BLOCKED, + "The native broker transport is qualified separately; a reviewed launcher has not yet bound it to a worker PID and OS token.", + launch_refused=True, + ) + + +def build_report() -> dict[str, Any]: + checks = [_probe_resource_policy(), _probe_worker_package(), _probe_broker_binding()] + ready = all(check["status"] == PASS for check in checks) + return { + "name": "cortex-native-launcher-qualification", + "probe": "cortex-native-launcher-qualification", + "schema_version": 1, + "repository_root": str(ROOT), + "checks": checks, + "provider_launch_authorized": False, + "qualification_status": PASS if ready else BLOCKED, + } + + +def main() -> int: + 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 the worker and broker gates are also green.", + ) + args = parser.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()) diff --git a/tools/execution_spikes/recipe_sandbox_qualification.py b/tools/execution_spikes/recipe_sandbox_qualification.py index 06a6387..3b6f1cd 100644 --- a/tools/execution_spikes/recipe_sandbox_qualification.py +++ b/tools/execution_spikes/recipe_sandbox_qualification.py @@ -289,13 +289,22 @@ def _probe_signed_worker_precondition() -> dict[str, Any]: def _probe_future_worker_controls() -> list[dict[str, Any]]: - """Expose controls that cannot be claimed by the current fixed helpers.""" + """Run the fixed launcher policy probe and expose remaining blockers.""" + + helper = ROOT / "tools" / "execution_spikes" / "native_launcher_qualification.py" + launcher = _run_fixed_helper( + helper, + "cortex-native-launcher-qualification", + 30, + ) + launcher["name"] = "recipe_native_launcher_policy" return [ + launcher, _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.", + "The fixed resource-policy spike reports its configured/queryable limits, but release still requires a real worker launch and external enforcement review.", launch_refused=True, ), _result(