diff --git a/backend/cortex_backend/execution/native_broker.py b/backend/cortex_backend/execution/native_broker.py index 3aae984..fbce49a 100644 --- a/backend/cortex_backend/execution/native_broker.py +++ b/backend/cortex_backend/execution/native_broker.py @@ -21,6 +21,7 @@ import secrets import struct import sys +from time import sleep from typing import Any, Callable, Literal from cryptography.hazmat.primitives import hashes, serialization @@ -249,6 +250,15 @@ def _load_win32() -> _Win32: ctypes.c_void_p, ] kernel32.ReadFile.restype = wintypes.BOOL + kernel32.PeekNamedPipe.argtypes = [ + wintypes.HANDLE, + ctypes.c_void_p, + wintypes.DWORD, + ctypes.POINTER(wintypes.DWORD), + ctypes.POINTER(wintypes.DWORD), + ctypes.POINTER(wintypes.DWORD), + ] + kernel32.PeekNamedPipe.restype = wintypes.BOOL kernel32.WriteFile.argtypes = [ wintypes.HANDLE, ctypes.c_void_p, @@ -459,6 +469,22 @@ def __init__(self, win: _Win32, handle: Any) -> None: def read(self, size: int) -> bytes: if size <= 0 or size > 256 * 1024: raise NativeBrokerError("native_pipe_read_invalid") + # ReadFile is synchronous. Polling availability first keeps a broker + # reader thread cancellation-live while a packaged provider transforms. + available = wintypes.DWORD() + while True: + if not self._win.kernel32.PeekNamedPipe( + self._handle, + None, + 0, + None, + ctypes.byref(available), + None, + ): + raise _pipe_error(ctypes.get_last_error()) + if available.value: + break + sleep(0.005) buffer = ctypes.create_string_buffer(size) count = wintypes.DWORD() if not self._win.kernel32.ReadFile( diff --git a/docs/adr/0001-phase2-evidence.md b/docs/adr/0001-phase2-evidence.md index 737e21f..20a71de 100644 --- a/docs/adr/0001-phase2-evidence.md +++ b/docs/adr/0001-phase2-evidence.md @@ -1,7 +1,7 @@ # ADR-0001 Phase 2 evidence log - **Phase:** 2 — signed image recipes and calculator/check primitives -- **Status:** Typed contract, signed-manifest verification, native broker transport, authenticated worker loop, signed bundle installation, trusted artifact boundary, signed worker launch/broker qualification, and qualification-only provider core complete; the packaged provider transform and OS sandbox release gates remain open +- **Status:** Typed contract, signed-manifest verification, native broker transport, authenticated worker loop, signed bundle installation, trusted artifact boundary, signed worker launch/broker qualification, and packaged provider transform qualification complete; watchdog/hostile-decoder evidence, external review, and lifecycle release gates remain open - **Scope:** Provider-independent contracts plus a qualification-only fixed-function core - **Source decision:** [Capability-tiered agentic execution harness](0001-capability-tiered-agentic-execution-harness.md) - **Contract ADR:** [Phase 2 typed recipe and primitive contract](0001-phase2-recipe-contract.md) @@ -24,9 +24,9 @@ | 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. | -| Windows recipe sandbox qualification harness | **Complete (signed launch/broker; provider transform blocked)** | `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 packaged protocol. The fixed corpus passes through `input_chunk`; the provider `input_complete` transform times out and the harness fails closed. | +| Windows recipe sandbox qualification harness | **Complete (signed launch/broker/provider transform)** | `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 grayscale corpus through `collect_output`. 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 | **Blocked / release gate** | Signed installation, provenance, AppContainer/job identity, broker handshake, `prepare`, and `input_chunk` are live-qualified. The actual provider transform still needs to complete inside the packaged worker, followed by watchdog/hostile-decoder evidence, external review, and lifecycle wiring. | +| OS sandbox provider and provider-produced image outputs | **Blocked / release gate** | Signed installation, provenance, AppContainer/job identity, broker handshake, `prepare`, `input_chunk`, `input_complete`, and `collect_output` are live-qualified inside the packaged worker. The remaining gate is watchdog/cancellation and hostile-decoder evidence, external review, and production lifecycle wiring. | ## Security invariants @@ -93,6 +93,9 @@ 25. The native launcher grants only a per-profile inherited read/execute ACE on the verified package root, removes that ACE during worker cleanup, and never grants package write/delete access; a qualification timeout is always a blocked result. +26. Native broker reads poll bounded pipe availability before synchronous reads so a + cancellation reader cannot starve the provider transform; pipe errors still fail + closed and do not bypass framing, authentication, or sequence checks. ## Re-run target @@ -137,11 +140,13 @@ verified its complete 822-file closure (one `image_transform` role plus 821 iner `resource` entries); no key or signed artifact was retained. **Signed worker qualification result (2026-07-24):** The disposable -`recipe_worker_e2e_qualification.py --json --timeout-seconds 5` run passed +`recipe_worker_e2e_qualification.py --json --timeout-seconds 5 --strict` run passed ephemeral signing/installation, active provenance verification, AppContainer/job -policy and identity binding, authenticated broker handshake, `prepare`, and -`input_chunk`. It blocked at `input_complete` with `worker_response_timeout` while -the packaged provider transform remained inside the worker. Bounded cleanup closed -the broker, binder, worker, and profile; no `recipe_worker.exe` process remained. -This is evidence for the launch/protocol boundary only and does not close the -provider or production lifecycle release gate. +policy and identity binding, authenticated broker handshake, `prepare`, +`input_chunk`, `input_complete`, and `collect_output`. The transport fix polls +`PeekNamedPipe` with a 5 ms bounded wait before synchronous reads, preventing the +worker's cancellation reader from starving the provider transform. Bounded cleanup +closed the broker, binder, worker, and profile; no `recipe_worker.exe` process +remained. This closes the packaged provider-transform qualification gate but does +not close the remaining watchdog/hostile-decoder, external-review, or lifecycle +release gates. diff --git a/tests/test_phase2_native_broker.py b/tests/test_phase2_native_broker.py index 27cb44a..3a70234 100644 --- a/tests/test_phase2_native_broker.py +++ b/tests/test_phase2_native_broker.py @@ -3,6 +3,7 @@ from __future__ import annotations from dataclasses import dataclass +import ctypes import os import queue import secrets @@ -28,6 +29,7 @@ _HANDSHAKE_HEADER, _HANDSHAKE_HELLO, _HANDSHAKE_SERVER, + _PipeIO, _PROCESS_QUERY_LIMITED_INFORMATION, _close_handle, _client_handshake, @@ -232,6 +234,44 @@ def write(self, _payload: bytes) -> None: assert closed == [True] +def test_pipe_io_waits_for_available_bytes_before_blocking_read(): + class FakeKernel32: + def __init__(self) -> None: + self.peek_calls = 0 + + def PeekNamedPipe( + self, + _handle: object, + _buffer: object, + _buffer_size: int, + _bytes_read: object, + available: object, + _bytes_left: object, + ) -> bool: + self.peek_calls += 1 + available._obj.value = 0 if self.peek_calls == 1 else 3 + return True + + def ReadFile( + self, + _handle: object, + buffer: object, + _size: int, + count: object, + _overlapped: object, + ) -> bool: + payload = b"abc" + ctypes.memmove(buffer, payload, len(payload)) + count._obj.value = len(payload) + return True + + kernel32 = FakeKernel32() + pipe = _PipeIO(type("Win", (), {"kernel32": kernel32})(), object()) + + assert pipe.read(16) == b"abc" + assert kernel32.peek_calls == 2 + + def test_native_configs_require_local_pipe_and_expected_process_binding(): with pytest.raises(ValueError): NativeBrokerServerConfig(r"\\.\pipe\..\unsafe", _policy()) diff --git a/tools/execution_spikes/README.md b/tools/execution_spikes/README.md index 37845bc..43c7618 100644 --- a/tools/execution_spikes/README.md +++ b/tools/execution_spikes/README.md @@ -79,11 +79,14 @@ It accepts no user files, model text, commands, or production trust material. verification can take several minutes on Windows; the protocol timeout applies after launch and is fail-closed. -The current evidence result is intentionally blocked at the provider transform: +The current evidence result passes the packaged provider transform: signed installation, provenance, AppContainer/job identity binding, broker -handshake, `prepare`, and `input_chunk` pass, while `input_complete` times out -inside the packaged provider. The harness reports `worker_response_timeout`, -cleans up boundedly, and never reports a green result for this partial run. +handshake, `prepare`, `input_chunk`, `input_complete`, and `collect_output` all +complete through the authenticated broker. The native read path polls +`PeekNamedPipe` with a bounded 5 ms wait before `ReadFile`, keeping the worker's +cancellation reader live without starving the provider transform. The harness +still does not close the remaining watchdog/hostile-decoder, external-review, or +production-lifecycle release gates. ## What the probes prove