From f911abf2d36d0b7d40ba071ece6c1147b1653ba2 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 12 Aug 2026 01:31:03 +0000 Subject: [PATCH 01/70] feat(egress-gate): add attested Pi admission --- .../proto/supervisor_middleware.proto | 63 ++- projects/egress-gate/pyproject.toml | 1 + .../src/egress_gate/admission/__init__.py | 72 +++ .../src/egress_gate/admission/adapters.py | 428 ++++++++++++++++++ .../src/egress_gate/admission/canonical.py | 153 +++++++ .../src/egress_gate/admission/models.py | 117 +++++ .../src/egress_gate/admission/processor.py | 273 +++++++++++ .../src/egress_gate/admission/receipts.py | 250 ++++++++++ .../bindings/supervisor_middleware_pb2.py | 86 ++-- .../bindings/supervisor_middleware_pb2.pyi | 89 +++- .../supervisor_middleware_pb2_grpc.py | 51 ++- projects/egress-gate/src/egress_gate/cli.py | 12 + .../egress-gate/src/egress_gate/request.py | 35 ++ .../src/egress_gate/request_processor.py | 5 + .../src/egress_gate/service/server.py | 2 + .../src/egress_gate/service/servicer.py | 171 ++++++- .../egress-gate/tests/admission/__init__.py | 1 + .../tests/admission/test_admission.py | 325 +++++++++++++ .../tests/service/test_grpc_integration.py | 151 ++++++ projects/egress-gate/tests/test_cli.py | 6 +- projects/egress-gate/uv.lock | 165 +++++++ 21 files changed, 2406 insertions(+), 50 deletions(-) create mode 100644 projects/egress-gate/src/egress_gate/admission/__init__.py create mode 100644 projects/egress-gate/src/egress_gate/admission/adapters.py create mode 100644 projects/egress-gate/src/egress_gate/admission/canonical.py create mode 100644 projects/egress-gate/src/egress_gate/admission/models.py create mode 100644 projects/egress-gate/src/egress_gate/admission/processor.py create mode 100644 projects/egress-gate/src/egress_gate/admission/receipts.py create mode 100644 projects/egress-gate/tests/admission/__init__.py create mode 100644 projects/egress-gate/tests/admission/test_admission.py diff --git a/projects/egress-gate/proto/supervisor_middleware.proto b/projects/egress-gate/proto/supervisor_middleware.proto index dbde411c..b30cb233 100644 --- a/projects/egress-gate/proto/supervisor_middleware.proto +++ b/projects/egress-gate/proto/supervisor_middleware.proto @@ -9,7 +9,7 @@ import "google/protobuf/empty.proto"; import "google/protobuf/struct.proto"; // SupervisorMiddleware lets an operator-run service inspect and transform -// sandbox HTTP egress before OpenShell injects credentials. +// sandbox HTTP egress or evaluate a supported agent-harness request. service SupervisorMiddleware { // Describe returns the service manifest and declared bindings. rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); @@ -20,6 +20,10 @@ service SupervisorMiddleware { // EvaluateHttpRequest returns an allow, deny, or mutation decision for one // buffered HTTP request. rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); + + // EvaluateAgentConversation returns an allow, deny, or replacement decision for + // one versioned, harness-native request before the harness commits or sends it. + rpc EvaluateAgentConversation(AgentConversationEvaluation) returns (AgentConversationResult); } // MiddlewareManifest describes one middleware service and the bindings it @@ -38,9 +42,9 @@ message MiddlewareManifest { // MiddlewareBinding declares one operation and phase supported by a service. message MiddlewareBinding { - // Supported operation. V1 supports HTTP_REQUEST. + // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported evaluation phase. V1 supports PRE_CREDENTIALS. + // Supported evaluation phase. SupervisorMiddlewarePhase phase = 2; // Maximum request or replacement body this binding can process. uint64 max_body_bytes = 3; @@ -50,6 +54,12 @@ message MiddlewareBinding { // Values use an integer with an `ms` or `s` suffix and must be between // 10ms and 30s. string timeout = 4; + // Agent harness supported by an AGENT_CONVERSATION binding. Empty for HTTP_REQUEST. + string harness = 5; + // Harness hook supported by an AGENT_CONVERSATION binding. Empty for HTTP_REQUEST. + string hook = 6; + // Version of the harness-native request schema. Empty for HTTP_REQUEST. + string schema_version = 7; } // ValidateConfigRequest contains one policy configuration to validate. @@ -104,12 +114,14 @@ message HttpHeader { enum SupervisorMiddlewareOperation { SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; + SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION = 2; } // Ordered phase within a supervisor operation. enum SupervisorMiddlewarePhase { SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS = 1; + SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT = 2; } // RequestContext identifies the sandbox request being evaluated. @@ -148,6 +160,51 @@ message Process { repeated string ancestors = 3; } +// AgentConversationTarget identifies the harness hook and provider destination for +// which an allowed model request may receive a receipt. +message AgentConversationTarget { + string harness = 1; + string harness_version = 2; + string hook = 3; + string schema_version = 4; + string scheme = 5; + string host = 6; + uint32 port = 7; + string path = 8; +} + +// AgentConversationEvaluation is stamped by the supervisor-owned bridge. Workload +// callers supply only the harness request and untrusted request provenance. +message AgentConversationEvaluation { + SupervisorMiddlewarePhase phase = 1; + RequestContext context = 2; + google.protobuf.Struct config = 3; + AgentConversationTarget target = 4; + reserved 5; + string middleware_name = 6; + string session_id = 7; + string turn_id = 8; + bytes request_body = 9; + string source = 10; + string delivery = 11; + string request_kind = 12; + optional uint32 candidate_index = 13; +} + +// AgentConversationResult carries the authority decision, an optional complete +// replacement body, and a model-request receipt opaque to OpenShell. +message AgentConversationResult { + Decision decision = 1; + string reason = 2; + reserved 3, 4; + bytes attestation = 5; + repeated Finding findings = 6; + map metadata = 7; + string reason_code = 8; + bytes replacement_body = 9; + bool has_replacement_body = 10; +} + // Decision controls whether OpenShell continues processing the request. enum Decision { // Invalid response value handled according to the policy failure mode. diff --git a/projects/egress-gate/pyproject.toml b/projects/egress-gate/pyproject.toml index 99254472..3aa84f68 100644 --- a/projects/egress-gate/pyproject.toml +++ b/projects/egress-gate/pyproject.toml @@ -10,6 +10,7 @@ authors = [ { name = "NVIDIA CORPORATION & AFFILIATES" }, ] dependencies = [ + "cryptography>=50,<51", "grpcio>=1.81.1,<2", "protobuf>=7.36,<8", # 7.36.0 fixes protobuf security advisories. "pydantic>=2.11,<3", diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py new file mode 100644 index 00000000..2bea2f8c --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -0,0 +1,72 @@ +"""First-class harness admission and attested-egress APIs.""" + +from egress_gate.admission.adapters import ( + HarnessAdapter, + HarnessAdapterRegistry, + OpenAIChatCompletionsV1Adapter, + PiInputV1, + PiV1Adapter, + PreparedHarnessRequest, + ProviderAdapterRegistry, + ProviderRequestAdapter, + create_pi_adapter_registry, + create_provider_adapter_registry, +) +from egress_gate.admission.canonical import ( + CanonicalFunctionCallV1, + CanonicalGenerationV1, + CanonicalMessageV1, + CanonicalRole, + CanonicalToolChoiceV1, + CanonicalToolV1, + ModelRequestV1, + canonical_json_bytes, +) +from egress_gate.admission.models import ( + PI_HARNESS_VERSION, + AdmissionDecision, + AdmissionHook, + HarnessAdmissionContext, + HarnessAdmissionRequest, + HarnessAdmissionResult, + PromptProvenance, +) +from egress_gate.admission.processor import ( + RECEIPT_HEADER, + AttestedEgressProcessor, + HarnessAdmissionProcessor, +) +from egress_gate.admission.receipts import ReceiptAuthority, ReceiptClaimsV1 + +__all__ = [ + "AdmissionDecision", + "AdmissionHook", + "AttestedEgressProcessor", + "CanonicalFunctionCallV1", + "CanonicalGenerationV1", + "CanonicalMessageV1", + "CanonicalRole", + "CanonicalToolChoiceV1", + "CanonicalToolV1", + "HarnessAdapter", + "HarnessAdapterRegistry", + "HarnessAdmissionContext", + "HarnessAdmissionProcessor", + "HarnessAdmissionRequest", + "HarnessAdmissionResult", + "PromptProvenance", + "PI_HARNESS_VERSION", + "ModelRequestV1", + "OpenAIChatCompletionsV1Adapter", + "PiInputV1", + "PiV1Adapter", + "PreparedHarnessRequest", + "ProviderAdapterRegistry", + "ProviderRequestAdapter", + "RECEIPT_HEADER", + "ReceiptAuthority", + "ReceiptClaimsV1", + "canonical_json_bytes", + "create_pi_adapter_registry", + "create_provider_adapter_registry", +] diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py new file mode 100644 index 00000000..27f0b228 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -0,0 +1,428 @@ +"""Registered Pi and provider request-shape adapters.""" + +from __future__ import annotations + +import json +from typing import Literal, Protocol + +from pydantic import ( + Field, + TypeAdapter, + ValidationError, + field_validator, + model_validator, +) + +from egress_gate.admission.canonical import ( + CanonicalFunctionCallV1, + CanonicalGenerationV1, + CanonicalMessageV1, + CanonicalRole, + CanonicalToolChoiceV1, + CanonicalToolV1, + ModelRequestV1, + canonical_json_bytes, +) +from egress_gate.admission.models import ( + AdmissionHook, + HarnessAdmissionContext, + HarnessAdmissionRequest, +) +from egress_gate.base import StrictDomainModel +from egress_gate.errors import BodyFormatError, GateInputError +from egress_gate.request import HttpRequest +from egress_gate.request_content import JsonDocument +from egress_gate.string_validators import ScalarString +from egress_gate.timeout import Timeout + + +class AdmissionShapeError(ValueError): + """A content-safe signal that an admission shape is unsupported.""" + + +class AdmissionMutationError(ValueError): + """A content-safe signal that a Gate changed a read-only field.""" + + +class ProviderShapeError(ValueError): + """A content-safe signal that a provider request is unsupported.""" + + +class PiInputV1(StrictDomainModel): + """Rendered text submitted by the pinned Pi extension.""" + + schema_version: Literal["openshell.pi-input.v1"] + text: ScalarString + + +class PreparedHarnessRequest: + """Parsed Pi request plus its canonical Gate projection.""" + + def __init__( + self, + *, + native: PiInputV1, + projected_body: bytes, + original_body: bytes, + ) -> None: + self.native = native + self.projected_body = projected_body + self.original_body = original_body + + +class HarnessAdapter(Protocol): + """Fixed-authority translation for one registered harness hook.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: ... + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, PiInputV1]: ... + + +class PiV1Adapter: + """Strict rendered-prompt adapter.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: + native = _parse_pi_body(request.request_body, timeout) + return PreparedHarnessRequest( + native=native, + projected_body=canonical_json_bytes(native), + original_body=request.request_body, + ) + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, PiInputV1]: + updated = _parse_pi_body(projected_body, timeout) + encoded = canonical_json_bytes(updated) + replacement = ( + None + if canonical_json_bytes(updated) == canonical_json_bytes(prepared.native) + else encoded + ) + return replacement, updated + + +class HarnessAdapterRegistry: + """Small explicit registry for supported harness admission shapes.""" + + def __init__(self) -> None: + self._adapters: dict[tuple[str, str, str], HarnessAdapter] = {} + + def register( + self, + harness: str, + hook: AdmissionHook, + schema_version: str, + adapter: HarnessAdapter, + ) -> None: + key = (harness, hook.value, schema_version) + if key in self._adapters: + raise ValueError("harness adapter is already registered") + self._adapters[key] = adapter + + def resolve(self, context: HarnessAdmissionContext) -> HarnessAdapter: + key = (context.harness, context.hook.value, context.schema_version) + try: + return self._adapters[key] + except KeyError: + raise AdmissionShapeError( + "harness admission shape is unsupported" + ) from None + + +class _ProviderTextBlock(StrictDomainModel): + type: Literal["text"] + text: ScalarString + + +class _ProviderFunction(StrictDomainModel): + name: ScalarString + arguments: ScalarString + + +class _ProviderToolCall(StrictDomainModel): + id: ScalarString + type: Literal["function"] + function: _ProviderFunction + + +class _ProviderMessage(StrictDomainModel): + role: Literal["system", "developer", "user", "assistant", "tool"] + content: ScalarString | tuple[_ProviderTextBlock, ...] | None = None + name: ScalarString | None = None + tool_call_id: ScalarString | None = None + tool_calls: tuple[_ProviderToolCall, ...] = () + + @field_validator("content", "tool_calls", mode="before") + @classmethod + def _provider_sequences_are_tuples(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + @model_validator(mode="after") + def _optional_fields_have_one_representation(self) -> _ProviderMessage: + if "content" not in self.model_fields_set: + raise ValueError("provider messages must include content") + if "name" in self.model_fields_set and self.name is None: + raise ValueError("provider message name cannot be null") + if "tool_call_id" in self.model_fields_set and self.tool_call_id is None: + raise ValueError("provider tool-call ID cannot be null") + if "tool_calls" in self.model_fields_set and not self.tool_calls: + raise ValueError("provider tool calls cannot be empty") + return self + + +class _ProviderFunctionDefinition(StrictDomainModel): + name: ScalarString + description: ScalarString + parameters: dict[str, object] + strict: bool + + +class _ProviderTool(StrictDomainModel): + type: Literal["function"] + function: _ProviderFunctionDefinition + + +class _ProviderNamedChoiceFunction(StrictDomainModel): + name: ScalarString + + +class _ProviderNamedToolChoice(StrictDomainModel): + type: Literal["function"] + function: _ProviderNamedChoiceFunction + + +class _ProviderStreamOptions(StrictDomainModel): + include_usage: Literal[True] + + +class _ProviderRequest(StrictDomainModel): + model: ScalarString + messages: tuple[_ProviderMessage, ...] + tools: tuple[_ProviderTool, ...] = () + tool_choice: Literal["auto", "none", "required"] | _ProviderNamedToolChoice = "auto" + temperature: int | float | None = Field(default=None, allow_inf_nan=False) + max_completion_tokens: int = Field(ge=1) + stream: Literal[True] + stream_options: _ProviderStreamOptions + store: Literal[False] + prompt_cache_key: ScalarString | None = None + prompt_cache_retention: Literal["24h"] | None = None + reasoning_effort: ScalarString | None = None + + @field_validator("messages", "tools", mode="before") + @classmethod + def _provider_collections_are_tuples(cls, value: object) -> object: + return tuple(value) if isinstance(value, list | tuple) else value + + +class ProviderRequestAdapter(Protocol): + """Validate and project a provider request for rendered-prompt extraction.""" + + schema_version: str + + def canonicalize( + self, request: HttpRequest, timeout: Timeout + ) -> ModelRequestV1: ... + + def rendered_prompt(self, request: HttpRequest, timeout: Timeout) -> PiInputV1: ... + + +class OpenAIChatCompletionsV1Adapter: + """Pinned OpenAI-compatible Chat Completions request adapter.""" + + schema_version = "openai.chat-completions.v1" + + def canonicalize(self, request: HttpRequest, timeout: Timeout) -> ModelRequestV1: + if request.target.method.upper() != "POST": + raise ProviderShapeError("provider request method is unsupported") + content_types = [ + header.value.strip().lower() + for header in request.headers + if header.name.lower() == "content-type" + ] + if content_types != ["application/json"]: + raise ProviderShapeError("provider request requires one JSON content type") + if any(header.name.lower() == "content-encoding" for header in request.headers): + raise ProviderShapeError("provider request content encoding is unsupported") + value = _load_json(request.body, ProviderShapeError, timeout) + try: + provider = _PROVIDER_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise ProviderShapeError("provider request body is unsupported") from None + if not isinstance(provider, _ProviderRequest): + raise ProviderShapeError("provider request body is unsupported") + messages = tuple( + _provider_message_to_canonical(item) for item in provider.messages + ) + tools = tuple( + CanonicalToolV1( + name=item.function.name, + description=item.function.description, + input_schema=item.function.parameters, + ) + for item in provider.tools + ) + if isinstance(provider.tool_choice, str): + tool_choice = CanonicalToolChoiceV1(mode=provider.tool_choice) + else: + tool_choice = CanonicalToolChoiceV1( + mode="function", + function_name=provider.tool_choice.function.name, + ) + return ModelRequestV1( + model=provider.model, + messages=messages, + tools=tools, + tool_choice=tool_choice, + generation=CanonicalGenerationV1( + temperature=provider.temperature, + max_tokens=provider.max_completion_tokens, + ), + ) + + def rendered_prompt(self, request: HttpRequest, timeout: Timeout) -> PiInputV1: + """Extract the last user text from the first provider request.""" + canonical = self.canonicalize(request, timeout) + for message in reversed(canonical.messages): + if message.role is CanonicalRole.USER and message.content is not None: + return PiInputV1( + schema_version="openshell.pi-input.v1", + text=message.content, + ) + raise ProviderShapeError("provider request has no user prompt") + + +class ProviderAdapterRegistry: + """Explicit versioned provider-adapter registry.""" + + def __init__(self) -> None: + self._adapters: dict[str, ProviderRequestAdapter] = {} + + def register(self, adapter: ProviderRequestAdapter) -> None: + if adapter.schema_version in self._adapters: + raise ValueError("provider adapter is already registered") + self._adapters[adapter.schema_version] = adapter + + def resolve(self, schema_version: str) -> ProviderRequestAdapter: + try: + return self._adapters[schema_version] + except KeyError: + raise ProviderShapeError("provider adapter is unsupported") from None + + +def create_pi_adapter_registry() -> HarnessAdapterRegistry: + """Return the built-in Pi v1 admission registry.""" + registry = HarnessAdapterRegistry() + registry.register( + "pi", + AdmissionHook.RENDERED_PROMPT, + "openshell.pi-input.v1", + PiV1Adapter(), + ) + return registry + + +def create_provider_adapter_registry() -> ProviderAdapterRegistry: + """Return the milestone-one provider registry.""" + registry = ProviderAdapterRegistry() + registry.register(OpenAIChatCompletionsV1Adapter()) + return registry + + +def _parse_pi_body(body: bytes, timeout: Timeout) -> PiInputV1: + value = _load_json(body, AdmissionShapeError, timeout) + try: + parsed = _PI_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi request body is unsupported") from None + if not isinstance(parsed, PiInputV1): + raise AdmissionShapeError("Pi request body is unsupported") + if canonical_json_bytes(parsed) != body: + raise AdmissionShapeError("Pi request body is not canonical JSON") + return parsed + + +def _load_json(body: bytes, error_type: type[ValueError], timeout: Timeout) -> object: + try: + JsonDocument.parse(body, timeout=timeout) + except (BodyFormatError, GateInputError): + raise error_type("request body is not canonical JSON") from None + try: + text = body.decode("utf-8", errors="strict") + return json.loads(text, object_pairs_hook=_unique_object) + except (UnicodeDecodeError, json.JSONDecodeError, RecursionError, ValueError): + raise error_type("request body is not canonical JSON") from None + + +def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: + output: dict[str, object] = {} + for key, value in pairs: + if key in output: + raise ValueError("duplicate JSON object key") + output[key] = value + return output + + +def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1: + if isinstance(item.content, tuple): + if len(item.content) != 1: + raise ProviderShapeError("multipart text requires exactly one block") + content = item.content[0].text + else: + content = item.content + return CanonicalMessageV1( + role=CanonicalRole(item.role), + content=content, + name=item.name, + tool_call_id=item.tool_call_id, + tool_calls=tuple( + CanonicalFunctionCallV1( + id=call.id, + name=call.function.name, + arguments=call.function.arguments, + ) + for call in item.tool_calls + ), + ) + + +_PI_ADAPTER = TypeAdapter(PiInputV1) +_PROVIDER_ADAPTER = TypeAdapter(_ProviderRequest) + + +__all__ = [ + "AdmissionMutationError", + "AdmissionShapeError", + "HarnessAdapter", + "HarnessAdapterRegistry", + "OpenAIChatCompletionsV1Adapter", + "PiInputV1", + "PiV1Adapter", + "PreparedHarnessRequest", + "ProviderAdapterRegistry", + "ProviderRequestAdapter", + "ProviderShapeError", + "create_pi_adapter_registry", + "create_provider_adapter_registry", +] diff --git a/projects/egress-gate/src/egress_gate/admission/canonical.py b/projects/egress-gate/src/egress_gate/admission/canonical.py new file mode 100644 index 00000000..f7ac136f --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/canonical.py @@ -0,0 +1,153 @@ +"""Strict canonical model-request schema and encoding.""" + +from __future__ import annotations + +import json +import math +from enum import StrEnum +from typing import Literal + +from pydantic import Field, field_validator, model_validator + +from egress_gate.base import StrictDomainModel +from egress_gate.string_validators import ScalarString + + +class CanonicalRole(StrEnum): + """Roles supported by the pinned provider schema.""" + + SYSTEM = "system" + DEVELOPER = "developer" + USER = "user" + ASSISTANT = "assistant" + TOOL = "tool" + + +class CanonicalFunctionCallV1(StrictDomainModel): + """One model-produced function call without lossy argument parsing.""" + + id: ScalarString + name: ScalarString + arguments: ScalarString + + +class CanonicalMessageV1(StrictDomainModel): + """One ordered, provider-visible message.""" + + role: CanonicalRole + content: ScalarString | None + name: ScalarString | None = None + tool_call_id: ScalarString | None = None + tool_calls: tuple[CanonicalFunctionCallV1, ...] = () + + @model_validator(mode="after") + def _role_fields_are_consistent(self) -> CanonicalMessageV1: + if self.role is CanonicalRole.TOOL: + if self.content is None or self.tool_call_id is None or self.tool_calls: + raise ValueError("tool messages require content and tool_call_id") + elif self.tool_call_id is not None: + raise ValueError("only tool messages may carry tool_call_id") + if self.tool_calls and self.role is not CanonicalRole.ASSISTANT: + raise ValueError("only assistant messages may carry tool calls") + if self.content is None and not self.tool_calls: + raise ValueError("messages require content or tool calls") + return self + + +class CanonicalToolV1(StrictDomainModel): + """One complete function-tool definition.""" + + name: ScalarString + description: ScalarString + input_schema: dict[str, object] + + @field_validator("input_schema") + @classmethod + def _schema_is_canonical_json(cls, value: dict[str, object]) -> dict[str, object]: + _validate_json_value(value) + return value + + +class CanonicalToolChoiceV1(StrictDomainModel): + """Pinned OpenAI tool-selection semantics.""" + + mode: Literal["auto", "none", "required", "function"] + function_name: ScalarString | None = None + + @model_validator(mode="after") + def _function_name_matches_mode(self) -> CanonicalToolChoiceV1: + if (self.mode == "function") != (self.function_name is not None): + raise ValueError("function tool choice requires exactly one name") + return self + + +class CanonicalGenerationV1(StrictDomainModel): + """Semantic generation fields accepted from the pinned Pi serializer.""" + + temperature: float | None = Field(default=None, allow_inf_nan=False) + max_tokens: int = Field(ge=1) + + @field_validator("temperature", mode="before") + @classmethod + def _normalize_temperature(cls, value: object) -> float | None: + if value is None: + return value + if isinstance(value, bool) or not isinstance(value, int | float): + raise ValueError("temperature must be numeric") + normalized = float(value) + return 0.0 if normalized == 0 else normalized + + +class ModelRequestV1(StrictDomainModel): + """Validated semantic view of one supported provider request.""" + + schema_version: Literal["model-request.v1"] = "model-request.v1" + model: ScalarString + messages: tuple[CanonicalMessageV1, ...] + tools: tuple[CanonicalToolV1, ...] + tool_choice: CanonicalToolChoiceV1 + generation: CanonicalGenerationV1 + + +def canonical_json_bytes(value: StrictDomainModel) -> bytes: + """Encode a validated model with stable UTF-8 JSON semantics.""" + return json.dumps( + value.model_dump(mode="json"), + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + + +def _validate_json_value(value: object) -> None: + if value is None or isinstance(value, str | bool | int): + return + if isinstance(value, float): + if not math.isfinite(value): + raise ValueError("JSON numbers must be finite") + return + if isinstance(value, list): + for item in value: + _validate_json_value(item) + return + if isinstance(value, dict): + for key, item in value.items(): + if not isinstance(key, str): + raise ValueError("JSON object keys must be strings") + key.encode("utf-8", errors="strict") + _validate_json_value(item) + return + raise ValueError("value is not canonical JSON") + + +__all__ = [ + "CanonicalFunctionCallV1", + "CanonicalGenerationV1", + "CanonicalMessageV1", + "CanonicalRole", + "CanonicalToolChoiceV1", + "CanonicalToolV1", + "ModelRequestV1", + "canonical_json_bytes", +] diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py new file mode 100644 index 00000000..84c18980 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -0,0 +1,117 @@ +"""Public, transport-neutral models for harness admission.""" + +from __future__ import annotations + +from enum import StrEnum +from typing import Literal + +from pydantic import Field, model_validator + +from egress_gate.base import StrictDomainModel +from egress_gate.constants import MAX_BODY_BYTES, MAX_PROTO_FINDING_GROUPS +from egress_gate.request import HttpTarget +from egress_gate.result import ReasonCode, SourcedFinding +from egress_gate.string_validators import BoundedMetadataString, ScalarString + +PI_HARNESS_VERSION = "extension-v1" + + +class AdmissionHook(StrEnum): + """Supported Pi admission boundaries.""" + + RENDERED_PROMPT = "rendered_prompt_admission" + + +class AdmissionDecision(StrEnum): + """Disposition of a harness request.""" + + ALLOW = "allow" + REPLACE = "replace" + DENY = "deny" + + +class PromptProvenance(StrictDomainModel): + """Request-local correlation assertions for one rendered submission.""" + + kind: Literal["rendered_prompt"] + session_id: BoundedMetadataString + submission_id: BoundedMetadataString + + +class HarnessAdmissionRequest(StrictDomainModel): + """One complete harness-native rendered prompt.""" + + request_body: bytes = Field(max_length=MAX_BODY_BYTES, repr=False) + provenance: PromptProvenance + + +class HarnessAdmissionContext(StrictDomainModel): + """Trusted admission context stamped outside the workload.""" + + request_id: BoundedMetadataString + sandbox_id: BoundedMetadataString + middleware_name: BoundedMetadataString + harness: Literal["pi"] + harness_version: Literal["extension-v1"] + hook: AdmissionHook + schema_version: Literal["openshell.pi-input.v1"] + provider_target: HttpTarget + provider_adapter_schema: Literal["openai.chat-completions.v1"] + + +class HarnessAdmissionResult(StrictDomainModel): + """Atomic policy decision returned to a managed harness.""" + + hook: AdmissionHook + decision: AdmissionDecision + replacement_body: bytes | None = Field( + default=None, + max_length=MAX_BODY_BYTES, + repr=False, + ) + receipt: bytes | None = Field( + default=None, + min_length=1, + max_length=8 * 1024, + repr=False, + ) + findings: tuple[SourcedFinding, ...] = Field( + default=(), max_length=MAX_PROTO_FINDING_GROUPS + ) + reason_code: ReasonCode | None = None + policy_fingerprint: ScalarString + + @model_validator(mode="after") + def _decision_contract_is_consistent(self) -> HarnessAdmissionResult: + if self.decision is AdmissionDecision.DENY: + if self.reason_code is None: + raise ValueError("denial requires a reason code") + if self.replacement_body is not None or self.receipt is not None: + raise ValueError("denial cannot carry a replacement or receipt") + else: + if self.reason_code is not None: + raise ValueError("allow decisions cannot carry a reason code") + if ( + self.decision is AdmissionDecision.REPLACE + and self.replacement_body is None + ): + raise ValueError("replace decisions require a replacement body") + if ( + self.decision is AdmissionDecision.ALLOW + and self.replacement_body is not None + ): + raise ValueError("allow decisions cannot carry a replacement body") + if self.receipt is None: + raise ValueError("admission requires a receipt") + return self + + +__all__ = [ + "AdmissionDecision", + "AdmissionHook", + "HarnessAdmissionContext", + "HarnessAdmissionRequest", + "HarnessAdmissionResult", + "PromptProvenance", + "PI_HARNESS_VERSION", +] diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py new file mode 100644 index 00000000..9b5a0f07 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -0,0 +1,273 @@ +"""Harness-admission orchestration and attested network egress.""" + +from __future__ import annotations + +from typing import Literal + +from pydantic import ValidationError + +from egress_gate.admission.adapters import ( + AdmissionMutationError, + AdmissionShapeError, + HarnessAdapterRegistry, + ProviderAdapterRegistry, + ProviderShapeError, +) +from egress_gate.admission.canonical import canonical_json_bytes +from egress_gate.admission.models import ( + AdmissionDecision, + AdmissionHook, + HarnessAdmissionContext, + HarnessAdmissionRequest, + HarnessAdmissionResult, +) +from egress_gate.admission.receipts import ReceiptAuthority, ReceiptVerificationError +from egress_gate.errors import EgressGateError, GateError, TimeoutExpiredError +from egress_gate.request import ( + EnforcementPoint, + HarnessAdmissionMetadata, + HttpRequest, + RemoveHeaderMutation, + RequestContext, + RequestMutations, +) +from egress_gate.request_processor import RequestProcessor, apply_request_mutations +from egress_gate.result import ( + DecisionSourceKind, + EgressDecision, + EgressResult, + GateDecisionSource, +) +from egress_gate.timeout import Timeout + +RECEIPT_HEADER = "x-openshell-middleware-egress-receipt" + + +class HarnessAdmissionProcessor: + """Apply the configured Gate pipeline through one registered harness adapter.""" + + def __init__( + self, + request_processor: RequestProcessor, + adapters: HarnessAdapterRegistry, + receipt_authority: ReceiptAuthority, + ) -> None: + fingerprint = request_processor.policy_fingerprint + if not fingerprint: + raise ValueError("admission requires a policy fingerprint") + self._request_processor = request_processor + self._adapters = adapters + self._receipt_authority = receipt_authority + self._policy_fingerprint = fingerprint + + @property + def readiness(self) -> dict[str, str]: + """Return content-safe compatibility metadata for a managed launcher.""" + return { + "admission_schema": "openshell.pi-input.v1", + "canonicalization": "canonical-json.v1", + "provider_adapter": "openai.chat-completions.v1", + "receipt_version": "egress-receipt.v1", + "key_id": self._receipt_authority.key_id, + "policy_fingerprint": self._policy_fingerprint, + } + + def process( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + *, + timeout: Timeout, + ) -> HarnessAdmissionResult: + """Return an explicit allow, replacement, or fail-closed denial.""" + try: + adapter = self._adapters.resolve(context) + prepared = adapter.prepare(request, context, timeout) + projected = HttpRequest( + context=RequestContext( + request_id=context.request_id, + sandbox_id=context.sandbox_id, + enforcement_point=EnforcementPoint.HARNESS_ADMISSION, + harness_admission=HarnessAdmissionMetadata( + harness=context.harness, + harness_version=context.harness_version, + hook=context.hook.value, + schema_version=context.schema_version, + ), + ), + target=context.provider_target, + headers=(), + body=prepared.projected_body, + ) + gate_result = self._request_processor.process(projected, timeout=timeout) + timeout.raise_if_expired() + if gate_result.decision is EgressDecision.DENY: + return HarnessAdmissionResult( + hook=context.hook, + decision=AdmissionDecision.DENY, + findings=gate_result.findings, + reason_code=gate_result.reason_code, + policy_fingerprint=self._policy_fingerprint, + ) + if gate_result.request_mutations.header_mutations: + raise AdmissionMutationError("admission cannot mutate HTTP headers") + final_request = apply_request_mutations( + projected, gate_result.request_mutations + ) + replacement, rendered_prompt = adapter.validate_result( + prepared, final_request.body, context, timeout + ) + timeout.raise_if_expired() + receipt = self._receipt_authority.issue( + rendered_prompt, + context, + request.provenance, + policy_fingerprint=self._policy_fingerprint, + ) + timeout.raise_if_expired() + return HarnessAdmissionResult( + hook=context.hook, + decision=( + AdmissionDecision.REPLACE + if replacement is not None + else AdmissionDecision.ALLOW + ), + replacement_body=replacement, + receipt=receipt, + findings=gate_result.findings, + policy_fingerprint=self._policy_fingerprint, + ) + except (AdmissionShapeError, AdmissionMutationError, ValidationError): + return self._deny("admission_contract_invalid", context.hook) + except TimeoutExpiredError: + return self._deny("admission_unavailable", context.hook) + except (EgressGateError, GateError, ValueError): + return self._deny("admission_unavailable", context.hook) + except Exception: + return self._deny("admission_unavailable", context.hook) + + def _deny(self, reason_code: str, hook: AdmissionHook) -> HarnessAdmissionResult: + return HarnessAdmissionResult( + hook=hook, + decision=AdmissionDecision.DENY, + reason_code=reason_code, + policy_fingerprint=self._policy_fingerprint, + ) + + +class AttestedEgressProcessor: + """Verify a receipt, run network Gates, and reject prompt divergence.""" + + def __init__( + self, + request_processor: RequestProcessor, + provider_adapters: ProviderAdapterRegistry, + receipt_authority: ReceiptAuthority, + *, + middleware_name: str, + harness_version: Literal["extension-v1"], + ) -> None: + fingerprint = request_processor.policy_fingerprint + if not fingerprint: + raise ValueError("attested egress requires a policy fingerprint") + self._request_processor = request_processor + self._provider_adapters = provider_adapters + self._receipt_authority = receipt_authority + self._middleware_name = middleware_name + self._harness_version = harness_version + self._provider_adapter_schema = "openai.chat-completions.v1" + self._policy_fingerprint = fingerprint + + def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: + """Deny any unattested or semantically changed provider request.""" + if request.context.enforcement_point is not EnforcementPoint.NETWORK_EGRESS: + return self._deny("network_context_invalid") + receipt_headers = tuple( + header + for header in request.headers + if header.name.lower() == RECEIPT_HEADER + ) + if len(receipt_headers) != 1: + reason = "receipt_missing" if not receipt_headers else "receipt_duplicate" + return self._deny(reason) + stripped = request.model_copy( + update={ + "headers": tuple( + header + for header in request.headers + if header.name.lower() != RECEIPT_HEADER + ) + } + ) + try: + adapter = self._provider_adapters.resolve(self._provider_adapter_schema) + rendered_prompt = adapter.rendered_prompt(stripped, timeout) + timeout.raise_if_expired() + context = HarnessAdmissionContext( + request_id=request.context.request_id, + sandbox_id=request.context.sandbox_id, + middleware_name=self._middleware_name, + harness="pi", + harness_version=self._harness_version, + hook=AdmissionHook.RENDERED_PROMPT, + schema_version="openshell.pi-input.v1", + provider_target=request.target, + provider_adapter_schema="openai.chat-completions.v1", + ) + self._receipt_authority.verify( + receipt_headers[0].value.encode("ascii"), + rendered_prompt, + context, + policy_fingerprint=self._policy_fingerprint, + ) + timeout.raise_if_expired() + gate_result = self._request_processor.process(stripped, timeout=timeout) + timeout.raise_if_expired() + if gate_result.decision is EgressDecision.DENY: + return gate_result + final_request = apply_request_mutations( + stripped, gate_result.request_mutations + ) + final_prompt = adapter.rendered_prompt(final_request, timeout) + if canonical_json_bytes(final_prompt) != canonical_json_bytes( + rendered_prompt + ): + return self._deny("semantic_mutation_denied") + timeout.raise_if_expired() + mutations = RequestMutations( + replacement_body=gate_result.request_mutations.replacement_body, + header_mutations=gate_result.request_mutations.header_mutations + + (RemoveHeaderMutation(kind="remove", name=RECEIPT_HEADER),), + ) + return gate_result.model_copy(update={"request_mutations": mutations}) + except UnicodeEncodeError: + return self._deny("receipt_malformed") + except ReceiptVerificationError as error: + return self._deny(error.reason_code) + except TimeoutExpiredError: + return self._deny("egress_verification_failed") + except (ProviderShapeError, ValidationError): + return self._deny("provider_shape_unsupported") + except (EgressGateError, GateError, ValueError): + return self._deny("egress_verification_failed") + except Exception: + return self._deny("egress_verification_failed") + + def _deny(self, reason_code: str) -> EgressResult: + return EgressResult( + decision=EgressDecision.DENY, + decision_source=GateDecisionSource( + kind=DecisionSourceKind.GATE, + gate_name="receipt-verifier", + gate_type="receipt-verifier", + ), + reason_code=reason_code, + policy_fingerprint=self._policy_fingerprint, + ) + + +__all__ = [ + "AttestedEgressProcessor", + "HarnessAdmissionProcessor", + "RECEIPT_HEADER", +] diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py new file mode 100644 index 00000000..4c2603fa --- /dev/null +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -0,0 +1,250 @@ +"""Short-lived Ed25519 admission receipts.""" + +from __future__ import annotations + +import base64 +import hashlib +import secrets +import threading +from datetime import UTC, datetime +from typing import Literal + +from cryptography.exceptions import InvalidSignature +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import ( + Ed25519PrivateKey, +) +from pydantic import Field, ValidationError + +from egress_gate.admission.adapters import PiInputV1 +from egress_gate.admission.canonical import canonical_json_bytes +from egress_gate.admission.models import ( + AdmissionHook, + HarnessAdmissionContext, + PromptProvenance, +) +from egress_gate.base import StrictDomainModel +from egress_gate.string_validators import BoundedMetadataString, ScalarString + + +class ReceiptClaimsV1(StrictDomainModel): + """All security context signed into one rendered-prompt receipt.""" + + receipt_version: Literal["egress-receipt.v1"] = "egress-receipt.v1" + canonicalization_version: Literal["canonical-json.v1"] = "canonical-json.v1" + harness: Literal["pi"] + harness_version: Literal["extension-v1"] + harness_schema: Literal["openshell.pi-input.v1"] + hook: Literal["rendered_prompt_admission"] + middleware_binding: BoundedMetadataString + policy_fingerprint: ScalarString + sandbox_id: BoundedMetadataString + session_id: BoundedMetadataString + submission_id: BoundedMetadataString + receipt_id: str = Field(pattern=r"^[0-9a-f]{32}$") + provider_adapter_schema: Literal["openai.chat-completions.v1"] + scheme: ScalarString + host: ScalarString + port: int = Field(ge=0, le=2**32 - 1) + method: ScalarString + path: ScalarString + query: ScalarString + rendered_prompt_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + issued_at: int = Field(ge=0) + expires_at: int = Field(ge=0) + key_id: str = Field(pattern=r"^[0-9a-f]{16}$") + + +class ReceiptVerificationError(ValueError): + """A bounded receipt verification failure.""" + + def __init__(self, reason_code: str) -> None: + super().__init__(reason_code) + self.reason_code = reason_code + + +class ReceiptAuthority: + """Single-instance Ed25519 issuer and verifier with an ephemeral default key.""" + + def __init__( + self, + private_key: Ed25519PrivateKey | None = None, + *, + lifetime_seconds: int = 30, + allowed_clock_skew_seconds: int = 5, + ) -> None: + if not 1 <= lifetime_seconds <= 300: + raise ValueError("receipt lifetime must be between 1 and 300 seconds") + if not 0 <= allowed_clock_skew_seconds <= 30: + raise ValueError("receipt clock skew must be between 0 and 30 seconds") + self._private_key = private_key or Ed25519PrivateKey.generate() + self._public_key = self._private_key.public_key() + public_bytes = self._public_key.public_bytes( + encoding=serialization.Encoding.Raw, + format=serialization.PublicFormat.Raw, + ) + self._key_id = hashlib.sha256(public_bytes).hexdigest()[:16] + self._lifetime_seconds = lifetime_seconds + self._allowed_clock_skew_seconds = allowed_clock_skew_seconds + self._consumed_receipts: dict[str, int] = {} + self._consumed_receipts_lock = threading.Lock() + + @property + def key_id(self) -> str: + """Return the non-secret identifier of the active ephemeral key.""" + return self._key_id + + def issue( + self, + rendered_prompt: PiInputV1, + context: HarnessAdmissionContext, + provenance: PromptProvenance, + *, + policy_fingerprint: str, + now: int | None = None, + ) -> bytes: + """Issue one opaque receipt after final admission validation.""" + if context.hook is not AdmissionHook.RENDERED_PROMPT: + raise ValueError("receipts may be issued only for rendered prompts") + issued_at = _now_seconds() if now is None else now + target = context.provider_target + claims = ReceiptClaimsV1( + harness=context.harness, + harness_version=context.harness_version, + harness_schema=context.schema_version, + hook=context.hook.value, + middleware_binding=context.middleware_name, + policy_fingerprint=policy_fingerprint, + sandbox_id=context.sandbox_id, + session_id=provenance.session_id, + submission_id=provenance.submission_id, + receipt_id=secrets.token_hex(16), + provider_adapter_schema=context.provider_adapter_schema, + scheme=target.scheme, + host=target.host, + port=target.port, + method=target.method, + path=target.path, + query=target.query, + rendered_prompt_hash=_prompt_hash(rendered_prompt), + issued_at=issued_at, + expires_at=issued_at + self._lifetime_seconds, + key_id=self._key_id, + ) + payload = canonical_json_bytes(claims) + signature = self._private_key.sign(payload) + return b"eg1." + _encode(payload) + b"." + _encode(signature) + + def verify( + self, + receipt: bytes, + rendered_prompt: PiInputV1, + context: HarnessAdmissionContext, + *, + policy_fingerprint: str, + now: int | None = None, + ) -> ReceiptClaimsV1: + """Verify signature, lifetime, trusted context, target, and prompt hash.""" + if context.hook is not AdmissionHook.RENDERED_PROMPT: + raise ReceiptVerificationError("receipt_context_mismatch") + payload, signature = _decode_receipt(receipt) + try: + self._public_key.verify(signature, payload) + except InvalidSignature: + raise ReceiptVerificationError("receipt_signature_invalid") from None + try: + claims = ReceiptClaimsV1.model_validate_json(payload, strict=True) + except ValidationError: + raise ReceiptVerificationError("receipt_malformed") from None + if canonical_json_bytes(claims) != payload: + raise ReceiptVerificationError("receipt_malformed") + current = _now_seconds() if now is None else now + if claims.key_id != self._key_id: + raise ReceiptVerificationError("receipt_key_mismatch") + if claims.issued_at > current + self._allowed_clock_skew_seconds: + raise ReceiptVerificationError("receipt_not_yet_valid") + if claims.expires_at <= current or claims.expires_at <= claims.issued_at: + raise ReceiptVerificationError("receipt_expired") + target = context.provider_target + expected = ( + context.harness, + context.harness_version, + context.schema_version, + AdmissionHook.RENDERED_PROMPT.value, + context.middleware_name, + policy_fingerprint, + context.sandbox_id, + context.provider_adapter_schema, + target.scheme, + target.host, + target.port, + target.method, + target.path, + target.query, + _prompt_hash(rendered_prompt), + ) + actual = ( + claims.harness, + claims.harness_version, + claims.harness_schema, + claims.hook, + claims.middleware_binding, + claims.policy_fingerprint, + claims.sandbox_id, + claims.provider_adapter_schema, + claims.scheme, + claims.host, + claims.port, + claims.method, + claims.path, + claims.query, + claims.rendered_prompt_hash, + ) + if actual != expected: + raise ReceiptVerificationError("receipt_context_mismatch") + with self._consumed_receipts_lock: + self._consumed_receipts = { + receipt_id: expires_at + for receipt_id, expires_at in self._consumed_receipts.items() + if expires_at > current + } + if claims.receipt_id in self._consumed_receipts: + raise ReceiptVerificationError("receipt_replayed") + self._consumed_receipts[claims.receipt_id] = claims.expires_at + return claims + + +def _prompt_hash(rendered_prompt: PiInputV1) -> str: + return hashlib.sha256(canonical_json_bytes(rendered_prompt)).hexdigest() + + +def _encode(value: bytes) -> bytes: + return base64.urlsafe_b64encode(value).rstrip(b"=") + + +def _decode(value: bytes) -> bytes: + padding = b"=" * (-len(value) % 4) + try: + return base64.b64decode(value + padding, altchars=b"-_", validate=True) + except ValueError: + raise ReceiptVerificationError("receipt_malformed") from None + + +def _decode_receipt(receipt: bytes) -> tuple[bytes, bytes]: + if len(receipt) > 8 * 1024: + raise ReceiptVerificationError("receipt_malformed") + parts = receipt.split(b".") + if len(parts) != 3 or parts[0] != b"eg1" or not parts[1] or not parts[2]: + raise ReceiptVerificationError("receipt_malformed") + return _decode(parts[1]), _decode(parts[2]) + + +def _now_seconds() -> int: + return int(datetime.now(UTC).timestamp()) + + +__all__ = [ + "ReceiptAuthority", + "ReceiptClaimsV1", + "ReceiptVerificationError", +] diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py index c254b0f3..d0e51411 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py @@ -26,53 +26,63 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"y\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\"\xca\x01\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x16\n\x0emax_body_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"w\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\x82\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01*y\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xcd\x02\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResultb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"y\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\"\x81\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x16\n\x0emax_body_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"w\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xc9\x03\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0c\x12\x0e\n\x06source\x18\n \x01(\t\x12\x10\n\x08\x64\x65livery\x18\x0b \x01(\t\x12\x14\n\x0crequest_kind\x18\x0c \x01(\t\x12\x1c\n\x0f\x63\x61ndidate_index\x18\r \x01(\rH\x00\x88\x01\x01\x42\x12\n\x10_candidate_indexJ\x04\x08\x05\x10\x06\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xba\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x02*\xa8\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x02*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xd3\x03\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResultb\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'supervisor_middleware_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._loaded_options = None + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_HTTPREQUESTRESULT_METADATAENTRY']._loaded_options = None _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=2037 - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=2167 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=2169 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=2290 - _globals['_DECISION']._serialized_start=2292 - _globals['_DECISION']._serialized_end=2367 - _globals['_EXISTINGHEADERACTION']._serialized_start=2370 - _globals['_EXISTINGHEADERACTION']._serialized_end=2538 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=3108 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=3294 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=3297 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=3465 + _globals['_DECISION']._serialized_start=3467 + _globals['_DECISION']._serialized_end=3542 + _globals['_EXISTINGHEADERACTION']._serialized_start=3545 + _globals['_EXISTINGHEADERACTION']._serialized_end=3713 _globals['_MIDDLEWAREMANIFEST']._serialized_start=115 _globals['_MIDDLEWAREMANIFEST']._serialized_end=236 _globals['_MIDDLEWAREBINDING']._serialized_start=239 - _globals['_MIDDLEWAREBINDING']._serialized_end=441 - _globals['_VALIDATECONFIGREQUEST']._serialized_start=443 - _globals['_VALIDATECONFIGREQUEST']._serialized_end=532 - _globals['_VALIDATECONFIGRESPONSE']._serialized_start=534 - _globals['_VALIDATECONFIGRESPONSE']._serialized_end=589 - _globals['_HTTPREQUESTEVALUATION']._serialized_start=592 - _globals['_HTTPREQUESTEVALUATION']._serialized_end=934 - _globals['_HTTPHEADER']._serialized_start=936 - _globals['_HTTPHEADER']._serialized_end=977 - _globals['_REQUESTCONTEXT']._serialized_start=979 - _globals['_REQUESTCONTEXT']._serialized_end=1098 - _globals['_HTTPREQUESTTARGET']._serialized_start=1100 - _globals['_HTTPREQUESTTARGET']._serialized_end=1208 - _globals['_PROCESS']._serialized_start=1210 - _globals['_PROCESS']._serialized_end=1267 - _globals['_FINDING']._serialized_start=1269 - _globals['_FINDING']._serialized_end=1360 - _globals['_WRITEHEADER']._serialized_start=1362 - _globals['_WRITEHEADER']._serialized_end=1472 - _globals['_REMOVEHEADER']._serialized_start=1474 - _globals['_REMOVEHEADER']._serialized_end=1502 - _globals['_HEADERMUTATION']._serialized_start=1505 - _globals['_HEADERMUTATION']._serialized_end=1646 - _globals['_HTTPREQUESTRESULT']._serialized_start=1649 - _globals['_HTTPREQUESTRESULT']._serialized_end=2034 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=1987 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2034 - _globals['_SUPERVISORMIDDLEWARE']._serialized_start=2541 - _globals['_SUPERVISORMIDDLEWARE']._serialized_end=2874 + _globals['_MIDDLEWAREBINDING']._serialized_end=496 + _globals['_VALIDATECONFIGREQUEST']._serialized_start=498 + _globals['_VALIDATECONFIGREQUEST']._serialized_end=587 + _globals['_VALIDATECONFIGRESPONSE']._serialized_start=589 + _globals['_VALIDATECONFIGRESPONSE']._serialized_end=644 + _globals['_HTTPREQUESTEVALUATION']._serialized_start=647 + _globals['_HTTPREQUESTEVALUATION']._serialized_end=989 + _globals['_HTTPHEADER']._serialized_start=991 + _globals['_HTTPHEADER']._serialized_end=1032 + _globals['_REQUESTCONTEXT']._serialized_start=1034 + _globals['_REQUESTCONTEXT']._serialized_end=1153 + _globals['_HTTPREQUESTTARGET']._serialized_start=1155 + _globals['_HTTPREQUESTTARGET']._serialized_end=1263 + _globals['_PROCESS']._serialized_start=1265 + _globals['_PROCESS']._serialized_end=1322 + _globals['_AGENTCONVERSATIONTARGET']._serialized_start=1325 + _globals['_AGENTCONVERSATIONTARGET']._serialized_end=1488 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=1491 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=1948 + _globals['_AGENTCONVERSATIONRESULT']._serialized_start=1951 + _globals['_AGENTCONVERSATIONRESULT']._serialized_end=2338 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2279 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2326 + _globals['_FINDING']._serialized_start=2340 + _globals['_FINDING']._serialized_end=2431 + _globals['_WRITEHEADER']._serialized_start=2433 + _globals['_WRITEHEADER']._serialized_end=2543 + _globals['_REMOVEHEADER']._serialized_start=2545 + _globals['_REMOVEHEADER']._serialized_end=2573 + _globals['_HEADERMUTATION']._serialized_start=2576 + _globals['_HEADERMUTATION']._serialized_end=2717 + _globals['_HTTPREQUESTRESULT']._serialized_start=2720 + _globals['_HTTPREQUESTRESULT']._serialized_end=3105 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2279 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2326 + _globals['_SUPERVISORMIDDLEWARE']._serialized_start=3716 + _globals['_SUPERVISORMIDDLEWARE']._serialized_end=4183 # @@protoc_insertion_point(module_scope) diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi index 10eac7f5..accf5f19 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi @@ -13,11 +13,13 @@ class SupervisorMiddlewareOperation(int, metaclass=_enum_type_wrapper.EnumTypeWr __slots__ = () SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: _ClassVar[SupervisorMiddlewareOperation] + SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: _ClassVar[SupervisorMiddlewareOperation] class SupervisorMiddlewarePhase(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: _ClassVar[SupervisorMiddlewarePhase] SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: _ClassVar[SupervisorMiddlewarePhase] + SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: _ClassVar[SupervisorMiddlewarePhase] class Decision(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () @@ -33,8 +35,10 @@ class ExistingHeaderAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): EXISTING_HEADER_ACTION_SKIP: _ClassVar[ExistingHeaderAction] SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: SupervisorMiddlewareOperation +SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: SupervisorMiddlewarePhase +SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: SupervisorMiddlewarePhase DECISION_UNSPECIFIED: Decision DECISION_ALLOW: Decision DECISION_DENY: Decision @@ -54,16 +58,22 @@ class MiddlewareManifest(_message.Message): def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ...) -> None: ... class MiddlewareBinding(_message.Message): - __slots__ = ("operation", "phase", "max_body_bytes", "timeout") + __slots__ = ("operation", "phase", "max_body_bytes", "timeout", "harness", "hook", "schema_version") OPERATION_FIELD_NUMBER: _ClassVar[int] PHASE_FIELD_NUMBER: _ClassVar[int] MAX_BODY_BYTES_FIELD_NUMBER: _ClassVar[int] TIMEOUT_FIELD_NUMBER: _ClassVar[int] + HARNESS_FIELD_NUMBER: _ClassVar[int] + HOOK_FIELD_NUMBER: _ClassVar[int] + SCHEMA_VERSION_FIELD_NUMBER: _ClassVar[int] operation: SupervisorMiddlewareOperation phase: SupervisorMiddlewarePhase max_body_bytes: int timeout: str - def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_body_bytes: _Optional[int] = ..., timeout: _Optional[str] = ...) -> None: ... + harness: str + hook: str + schema_version: str + def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_body_bytes: _Optional[int] = ..., timeout: _Optional[str] = ..., harness: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ...) -> None: ... class ValidateConfigRequest(_message.Message): __slots__ = ("config", "middleware_name") @@ -143,6 +153,81 @@ class Process(_message.Message): ancestors: _containers.RepeatedScalarFieldContainer[str] def __init__(self, binary: _Optional[str] = ..., pid: _Optional[int] = ..., ancestors: _Optional[_Iterable[str]] = ...) -> None: ... +class AgentConversationTarget(_message.Message): + __slots__ = ("harness", "harness_version", "hook", "schema_version", "scheme", "host", "port", "path") + HARNESS_FIELD_NUMBER: _ClassVar[int] + HARNESS_VERSION_FIELD_NUMBER: _ClassVar[int] + HOOK_FIELD_NUMBER: _ClassVar[int] + SCHEMA_VERSION_FIELD_NUMBER: _ClassVar[int] + SCHEME_FIELD_NUMBER: _ClassVar[int] + HOST_FIELD_NUMBER: _ClassVar[int] + PORT_FIELD_NUMBER: _ClassVar[int] + PATH_FIELD_NUMBER: _ClassVar[int] + harness: str + harness_version: str + hook: str + schema_version: str + scheme: str + host: str + port: int + path: str + def __init__(self, harness: _Optional[str] = ..., harness_version: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ..., scheme: _Optional[str] = ..., host: _Optional[str] = ..., port: _Optional[int] = ..., path: _Optional[str] = ...) -> None: ... + +class AgentConversationEvaluation(_message.Message): + __slots__ = ("phase", "context", "config", "target", "middleware_name", "session_id", "turn_id", "request_body", "source", "delivery", "request_kind", "candidate_index") + PHASE_FIELD_NUMBER: _ClassVar[int] + CONTEXT_FIELD_NUMBER: _ClassVar[int] + CONFIG_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + SESSION_ID_FIELD_NUMBER: _ClassVar[int] + TURN_ID_FIELD_NUMBER: _ClassVar[int] + REQUEST_BODY_FIELD_NUMBER: _ClassVar[int] + SOURCE_FIELD_NUMBER: _ClassVar[int] + DELIVERY_FIELD_NUMBER: _ClassVar[int] + REQUEST_KIND_FIELD_NUMBER: _ClassVar[int] + CANDIDATE_INDEX_FIELD_NUMBER: _ClassVar[int] + phase: SupervisorMiddlewarePhase + context: RequestContext + config: _struct_pb2.Struct + target: AgentConversationTarget + middleware_name: str + session_id: str + turn_id: str + request_body: bytes + source: str + delivery: str + request_kind: str + candidate_index: int + def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[AgentConversationTarget, _Mapping]] = ..., middleware_name: _Optional[str] = ..., session_id: _Optional[str] = ..., turn_id: _Optional[str] = ..., request_body: _Optional[bytes] = ..., source: _Optional[str] = ..., delivery: _Optional[str] = ..., request_kind: _Optional[str] = ..., candidate_index: _Optional[int] = ...) -> None: ... + +class AgentConversationResult(_message.Message): + __slots__ = ("decision", "reason", "attestation", "findings", "metadata", "reason_code", "replacement_body", "has_replacement_body") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + DECISION_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + ATTESTATION_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + REPLACEMENT_BODY_FIELD_NUMBER: _ClassVar[int] + HAS_REPLACEMENT_BODY_FIELD_NUMBER: _ClassVar[int] + decision: Decision + reason: str + attestation: bytes + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + reason_code: str + replacement_body: bytes + has_replacement_body: bool + def __init__(self, decision: _Optional[_Union[Decision, str]] = ..., reason: _Optional[str] = ..., attestation: _Optional[bytes] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ..., reason_code: _Optional[str] = ..., replacement_body: _Optional[bytes] = ..., has_replacement_body: _Optional[bool] = ...) -> None: ... + class Finding(_message.Message): __slots__ = ("type", "label", "count", "confidence", "severity") TYPE_FIELD_NUMBER: _ClassVar[int] diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py index a4914b37..aab704aa 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py @@ -28,7 +28,7 @@ class SupervisorMiddlewareStub: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress before OpenShell injects credentials. + sandbox HTTP egress or evaluate a supported agent-harness request. """ def __init__(self, channel): @@ -52,11 +52,16 @@ def __init__(self, channel): request_serializer=supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, response_deserializer=supervisor__middleware__pb2.HttpRequestResult.FromString, _registered_method=True) + self.EvaluateAgentConversation = channel.unary_unary( + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateAgentConversation', + request_serializer=supervisor__middleware__pb2.AgentConversationEvaluation.SerializeToString, + response_deserializer=supervisor__middleware__pb2.AgentConversationResult.FromString, + _registered_method=True) class SupervisorMiddlewareServicer: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress before OpenShell injects credentials. + sandbox HTTP egress or evaluate a supported agent-harness request. """ def Describe(self, request, context): @@ -81,6 +86,14 @@ def EvaluateHttpRequest(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def EvaluateAgentConversation(self, request, context): + """EvaluateAgentConversation returns an allow, deny, or replacement decision for + one versioned, harness-native request before the harness commits or sends it. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_SupervisorMiddlewareServicer_to_server(servicer, server): rpc_method_handlers = { @@ -99,6 +112,11 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): request_deserializer=supervisor__middleware__pb2.HttpRequestEvaluation.FromString, response_serializer=supervisor__middleware__pb2.HttpRequestResult.SerializeToString, ), + 'EvaluateAgentConversation': grpc.unary_unary_rpc_method_handler( + servicer.EvaluateAgentConversation, + request_deserializer=supervisor__middleware__pb2.AgentConversationEvaluation.FromString, + response_serializer=supervisor__middleware__pb2.AgentConversationResult.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'openshell.middleware.v1.SupervisorMiddleware', rpc_method_handlers) @@ -109,7 +127,7 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class SupervisorMiddleware: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress before OpenShell injects credentials. + sandbox HTTP egress or evaluate a supported agent-harness request. """ @staticmethod @@ -192,3 +210,30 @@ def EvaluateHttpRequest(request, timeout, metadata, _registered_method=True) + + @staticmethod + def EvaluateAgentConversation(request, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.unary_unary( + request, + target, + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateAgentConversation', + supervisor__middleware__pb2.AgentConversationEvaluation.SerializeToString, + supervisor__middleware__pb2.AgentConversationResult.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 6f789c29..1194d37e 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -167,6 +167,17 @@ def serve( ), ), ] = f"{DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING:g}s", + require_pi_receipt: Annotated[ + bool, + typer.Option( + "--require-pi-receipt/--no-require-pi-receipt", + help=( + "Require and verify a matching Pi rendered-prompt receipt " + "on HTTP egress. Enabled by default; disable only for an " + "explicitly unmanaged deployment." + ), + ), + ] = True, ) -> None: """Start the Egress Gate gRPC service and run until shutdown.""" options = _command_options(context) @@ -209,6 +220,7 @@ def serve( EgressGateServer( options.registry, timeout_middleware_processing=timeout_middleware_processing, + require_pi_receipt=require_pi_receipt, ).serve_sync(listen) except EgressGateError as error: _render_egress_error("Egress Gate could not start", error) diff --git a/projects/egress-gate/src/egress_gate/request.py b/projects/egress-gate/src/egress_gate/request.py index 98201ffe..7ac9fa1c 100644 --- a/projects/egress-gate/src/egress_gate/request.py +++ b/projects/egress-gate/src/egress_gate/request.py @@ -26,6 +26,22 @@ HeaderValue = ScalarString +class EnforcementPoint(StrEnum): + """The trusted boundary at which a request is being evaluated.""" + + NETWORK_EGRESS = "network_egress" + HARNESS_ADMISSION = "harness_admission" + + +class HarnessAdmissionMetadata(StrictDomainModel): + """Bounded harness-shape metadata stamped by the trusted transport.""" + + harness: ScalarString + harness_version: ScalarString + hook: ScalarString + schema_version: ScalarString + + class Process(StrictDomainModel): """The originating workload process and its executable ancestry.""" @@ -40,6 +56,8 @@ class RequestContext(StrictDomainModel): request_id: ScalarString sandbox_id: ScalarString originating_process: Process | None = None + enforcement_point: EnforcementPoint = EnforcementPoint.NETWORK_EGRESS + harness_admission: HarnessAdmissionMetadata | None = None @model_validator(mode="after") def _context_strings_are_bounded(self) -> RequestContext: @@ -52,8 +70,23 @@ def _context_strings_are_bounded(self) -> RequestContext: len(ancestor.encode("utf-8")) for ancestor in self.originating_process.ancestors ) + if self.harness_admission is not None: + string_bytes += sum( + len(value.encode("utf-8")) + for value in ( + self.harness_admission.harness, + self.harness_admission.harness_version, + self.harness_admission.hook, + self.harness_admission.schema_version, + ) + ) if string_bytes > MAX_PROTO_CONTEXT_BYTES: raise ValueError("request context strings exceed the size limit") + if self.enforcement_point is EnforcementPoint.HARNESS_ADMISSION: + if self.harness_admission is None: + raise ValueError("harness admission requires trusted metadata") + elif self.harness_admission is not None: + raise ValueError("network egress cannot carry harness metadata") return self @@ -178,10 +211,12 @@ def is_empty(self) -> bool: __all__ = [ + "EnforcementPoint", "ExistingHeaderAction", "HeaderMutation", "HeaderName", "HeaderValue", + "HarnessAdmissionMetadata", "HttpHeader", "HttpRequest", "HttpTarget", diff --git a/projects/egress-gate/src/egress_gate/request_processor.py b/projects/egress-gate/src/egress_gate/request_processor.py index 0b94dadb..e19d3fe6 100644 --- a/projects/egress-gate/src/egress_gate/request_processor.py +++ b/projects/egress-gate/src/egress_gate/request_processor.py @@ -92,6 +92,11 @@ def __init__( self._gates = gates self._policy_fingerprint = policy_fingerprint + @property + def policy_fingerprint(self) -> str | None: + """Return the immutable fingerprint of the prepared policy.""" + return self._policy_fingerprint + def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: """Evaluate one request and return an atomic final domain result.""" if not isinstance(request, HttpRequest): diff --git a/projects/egress-gate/src/egress_gate/service/server.py b/projects/egress-gate/src/egress_gate/service/server.py index 146838d6..dca0f249 100644 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ b/projects/egress-gate/src/egress_gate/service/server.py @@ -34,10 +34,12 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, + require_pi_receipt: bool = False, ) -> None: self._middleware = EgressGateMiddleware( registry, timeout_middleware_processing=timeout_middleware_processing, + require_pi_receipt=require_pi_receipt, ) def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index a5099a08..317e3ae0 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -12,12 +12,26 @@ from collections.abc import Callable, Iterable from concurrent.futures import Future, ThreadPoolExecutor from threading import Lock -from typing import Never, Protocol, TypedDict, TypeVar +from typing import Literal, Never, Protocol, TypedDict, TypeVar import grpc from google.protobuf import json_format from google.protobuf.message import Message +from egress_gate.admission import ( + PI_HARNESS_VERSION, + RECEIPT_HEADER, + AdmissionDecision, + AdmissionHook, + AttestedEgressProcessor, + HarnessAdmissionContext, + HarnessAdmissionProcessor, + HarnessAdmissionRequest, + PromptProvenance, + ReceiptAuthority, + create_pi_adapter_registry, + create_provider_adapter_registry, +) from egress_gate.bindings import supervisor_middleware_pb2 as pb2 from egress_gate.bindings import supervisor_middleware_pb2_grpc as pb2_grpc from egress_gate.config import EgressGateConfig @@ -65,6 +79,7 @@ DecisionSourceKind, EgressDecision, EgressResult, + GateDecisionSource, SourcedFinding, ) from egress_gate.string_validators import validate_bounded_metadata_string @@ -75,6 +90,27 @@ ) +def _require_pi_harness(value: str) -> Literal["pi"]: + if value == "pi": + return value + raise ValueError("invalid admission harness") + + +def _require_pi_schema(value: str) -> Literal["openshell.pi-input.v1"]: + if value == "openshell.pi-input.v1": + return value + raise ValueError("invalid admission schema") + + +def _require_pi_harness_version(value: str) -> Literal["extension-v1"]: + if value == PI_HARNESS_VERSION: + return value + raise ValueError("invalid Pi harness version") + + +MAX_AGENT_ADMISSION_BODY_BYTES = 32 * 1024 + + class EgressGateMiddleware(pb2_grpc.SupervisorMiddlewareServicer): """Validate, prepare, resolve, and run Egress Gate policies.""" @@ -83,6 +119,7 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, + require_pi_receipt: bool = False, ) -> None: registry.configuration_json_schema() self._registry = registry @@ -90,6 +127,8 @@ def __init__( validate_timeout_middleware_processing(timeout_middleware_processing) ) self._policy = _ActivePolicy(registry) + self._receipt_authority = ReceiptAuthority() + self._require_pi_receipt = require_pi_receipt self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING) self._processing_executor = ThreadPoolExecutor( max_workers=MAX_CONCURRENT_PROCESSING, @@ -125,7 +164,19 @@ async def Describe( operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST, phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, max_body_bytes=MAX_BODY_BYTES, - ) + ), + *( + pb2.MiddlewareBinding( + operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION, + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, + max_body_bytes=MAX_AGENT_ADMISSION_BODY_BYTES, + harness="pi", + hook=hook.value, + schema_version="openshell.pi-input.v1", + ) + for hook in AdmissionHook + if self._require_pi_receipt + ), ], ) @@ -151,6 +202,101 @@ async def EvaluateHttpRequest( """Resolve the prepared pipeline and evaluate one current request.""" return await self._evaluate_rpc(request, context) + async def EvaluateAgentConversation( + self, + request: pb2.AgentConversationEvaluation, + context: grpc.aio.ServicerContext[ + pb2.AgentConversationEvaluation, + pb2.AgentConversationResult, + ], + ) -> pb2.AgentConversationResult: + """Evaluate one supervisor-stamped Pi admission request.""" + timeout = Timeout.from_seconds(self._timeout_middleware_processing_seconds) + return await self._run_in_worker( + lambda: self._evaluate_agent_admission(request, timeout), + timeout=timeout, + ) + + def _evaluate_agent_admission( + self, + request: pb2.AgentConversationEvaluation, + timeout: Timeout, + ) -> pb2.AgentConversationResult: + try: + if not self._require_pi_receipt: + raise ValueError("agent admission is disabled") + if request.phase != pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: + raise ValueError("invalid admission phase") + if len(request.request_body) > MAX_AGENT_ADMISSION_BODY_BYTES: + raise ValueError("admission request body is too large") + hook = AdmissionHook(request.target.hook) + target = HttpTarget( + scheme=request.target.scheme, + host=request.target.host, + port=request.target.port, + method="POST", + path=request.target.path, + query="", + ) + provenance = PromptProvenance( + kind="rendered_prompt", + session_id=request.session_id, + submission_id=request.turn_id, + ) + processor = HarnessAdmissionProcessor( + self._policy.processor_for( + _mapping_from_proto(request.config), timeout=timeout + ), + create_pi_adapter_registry(), + self._receipt_authority, + ) + result = processor.process( + HarnessAdmissionRequest( + request_body=request.request_body, + provenance=provenance, + ), + HarnessAdmissionContext( + request_id=request.context.request_id, + sandbox_id=request.context.sandbox_id, + middleware_name=request.middleware_name, + harness=_require_pi_harness(request.target.harness), + harness_version=_require_pi_harness_version( + request.target.harness_version + ), + hook=hook, + schema_version=_require_pi_schema(request.target.schema_version), + provider_target=target, + provider_adapter_schema="openai.chat-completions.v1", + ), + timeout=timeout, + ) + response = pb2.AgentConversationResult( + decision=( + pb2.DECISION_DENY + if result.decision is AdmissionDecision.DENY + else pb2.DECISION_ALLOW + ), + reason_code=result.reason_code or "", + attestation=result.receipt or b"", + replacement_body=result.replacement_body or b"", + has_replacement_body=result.replacement_body is not None, + ) + response.findings.extend( + _finding_to_proto(item) for item in result.findings + ) + response.metadata.update( + { + **processor.readiness, + "policy_fingerprint": result.policy_fingerprint, + } + ) + return response + except Exception: + return pb2.AgentConversationResult( + decision=pb2.DECISION_DENY, + reason_code="admission_unavailable", + ) + def _validate_config( self, request: pb2.ValidateConfigRequest, @@ -255,6 +401,27 @@ def _prepare_and_process( values, timeout=timeout, ) + if self._require_pi_receipt: + return AttestedEgressProcessor( + processor, + create_provider_adapter_registry(), + self._receipt_authority, + middleware_name=request.middleware_name, + harness_version=PI_HARNESS_VERSION, + ).process(domain_request, timeout=timeout) + if any( + header.name.lower() == RECEIPT_HEADER for header in domain_request.headers + ): + return EgressResult( + decision=EgressDecision.DENY, + decision_source=GateDecisionSource( + kind=DecisionSourceKind.GATE, + gate_name="reserved-receipt-header", + gate_type="reserved-receipt-header", + ), + reason_code="reserved_receipt_header", + policy_fingerprint=processor.policy_fingerprint, + ) return processor.process(domain_request, timeout=timeout) async def _run_in_worker( diff --git a/projects/egress-gate/tests/admission/__init__.py b/projects/egress-gate/tests/admission/__init__.py new file mode 100644 index 00000000..87d79542 --- /dev/null +++ b/projects/egress-gate/tests/admission/__init__.py @@ -0,0 +1 @@ +"""Admission and attested-egress tests.""" diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py new file mode 100644 index 00000000..13f5637a --- /dev/null +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -0,0 +1,325 @@ +"""Conformance tests for rendered-prompt admission and attested egress.""" + +from __future__ import annotations + +import json + +from egress_gate.admission import ( + RECEIPT_HEADER, + AdmissionDecision, + AdmissionHook, + AttestedEgressProcessor, + HarnessAdmissionContext, + HarnessAdmissionProcessor, + HarnessAdmissionRequest, + PiInputV1, + PromptProvenance, + ReceiptAuthority, + canonical_json_bytes, + create_pi_adapter_registry, + create_provider_adapter_registry, +) +from egress_gate.gates import create_builtin_registry +from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext +from egress_gate.timeout import Timeout + +DENY_MARKER = "OPEN_SHELL_ADMISSION_DENY_TEST" +REPLACE_MARKER = "OPEN_SHELL_ADMISSION_REPLACE_TEST" + + +def _processors() -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor]: + registry = create_builtin_registry() + config = registry.validate_config( + { + "gates": [ + { + "name": "deny-marker", + "kind": "regex", + "scan": {"kind": "body", "action": {"kind": "deny"}}, + "pattern_catalog": { + "entities": [ + { + "name": "unsafe-marker", + "rules": [ + { + "name": "exact-marker", + "pattern": DENY_MARKER, + "confidence": "high", + } + ], + } + ] + }, + }, + { + "name": "replace-marker", + "kind": "regex", + "scan": { + "kind": "body", + "action": {"kind": "replace", "template": "[REDACTED]"}, + }, + "pattern_catalog": { + "entities": [ + { + "name": "replacement-marker", + "rules": [ + { + "name": "exact-marker", + "pattern": REPLACE_MARKER, + "confidence": "high", + } + ], + } + ] + }, + }, + ], + "default_decision": "allow", + } + ) + request_processor = registry.prepare_processor( + config, timeout=Timeout.from_seconds(1) + ) + authority = ReceiptAuthority(lifetime_seconds=30) + return ( + HarnessAdmissionProcessor( + request_processor, create_pi_adapter_registry(), authority + ), + AttestedEgressProcessor( + request_processor, + create_provider_adapter_registry(), + authority, + middleware_name="pi-egress", + harness_version="extension-v1", + ), + ) + + +def _target() -> HttpTarget: + return HttpTarget( + scheme="https", + host="provider.test", + port=443, + method="POST", + path="/v1/chat/completions", + query="", + ) + + +def _admit(processor: HarnessAdmissionProcessor, text: str): + body = canonical_json_bytes( + PiInputV1(schema_version="openshell.pi-input.v1", text=text) + ) + return body, _admit_body(processor, body) + + +def _admit_body( + processor: HarnessAdmissionProcessor, + body: bytes, + *, + timeout: Timeout | None = None, +): + result = processor.process( + HarnessAdmissionRequest( + request_body=body, + provenance=PromptProvenance( + kind="rendered_prompt", + session_id="session-1", + submission_id="submission-1", + ), + ), + HarnessAdmissionContext( + request_id="admission-1", + sandbox_id="sandbox-1", + middleware_name="pi-egress", + harness="pi", + harness_version="extension-v1", + hook=AdmissionHook.RENDERED_PROMPT, + schema_version="openshell.pi-input.v1", + provider_target=_target(), + provider_adapter_schema="openai.chat-completions.v1", + ), + timeout=timeout or Timeout.from_seconds(1), + ) + return result + + +def _provider_request(prompt: str, receipt: bytes | None) -> HttpRequest: + body = json.dumps( + { + "model": "fixture-model", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": prompt}, + ], + "tools": [], + "tool_choice": "auto", + "temperature": 0, + "max_completion_tokens": 128, + "stream": True, + "stream_options": {"include_usage": True}, + "store": False, + "prompt_cache_key": "session-1", + }, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + headers = [HttpHeader(name="content-type", value="application/json")] + if receipt is not None: + headers.append(HttpHeader(name=RECEIPT_HEADER, value=receipt.decode("ascii"))) + return HttpRequest( + context=RequestContext(request_id="network-1", sandbox_id="sandbox-1"), + target=_target(), + headers=tuple(headers), + body=body, + ) + + +def test_safe_rendered_prompt_receipt_authorizes_first_request_and_is_stripped() -> ( + None +): + admission, egress = _processors() + _, admitted = _admit(admission, "safe rendered prompt") + + assert admitted.decision is AdmissionDecision.ALLOW + assert admitted.receipt is not None + result = egress.process( + _provider_request("safe rendered prompt", admitted.receipt), + timeout=Timeout.from_seconds(1), + ) + + assert result.decision.value == "allow" + assert [ + mutation.name for mutation in result.request_mutations.header_mutations + ] == [RECEIPT_HEADER] + + +def test_rendered_prompt_receipt_is_consumed_after_first_request() -> None: + admission, egress = _processors() + _, admitted = _admit(admission, "safe rendered prompt") + assert admitted.receipt is not None + request = _provider_request("safe rendered prompt", admitted.receipt) + + first = egress.process(request, timeout=Timeout.from_seconds(1)) + replay = egress.process(request, timeout=Timeout.from_seconds(1)) + + assert first.decision.value == "allow" + assert replay.decision.value == "deny" + assert replay.reason_code == "receipt_replayed" + + +def test_denial_returns_no_receipt_or_replacement() -> None: + admission, _ = _processors() + _, denied = _admit(admission, f"do not persist {DENY_MARKER}") + + assert denied.decision is AdmissionDecision.DENY + assert denied.receipt is None + assert denied.replacement_body is None + + +def test_redaction_receipt_binds_only_the_replacement() -> None: + admission, egress = _processors() + original = f"hide {REPLACE_MARKER} please" + _, admitted = _admit(admission, original) + + assert admitted.decision is AdmissionDecision.REPLACE + assert admitted.receipt is not None + assert admitted.replacement_body is not None + replacement = PiInputV1.model_validate_json( + admitted.replacement_body, strict=True + ).text + assert replacement == "hide [REDACTED] please" + assert ( + egress.process( + _provider_request(original, admitted.receipt), + timeout=Timeout.from_seconds(1), + ).reason_code + == "receipt_context_mismatch" + ) + assert ( + egress.process( + _provider_request(replacement, admitted.receipt), + timeout=Timeout.from_seconds(1), + ).decision.value + == "allow" + ) + + +def test_changed_prompt_and_unattested_continuation_fail_closed() -> None: + admission, egress = _processors() + _, admitted = _admit(admission, "safe rendered prompt") + assert admitted.receipt is not None + + changed = egress.process( + _provider_request("changed prompt", admitted.receipt), + timeout=Timeout.from_seconds(1), + ) + continuation = egress.process( + _provider_request("safe rendered prompt", None), + timeout=Timeout.from_seconds(1), + ) + + assert changed.reason_code == "receipt_context_mismatch" + assert continuation.reason_code == "receipt_missing" + + +def test_malformed_and_duplicate_admission_json_are_contract_errors() -> None: + admission, _ = _processors() + + malformed = _admit_body(admission, b"{") + duplicate = _admit_body( + admission, + b'{"schema_version":"openshell.pi-input.v1",' + b'"schema_version":"openshell.pi-input.v1","text":"safe"}', + ) + + assert malformed.reason_code == "admission_contract_invalid" + assert duplicate.reason_code == "admission_contract_invalid" + + +def test_admission_json_limits_and_deadlines_remain_availability_errors() -> None: + admission, _ = _processors() + over_depth = b"[" * 129 + b"0" + b"]" * 129 + + limited = _admit_body(admission, over_depth) + expired = _admit_body(admission, b"{}", timeout=Timeout(deadline=0.0)) + + assert limited.reason_code == "admission_unavailable" + assert expired.reason_code == "admission_unavailable" + + +def test_provider_malformed_json_is_an_unsupported_shape() -> None: + admission, egress = _processors() + _, admitted = _admit(admission, "safe rendered prompt") + assert admitted.receipt is not None + malformed = _provider_request("safe rendered prompt", admitted.receipt).model_copy( + update={"body": b"{"} + ) + + result = egress.process(malformed, timeout=Timeout.from_seconds(1)) + + assert result.reason_code == "provider_shape_unsupported" + + +def test_direct_openai_reasoning_effort_is_supported() -> None: + admission, egress = _processors() + _, admitted = _admit(admission, "safe rendered prompt") + assert admitted.receipt is not None + request = _provider_request("safe rendered prompt", admitted.receipt) + provider_body = json.loads(request.body) + provider_body["reasoning_effort"] = "medium" + request = request.model_copy( + update={ + "body": json.dumps( + provider_body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + + result = egress.process(request, timeout=Timeout.from_seconds(1)) + + assert result.decision.value == "allow" diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index 1ecbec0e..95714ef4 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -5,6 +5,7 @@ from __future__ import annotations +import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -13,6 +14,10 @@ from google.protobuf import empty_pb2, json_format, message_factory from google.protobuf.message import Message +from egress_gate.admission import ( + PiInputV1, + canonical_json_bytes, +) from egress_gate.bindings import supervisor_middleware_pb2 as pb2 from egress_gate.bindings import supervisor_middleware_pb2_grpc as pb2_grpc from egress_gate.errors import EgressGateError, ErrorCode @@ -153,6 +158,152 @@ async def test_generated_stub_round_trip_covers_manifest_and_gate_actions() -> N assert denied.reason_code == "egress_gate_regex_denied" +@pytest.mark.asyncio +async def test_generated_stub_issues_a_rendered_prompt_receipt() -> None: + body = canonical_json_bytes( + PiInputV1(schema_version="openshell.pi-input.v1", text="safe") + ) + request = pb2.AgentConversationEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, + context=pb2.RequestContext(request_id="admission-1", sandbox_id="sandbox"), + config=_config(action_kind="detect"), + target=pb2.AgentConversationTarget( + harness="pi", + harness_version="extension-v1", + hook="rendered_prompt_admission", + schema_version="openshell.pi-input.v1", + scheme="https", + host="provider.invalid", + port=443, + path="/v1/chat/completions", + ), + middleware_name="pi-egress", + session_id="session-1", + turn_id="submission-1", + request_body=body, + ) + middleware = EgressGateMiddleware( + create_builtin_registry(), require_pi_receipt=True + ) + async with _running_stub(middleware) as (stub, _): + response = await stub.EvaluateAgentConversation(request) + + assert response.decision == pb2.DECISION_ALLOW + assert response.attestation.startswith(b"eg1.") + assert response.has_replacement_body is False + assert response.metadata["admission_schema"] == "openshell.pi-input.v1" + + +@pytest.mark.asyncio +async def test_agent_admission_is_unavailable_when_receipt_enforcement_is_off() -> None: + request = pb2.AgentConversationEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT + ) + middleware = EgressGateMiddleware(create_builtin_registry()) + async with _running_stub(middleware) as (stub, _): + response = await stub.EvaluateAgentConversation(request) + + assert response.decision == pb2.DECISION_DENY + assert response.reason_code == "admission_unavailable" + + +@pytest.mark.asyncio +async def test_admission_receipt_is_verified_and_stripped_for_http_egress() -> None: + pi_body = canonical_json_bytes( + PiInputV1(schema_version="openshell.pi-input.v1", text="safe") + ) + admission = pb2.AgentConversationEvaluation( + phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, + context=pb2.RequestContext(request_id="admission-2", sandbox_id="sandbox"), + config=_config(action_kind="detect"), + target=pb2.AgentConversationTarget( + harness="pi", + harness_version="extension-v1", + hook="rendered_prompt_admission", + schema_version="openshell.pi-input.v1", + scheme="https", + host="provider.invalid", + port=443, + path="/v1/chat/completions", + ), + middleware_name="pi-egress", + session_id="session-1", + turn_id="submission-2", + request_body=pi_body, + ) + provider_body = json.dumps( + { + "model": "fixture-model", + "messages": [ + {"role": "system", "content": "system"}, + {"role": "user", "content": "safe"}, + ], + "temperature": 0, + "max_completion_tokens": 128, + "tool_choice": "auto", + "stream": True, + "stream_options": {"include_usage": True}, + "store": False, + "prompt_cache_key": "session-1", + }, + separators=(",", ":"), + ).encode() + middleware = EgressGateMiddleware( + create_builtin_registry(), require_pi_receipt=True + ) + async with _running_stub(middleware) as (stub, _): + admitted = await stub.EvaluateAgentConversation(admission) + network = _evaluation(provider_body, action_kind="detect") + network.context.request_id = "network-2" + network.target.host = "provider.invalid" + network.target.path = "/v1/chat/completions" + network.middleware_name = "pi-egress" + network.headers.extend( + [ + pb2.HttpHeader(name="content-type", value="application/json"), + pb2.HttpHeader( + name="x-openshell-middleware-egress-receipt", + value=admitted.attestation.decode("ascii"), + ), + ] + ) + allowed = await stub.EvaluateHttpRequest(network) + missing = _evaluation(provider_body, action_kind="detect") + missing.target.host = "provider.invalid" + missing.target.path = "/v1/chat/completions" + missing.middleware_name = "pi-egress" + missing.headers.append( + pb2.HttpHeader(name="content-type", value="application/json") + ) + denied = await stub.EvaluateHttpRequest(missing) + + assert allowed.decision == pb2.DECISION_ALLOW + assert ( + allowed.header_mutations[0].remove.name + == "x-openshell-middleware-egress-receipt" + ) + assert denied.decision == pb2.DECISION_DENY + assert denied.reason_code == "receipt_missing" + + +@pytest.mark.asyncio +async def test_unmanaged_http_rejects_the_reserved_receipt_header() -> None: + middleware = EgressGateMiddleware(create_builtin_registry()) + request = _evaluation(b"safe", action_kind="detect") + request.headers.append( + pb2.HttpHeader( + name="X-OpenShell-Middleware-Egress-Receipt", + value="eg1.untrusted", + ) + ) + + async with _running_stub(middleware) as (stub, _): + response = await stub.EvaluateHttpRequest(request) + + assert response.decision == pb2.DECISION_DENY + assert response.reason_code == "reserved_receipt_header" + + @pytest.mark.asyncio async def test_generated_stub_returns_three_gate_progressive_redaction() -> None: middleware = EgressGateMiddleware(create_builtin_registry()) diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 7ee04eec..04607e54 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -74,8 +74,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, + require_pi_receipt: bool = False, ) -> None: - del registry + del registry, require_pi_receipt self.timeout_middleware_processing = timeout_middleware_processing def serve_sync(self, listen: str) -> None: @@ -535,8 +536,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, + require_pi_receipt: bool = False, ) -> None: - del registry, timeout_middleware_processing + del registry, timeout_middleware_processing, require_pi_receipt def serve_sync(self, listen: str) -> None: calls.append(listen) diff --git a/projects/egress-gate/uv.lock b/projects/egress-gate/uv.lock index 88ae26e6..ef9e1ad1 100644 --- a/projects/egress-gate/uv.lock +++ b/projects/egress-gate/uv.lock @@ -56,6 +56,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "charset-normalizer" version = "3.4.9" @@ -139,6 +237,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/de/41/6cbdcf9142d00fe82836fbb51e503e58088575cf7a0fe1dbff6695bf0840/cryptography-50.0.0.tar.gz", hash = "sha256:eeac2acb5a20ed25e0ad6d1df9891a520b78b404266b6d11778f25d5d691a6c9", size = 880201, upload-time = "2026-07-31T14:25:10.11Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c5/5c/59086b4aac5e879d38ddbcf74e4be7ade89cebc3eb199a55da998c3bb46a/cryptography-50.0.0-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:031e2d5dd4bb9caa3ca9c82e5a197fd8ae680232cee62603d1a813f3f07e3d03", size = 4001252, upload-time = "2026-07-31T14:23:33.331Z" }, + { url = "https://files.pythonhosted.org/packages/57/ef/8f2df13c7216bcad3e1c74e07f6e193d93e998e114f524a53877c9af27ad/cryptography-50.0.0-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:fd9192b7b70c573d7f214eb1ae35e00d359f6f5e4b27c7e21e30de1fc6204645", size = 4719554, upload-time = "2026-07-31T14:23:35.611Z" }, + { url = "https://files.pythonhosted.org/packages/d9/41/029086c34d91052fc3b88bcc8056f709a7c915c7a23b235a54eb800b1c97/cryptography-50.0.0-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:06a32a980526a6ab9a4b9bf8f7385800791e2bb960903cb6b530e4817509a3b7", size = 4702130, upload-time = "2026-07-31T14:23:37.635Z" }, + { url = "https://files.pythonhosted.org/packages/7d/ff/b6ce0954962e7f7b969f850a883744197bb3910bdfd7b6da162eab7d9f68/cryptography-50.0.0-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:a1b30560f2acc95aa8b2e06e716a13dbfc97314747b80d9707e307f77b40d6b3", size = 4725244, upload-time = "2026-07-31T14:23:39.471Z" }, + { url = "https://files.pythonhosted.org/packages/06/1e/63a1027cb7fec360a182208e1b7767d5aa1fe57be3d6aa856e69a321edc0/cryptography-50.0.0-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:8d89f3976b10b4ce31118de72329025f70d2c6ead14a8217c5514dd2c6d5a78f", size = 5342265, upload-time = "2026-07-31T14:23:41.286Z" }, + { url = "https://files.pythonhosted.org/packages/6b/72/a1116d683a6d7ece94590013882515de087edf9ef0e6292aae615a44df73/cryptography-50.0.0-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:b42a28c1844fd9de8f3f7d540e36b66f3a9c83fceac7170ebc7a6a19edd9dcae", size = 4734609, upload-time = "2026-07-31T14:23:43.139Z" }, + { url = "https://files.pythonhosted.org/packages/15/37/36a9c479bbe49acea2636c7fd3360d20f7b7e079c300352011c44850b181/cryptography-50.0.0-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:900131fafd8aead39ac7dd3a7e833be754c17a95cfd91221636949fe4eb0aa8a", size = 4356517, upload-time = "2026-07-31T14:23:44.939Z" }, + { url = "https://files.pythonhosted.org/packages/32/98/8a151d64367204cbc63ec65d37502f1d9c53cf4bfc6ec3c532614dbec60d/cryptography-50.0.0-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:07949c449a1abcf60d1ee6e88956d89404c7df3c8258f46589e912988e551987", size = 4724529, upload-time = "2026-07-31T14:23:46.93Z" }, + { url = "https://files.pythonhosted.org/packages/22/f6/ec13b470172126464a86bf54d2294a46d29837fc51ba3e45d4047946fb5e/cryptography-50.0.0-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:f89831ef99dd7dd169ab06d63a831adb9e20a87aac6d380266bbda5823349169", size = 5299852, upload-time = "2026-07-31T14:23:48.851Z" }, + { url = "https://files.pythonhosted.org/packages/da/3a/f05e32c99d440c9bb891ea0e36c9091891e36be5a9a87ab2ee6ea20729f6/cryptography-50.0.0-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:82148ec5bddac30b51a5b3c1945075f896fa022cb93f8e4a01e9f6ee95292c5f", size = 4734462, upload-time = "2026-07-31T14:23:50.861Z" }, + { url = "https://files.pythonhosted.org/packages/ca/dc/bd72b26be8953f80625f63151efd38eee71c76ca6cf591c08ff34615a79e/cryptography-50.0.0-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:1489e263a8048bb8b6a8bac662eb2d402ea5d2b7b4699b72f385f1e2772db105", size = 4852708, upload-time = "2026-07-31T14:23:52.715Z" }, + { url = "https://files.pythonhosted.org/packages/27/20/c930314a2ab476d15dec966ec87e2e9637bb02b06106b12c0396c57bb603/cryptography-50.0.0-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7cec5b856506da6defb290f30c9ee687d5f5e8cb0bd3f6459dde43b0b4fa40ef", size = 5004179, upload-time = "2026-07-31T14:23:54.887Z" }, + { url = "https://files.pythonhosted.org/packages/32/2e/c9db68a0c4bfa28e310707527c0ee3a2bd254104d2e02e68f368e197aa4c/cryptography-50.0.0-cp311-abi3-win_amd64.whl", hash = "sha256:bd1c592e4d5974f0d08d4888e432157adba757c66da0246918e43677fafa2d30", size = 3840395, upload-time = "2026-07-31T14:23:56.677Z" }, + { url = "https://files.pythonhosted.org/packages/c3/fb/951032a3bf22a5697c83183fb6294a4843772947a70e616c57b3ff5f522e/cryptography-50.0.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:49e7d93abdbd2990caced757e5fade25302f719c3c8fb6e6fff2dde98999fc41", size = 3989258, upload-time = "2026-07-31T14:23:58.881Z" }, + { url = "https://files.pythonhosted.org/packages/d4/67/91eb047e69c5e845f2f14b8a2e4a1aab0f283cb885531e9e22c8adb176bc/cryptography-50.0.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:19736989797678c6af1e55cd49055cdbcb55d8f6b5583ac5335f933aba9101dc", size = 4700648, upload-time = "2026-07-31T14:24:00.702Z" }, + { url = "https://files.pythonhosted.org/packages/30/82/85f0f7425c856b9f96459411eb12e74ef72df9caf6f8f15bf23a33ff131f/cryptography-50.0.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:80b63928fa35083b33966ce1efb70e5b9607181e49dcd1c22c8c005e319f667f", size = 4682442, upload-time = "2026-07-31T14:24:02.538Z" }, + { url = "https://files.pythonhosted.org/packages/1a/28/b555a365adff1cca2fbe7b9e487d68a40de6bc67ff2cb587473eb43de0e7/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:d58c3db7cd6eed54e6c06744db55456b65ebd7492ddeae9c1e93cfca7aa857d3", size = 4707596, upload-time = "2026-07-31T14:24:04.394Z" }, + { url = "https://files.pythonhosted.org/packages/72/d8/f52538140cc719df62a01cf87d1c7142318d235817109d6f4054d7c352d6/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:df2a58a472f332225671c35b0a830208b86d004f82baa8530fa3782c85646533", size = 5314552, upload-time = "2026-07-31T14:24:06.31Z" }, + { url = "https://files.pythonhosted.org/packages/38/14/6120e5bd7c5aa022ad15424ba4d5c5269d0d9448ed4d55e492ea91e3c1c4/cryptography-50.0.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:11b74db56cdbe3cdee6e3f6982ecb70334fa10dce99ed58bf7894aaaa3b2a037", size = 4717113, upload-time = "2026-07-31T14:24:08.349Z" }, + { url = "https://files.pythonhosted.org/packages/fa/71/190bf38c3ee2e0f8efc9860ae100c9df4169742eef274b91e7aa1cb133b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f59e38625469987d7ef6d495323c55e7db6c212eaf6112267e0d3b565a2e9c9f", size = 4338580, upload-time = "2026-07-31T14:24:10.227Z" }, + { url = "https://files.pythonhosted.org/packages/3a/63/504ccfbbe61fd8aa983f7f146399cdf034c72c2fc55f5b2dfdcdcdb20c99/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:ecfed7367f965a0328cfbdd70da860f15441f002f613185668c6e6ebf5a0ac11", size = 4707038, upload-time = "2026-07-31T14:24:12.169Z" }, + { url = "https://files.pythonhosted.org/packages/01/77/2cf79bbfc4d12ca106437a6e170d6aaa01a373e93093118aaaef0e801bd4/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:9aa87839c383bdbab6ef865787a1fb877af8dd03464c4400322726feaaadfc6d", size = 5273110, upload-time = "2026-07-31T14:24:14.38Z" }, + { url = "https://files.pythonhosted.org/packages/e5/45/8aae2972c520145377ea3559a605a899bebe227bf070b33cdb445929a9b9/cryptography-50.0.0-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:6ba6a53445bd3cfa809ef3ef5f1589aa6ba08784a1d962bf47d0940e871dab1c", size = 4716439, upload-time = "2026-07-31T14:24:16.415Z" }, + { url = "https://files.pythonhosted.org/packages/7b/20/4fe50b619a48c2525cc46e2dbc1ac490708d704be5d467bdaac6dc955682/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:3f5735ffe4996d28b809371756219f5354864902a3b9e7c0b9ee87041209fc9c", size = 4837383, upload-time = "2026-07-31T14:24:18.553Z" }, + { url = "https://files.pythonhosted.org/packages/92/91/3a31366e183343d3703f8995c095f5734676bd6938118047e50fcf279eb4/cryptography-50.0.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:1b4a266766514614f8aa60416e71f2fc6e575d36e7bdc90f644fadb2f4b75b95", size = 4985772, upload-time = "2026-07-31T14:24:20.385Z" }, + { url = "https://files.pythonhosted.org/packages/74/9a/02ffe35b2853d121689871eb5dce862092562b3a1ed5cc98f1aaed441506/cryptography-50.0.0-cp314-cp314t-win_amd64.whl", hash = "sha256:12b9c6996425c76ea6c457ace4f3073e715b8c545add07cd1a8f3a4f90691269", size = 3816291, upload-time = "2026-07-31T14:24:22.125Z" }, + { url = "https://files.pythonhosted.org/packages/03/37/73d005be173aff344af30e9fd2a576575cb2391a7101d9cd3842e1fa8cce/cryptography-50.0.0-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ccdc4a71a4dabae05de219404f9f4abc38e3b58422177ff93d0da05967dafa07", size = 4036009, upload-time = "2026-07-31T14:24:24.122Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c6/7a6202a534e32103a285b7834a120869557fe198d51d7cfe59754c8bda9c/cryptography-50.0.0-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:910e1d2668e7de9648f2bcee30e180db2a6b15c30f887d7c4c93ddf96e3992e3", size = 4745252, upload-time = "2026-07-31T14:24:26.118Z" }, + { url = "https://files.pythonhosted.org/packages/85/4f/0fa8c2f4428198f15d9ff8d63400e27afbf94ce833f6108da1eb3753f945/cryptography-50.0.0-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a91296cb61e8df6f86d0c19cc4068228da256bf59bf86049fbd821084565327f", size = 4728939, upload-time = "2026-07-31T14:24:27.994Z" }, + { url = "https://files.pythonhosted.org/packages/d1/63/54dd723490ba2dc09b299682c10b38db38f159728bcaae8c591b8af2f22d/cryptography-50.0.0-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e722f16708d854fe924790e051061f6704a472c3bac347b6fd88033ea8dd0dc5", size = 4748483, upload-time = "2026-07-31T14:24:30.254Z" }, + { url = "https://files.pythonhosted.org/packages/1d/dd/7c77d26285cc7f6991efce64a0f5b4f9383bfa5dd8c5033003eaf7db4cdb/cryptography-50.0.0-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:d764dcf130c428ef66786f866dd750f53182bc608813489915e9fc106bb0c82f", size = 5367599, upload-time = "2026-07-31T14:24:32.457Z" }, + { url = "https://files.pythonhosted.org/packages/46/c9/f60aed34c013f317f92817b6c171c2d22a78270fa41109bd4b08af26b194/cryptography-50.0.0-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:105110f43a471dbd0060b9c9516cb8a6a79233631a04cc2ba16f28323ac6e025", size = 4762647, upload-time = "2026-07-31T14:24:34.599Z" }, + { url = "https://files.pythonhosted.org/packages/be/f3/f9a0173b139372c3a48ed98154b45cc6b9de17c789d5ab552e621c293609/cryptography-50.0.0-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:828743d939e9629bc267b8e2d08d8bb67cd4319c771a33d4b18b22dd8fb7440a", size = 4385197, upload-time = "2026-07-31T14:24:36.647Z" }, + { url = "https://files.pythonhosted.org/packages/d8/36/83bb81f6e569bc38e1e4a7bc80f29b46bb9601920bc455fc8e888f5d5742/cryptography-50.0.0-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:2a8183b489dc1f7f80f135780fadc1108f14b31b8a40411c7a5b17425f65f28b", size = 4748095, upload-time = "2026-07-31T14:24:39.493Z" }, + { url = "https://files.pythonhosted.org/packages/6b/16/d3008eff98c764979865834c3d386d4fd041b5f52e7f34fc29ac1a5eb515/cryptography-50.0.0-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:6e7d61120573a7f2cd94cc095f9e81f6967c61ccdf194285aa143ecec8e0b708", size = 5325948, upload-time = "2026-07-31T14:24:41.556Z" }, + { url = "https://files.pythonhosted.org/packages/9c/f8/d97f9603efda3888187bfdb893f26c41be4735c10631d05d284ee6b047c4/cryptography-50.0.0-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:37fdb0d0111f1e2ff07139dfb79f1b49531f8e213c46f1163dd7642979b58c47", size = 4762400, upload-time = "2026-07-31T14:24:43.636Z" }, + { url = "https://files.pythonhosted.org/packages/64/a2/4615c8f7d81a00b1d6e6afe19f694e1543582349fb5f4076f6cb5dc36485/cryptography-50.0.0-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:c87f62a3d3b9888ed0fdde100ec06aa61ca9cd44bad9057d1dff9a516b5f5bb9", size = 4878208, upload-time = "2026-07-31T14:24:45.522Z" }, + { url = "https://files.pythonhosted.org/packages/d2/1a/efcfb02f91407149a0dacffffab791f7e19bf6385f63b3666dc8b5e5c9c8/cryptography-50.0.0-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:65c2c3add92b45fd0709db8594536aea39c2a67af0e27ffcf049c498501140b7", size = 5037050, upload-time = "2026-07-31T14:24:47.697Z" }, + { url = "https://files.pythonhosted.org/packages/57/30/4a22984d4f1bdfb8c054f07a92bc176b97a3134cc1d6c4b3bffb1f3688b4/cryptography-50.0.0-cp39-abi3-win_amd64.whl", hash = "sha256:d24fead1d4d076e1bfb006dcec392074a3cd8d7b4fc8a595aa64073b2b7a96ba", size = 3874135, upload-time = "2026-07-31T14:24:50.085Z" }, + { url = "https://files.pythonhosted.org/packages/9d/3e/e54cde8c01631a5a8226ccd617eab9e57fd5cfdad90f1a9e6bb570794631/cryptography-50.0.0-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:5e34edd123674534acd70147f0ca331eaa2c74e6325fb2028c886aa26ba0b68c", size = 3963170, upload-time = "2026-07-31T14:24:51.968Z" }, + { url = "https://files.pythonhosted.org/packages/01/b6/0b9e125e90f3d2dcf599a218a899cda7326a3158cfa258723f0b398b08f6/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:8eb5e1172eb569ea8a872796576e6a67c276351728b6455d5beb01242b027c6a", size = 4692441, upload-time = "2026-07-31T14:24:53.743Z" }, + { url = "https://files.pythonhosted.org/packages/53/c9/a5151588710785a96d7bc4de27d4cd62f263bbbcb203cfe29df537eb6505/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:910d11e1a385c654bf738bf3e6b8e6ed5de0f5610fcae2be9e5b398d8081d20e", size = 4699810, upload-time = "2026-07-31T14:24:55.746Z" }, + { url = "https://files.pythonhosted.org/packages/c7/1a/15b92b25eb6ce3089cd49377ae990a0f3ad485a510f968aed1f19dbdcdf2/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:62598a8a57f815db4c6259a4e97d857dab56697e7de8e8ab02352ab74da1995d", size = 4691924, upload-time = "2026-07-31T14:24:58.082Z" }, + { url = "https://files.pythonhosted.org/packages/62/15/219075012ab13e8905f3cd572204f4acb4b111df787104346b9bc0cea789/cryptography-50.0.0-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:07479a1cb08219ab719147e742e76090c9c773321959bb94946fffdd397a6437", size = 4699593, upload-time = "2026-07-31T14:24:59.951Z" }, + { url = "https://files.pythonhosted.org/packages/8e/b5/c2c5fce26f0ee40d21bafe7f191d29a34b35a65ac4fe8a1191d1983612e9/cryptography-50.0.0-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:c99c003e088647b8a5b7c145d6f78c335f6348332b62e142d411c4b63d1460b9", size = 3813796, upload-time = "2026-07-31T14:25:02.298Z" }, +] + [[package]] name = "cyclonedx-python-lib" version = "11.11.0" @@ -169,6 +323,7 @@ name = "egress-gate" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "cryptography" }, { name = "grpcio" }, { name = "protobuf" }, { name = "pydantic" }, @@ -190,6 +345,7 @@ dev = [ [package.metadata] requires-dist = [ + { name = "cryptography", specifier = ">=50,<51" }, { name = "grpcio", specifier = ">=1.81.1,<2" }, { name = "protobuf", specifier = ">=7.36,<8" }, { name = "pydantic", specifier = ">=2.11,<3" }, @@ -501,6 +657,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/9b/bf/7595e817906a29453ba4d99394e781b6fabe55d21f3c15d240f85dd06bb1/py_serializable-2.1.0-py3-none-any.whl", hash = "sha256:b56d5d686b5a03ba4f4db5e769dc32336e142fc3bd4d68a8c25579ebb0a67304", size = 23045, upload-time = "2025-07-21T09:56:46.848Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.4" From 1de927165056ec6d2cb70ede8a0aa124c8be6d28 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 12 Aug 2026 01:31:27 +0000 Subject: [PATCH 02/70] docs(egress-gate): add Pi admission example --- projects/egress-gate/README.md | 19 +- .../examples/pi-attested-admission/README.md | 86 +++++++ .../egress-gate-config.yaml | 29 +++ .../pi-attested-admission/run_example.py | 242 ++++++++++++++++++ .../tests/admission/test_example.py | 41 +++ 5 files changed, 413 insertions(+), 4 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/README.md create mode 100644 projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml create mode 100644 projects/egress-gate/examples/pi-attested-admission/run_example.py create mode 100644 projects/egress-gate/tests/admission/test_example.py diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 1471f75e..940e1e28 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -24,7 +24,7 @@ commands work from any directory and do not depend on repository-only files: egress-gate gates list egress-gate gates schema egress-gate validate --policy /absolute/path/to/your-policy.yaml -egress-gate serve --listen 127.0.0.1:50051 +egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-receipt ``` ## Source-checkout quickstart @@ -39,7 +39,7 @@ uv run egress-gate gates list uv run egress-gate gates schema uv run egress-gate validate \ --policy examples/regex-redaction/egress-gate-config.yaml -uv run egress-gate serve --listen 127.0.0.1:50051 +uv run egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-receipt uv run egress-gate evaluate \ --policy examples/regex-redaction/egress-gate-config.yaml \ --cases examples/regex-redaction/cases.yaml @@ -49,6 +49,13 @@ Use `0.0.0.0` only when the OpenShell supervisor must reach the service across network namespaces. The development server uses plaintext gRPC. Restrict its listen port to trusted networks. +The CLI requires managed Pi admission receipts by default, coupling receipt +issuance to provider egress verification. The general Gate quickstarts opt out +explicitly. Keep the default, or pass `--require-pi-receipt`, for managed Pi; +use `--no-require-pi-receipt` only for an intentionally unmanaged deployment. +See the [managed Pi example](examples/pi-attested-admission/README.md) for the +matching Pi and OpenShell fork branches, startup contract, and current limits. + ## Policy shape The registry builds an exact strict schema from installed gate types: @@ -87,7 +94,7 @@ need initialization, helper bases, or typed resources use the full class-based ```bash uv run egress-gate --registry my_gates:registry gates list -uv run egress-gate --registry my_gates:registry serve +uv run egress-gate --registry my_gates:registry serve --no-require-pi-receipt ``` OpenShell owns interception, routing, and credential attachment. Egress Gate @@ -103,11 +110,14 @@ from egress_gate.service import EgressGateServer server = EgressGateServer( create_builtin_registry(), timeout_middleware_processing=10, + require_pi_receipt=False, ) server.serve_sync("127.0.0.1:50051") ``` -In this example, `timeout_middleware_processing` gives each evaluation 10 +Make the `require_pi_receipt` choice explicit in programmatic deployments; set +it to `True` for managed Pi. In this unmanaged example, +`timeout_middleware_processing` gives each evaluation 10 seconds. Omitting it uses the one-second service default. The value is expressed in seconds, must be at least 10 milliseconds, and must resolve to whole milliseconds. The service passes one resulting `Timeout` through slot @@ -136,6 +146,7 @@ timeout failures must deny. - [Architecture](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/architecture/index.md) - [Limits and failures](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/reference/limits-and-failures.md) - [Regex redaction composition](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/regex-redaction) +- [Pi attested-admission example](examples/pi-attested-admission/README.md) - [Function-based custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom-gate) - [Class-based custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/class-based-gate) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md new file mode 100644 index 00000000..d14d569a --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -0,0 +1,86 @@ +# Pi attested-admission example + +This credential-free example exercises Egress Gate's public harness-admission +and attested-egress APIs across the state boundaries a managed Pi runtime must +enforce. It uses the real configured regex Gates, admission processor, Pi shape +adapter, Ed25519 receipt issuer, provider adapter, network Gate pass, and +receipt-header stripping. The deterministic provider recorder is local; no API +key or external service is needed. + +From `projects/egress-gate/`, run: + +```bash +uv run python examples/pi-attested-admission/run_example.py \ + --session-file /tmp/pi-egress-example/session.jsonl +``` + +The command prints JSON evidence for the intentionally small MVP: + +- a safe idle, text-only rendered prompt and its first provider request; +- denial before the candidate changes the session or reaches the provider; +- candidate replacement before persistence and provider serialization; +- fail-closed denial of an unattested continuation; and +- removal of the internal receipt header before the provider recorder. + +Inspect the resulting accepted history with: + +```bash +python3 -m json.tool --json-lines /tmp/pi-egress-example/session.jsonl +``` + +The output reports receipt, canonicalization, provider-adapter, active key ID, +and policy versions, but never prints receipt bytes or denied content. + +This hermetic executable is the Egress Gate component layer of the broader Pi +integration. `ManagedPiSession` deliberately models the required ordering: +rendered-prompt admission, optional candidate replacement, candidate commit, then +attested network egress. It is not presented as the pinned downstream Pi fork +or the full OpenShell sandbox layer; those runtime artifacts must use the same +public API and preserve this ordering. + +## Run the managed forks + +Use the matching integration branches: + +- [Pi `openshell/pi-egress-admission`](https://github.com/johnnygreco/pi/tree/openshell/pi-egress-admission) +- [OpenShell `openshell/pi-egress-admission`](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) + +Register this service as an OpenShell supervisor middleware and start it without +`--no-require-pi-receipt`. Configure exactly one network middleware entry for +the OpenAI provider host. When OpenShell sees that the service advertises the +Pi admission binding, it exposes the loopback bridge and sets +`OPENSHELL_PI_CONVERSATION_URL` in the sandbox. The pinned Pi fork detects that +variable and loads its bundled `openshell-input-admission.ts` extension. A +normal Egress Gate deployment that does not use managed Pi must start with +`--no-require-pi-receipt`; it advertises and evaluates only HTTP middleware. + +The managed path currently supports direct OpenAI Chat Completions requests +from the pinned Pi serializer. It does not support images, steering or queued +follow-ups while streaming, compaction requests, provider retries, or automatic +continuations after tool calls. Those paths fail closed. The next increment is +a separate pre-provider-request admission boundary that issues one receipt for +each automatic call; it does not change the rendered-prompt hook or its +pre-persistence denial guarantee. + +Version 1 supports the direct OpenAI Chat Completions subset emitted by the +pinned Pi serializer: text messages, function tools and calls/results, +`max_completion_tokens`, optional `temperature` and `reasoning_effort`, tool +choice, `stream: true`, `stream_options.include_usage: true`, `store: false`, +and optional `prompt_cache_key` and `prompt_cache_retention: "24h"` cache +fields. Compatibility-provider fields, custom sampling parameters, unknown +fields, unsupported content variants, and lossy multipart forms fail closed. +The provider adapter accepts either a string or one OpenAI text +block for message content because the pinned fixture treats those as the same +single text value. It otherwise requires one representation: `content` is +present, optional message metadata is omitted instead of `null`, and empty tool +call arrays are omitted. Integer, floating-point, and negative-zero spellings of +the same temperature are normalized because the pinned fixture treats them as +one numeric value. Provider requests require exactly one parameter-free +`Content-Type: application/json` header and no `Content-Encoding`. + +Each receipt is short-lived and consumed by the first matching provider +request. It binds the admitted rendered prompt, sandbox, middleware policy, and +provider target. It does not prove which JavaScript extension called the +supervisor bridge, and it does not attest the complete conversation or provider +payload. OpenShell reruns the configured Gates on the actual HTTP request before +forwarding it and strips the internal receipt header. diff --git a/projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml b/projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml new file mode 100644 index 00000000..fe4d43f2 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml @@ -0,0 +1,29 @@ +gates: + - name: deny-marker + kind: regex + scan: + kind: body + action: + kind: deny + pattern_catalog: + entities: + - name: unsafe-marker + rules: + - name: exact-deny-marker + pattern: OPEN_SHELL_ADMISSION_DENY_TEST + confidence: high + - name: replace-marker + kind: regex + scan: + kind: body + action: + kind: replace + template: "[REDACTED]" + pattern_catalog: + entities: + - name: replacement-marker + rules: + - name: exact-replacement-marker + pattern: OPEN_SHELL_ADMISSION_REPLACE_TEST + confidence: high +default_decision: allow diff --git a/projects/egress-gate/examples/pi-attested-admission/run_example.py b/projects/egress-gate/examples/pi-attested-admission/run_example.py new file mode 100644 index 00000000..2329156b --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/run_example.py @@ -0,0 +1,242 @@ +"""Hermetic rendered-prompt admission example for the Pi MVP.""" + +from __future__ import annotations + +import argparse +import json +import tempfile +from pathlib import Path + +import yaml + +from egress_gate.admission import ( + RECEIPT_HEADER, + AdmissionDecision, + AdmissionHook, + AttestedEgressProcessor, + HarnessAdmissionContext, + HarnessAdmissionProcessor, + HarnessAdmissionRequest, + PiInputV1, + PromptProvenance, + ReceiptAuthority, + canonical_json_bytes, + create_pi_adapter_registry, + create_provider_adapter_registry, +) +from egress_gate.gates import create_builtin_registry +from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext +from egress_gate.request_processor import apply_request_mutations +from egress_gate.timeout import Timeout + +DENY_MARKER = "OPEN_SHELL_ADMISSION_DENY_TEST" +REPLACE_MARKER = "OPEN_SHELL_ADMISSION_REPLACE_TEST" +MIDDLEWARE_NAME = "pi-egress" + + +class ManagedPiSession: + """Model the extension's admit, optionally replace, commit, and send order.""" + + def __init__( + self, + session_file: Path, + admission: HarnessAdmissionProcessor, + egress: AttestedEgressProcessor, + ) -> None: + self._session_file = session_file + self._admission = admission + self._egress = egress + self._messages: list[dict[str, str]] = [] + self.provider_requests: list[HttpRequest] = [] + self._sequence = 0 + self._write_session() + + def submit(self, rendered_prompt: str) -> dict[str, object]: + before_messages = len(self._messages) + before_requests = len(self.provider_requests) + body = canonical_json_bytes( + PiInputV1(schema_version="openshell.pi-input.v1", text=rendered_prompt) + ) + admitted = self._admission.process( + HarnessAdmissionRequest( + request_body=body, + provenance=PromptProvenance( + kind="rendered_prompt", + session_id="example-session", + submission_id=self._next_id("submission"), + ), + ), + _admission_context(self._next_id("admission")), + timeout=Timeout.from_seconds(1), + ) + if admitted.decision is AdmissionDecision.DENY: + return { + "decision": "deny", + "reason_code": admitted.reason_code, + "session_unchanged": len(self._messages) == before_messages, + "provider_calls": len(self.provider_requests) - before_requests, + } + + accepted_body = admitted.replacement_body or body + accepted_prompt = PiInputV1.model_validate_json(accepted_body, strict=True).text + self._messages.append({"role": "user", "content": accepted_prompt}) + self._write_session() + request = _provider_request( + accepted_prompt, admitted.receipt, request_id=self._next_id("network") + ) + egress = self._egress.process(request, timeout=Timeout.from_seconds(1)) + if egress.decision.value != "allow": + raise RuntimeError(f"attested egress denied: {egress.reason_code}") + forwarded = apply_request_mutations(request, egress.request_mutations) + if any(header.name.lower() == RECEIPT_HEADER for header in forwarded.headers): + raise RuntimeError("internal receipt reached provider fixture") + self.provider_requests.append(forwarded) + history = self._session_file.read_text(encoding="utf-8") + return { + "decision": admitted.decision.value, + "provider_calls": len(self.provider_requests) - before_requests, + "receipt_count": int(admitted.receipt is not None), + "original_absent": rendered_prompt not in history, + "replacement_present": accepted_prompt in history, + "provider_original_absent": rendered_prompt.encode() not in forwarded.body, + "provider_replacement_present": accepted_prompt.encode() in forwarded.body, + } + + def continuation_without_receipt(self) -> str | None: + result = self._egress.process( + _provider_request( + "continuation", None, request_id=self._next_id("continuation") + ), + timeout=Timeout.from_seconds(1), + ) + return result.reason_code + + def _write_session(self) -> None: + self._session_file.write_text( + "".join( + json.dumps(message, sort_keys=True) + "\n" for message in self._messages + ), + encoding="utf-8", + ) + + def _next_id(self, prefix: str) -> str: + self._sequence += 1 + return f"{prefix}-{self._sequence}" + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--session-file", type=Path) + options = parser.parse_args() + session_file = options.session_file or ( + Path(tempfile.mkdtemp(prefix="pi-egress-example-")) / "session.jsonl" + ) + session_file.parent.mkdir(parents=True, exist_ok=True) + admission, egress = _processors() + session = ManagedPiSession(session_file, admission, egress) + + safe = session.submit("safe rendered prompt") + before_denial = session_file.read_bytes() + denied = session.submit(f"unsafe {DENY_MARKER}") + denied["denied_content_absent"] = ( + DENY_MARKER.encode() not in session_file.read_bytes() + ) + denied["session_unchanged"] = before_denial == session_file.read_bytes() + replacement = session.submit(f"replace {REPLACE_MARKER}") + evidence = { + "versions": admission.readiness, + "safe_direct": safe, + "direct_denial": denied, + "replacement_turn": replacement, + "continuation": {"reason_code": session.continuation_without_receipt()}, + "provider": { + "request_count": len(session.provider_requests), + "receipt_headers_seen": sum( + header.name.lower() == RECEIPT_HEADER + for request in session.provider_requests + for header in request.headers + ), + }, + } + print(json.dumps(evidence, indent=2, sort_keys=True)) + + +def _processors() -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor]: + example_dir = Path(__file__).resolve().parent + registry = create_builtin_registry() + config = registry.validate_config( + yaml.safe_load( + (example_dir / "egress-gate-config.yaml").read_text(encoding="utf-8") + ) + ) + processor = registry.prepare_processor(config, timeout=Timeout.from_seconds(1)) + authority = ReceiptAuthority() + return ( + HarnessAdmissionProcessor(processor, create_pi_adapter_registry(), authority), + AttestedEgressProcessor( + processor, + create_provider_adapter_registry(), + authority, + middleware_name=MIDDLEWARE_NAME, + harness_version="extension-v1", + ), + ) + + +def _target() -> HttpTarget: + return HttpTarget( + scheme="https", + host="provider.fixture", + port=443, + method="POST", + path="/v1/chat/completions", + query="", + ) + + +def _admission_context(request_id: str) -> HarnessAdmissionContext: + return HarnessAdmissionContext( + request_id=request_id, + sandbox_id="example-sandbox", + middleware_name=MIDDLEWARE_NAME, + harness="pi", + harness_version="extension-v1", + hook=AdmissionHook.RENDERED_PROMPT, + schema_version="openshell.pi-input.v1", + provider_target=_target(), + provider_adapter_schema="openai.chat-completions.v1", + ) + + +def _provider_request( + prompt: str, receipt: bytes | None, *, request_id: str +) -> HttpRequest: + body = json.dumps( + { + "model": "fixture-model", + "messages": [{"role": "user", "content": prompt}], + "tools": [], + "tool_choice": "auto", + "temperature": 0, + "max_completion_tokens": 128, + "stream": True, + "stream_options": {"include_usage": True}, + "store": False, + "prompt_cache_key": "example-session", + }, + separators=(",", ":"), + sort_keys=True, + ).encode() + headers = [HttpHeader(name="content-type", value="application/json")] + if receipt is not None: + headers.append(HttpHeader(name=RECEIPT_HEADER, value=receipt.decode("ascii"))) + return HttpRequest( + context=RequestContext(request_id=request_id, sandbox_id="example-sandbox"), + target=_target(), + headers=tuple(headers), + body=body, + ) + + +if __name__ == "__main__": + main() diff --git a/projects/egress-gate/tests/admission/test_example.py b/projects/egress-gate/tests/admission/test_example.py new file mode 100644 index 00000000..e144e827 --- /dev/null +++ b/projects/egress-gate/tests/admission/test_example.py @@ -0,0 +1,41 @@ +"""Black-box smoke test for the documented Pi admission example.""" + +from __future__ import annotations + +import json +import subprocess +import sys +from pathlib import Path + + +def test_documented_example_produces_acceptance_evidence(tmp_path: Path) -> None: + project_root = Path(__file__).parents[2] + session_file = tmp_path / "session.jsonl" + + completed = subprocess.run( + [ + sys.executable, + "examples/pi-attested-admission/run_example.py", + "--session-file", + str(session_file), + ], + cwd=project_root, + check=True, + capture_output=True, + text=True, + ) + evidence = json.loads(completed.stdout) + + assert evidence["safe_direct"]["decision"] == "allow" + assert evidence["safe_direct"]["provider_calls"] == 1 + assert evidence["safe_direct"]["receipt_count"] == 1 + assert evidence["direct_denial"]["session_unchanged"] is True + assert evidence["direct_denial"]["denied_content_absent"] is True + assert evidence["direct_denial"]["provider_calls"] == 0 + assert evidence["replacement_turn"]["original_absent"] is True + assert evidence["replacement_turn"]["replacement_present"] is True + assert evidence["replacement_turn"]["provider_original_absent"] is True + assert evidence["replacement_turn"]["provider_replacement_present"] is True + assert evidence["continuation"]["reason_code"] == "receipt_missing" + assert evidence["provider"]["receipt_headers_seen"] == 0 + assert session_file.is_file() From 2ae7555ab059f7243f7338fac59e666abac9e84f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 12 Aug 2026 01:56:41 +0000 Subject: [PATCH 03/70] fix(egress-gate): own Pi integration extension --- .../examples/pi-attested-admission/README.md | 16 ++- .../openshell-input-admission.ts | 135 ++++++++++++++++++ 2 files changed, 147 insertions(+), 4 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index d14d569a..69166315 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -49,10 +49,18 @@ Register this service as an OpenShell supervisor middleware and start it without `--no-require-pi-receipt`. Configure exactly one network middleware entry for the OpenAI provider host. When OpenShell sees that the service advertises the Pi admission binding, it exposes the loopback bridge and sets -`OPENSHELL_PI_CONVERSATION_URL` in the sandbox. The pinned Pi fork detects that -variable and loads its bundled `openshell-input-admission.ts` extension. A -normal Egress Gate deployment that does not use managed Pi must start with -`--no-require-pi-receipt`; it advertises and evaluates only HTTP middleware. +`OPENSHELL_PI_CONVERSATION_URL` in the sandbox. Start the pinned Pi fork with +the standard extension option and this example's extension: + +```shell +pi --extension ./openshell-input-admission.ts +``` + +Pi remains unaware of OpenShell; the deployment is responsible for loading the +extension. Receipt enforcement makes a missing or inactive extension fail +closed at provider egress. A normal Egress Gate deployment that does not use +managed Pi must start with `--no-require-pi-receipt`; it advertises and +evaluates only HTTP middleware. The managed path currently supports direct OpenAI Chat Completions requests from the pinned Pi serializer. It does not support images, steering or queued diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts new file mode 100644 index 00000000..4f33df47 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts @@ -0,0 +1,135 @@ +/** + * OpenShell direct-input admission for Pi. + * + * Load this extension explicitly with Pi's standard --extension option. It + * admits one idle, text-only user submission after rendering and before Pi + * persists it, then attaches the returned receipt to the first provider + * request. Steering, follow-ups, images, compaction, and post-tool + * continuations are unsupported and fail closed. + */ +import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +const BRIDGE_URL_ENV = "OPENSHELL_PI_CONVERSATION_URL"; +const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; +const SCHEMA_VERSION = "openshell.pi-input.v1"; +const MAX_RESPONSE_BYTES = 256 * 1024; +const MAX_RECEIPT_BYTES = 8 * 1024; + +interface BridgeResponse { + decision: "allow" | "deny"; + replacement_body?: number[]; + receipt?: number[]; + reason_code?: string; +} + +interface CandidateEnvelope { + schema_version: typeof SCHEMA_VERSION; + text: string; +} + +export default function (pi: ExtensionAPI) { + let pendingReceipt: string | undefined; + + pi.on("before_user_message_commit", async (event, ctx) => { + try { + pendingReceipt = undefined; + if (!ctx.isIdle() || event.images?.length) { + notifySafely(ctx, "OpenShell admission currently supports only idle, text-only prompts"); + return { action: "cancel" }; + } + const bridgeUrl = process.env[BRIDGE_URL_ENV]; + if (!bridgeUrl) throw new Error(`${BRIDGE_URL_ENV} is required for OpenShell admission`); + const envelope: CandidateEnvelope = { schema_version: SCHEMA_VERSION, text: event.text }; + const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); + const response = await fetch(bridgeUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + harness_version: "extension-v1", + session_id: ctx.sessionManager.getSessionId(), + submission_id: crypto.randomUUID(), + request_body: Array.from(requestBody), + }), + signal: ctx.signal, + }); + if (!response.ok) throw new Error("OpenShell admission is unavailable"); + const encoded = new Uint8Array(await response.arrayBuffer()); + if (encoded.byteLength > MAX_RESPONSE_BYTES) throw new Error("OpenShell admission response is too large"); + const result = parseBridgeResponse(JSON.parse(new TextDecoder().decode(encoded))); + if (result.decision === "deny") { + notifySafely(ctx, `OpenShell denied the prompt (${result.reason_code ?? "policy_denied"})`); + return { action: "cancel" }; + } + + pendingReceipt = decodeReceipt(result.receipt); + if (!result.replacement_body) return; + const replacement = parseEnvelope(new Uint8Array(result.replacement_body)); + return { action: "transform", text: replacement.text }; + } catch { + pendingReceipt = undefined; + notifySafely(ctx, "OpenShell admission is unavailable"); + return { action: "cancel" }; + } + }); + + pi.on("before_provider_headers", (event) => { + if (!pendingReceipt) throw new Error("OpenShell candidate admission receipt is missing"); + if (Object.keys(event.headers).some((name) => name.toLowerCase() === RECEIPT_HEADER)) { + throw new Error("OpenShell receipt header is reserved"); + } + event.headers[RECEIPT_HEADER] = pendingReceipt; + pendingReceipt = undefined; + }); +} + +function notifySafely(ctx: ExtensionContext, message: string): void { + try { + ctx.ui.notify(message, "warning"); + } catch { + // Admission remains fail closed when a UI implementation cannot notify. + } +} + +function parseBridgeResponse(value: unknown): BridgeResponse { + if (!isRecord(value) || (value.decision !== "allow" && value.decision !== "deny")) { + throw new Error("OpenShell admission returned an invalid response"); + } + if (value.decision === "deny") { + if (value.receipt !== undefined || value.replacement_body !== undefined) { + throw new Error("OpenShell admission returned an invalid denial"); + } + return { + decision: "deny", + reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined, + }; + } + if (!isByteArray(value.receipt) || (value.replacement_body !== undefined && !isByteArray(value.replacement_body))) { + throw new Error("OpenShell admission returned an invalid allow response"); + } + return { decision: "allow", receipt: value.receipt, replacement_body: value.replacement_body }; +} + +function parseEnvelope(body: Uint8Array): CandidateEnvelope { + const value: unknown = JSON.parse(new TextDecoder().decode(body)); + if (!isRecord(value) || value.schema_version !== SCHEMA_VERSION || typeof value.text !== "string") { + throw new Error("OpenShell admission returned an invalid replacement"); + } + return { schema_version: SCHEMA_VERSION, text: value.text }; +} + +function decodeReceipt(value: number[] | undefined): string { + if (!value || value.length === 0 || value.length > MAX_RECEIPT_BYTES) { + throw new Error("OpenShell admission receipt is invalid"); + } + const receipt = new TextDecoder("ascii", { fatal: true }).decode(new Uint8Array(value)); + if (!/^[\x21-\x7e]+$/.test(receipt)) throw new Error("OpenShell admission receipt is invalid"); + return receipt; +} + +function isByteArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} From 22c3e0559e0adc39a416b5e5f078b618a2358653 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 12 Aug 2026 02:10:31 +0000 Subject: [PATCH 04/70] refactor(egress-gate): use user message append hook --- projects/egress-gate/examples/pi-attested-admission/README.md | 2 +- .../examples/pi-attested-admission/openshell-input-admission.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 69166315..115886be 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -42,7 +42,7 @@ public API and preserve this ordering. Use the matching integration branches: -- [Pi `openshell/pi-egress-admission`](https://github.com/johnnygreco/pi/tree/openshell/pi-egress-admission) +- [Pi user-message append hook PR](https://github.com/johnnygreco/pi/pull/1) - [OpenShell `openshell/pi-egress-admission`](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) Register this service as an OpenShell supervisor middleware and start it without diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts index 4f33df47..58fb373e 100644 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts @@ -30,7 +30,7 @@ interface CandidateEnvelope { export default function (pi: ExtensionAPI) { let pendingReceipt: string | undefined; - pi.on("before_user_message_commit", async (event, ctx) => { + pi.on("before_user_message_append", async (event, ctx) => { try { pendingReceipt = undefined; if (!ctx.isIdle() || event.images?.length) { From a421530e967f78b1652785fc1c61065b91f9d9b7 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 12 Aug 2026 15:16:57 +0000 Subject: [PATCH 05/70] refactor(egress-gate): focus Pi example on deny and redact --- .../examples/pi-attested-admission/README.md | 119 ++++------- .../egress-gate-config.yaml | 4 +- .../pi-attested-admission/run_example.py | 191 +++++++----------- .../tests/admission/test_example.py | 36 ++-- 4 files changed, 127 insertions(+), 223 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 115886be..1c581a64 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,94 +1,61 @@ -# Pi attested-admission example +# Pi deny-or-redact example -This credential-free example exercises Egress Gate's public harness-admission -and attested-egress APIs across the state boundaries a managed Pi runtime must -enforce. It uses the real configured regex Gates, admission processor, Pi shape -adapter, Ed25519 receipt issuer, provider adapter, network Gate pass, and -receipt-header stripping. The deterministic provider recorder is local; no API -key or external service is needed. +This example demonstrates two outcomes for a rendered Pi prompt: -From `projects/egress-gate/`, run: +- **deny:** the prompt is not appended to chat history and no provider request + is made; +- **redact:** the replacement is appended to history and the provider receives + that same replacement. -```bash -uv run python examples/pi-attested-admission/run_example.py \ - --session-file /tmp/pi-egress-example/session.jsonl -``` - -The command prints JSON evidence for the intentionally small MVP: - -- a safe idle, text-only rendered prompt and its first provider request; -- denial before the candidate changes the session or reaches the provider; -- candidate replacement before persistence and provider serialization; -- fail-closed denial of an unattested continuation; and -- removal of the internal receipt header before the provider recorder. - -Inspect the resulting accepted history with: +Run the credential-free demonstration from `projects/egress-gate/`: -```bash -python3 -m json.tool --json-lines /tmp/pi-egress-example/session.jsonl +```shell +uv run python examples/pi-attested-admission/run_example.py ``` -The output reports receipt, canonicalization, provider-adapter, active key ID, -and policy versions, but never prints receipt bytes or denied content. +Its complete output is intentionally small: + +```json +{ + "deny": { + "decision": "deny", + "history_unchanged": true, + "provider_unchanged": true + }, + "redact": { + "decision": "replace", + "history": ["please [REDACTED]"], + "provider_prompts": ["please [REDACTED]"] + } +} +``` -This hermetic executable is the Egress Gate component layer of the broader Pi -integration. `ManagedPiSession` deliberately models the required ordering: -rendered-prompt admission, optional candidate replacement, candidate commit, then -attested network egress. It is not presented as the pinned downstream Pi fork -or the full OpenShell sandbox layer; those runtime artifacts must use the same -public API and preserve this ordering. +The example uses the real regex policy, admission processor, signed receipt, +provider-request validation, and egress processor. The receipt is internal +plumbing: it proves that the redacted prompt admitted before history append is +the prompt authorized at provider egress. -## Run the managed forks +## Managed Pi setup -Use the matching integration branches: +Use the matching branches: - [Pi user-message append hook PR](https://github.com/johnnygreco/pi/pull/1) -- [OpenShell `openshell/pi-egress-admission`](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) +- [OpenShell integration branch](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) -Register this service as an OpenShell supervisor middleware and start it without -`--no-require-pi-receipt`. Configure exactly one network middleware entry for -the OpenAI provider host. When OpenShell sees that the service advertises the -Pi admission binding, it exposes the loopback bridge and sets -`OPENSHELL_PI_CONVERSATION_URL` in the sandbox. Start the pinned Pi fork with -the standard extension option and this example's extension: +Register Egress Gate as an OpenShell supervisor middleware with Pi receipt +enforcement enabled. OpenShell exposes the admission bridge through +`OPENSHELL_PI_CONVERSATION_URL`. Load this directory's extension using Pi's +existing extension option: ```shell pi --extension ./openshell-input-admission.ts ``` -Pi remains unaware of OpenShell; the deployment is responsible for loading the -extension. Receipt enforcement makes a missing or inactive extension fail -closed at provider egress. A normal Egress Gate deployment that does not use -managed Pi must start with `--no-require-pi-receipt`; it advertises and -evaluates only HTTP middleware. - -The managed path currently supports direct OpenAI Chat Completions requests -from the pinned Pi serializer. It does not support images, steering or queued -follow-ups while streaming, compaction requests, provider retries, or automatic -continuations after tool calls. Those paths fail closed. The next increment is -a separate pre-provider-request admission boundary that issues one receipt for -each automatic call; it does not change the rendered-prompt hook or its -pre-persistence denial guarantee. - -Version 1 supports the direct OpenAI Chat Completions subset emitted by the -pinned Pi serializer: text messages, function tools and calls/results, -`max_completion_tokens`, optional `temperature` and `reasoning_effort`, tool -choice, `stream: true`, `stream_options.include_usage: true`, `store: false`, -and optional `prompt_cache_key` and `prompt_cache_retention: "24h"` cache -fields. Compatibility-provider fields, custom sampling parameters, unknown -fields, unsupported content variants, and lossy multipart forms fail closed. -The provider adapter accepts either a string or one OpenAI text -block for message content because the pinned fixture treats those as the same -single text value. It otherwise requires one representation: `content` is -present, optional message metadata is omitted instead of `null`, and empty tool -call arrays are omitted. Integer, floating-point, and negative-zero spellings of -the same temperature are normalized because the pinned fixture treats them as -one numeric value. Provider requests require exactly one parameter-free -`Content-Type: application/json` header and no `Content-Encoding`. +Pi remains unaware of OpenShell. The extension calls the bridge from +`before_user_message_append`: a denial returns `cancel`, while a replacement +returns `transform`. It attaches the resulting receipt to the first provider +request. Missing receipts and currently unsupported continuations fail closed. -Each receipt is short-lived and consumed by the first matching provider -request. It binds the admitted rendered prompt, sandbox, middleware policy, and -provider target. It does not prove which JavaScript extension called the -supervisor bridge, and it does not attest the complete conversation or provider -payload. OpenShell reruns the configured Gates on the actual HTTP request before -forwarding it and strips the internal receipt header. +This initial integration supports idle, text-only, direct OpenAI Chat +Completions submissions. Images, queued input, retries, compaction, and +automatic continuations after tool calls are deferred. diff --git a/projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml b/projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml index fe4d43f2..62d2a160 100644 --- a/projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml +++ b/projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml @@ -10,7 +10,7 @@ gates: - name: unsafe-marker rules: - name: exact-deny-marker - pattern: OPEN_SHELL_ADMISSION_DENY_TEST + pattern: DENY_THIS confidence: high - name: replace-marker kind: regex @@ -24,6 +24,6 @@ gates: - name: replacement-marker rules: - name: exact-replacement-marker - pattern: OPEN_SHELL_ADMISSION_REPLACE_TEST + pattern: REDACT_THIS confidence: high default_decision: allow diff --git a/projects/egress-gate/examples/pi-attested-admission/run_example.py b/projects/egress-gate/examples/pi-attested-admission/run_example.py index 2329156b..91b75d89 100644 --- a/projects/egress-gate/examples/pi-attested-admission/run_example.py +++ b/projects/egress-gate/examples/pi-attested-admission/run_example.py @@ -1,16 +1,13 @@ -"""Hermetic rendered-prompt admission example for the Pi MVP.""" +"""Show that managed Pi can deny or redact before recording a user prompt.""" from __future__ import annotations -import argparse import json -import tempfile from pathlib import Path import yaml from egress_gate.admission import ( - RECEIPT_HEADER, AdmissionDecision, AdmissionHook, AttestedEgressProcessor, @@ -29,147 +26,97 @@ from egress_gate.request_processor import apply_request_mutations from egress_gate.timeout import Timeout -DENY_MARKER = "OPEN_SHELL_ADMISSION_DENY_TEST" -REPLACE_MARKER = "OPEN_SHELL_ADMISSION_REPLACE_TEST" +DENY_MARKER = "DENY_THIS" +REDACT_MARKER = "REDACT_THIS" MIDDLEWARE_NAME = "pi-egress" -class ManagedPiSession: - """Model the extension's admit, optionally replace, commit, and send order.""" +class PiExample: + """Preserve the extension's admit, append, then send ordering.""" def __init__( self, - session_file: Path, admission: HarnessAdmissionProcessor, egress: AttestedEgressProcessor, ) -> None: - self._session_file = session_file - self._admission = admission - self._egress = egress - self._messages: list[dict[str, str]] = [] - self.provider_requests: list[HttpRequest] = [] - self._sequence = 0 - self._write_session() - - def submit(self, rendered_prompt: str) -> dict[str, object]: - before_messages = len(self._messages) - before_requests = len(self.provider_requests) + self.admission = admission + self.egress = egress + self.history: list[str] = [] + self.provider_prompts: list[str] = [] + + def submit(self, prompt: str) -> AdmissionDecision: body = canonical_json_bytes( - PiInputV1(schema_version="openshell.pi-input.v1", text=rendered_prompt) + PiInputV1(schema_version="openshell.pi-input.v1", text=prompt) ) - admitted = self._admission.process( + admitted = self.admission.process( HarnessAdmissionRequest( request_body=body, provenance=PromptProvenance( kind="rendered_prompt", session_id="example-session", - submission_id=self._next_id("submission"), + submission_id=f"submission-{len(self.history) + 1}", ), ), - _admission_context(self._next_id("admission")), + _admission_context(), timeout=Timeout.from_seconds(1), ) if admitted.decision is AdmissionDecision.DENY: - return { - "decision": "deny", - "reason_code": admitted.reason_code, - "session_unchanged": len(self._messages) == before_messages, - "provider_calls": len(self.provider_requests) - before_requests, - } + return admitted.decision accepted_body = admitted.replacement_body or body accepted_prompt = PiInputV1.model_validate_json(accepted_body, strict=True).text - self._messages.append({"role": "user", "content": accepted_prompt}) - self._write_session() - request = _provider_request( - accepted_prompt, admitted.receipt, request_id=self._next_id("network") + self.history.append(accepted_prompt) + + request = _provider_request(accepted_prompt, admitted.receipt) + result = self.egress.process(request, timeout=Timeout.from_seconds(1)) + if result.decision.value != "allow": + raise RuntimeError(f"attested egress denied: {result.reason_code}") + forwarded = apply_request_mutations(request, result.request_mutations) + self.provider_prompts.append( + json.loads(forwarded.body)["messages"][-1]["content"] ) - egress = self._egress.process(request, timeout=Timeout.from_seconds(1)) - if egress.decision.value != "allow": - raise RuntimeError(f"attested egress denied: {egress.reason_code}") - forwarded = apply_request_mutations(request, egress.request_mutations) - if any(header.name.lower() == RECEIPT_HEADER for header in forwarded.headers): - raise RuntimeError("internal receipt reached provider fixture") - self.provider_requests.append(forwarded) - history = self._session_file.read_text(encoding="utf-8") - return { - "decision": admitted.decision.value, - "provider_calls": len(self.provider_requests) - before_requests, - "receipt_count": int(admitted.receipt is not None), - "original_absent": rendered_prompt not in history, - "replacement_present": accepted_prompt in history, - "provider_original_absent": rendered_prompt.encode() not in forwarded.body, - "provider_replacement_present": accepted_prompt.encode() in forwarded.body, - } - - def continuation_without_receipt(self) -> str | None: - result = self._egress.process( - _provider_request( - "continuation", None, request_id=self._next_id("continuation") - ), - timeout=Timeout.from_seconds(1), - ) - return result.reason_code - - def _write_session(self) -> None: - self._session_file.write_text( - "".join( - json.dumps(message, sort_keys=True) + "\n" for message in self._messages - ), - encoding="utf-8", - ) - - def _next_id(self, prefix: str) -> str: - self._sequence += 1 - return f"{prefix}-{self._sequence}" + return admitted.decision def main() -> None: - parser = argparse.ArgumentParser(description=__doc__) - parser.add_argument("--session-file", type=Path) - options = parser.parse_args() - session_file = options.session_file or ( - Path(tempfile.mkdtemp(prefix="pi-egress-example-")) / "session.jsonl" - ) - session_file.parent.mkdir(parents=True, exist_ok=True) admission, egress = _processors() - session = ManagedPiSession(session_file, admission, egress) - - safe = session.submit("safe rendered prompt") - before_denial = session_file.read_bytes() - denied = session.submit(f"unsafe {DENY_MARKER}") - denied["denied_content_absent"] = ( - DENY_MARKER.encode() not in session_file.read_bytes() + example = PiExample(admission, egress) + + before_history = list(example.history) + before_provider = list(example.provider_prompts) + denied = example.submit(f"please {DENY_MARKER}") + history_unchanged = example.history == before_history + provider_unchanged = example.provider_prompts == before_provider + + redacted = example.submit(f"please {REDACT_MARKER}") + print( + json.dumps( + { + "deny": { + "decision": denied.value, + "history_unchanged": history_unchanged, + "provider_unchanged": provider_unchanged, + }, + "redact": { + "decision": redacted.value, + "history": example.history, + "provider_prompts": example.provider_prompts, + }, + }, + indent=2, + sort_keys=True, + ) ) - denied["session_unchanged"] = before_denial == session_file.read_bytes() - replacement = session.submit(f"replace {REPLACE_MARKER}") - evidence = { - "versions": admission.readiness, - "safe_direct": safe, - "direct_denial": denied, - "replacement_turn": replacement, - "continuation": {"reason_code": session.continuation_without_receipt()}, - "provider": { - "request_count": len(session.provider_requests), - "receipt_headers_seen": sum( - header.name.lower() == RECEIPT_HEADER - for request in session.provider_requests - for header in request.headers - ), - }, - } - print(json.dumps(evidence, indent=2, sort_keys=True)) def _processors() -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor]: - example_dir = Path(__file__).resolve().parent registry = create_builtin_registry() - config = registry.validate_config( - yaml.safe_load( - (example_dir / "egress-gate-config.yaml").read_text(encoding="utf-8") - ) + policy = yaml.safe_load( + (Path(__file__).parent / "egress-gate-config.yaml").read_text() + ) + processor = registry.prepare_processor( + registry.validate_config(policy), timeout=Timeout.from_seconds(1) ) - processor = registry.prepare_processor(config, timeout=Timeout.from_seconds(1)) authority = ReceiptAuthority() return ( HarnessAdmissionProcessor(processor, create_pi_adapter_registry(), authority), @@ -194,9 +141,9 @@ def _target() -> HttpTarget: ) -def _admission_context(request_id: str) -> HarnessAdmissionContext: +def _admission_context() -> HarnessAdmissionContext: return HarnessAdmissionContext( - request_id=request_id, + request_id="admission-request", sandbox_id="example-sandbox", middleware_name=MIDDLEWARE_NAME, harness="pi", @@ -208,30 +155,30 @@ def _admission_context(request_id: str) -> HarnessAdmissionContext: ) -def _provider_request( - prompt: str, receipt: bytes | None, *, request_id: str -) -> HttpRequest: +def _provider_request(prompt: str, receipt: bytes | None) -> HttpRequest: body = json.dumps( { "model": "fixture-model", "messages": [{"role": "user", "content": prompt}], - "tools": [], - "tool_choice": "auto", - "temperature": 0, "max_completion_tokens": 128, "stream": True, "stream_options": {"include_usage": True}, "store": False, - "prompt_cache_key": "example-session", }, separators=(",", ":"), - sort_keys=True, ).encode() headers = [HttpHeader(name="content-type", value="application/json")] if receipt is not None: - headers.append(HttpHeader(name=RECEIPT_HEADER, value=receipt.decode("ascii"))) + headers.append( + HttpHeader( + name="x-openshell-middleware-egress-receipt", + value=receipt.decode("ascii"), + ) + ) return HttpRequest( - context=RequestContext(request_id=request_id, sandbox_id="example-sandbox"), + context=RequestContext( + request_id="provider-request", sandbox_id="example-sandbox" + ), target=_target(), headers=tuple(headers), body=body, diff --git a/projects/egress-gate/tests/admission/test_example.py b/projects/egress-gate/tests/admission/test_example.py index e144e827..cd35b9eb 100644 --- a/projects/egress-gate/tests/admission/test_example.py +++ b/projects/egress-gate/tests/admission/test_example.py @@ -1,4 +1,4 @@ -"""Black-box smoke test for the documented Pi admission example.""" +"""Black-box test for the documented Pi admission example.""" from __future__ import annotations @@ -8,17 +8,10 @@ from pathlib import Path -def test_documented_example_produces_acceptance_evidence(tmp_path: Path) -> None: +def test_example_denies_or_redacts_before_history_and_egress() -> None: project_root = Path(__file__).parents[2] - session_file = tmp_path / "session.jsonl" - completed = subprocess.run( - [ - sys.executable, - "examples/pi-attested-admission/run_example.py", - "--session-file", - str(session_file), - ], + [sys.executable, "examples/pi-attested-admission/run_example.py"], cwd=project_root, check=True, capture_output=True, @@ -26,16 +19,13 @@ def test_documented_example_produces_acceptance_evidence(tmp_path: Path) -> None ) evidence = json.loads(completed.stdout) - assert evidence["safe_direct"]["decision"] == "allow" - assert evidence["safe_direct"]["provider_calls"] == 1 - assert evidence["safe_direct"]["receipt_count"] == 1 - assert evidence["direct_denial"]["session_unchanged"] is True - assert evidence["direct_denial"]["denied_content_absent"] is True - assert evidence["direct_denial"]["provider_calls"] == 0 - assert evidence["replacement_turn"]["original_absent"] is True - assert evidence["replacement_turn"]["replacement_present"] is True - assert evidence["replacement_turn"]["provider_original_absent"] is True - assert evidence["replacement_turn"]["provider_replacement_present"] is True - assert evidence["continuation"]["reason_code"] == "receipt_missing" - assert evidence["provider"]["receipt_headers_seen"] == 0 - assert session_file.is_file() + assert evidence["deny"] == { + "decision": "deny", + "history_unchanged": True, + "provider_unchanged": True, + } + assert evidence["redact"] == { + "decision": "replace", + "history": ["please [REDACTED]"], + "provider_prompts": ["please [REDACTED]"], + } From fc2f84950ac34f8579dde542c84084b859ca05a6 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 17 Aug 2026 21:35:24 +0000 Subject: [PATCH 06/70] docs(egress-gate): replace simulated Pi example --- .../examples/pi-attested-admission/README.md | 265 +++++++++++++++--- .../pi-attested-admission/models.json | 25 ++ .../pi-attested-admission/policy.yaml | 64 +++++ .../pi-attested-admission/run_example.py | 189 ------------- .../tests/admission/test_example.py | 31 -- projects/egress-gate/tests/test_cli.py | 1 + 6 files changed, 315 insertions(+), 260 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/models.json create mode 100644 projects/egress-gate/examples/pi-attested-admission/policy.yaml delete mode 100644 projects/egress-gate/examples/pi-attested-admission/run_example.py delete mode 100644 projects/egress-gate/tests/admission/test_example.py diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 1c581a64..9c172eb0 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,61 +1,246 @@ -# Pi deny-or-redact example +# Managed Pi deny-or-redact example -This example demonstrates two outcomes for a rendered Pi prompt: +This directory contains a real OpenShell configuration for running the Pi +admission extension with Egress Gate. It does not contain a simulated Pi +session or provider. -- **deny:** the prompt is not appended to chat history and no provider request - is made; -- **redact:** the replacement is appended to history and the provider receives - that same replacement. +The policy demonstrates two outcomes for rendered Pi prompts: -Run the credential-free demonstration from `projects/egress-gate/`: +- `DENY_THIS` denies the submission before Pi appends it to session history or + starts a provider request. +- `REDACT_THIS` becomes `[REDACTED]` before Pi appends the submission. Pi sends + that same replacement in the provider request. + +This example makes real OpenAI API calls and may incur provider charges. + +## Prerequisites + +Use these matching fork branches: + +- [Pi user-message append hook PR](https://github.com/johnnygreco/pi/pull/1) +- [OpenShell integration branch](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) +- [OpenShell Research integration branch](https://github.com/NVIDIA/OpenShell-Research/tree/johnny/pi-attested-admission) + +Install the development prerequisites documented by each repository. The host +must have an `OPENAI_API_KEY`, and the host, gateway, and sandbox supervisor +must be able to reach the Egress Gate service. + +The instructions below use these checkout placeholders: + +```text +/path/to/pi +/path/to/OpenShell +/path/to/OpenShell-Research +``` + +Replace them with absolute paths on your machine. + +## 1. Build the Pi fork + +Build the coding-agent package from the Pi fork, pack it, and install it into a +standalone directory that can be uploaded to a sandbox: ```shell -uv run python examples/pi-attested-admission/run_example.py +cd /path/to/pi +npm install --ignore-scripts +npm run build +mkdir -p /tmp/pi-egress-pack /tmp/pi-egress-runtime +npm pack --workspace @earendil-works/pi-coding-agent \ + --pack-destination /tmp/pi-egress-pack ``` -Its complete output is intentionally small: +The last command prints the tarball name. Pass that exact file to: -```json -{ - "deny": { - "decision": "deny", - "history_unchanged": true, - "provider_unchanged": true - }, - "redact": { - "decision": "replace", - "history": ["please [REDACTED]"], - "provider_prompts": ["please [REDACTED]"] - } -} +```shell +npm install --prefix /tmp/pi-egress-runtime --ignore-scripts \ + /tmp/pi-egress-pack/earendil-works-pi-coding-agent-VERSION.tgz ``` -The example uses the real regex policy, admission processor, signed receipt, -provider-request validation, and egress processor. The receipt is internal -plumbing: it proves that the redacted prompt admitted before history append is -the prompt authorized at provider egress. +Replace `VERSION` with the version in the printed filename. The built CLI entry +point is then +`/tmp/pi-egress-runtime/node_modules/@earendil-works/pi-coding-agent/dist/cli.js`. -## Managed Pi setup +## 2. Register and start Egress Gate -Use the matching branches: +Stop any OpenShell gateway that uses the target gateway configuration. A +running gateway does not reload middleware registrations. -- [Pi user-message append hook PR](https://github.com/johnnygreco/pi/pull/1) -- [OpenShell integration branch](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) +From the Egress Gate project, add the operator middleware registration. Replace +`YOUR_HOST_IPV4` with a non-loopback IPv4 address reachable by the gateway and +sandbox supervisors: + +```shell +cd /path/to/OpenShell-Research/projects/egress-gate +uv run egress-gate add-gateway-registration \ + --host-ip YOUR_HOST_IPV4 \ + --name pi-egress \ + --port 50051 +``` + +In the same directory, start Egress Gate with Pi receipt enforcement enabled: + +```shell +uv run egress-gate --debug serve \ + --listen 0.0.0.0:50051 \ + --timeout 4s \ + --require-pi-receipt +``` + +Keep this terminal open. The service exposes both the rendered-prompt admission +binding and the HTTP egress binding used by this example. + +## 3. Start the OpenShell fork + +In another terminal, start the gateway from the matching OpenShell fork. It +loads the `pi-egress` registration added above: + +```shell +cd /path/to/OpenShell +mise trust +mise run gateway +``` + +Leave the gateway running. Use the repository's `scripts/bin/openshell` wrapper +for the remaining OpenShell commands so the CLI and gateway come from the same +fork. + +## 4. Create an OpenAI provider + +In a third terminal, create a provider whose credential is injected only when +the admitted request reaches `api.openai.com`: + +```shell +cd /path/to/OpenShell +/path/to/OpenShell/scripts/bin/openshell provider create \ + --name pi-openai \ + --type openai \ + --credential OPENAI_API_KEY +``` + +The bare credential name reads `OPENAI_API_KEY` from the host environment. It +does not place the real key in the sandbox environment. + +## 5. Create the managed Pi sandbox -Register Egress Gate as an OpenShell supervisor middleware with Pi receipt -enforcement enabled. OpenShell exposes the admission bridge through -`OPENSHELL_PI_CONVERSATION_URL`. Load this directory's extension using Pi's -existing extension option: +Run the following command from this example directory: ```shell -pi --extension ./openshell-input-admission.ts +cd /path/to/OpenShell-Research/projects/egress-gate/examples/pi-attested-admission +/path/to/OpenShell/scripts/bin/openshell sandbox create \ + --name pi-egress-demo \ + --from base \ + --provider pi-openai \ + --policy policy.yaml \ + --upload /tmp/pi-egress-runtime:/sandbox/pi-runtime \ + --upload ./openshell-input-admission.ts:/sandbox/openshell-input-admission.ts \ + --upload ./models.json:/sandbox/pi-agent/models.json \ + -- env PI_CODING_AGENT_DIR=/sandbox/pi-agent \ + node /sandbox/pi-runtime/node_modules/@earendil-works/pi-coding-agent/dist/cli.js \ + --provider openai-chat-completions \ + --model gpt-4o-mini \ + --extension /sandbox/openshell-input-admission.ts \ + --session-dir /sandbox/pi-sessions ``` -Pi remains unaware of OpenShell. The extension calls the bridge from -`before_user_message_append`: a denial returns `cancel`, while a replacement -returns `transform`. It attaches the resulting receipt to the first provider -request. Missing receipts and currently unsupported continuations fail closed. +OpenShell recognizes the configured Pi admission binding, starts its +loopback-only bridge, and sets `OPENSHELL_PI_CONVERSATION_URL` for the Pi +process. The extension calls that bridge from `before_user_message_append` and +attaches the returned receipt to the first provider request. Pi itself contains +no OpenShell-specific startup behavior. + +[`models.json`](models.json) pins this run to OpenAI Chat Completions. The +initial integration does not support the Responses API. + +## 6. Verify denial + +At the Pi prompt, submit: + +```text +Reply with exactly: DENY_THIS +``` + +Pi reports that OpenShell denied the prompt and does not start a model turn. +Run `/session` before exiting Pi to see the active session file. After exiting, +inspect all example session files: + +```shell +/path/to/OpenShell/scripts/bin/openshell sandbox exec -n pi-egress-demo -- \ + grep -R -n DENY_THIS /sandbox/pi-sessions +``` + +The command must produce no matches. The Egress Gate terminal has no +corresponding HTTP provider-request evaluation. + +## 7. Verify replacement + +Reconnect to the same sandbox and start Pi with the same extension and session +directory: + +```shell +/path/to/OpenShell/scripts/bin/openshell sandbox exec -n pi-egress-demo --tty -- \ + env PI_CODING_AGENT_DIR=/sandbox/pi-agent \ + node /sandbox/pi-runtime/node_modules/@earendil-works/pi-coding-agent/dist/cli.js \ + --provider openai-chat-completions \ + --model gpt-4o-mini \ + --extension /sandbox/openshell-input-admission.ts \ + --session-dir /sandbox/pi-sessions +``` + +Submit: + +```text +Reply with exactly: REDACT_THIS +``` + +The request makes a real model call. After exiting Pi, inspect the persisted +session: + +```shell +/path/to/OpenShell/scripts/bin/openshell sandbox exec -n pi-egress-demo -- \ + grep -R -n -E 'REDACT_THIS|\[REDACTED\]' /sandbox/pi-sessions +``` + +The session must contain `[REDACTED]` and must not contain `REDACT_THIS`. The +Egress Gate terminal records an allowed provider-request evaluation. A +successful request also proves that its rendered prompt matched the admitted +replacement: Egress Gate rejects a receipt when the provider request contains a +different final user prompt. The network middleware consumes the receipt, then +removes the internal receipt header before forwarding upstream. + +## Configuration correspondence + +[`egress-gate-config.yaml`](egress-gate-config.yaml) is the standalone Egress +Gate configuration. [`policy.yaml`](policy.yaml) embeds that exact configuration +under `network_middlewares.pi_egress_gate.config`, attaches the registered +`pi-egress` service, selects exactly `api.openai.com`, and fails closed if the +middleware is unavailable. + +OpenShell uses the same middleware configuration for rendered-prompt admission +and provider HTTP egress. This is what lets Egress Gate issue a receipt before +Pi persists the candidate and verify it again at the network boundary. + +## Current scope This initial integration supports idle, text-only, direct OpenAI Chat Completions submissions. Images, queued input, retries, compaction, and -automatic continuations after tool calls are deferred. +automatic continuations after tool calls are unsupported and fail closed. The +next comprehensive boundary is one receipt per provider request; it does not +require one Pi hook per message role. + +## Cleanup + +Delete the sandbox and provider: + +```shell +cd /path/to/OpenShell +/path/to/OpenShell/scripts/bin/openshell sandbox delete pi-egress-demo +/path/to/OpenShell/scripts/bin/openshell provider delete pi-openai +``` + +Stop the gateway before removing its static middleware registration, then +restart it: + +```shell +cd /path/to/OpenShell-Research/projects/egress-gate +uv run egress-gate remove-gateway-registration --name pi-egress +``` diff --git a/projects/egress-gate/examples/pi-attested-admission/models.json b/projects/egress-gate/examples/pi-attested-admission/models.json new file mode 100644 index 00000000..69c6f911 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/models.json @@ -0,0 +1,25 @@ +{ + "providers": { + "openai-chat-completions": { + "baseUrl": "https://api.openai.com/v1", + "api": "openai-completions", + "apiKey": "$OPENAI_API_KEY", + "models": [ + { + "id": "gpt-4o-mini", + "name": "GPT-4o mini (Chat Completions)", + "reasoning": false, + "input": ["text"], + "contextWindow": 128000, + "maxTokens": 16384, + "cost": { + "input": 0.15, + "output": 0.6, + "cacheRead": 0.075, + "cacheWrite": 0 + } + } + ] + } + } +} diff --git a/projects/egress-gate/examples/pi-attested-admission/policy.yaml b/projects/egress-gate/examples/pi-attested-admission/policy.yaml new file mode 100644 index 00000000..82e8fec5 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/policy.yaml @@ -0,0 +1,64 @@ +version: 1 + +filesystem_policy: + include_workdir: true + read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] + read_write: [/sandbox, /tmp, /dev/null] +landlock: + compatibility: best_effort +process: + run_as_user: sandbox + run_as_group: sandbox + +network_policies: + openai: + name: OpenAI Chat Completions + endpoints: + - host: api.openai.com + port: 443 + protocol: rest + enforcement: enforce + access: full + binaries: + - { path: /usr/bin/node } + - { path: /usr/local/bin/node } + +network_middlewares: + pi_egress_gate: + name: Admit rendered Pi prompts and inspect provider requests + middleware: pi-egress + order: 0 + config: + gates: + - name: deny-marker + kind: regex + scan: + kind: body + action: + kind: deny + pattern_catalog: + entities: + - name: unsafe-marker + rules: + - name: exact-deny-marker + pattern: DENY_THIS + confidence: high + - name: replace-marker + kind: regex + scan: + kind: body + action: + kind: replace + template: "[REDACTED]" + pattern_catalog: + entities: + - name: replacement-marker + rules: + - name: exact-replacement-marker + pattern: REDACT_THIS + confidence: high + default_decision: allow + on_error: fail_closed + endpoints: + include: + - api.openai.com diff --git a/projects/egress-gate/examples/pi-attested-admission/run_example.py b/projects/egress-gate/examples/pi-attested-admission/run_example.py deleted file mode 100644 index 91b75d89..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/run_example.py +++ /dev/null @@ -1,189 +0,0 @@ -"""Show that managed Pi can deny or redact before recording a user prompt.""" - -from __future__ import annotations - -import json -from pathlib import Path - -import yaml - -from egress_gate.admission import ( - AdmissionDecision, - AdmissionHook, - AttestedEgressProcessor, - HarnessAdmissionContext, - HarnessAdmissionProcessor, - HarnessAdmissionRequest, - PiInputV1, - PromptProvenance, - ReceiptAuthority, - canonical_json_bytes, - create_pi_adapter_registry, - create_provider_adapter_registry, -) -from egress_gate.gates import create_builtin_registry -from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext -from egress_gate.request_processor import apply_request_mutations -from egress_gate.timeout import Timeout - -DENY_MARKER = "DENY_THIS" -REDACT_MARKER = "REDACT_THIS" -MIDDLEWARE_NAME = "pi-egress" - - -class PiExample: - """Preserve the extension's admit, append, then send ordering.""" - - def __init__( - self, - admission: HarnessAdmissionProcessor, - egress: AttestedEgressProcessor, - ) -> None: - self.admission = admission - self.egress = egress - self.history: list[str] = [] - self.provider_prompts: list[str] = [] - - def submit(self, prompt: str) -> AdmissionDecision: - body = canonical_json_bytes( - PiInputV1(schema_version="openshell.pi-input.v1", text=prompt) - ) - admitted = self.admission.process( - HarnessAdmissionRequest( - request_body=body, - provenance=PromptProvenance( - kind="rendered_prompt", - session_id="example-session", - submission_id=f"submission-{len(self.history) + 1}", - ), - ), - _admission_context(), - timeout=Timeout.from_seconds(1), - ) - if admitted.decision is AdmissionDecision.DENY: - return admitted.decision - - accepted_body = admitted.replacement_body or body - accepted_prompt = PiInputV1.model_validate_json(accepted_body, strict=True).text - self.history.append(accepted_prompt) - - request = _provider_request(accepted_prompt, admitted.receipt) - result = self.egress.process(request, timeout=Timeout.from_seconds(1)) - if result.decision.value != "allow": - raise RuntimeError(f"attested egress denied: {result.reason_code}") - forwarded = apply_request_mutations(request, result.request_mutations) - self.provider_prompts.append( - json.loads(forwarded.body)["messages"][-1]["content"] - ) - return admitted.decision - - -def main() -> None: - admission, egress = _processors() - example = PiExample(admission, egress) - - before_history = list(example.history) - before_provider = list(example.provider_prompts) - denied = example.submit(f"please {DENY_MARKER}") - history_unchanged = example.history == before_history - provider_unchanged = example.provider_prompts == before_provider - - redacted = example.submit(f"please {REDACT_MARKER}") - print( - json.dumps( - { - "deny": { - "decision": denied.value, - "history_unchanged": history_unchanged, - "provider_unchanged": provider_unchanged, - }, - "redact": { - "decision": redacted.value, - "history": example.history, - "provider_prompts": example.provider_prompts, - }, - }, - indent=2, - sort_keys=True, - ) - ) - - -def _processors() -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor]: - registry = create_builtin_registry() - policy = yaml.safe_load( - (Path(__file__).parent / "egress-gate-config.yaml").read_text() - ) - processor = registry.prepare_processor( - registry.validate_config(policy), timeout=Timeout.from_seconds(1) - ) - authority = ReceiptAuthority() - return ( - HarnessAdmissionProcessor(processor, create_pi_adapter_registry(), authority), - AttestedEgressProcessor( - processor, - create_provider_adapter_registry(), - authority, - middleware_name=MIDDLEWARE_NAME, - harness_version="extension-v1", - ), - ) - - -def _target() -> HttpTarget: - return HttpTarget( - scheme="https", - host="provider.fixture", - port=443, - method="POST", - path="/v1/chat/completions", - query="", - ) - - -def _admission_context() -> HarnessAdmissionContext: - return HarnessAdmissionContext( - request_id="admission-request", - sandbox_id="example-sandbox", - middleware_name=MIDDLEWARE_NAME, - harness="pi", - harness_version="extension-v1", - hook=AdmissionHook.RENDERED_PROMPT, - schema_version="openshell.pi-input.v1", - provider_target=_target(), - provider_adapter_schema="openai.chat-completions.v1", - ) - - -def _provider_request(prompt: str, receipt: bytes | None) -> HttpRequest: - body = json.dumps( - { - "model": "fixture-model", - "messages": [{"role": "user", "content": prompt}], - "max_completion_tokens": 128, - "stream": True, - "stream_options": {"include_usage": True}, - "store": False, - }, - separators=(",", ":"), - ).encode() - headers = [HttpHeader(name="content-type", value="application/json")] - if receipt is not None: - headers.append( - HttpHeader( - name="x-openshell-middleware-egress-receipt", - value=receipt.decode("ascii"), - ) - ) - return HttpRequest( - context=RequestContext( - request_id="provider-request", sandbox_id="example-sandbox" - ), - target=_target(), - headers=tuple(headers), - body=body, - ) - - -if __name__ == "__main__": - main() diff --git a/projects/egress-gate/tests/admission/test_example.py b/projects/egress-gate/tests/admission/test_example.py deleted file mode 100644 index cd35b9eb..00000000 --- a/projects/egress-gate/tests/admission/test_example.py +++ /dev/null @@ -1,31 +0,0 @@ -"""Black-box test for the documented Pi admission example.""" - -from __future__ import annotations - -import json -import subprocess -import sys -from pathlib import Path - - -def test_example_denies_or_redacts_before_history_and_egress() -> None: - project_root = Path(__file__).parents[2] - completed = subprocess.run( - [sys.executable, "examples/pi-attested-admission/run_example.py"], - cwd=project_root, - check=True, - capture_output=True, - text=True, - ) - evidence = json.loads(completed.stdout) - - assert evidence["deny"] == { - "decision": "deny", - "history_unchanged": True, - "provider_unchanged": True, - } - assert evidence["redact"] == { - "decision": "replace", - "history": ["please [REDACTED]"], - "provider_prompts": ["please [REDACTED]"], - } diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 04607e54..c1edccb4 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -246,6 +246,7 @@ def test_cli_evaluate_runs_the_custom_gate_examples( @pytest.mark.parametrize( ("registry_reference", "example_directory", "registration_name"), [ + (None, "pi-attested-admission", "pi-egress"), (None, "regex-redaction", "eg-regex"), ( "examples.custom-gate.keyword_gate:registry", From c05a98d2e0cc601dea550c7cb124b46e0723e20d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 17 Aug 2026 22:00:19 +0000 Subject: [PATCH 07/70] fix(egress-gate): clean up Pi admission integration --- .../examples/pi-attested-admission/README.md | 17 ++++------ .../egress-gate-config.yaml | 29 ---------------- .../src/egress_gate/admission/__init__.py | 5 +++ .../src/egress_gate/admission/adapters.py | 5 ++- .../src/egress_gate/admission/canonical.py | 3 ++ .../src/egress_gate/admission/models.py | 11 ++++-- .../src/egress_gate/admission/processor.py | 6 ++++ .../src/egress_gate/admission/receipts.py | 3 ++ .../src/egress_gate/service/servicer.py | 8 ++--- .../egress-gate/tests/admission/__init__.py | 3 ++ .../tests/admission/test_admission.py | 34 ++++++++++++++----- projects/egress-gate/tests/test_cli.py | 12 ++++++- 12 files changed, 79 insertions(+), 57 deletions(-) delete mode 100644 projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 9c172eb0..99f4520e 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,8 +1,7 @@ # Managed Pi deny-or-redact example -This directory contains a real OpenShell configuration for running the Pi -admission extension with Egress Gate. It does not contain a simulated Pi -session or provider. +This directory contains an OpenShell configuration for running the Pi +admission extension with Egress Gate. The policy demonstrates two outcomes for rendered Pi prompts: @@ -207,17 +206,15 @@ replacement: Egress Gate rejects a receipt when the provider request contains a different final user prompt. The network middleware consumes the receipt, then removes the internal receipt header before forwarding upstream. -## Configuration correspondence +## Configuration -[`egress-gate-config.yaml`](egress-gate-config.yaml) is the standalone Egress -Gate configuration. [`policy.yaml`](policy.yaml) embeds that exact configuration -under `network_middlewares.pi_egress_gate.config`, attaches the registered -`pi-egress` service, selects exactly `api.openai.com`, and fails closed if the -middleware is unavailable. +[`policy.yaml`](policy.yaml) configures the `pi-egress` middleware for both +rendered-prompt admission and requests to `api.openai.com`. It fails closed if +the middleware is unavailable. OpenShell uses the same middleware configuration for rendered-prompt admission and provider HTTP egress. This is what lets Egress Gate issue a receipt before -Pi persists the candidate and verify it again at the network boundary. +Pi persists the candidate and verify the receipt again at the network boundary. ## Current scope diff --git a/projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml b/projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml deleted file mode 100644 index 62d2a160..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/egress-gate-config.yaml +++ /dev/null @@ -1,29 +0,0 @@ -gates: - - name: deny-marker - kind: regex - scan: - kind: body - action: - kind: deny - pattern_catalog: - entities: - - name: unsafe-marker - rules: - - name: exact-deny-marker - pattern: DENY_THIS - confidence: high - - name: replace-marker - kind: regex - scan: - kind: body - action: - kind: replace - template: "[REDACTED]" - pattern_catalog: - entities: - - name: replacement-marker - rules: - - name: exact-replacement-marker - pattern: REDACT_THIS - confidence: high -default_decision: allow diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py index 2bea2f8c..b60830a8 100644 --- a/projects/egress-gate/src/egress_gate/admission/__init__.py +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + """First-class harness admission and attested-egress APIs.""" from egress_gate.admission.adapters import ( @@ -23,6 +26,7 @@ canonical_json_bytes, ) from egress_gate.admission.models import ( + MAX_ADMISSION_BODY_BYTES, PI_HARNESS_VERSION, AdmissionDecision, AdmissionHook, @@ -54,6 +58,7 @@ "HarnessAdmissionProcessor", "HarnessAdmissionRequest", "HarnessAdmissionResult", + "MAX_ADMISSION_BODY_BYTES", "PromptProvenance", "PI_HARNESS_VERSION", "ModelRequestV1", diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index 27f0b228..80af00eb 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + """Registered Pi and provider request-shape adapters.""" from __future__ import annotations @@ -344,7 +347,7 @@ def create_pi_adapter_registry() -> HarnessAdapterRegistry: def create_provider_adapter_registry() -> ProviderAdapterRegistry: - """Return the milestone-one provider registry.""" + """Return the built-in OpenAI Chat Completions provider registry.""" registry = ProviderAdapterRegistry() registry.register(OpenAIChatCompletionsV1Adapter()) return registry diff --git a/projects/egress-gate/src/egress_gate/admission/canonical.py b/projects/egress-gate/src/egress_gate/admission/canonical.py index f7ac136f..cd5d08f0 100644 --- a/projects/egress-gate/src/egress_gate/admission/canonical.py +++ b/projects/egress-gate/src/egress_gate/admission/canonical.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + """Strict canonical model-request schema and encoding.""" from __future__ import annotations diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py index 84c18980..9cfbe944 100644 --- a/projects/egress-gate/src/egress_gate/admission/models.py +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + """Public, transport-neutral models for harness admission.""" from __future__ import annotations @@ -8,11 +11,12 @@ from pydantic import Field, model_validator from egress_gate.base import StrictDomainModel -from egress_gate.constants import MAX_BODY_BYTES, MAX_PROTO_FINDING_GROUPS +from egress_gate.constants import MAX_PROTO_FINDING_GROUPS from egress_gate.request import HttpTarget from egress_gate.result import ReasonCode, SourcedFinding from egress_gate.string_validators import BoundedMetadataString, ScalarString +MAX_ADMISSION_BODY_BYTES = 32 * 1024 PI_HARNESS_VERSION = "extension-v1" @@ -41,7 +45,7 @@ class PromptProvenance(StrictDomainModel): class HarnessAdmissionRequest(StrictDomainModel): """One complete harness-native rendered prompt.""" - request_body: bytes = Field(max_length=MAX_BODY_BYTES, repr=False) + request_body: bytes = Field(max_length=MAX_ADMISSION_BODY_BYTES, repr=False) provenance: PromptProvenance @@ -66,7 +70,7 @@ class HarnessAdmissionResult(StrictDomainModel): decision: AdmissionDecision replacement_body: bytes | None = Field( default=None, - max_length=MAX_BODY_BYTES, + max_length=MAX_ADMISSION_BODY_BYTES, repr=False, ) receipt: bytes | None = Field( @@ -112,6 +116,7 @@ def _decision_contract_is_consistent(self) -> HarnessAdmissionResult: "HarnessAdmissionContext", "HarnessAdmissionRequest", "HarnessAdmissionResult", + "MAX_ADMISSION_BODY_BYTES", "PromptProvenance", "PI_HARNESS_VERSION", ] diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py index 9b5a0f07..7aec8c0c 100644 --- a/projects/egress-gate/src/egress_gate/admission/processor.py +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + """Harness-admission orchestration and attested network egress.""" from __future__ import annotations @@ -15,6 +18,7 @@ ) from egress_gate.admission.canonical import canonical_json_bytes from egress_gate.admission.models import ( + MAX_ADMISSION_BODY_BYTES, AdmissionDecision, AdmissionHook, HarnessAdmissionContext, @@ -117,6 +121,8 @@ def process( replacement, rendered_prompt = adapter.validate_result( prepared, final_request.body, context, timeout ) + if replacement is not None and len(replacement) > MAX_ADMISSION_BODY_BYTES: + raise AdmissionMutationError("admission replacement body is too large") timeout.raise_if_expired() receipt = self._receipt_authority.issue( rendered_prompt, diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py index 4c2603fa..6442fcbf 100644 --- a/projects/egress-gate/src/egress_gate/admission/receipts.py +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + """Short-lived Ed25519 admission receipts.""" from __future__ import annotations diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index 317e3ae0..6a8ec786 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -19,6 +19,7 @@ from google.protobuf.message import Message from egress_gate.admission import ( + MAX_ADMISSION_BODY_BYTES, PI_HARNESS_VERSION, RECEIPT_HEADER, AdmissionDecision, @@ -108,9 +109,6 @@ def _require_pi_harness_version(value: str) -> Literal["extension-v1"]: raise ValueError("invalid Pi harness version") -MAX_AGENT_ADMISSION_BODY_BYTES = 32 * 1024 - - class EgressGateMiddleware(pb2_grpc.SupervisorMiddlewareServicer): """Validate, prepare, resolve, and run Egress Gate policies.""" @@ -169,7 +167,7 @@ async def Describe( pb2.MiddlewareBinding( operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION, phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, - max_body_bytes=MAX_AGENT_ADMISSION_BODY_BYTES, + max_body_bytes=MAX_ADMISSION_BODY_BYTES, harness="pi", hook=hook.value, schema_version="openshell.pi-input.v1", @@ -227,7 +225,7 @@ def _evaluate_agent_admission( raise ValueError("agent admission is disabled") if request.phase != pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: raise ValueError("invalid admission phase") - if len(request.request_body) > MAX_AGENT_ADMISSION_BODY_BYTES: + if len(request.request_body) > MAX_ADMISSION_BODY_BYTES: raise ValueError("admission request body is too large") hook = AdmissionHook(request.target.hook) target = HttpTarget( diff --git a/projects/egress-gate/tests/admission/__init__.py b/projects/egress-gate/tests/admission/__init__.py index 87d79542..a60fe663 100644 --- a/projects/egress-gate/tests/admission/__init__.py +++ b/projects/egress-gate/tests/admission/__init__.py @@ -1 +1,4 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + """Admission and attested-egress tests.""" diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index 13f5637a..567fa7d2 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + """Conformance tests for rendered-prompt admission and attested egress.""" from __future__ import annotations @@ -23,11 +26,13 @@ from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext from egress_gate.timeout import Timeout -DENY_MARKER = "OPEN_SHELL_ADMISSION_DENY_TEST" -REPLACE_MARKER = "OPEN_SHELL_ADMISSION_REPLACE_TEST" +DENY_TEXT = "DENY_THIS" +REDACT_TEXT = "REDACT_THIS" -def _processors() -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor]: +def _processors( + *, replacement_template: str = "[REDACTED]" +) -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor]: registry = create_builtin_registry() config = registry.validate_config( { @@ -43,7 +48,7 @@ def _processors() -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor]: "rules": [ { "name": "exact-marker", - "pattern": DENY_MARKER, + "pattern": DENY_TEXT, "confidence": "high", } ], @@ -56,7 +61,10 @@ def _processors() -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor]: "kind": "regex", "scan": { "kind": "body", - "action": {"kind": "replace", "template": "[REDACTED]"}, + "action": { + "kind": "replace", + "template": replacement_template, + }, }, "pattern_catalog": { "entities": [ @@ -65,7 +73,7 @@ def _processors() -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor]: "rules": [ { "name": "exact-marker", - "pattern": REPLACE_MARKER, + "pattern": REDACT_TEXT, "confidence": "high", } ], @@ -211,7 +219,7 @@ def test_rendered_prompt_receipt_is_consumed_after_first_request() -> None: def test_denial_returns_no_receipt_or_replacement() -> None: admission, _ = _processors() - _, denied = _admit(admission, f"do not persist {DENY_MARKER}") + _, denied = _admit(admission, f"do not persist {DENY_TEXT}") assert denied.decision is AdmissionDecision.DENY assert denied.receipt is None @@ -220,7 +228,7 @@ def test_denial_returns_no_receipt_or_replacement() -> None: def test_redaction_receipt_binds_only_the_replacement() -> None: admission, egress = _processors() - original = f"hide {REPLACE_MARKER} please" + original = f"hide {REDACT_TEXT} please" _, admitted = _admit(admission, original) assert admitted.decision is AdmissionDecision.REPLACE @@ -246,6 +254,16 @@ def test_redaction_receipt_binds_only_the_replacement() -> None: ) +def test_oversized_redaction_fails_before_receipt_issuance() -> None: + admission, _ = _processors(replacement_template="x" * 1024) + + _, denied = _admit(admission, REDACT_TEXT * 33) + + assert denied.decision is AdmissionDecision.DENY + assert denied.reason_code == "admission_contract_invalid" + assert denied.receipt is None + + def test_changed_prompt_and_unattested_continuation_fail_closed() -> None: admission, egress = _processors() _, admitted = _admit(admission, "safe rendered prompt") diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index c1edccb4..9be29c16 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -246,7 +246,6 @@ def test_cli_evaluate_runs_the_custom_gate_examples( @pytest.mark.parametrize( ("registry_reference", "example_directory", "registration_name"), [ - (None, "pi-attested-admission", "pi-egress"), (None, "regex-redaction", "eg-regex"), ( "examples.custom-gate.keyword_gate:registry", @@ -290,6 +289,17 @@ def test_openshell_example_policies_use_valid_gate_configuration( assert embedded_config == standalone_config +def test_pi_admission_policy_uses_valid_gate_configuration() -> None: + project_dir = Path(__file__).parents[1] + policy_path = project_dir / "examples/pi-attested-admission/policy.yaml" + policy = yaml.safe_load(policy_path.read_text()) + middleware = policy["network_middlewares"]["pi_egress_gate"] + + assert middleware["middleware"] == "pi-egress" + assert len(middleware["middleware"]) <= MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES + create_builtin_registry().validate_config(middleware["config"]) + + @pytest.mark.parametrize( ("example_directory", "name"), [ From 48fdf1eabd8cb84bea83a10a281f11cbda2d73c1 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 20 Aug 2026 21:48:56 +0000 Subject: [PATCH 08/70] docs(egress-gate): simplify Pi admission demo --- .../examples/pi-attested-admission/README.md | 244 +++++------------- .../examples/pi-attested-admission/demo.sh | 210 +++++++++++++++ .../tests/test_pi_example_commands.py | 49 ++++ 3 files changed, 329 insertions(+), 174 deletions(-) create mode 100755 projects/egress-gate/examples/pi-attested-admission/demo.sh create mode 100644 projects/egress-gate/tests/test_pi_example_commands.py diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 99f4520e..9c3e70dd 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,220 +1,122 @@ # Managed Pi deny-or-redact example -This directory contains an OpenShell configuration for running the Pi -admission extension with Egress Gate. +This example runs the forked Pi CLI inside OpenShell and sends its rendered +user submissions through Egress Gate. It makes real OpenAI API calls and may +incur provider charges. -The policy demonstrates two outcomes for rendered Pi prompts: +- `DENY_THIS` is rejected before Pi writes it to session history or starts a + model turn. +- `REDACT_THIS` becomes `[REDACTED]` before Pi writes or sends it. -- `DENY_THIS` denies the submission before Pi appends it to session history or - starts a provider request. -- `REDACT_THIS` becomes `[REDACTED]` before Pi appends the submission. Pi sends - that same replacement in the provider request. +## Before you start -This example makes real OpenAI API calls and may incur provider charges. - -## Prerequisites - -Use these matching fork branches: +Use the matching branches: - [Pi user-message append hook PR](https://github.com/johnnygreco/pi/pull/1) -- [OpenShell integration branch](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) +- [OpenShell managed admission PR](https://github.com/johnnygreco/OpenShell/pull/1) - [OpenShell Research integration branch](https://github.com/NVIDIA/OpenShell-Research/tree/johnny/pi-attested-admission) -Install the development prerequisites documented by each repository. The host -must have an `OPENAI_API_KEY`, and the host, gateway, and sandbox supervisor -must be able to reach the Egress Gate service. - -The instructions below use these checkout placeholders: +Install each repository's development prerequisites and export: -```text -/path/to/pi -/path/to/OpenShell -/path/to/OpenShell-Research +```shell +export OPENAI_API_KEY=your-key +export EGRESS_GATE_HOST_IP=192.168.1.20 ``` -Replace them with absolute paths on your machine. +`EGRESS_GATE_HOST_IP` must be a non-loopback IPv4 address reachable by the +gateway and sandbox supervisors. `hostname -I` usually shows the available +addresses; choose the address for the host network shared with OpenShell. -## 1. Build the Pi fork +The helper expects sibling checkouts named `pi`, `OpenShell`, and +`OpenShell-Research`. For another layout, set `PI_REPO` and `OPENSHELL_REPO` to +absolute paths. -Build the coding-agent package from the Pi fork, pack it, and install it into a -standalone directory that can be uploaded to a sandbox: +From the `OpenShell-Research` checkout, change to the example directory. Run +all remaining commands there: ```shell -cd /path/to/pi -npm install --ignore-scripts -npm run build -mkdir -p /tmp/pi-egress-pack /tmp/pi-egress-runtime -npm pack --workspace @earendil-works/pi-coding-agent \ - --pack-destination /tmp/pi-egress-pack +cd projects/egress-gate/examples/pi-attested-admission ``` -The last command prints the tarball name. Pass that exact file to: +You can inspect every command before running anything: ```shell -npm install --prefix /tmp/pi-egress-runtime --ignore-scripts \ - /tmp/pi-egress-pack/earendil-works-pi-coding-agent-VERSION.tgz +./demo.sh --print all ``` -Replace `VERSION` with the version in the printed filename. The built CLI entry -point is then -`/tmp/pi-egress-runtime/node_modules/@earendil-works/pi-coding-agent/dist/cli.js`. +## Try it -## 2. Register and start Egress Gate - -Stop any OpenShell gateway that uses the target gateway configuration. A -running gateway does not reload middleware registrations. - -From the Egress Gate project, add the operator middleware registration. Replace -`YOUR_HOST_IPV4` with a non-loopback IPv4 address reachable by the gateway and -sandbox supervisors: +Build the Pi fork and register Egress Gate with OpenShell: ```shell -cd /path/to/OpenShell-Research/projects/egress-gate -uv run egress-gate add-gateway-registration \ - --host-ip YOUR_HOST_IPV4 \ - --name pi-egress \ - --port 50051 +./demo.sh prepare ``` -In the same directory, start Egress Gate with Pi receipt enforcement enabled: +Keep Egress Gate running in one terminal: -```shell -uv run egress-gate --debug serve \ - --listen 0.0.0.0:50051 \ - --timeout 4s \ - --require-pi-receipt +```shell title="Terminal 1: Egress Gate" +./demo.sh serve ``` -Keep this terminal open. The service exposes both the rendered-prompt admission -binding and the HTTP egress binding used by this example. - -## 3. Start the OpenShell fork - -In another terminal, start the gateway from the matching OpenShell fork. It -loads the `pi-egress` registration added above: +Start the matching OpenShell gateway in a second terminal: -```shell -cd /path/to/OpenShell -mise trust -mise run gateway +```shell title="Terminal 2: OpenShell gateway" +./demo.sh gateway ``` -Leave the gateway running. Use the repository's `scripts/bin/openshell` wrapper -for the remaining OpenShell commands so the CLI and gateway come from the same -fork. - -## 4. Create an OpenAI provider - -In a third terminal, create a provider whose credential is injected only when -the admitted request reaches `api.openai.com`: +After the gateway reports that it is ready, launch the real Pi CLI from a +third terminal: -```shell -cd /path/to/OpenShell -/path/to/OpenShell/scripts/bin/openshell provider create \ - --name pi-openai \ - --type openai \ - --credential OPENAI_API_KEY +```shell title="Terminal 3: managed Pi" +./demo.sh launch ``` -The bare credential name reads `OPENAI_API_KEY` from the host environment. It -does not place the real key in the sandbox environment. - -## 5. Create the managed Pi sandbox +At the Pi prompt, submit both of these in the same session: -Run the following command from this example directory: - -```shell -cd /path/to/OpenShell-Research/projects/egress-gate/examples/pi-attested-admission -/path/to/OpenShell/scripts/bin/openshell sandbox create \ - --name pi-egress-demo \ - --from base \ - --provider pi-openai \ - --policy policy.yaml \ - --upload /tmp/pi-egress-runtime:/sandbox/pi-runtime \ - --upload ./openshell-input-admission.ts:/sandbox/openshell-input-admission.ts \ - --upload ./models.json:/sandbox/pi-agent/models.json \ - -- env PI_CODING_AGENT_DIR=/sandbox/pi-agent \ - node /sandbox/pi-runtime/node_modules/@earendil-works/pi-coding-agent/dist/cli.js \ - --provider openai-chat-completions \ - --model gpt-4o-mini \ - --extension /sandbox/openshell-input-admission.ts \ - --session-dir /sandbox/pi-sessions +```text +Reply with exactly: DENY_THIS ``` -OpenShell recognizes the configured Pi admission binding, starts its -loopback-only bridge, and sets `OPENSHELL_PI_CONVERSATION_URL` for the Pi -process. The extension calls that bridge from `before_user_message_append` and -attaches the returned receipt to the first provider request. Pi itself contains -no OpenShell-specific startup behavior. - -[`models.json`](models.json) pins this run to OpenAI Chat Completions. The -initial integration does not support the Responses API. - -## 6. Verify denial - -At the Pi prompt, submit: - ```text -Reply with exactly: DENY_THIS +Reply with exactly: REDACT_THIS ``` -Pi reports that OpenShell denied the prompt and does not start a model turn. -Run `/session` before exiting Pi to see the active session file. After exiting, -inspect all example session files: +The first submission is denied without starting a model turn. The second makes +a real model call using `[REDACTED]`. Exit Pi, then inspect its persisted +session: ```shell -/path/to/OpenShell/scripts/bin/openshell sandbox exec -n pi-egress-demo -- \ - grep -R -n DENY_THIS /sandbox/pi-sessions +./demo.sh verify ``` -The command must produce no matches. The Egress Gate terminal has no -corresponding HTTP provider-request evaluation. - -## 7. Verify replacement - -Reconnect to the same sandbox and start Pi with the same extension and session -directory: +The output must contain `[REDACTED]` and must not contain `DENY_THIS` or +`REDACT_THIS`. The command exits with an error if either check fails. -```shell -/path/to/OpenShell/scripts/bin/openshell sandbox exec -n pi-egress-demo --tty -- \ - env PI_CODING_AGENT_DIR=/sandbox/pi-agent \ - node /sandbox/pi-runtime/node_modules/@earendil-works/pi-coding-agent/dist/cli.js \ - --provider openai-chat-completions \ - --model gpt-4o-mini \ - --extension /sandbox/openshell-input-admission.ts \ - --session-dir /sandbox/pi-sessions -``` +## How it works -Submit: +1. Pi renders the user submission and calls its general-purpose + `before_user_message_append` extension hook. +2. The example extension sends that text to OpenShell's sandbox-local admission + bridge. +3. Egress Gate applies `policy.yaml`: it either denies the submission or + returns replacement text plus a short-lived receipt. +4. Pi appends only admitted or replacement text to session history. +5. OpenShell checks the receipt before the model request leaves the sandbox and + injects `OPENAI_API_KEY`; the key is never copied into the sandbox. -```text -Reply with exactly: REDACT_THIS -``` +## Inspect individual commands -The request makes a real model call. After exiting Pi, inspect the persisted -session: +The helper never requires you to trust hidden orchestration. Add `--print` to +any action to show its exact commands without executing them: ```shell -/path/to/OpenShell/scripts/bin/openshell sandbox exec -n pi-egress-demo -- \ - grep -R -n -E 'REDACT_THIS|\[REDACTED\]' /sandbox/pi-sessions +./demo.sh --print prepare +./demo.sh --print launch ``` -The session must contain `[REDACTED]` and must not contain `REDACT_THIS`. The -Egress Gate terminal records an allowed provider-request evaluation. A -successful request also proves that its rendered prompt matched the admitted -replacement: Egress Gate rejects a receipt when the provider request contains a -different final user prompt. The network middleware consumes the receipt, then -removes the internal receipt header before forwarding upstream. - -## Configuration - -[`policy.yaml`](policy.yaml) configures the `pi-egress` middleware for both -rendered-prompt admission and requests to `api.openai.com`. It fails closed if -the middleware is unavailable. - -OpenShell uses the same middleware configuration for rendered-prompt admission -and provider HTTP egress. This is what lets Egress Gate issue a receipt before -Pi persists the candidate and verify the receipt again at the network boundary. +The actions are deliberately small: `prepare` builds and packages the Pi fork; +`serve` runs Egress Gate; `gateway` runs the matching OpenShell fork; and +`launch` creates the credential provider and sandbox. ## Current scope @@ -226,18 +128,12 @@ require one Pi hook per message role. ## Cleanup -Delete the sandbox and provider: +Exit Pi, but leave the OpenShell gateway running while cleanup deletes the +sandbox and provider: ```shell -cd /path/to/OpenShell -/path/to/OpenShell/scripts/bin/openshell sandbox delete pi-egress-demo -/path/to/OpenShell/scripts/bin/openshell provider delete pi-openai +./demo.sh cleanup ``` -Stop the gateway before removing its static middleware registration, then -restart it: - -```shell -cd /path/to/OpenShell-Research/projects/egress-gate -uv run egress-gate remove-gateway-registration --name pi-egress -``` +Then stop the OpenShell gateway and Egress Gate with `Ctrl-C`. To run the +example again, start from `./demo.sh prepare`. diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh new file mode 100755 index 00000000..0ed5d354 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -0,0 +1,210 @@ +#!/usr/bin/env bash + +set -euo pipefail + +print_only=false +if [[ ${1:-} == "--print" ]]; then + print_only=true + shift +fi + +action=${1:-help} +script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +egress_gate_dir=$(cd -- "$script_dir/../.." && pwd) +workspace_dir=$(cd -- "$egress_gate_dir/../../.." && pwd) + +pi_repo=${PI_REPO:-$workspace_dir/pi} +openshell_repo=${OPENSHELL_REPO:-$workspace_dir/OpenShell} +host_ip=${EGRESS_GATE_HOST_IP:-YOUR_HOST_IPV4} +pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} +runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} +openshell_cli=$openshell_repo/scripts/bin/openshell + +print_command() { + local directory=$1 + shift + printf '(cd %q &&' "$directory" + printf ' %q' "$@" + printf ')\n' +} + +run_in() { + local directory=$1 + shift + if $print_only; then + print_command "$directory" "$@" + else + (cd -- "$directory" && "$@") + fi +} + +require_file() { + local path=$1 + local description=$2 + if [[ ! -f $path ]]; then + printf 'Missing %s: %s\n' "$description" "$path" >&2 + exit 1 + fi +} + +require_directory() { + local path=$1 + local description=$2 + if [[ ! -d $path ]]; then + printf 'Missing %s: %s\n' "$description" "$path" >&2 + exit 1 + fi +} + +require_value() { + local value=$1 + local name=$2 + if [[ -z $value ]]; then + printf 'Set %s before running this action.\n' "$name" >&2 + exit 1 + fi +} + +pi_tarball() { + require_file "$pi_repo/packages/coding-agent/package.json" "Pi coding-agent package" + local version + version=$(node -p "require(process.argv[1]).version" "$pi_repo/packages/coding-agent/package.json") + printf '%s/earendil-works-pi-coding-agent-%s.tgz' "$pack_dir" "$version" +} + +prepare() { + if ! $print_only; then + require_directory "$pi_repo" "Pi checkout" + require_value "${EGRESS_GATE_HOST_IP:-}" EGRESS_GATE_HOST_IP + fi + local tarball + tarball=$(pi_tarball) + + run_in "$pi_repo" npm install --ignore-scripts + run_in "$pi_repo" npm run build + run_in "$pi_repo" mkdir -p "$pack_dir" "$runtime_dir" + run_in "$pi_repo" npm pack --workspace @earendil-works/pi-coding-agent --pack-destination "$pack_dir" + run_in "$pi_repo" npm install --prefix "$runtime_dir" --ignore-scripts "$tarball" + run_in "$egress_gate_dir" uv run egress-gate add-gateway-registration \ + --host-ip "$host_ip" --name pi-egress --port 50051 +} + +serve() { + run_in "$egress_gate_dir" uv run egress-gate --debug serve \ + --listen 0.0.0.0:50051 --timeout 4s --require-pi-receipt +} + +gateway() { + if ! $print_only; then + require_directory "$openshell_repo" "OpenShell checkout" + fi + run_in "$openshell_repo" mise trust + run_in "$openshell_repo" mise run gateway +} + +launch() { + if ! $print_only; then + require_file "$openshell_cli" "OpenShell CLI wrapper" + require_file "$(pi_tarball)" "packed Pi coding-agent" + require_value "${OPENAI_API_KEY:-}" OPENAI_API_KEY + fi + + run_in "$openshell_repo" "$openshell_cli" provider create \ + --name pi-openai --type openai --credential OPENAI_API_KEY + run_in "$script_dir" "$openshell_cli" sandbox create \ + --name pi-egress-demo \ + --from base \ + --provider pi-openai \ + --policy policy.yaml \ + --upload "$runtime_dir:/sandbox/pi-runtime" \ + --upload "$script_dir/openshell-input-admission.ts:/sandbox/openshell-input-admission.ts" \ + --upload "$script_dir/models.json:/sandbox/pi-agent/models.json" \ + -- env PI_CODING_AGENT_DIR=/sandbox/pi-agent \ + node /sandbox/pi-runtime/node_modules/@earendil-works/pi-coding-agent/dist/cli.js \ + --provider openai-chat-completions \ + --model gpt-4o-mini \ + --extension /sandbox/openshell-input-admission.ts \ + --session-dir /sandbox/pi-sessions +} + +verify() { + if ! $print_only; then + require_file "$openshell_cli" "OpenShell CLI wrapper" + fi + local redacted='\[REDACTED\]' + local forbidden='DENY_THIS|REDACT_THIS' + + if $print_only; then + print_command "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + grep -R -n -E "$redacted" /sandbox/pi-sessions + printf '! ' + print_command "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + grep -R -n -E "$forbidden" /sandbox/pi-sessions + return + fi + + if ! run_in "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + grep -R -n -E "$redacted" /sandbox/pi-sessions; then + printf 'Verification failed: [REDACTED] was not found in Pi session history.\n' >&2 + exit 1 + fi + if run_in "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + grep -R -n -E "$forbidden" /sandbox/pi-sessions; then + printf 'Verification failed: denied or unredacted input was found in Pi session history.\n' >&2 + exit 1 + fi + printf 'Verified: session history contains [REDACTED] and no original test markers.\n' +} + +cleanup() { + if ! $print_only; then + require_file "$openshell_cli" "OpenShell CLI wrapper" + fi + run_in "$openshell_repo" "$openshell_cli" sandbox delete pi-egress-demo + run_in "$openshell_repo" "$openshell_cli" provider delete pi-openai + run_in "$egress_gate_dir" uv run egress-gate remove-gateway-registration --name pi-egress +} + +usage() { + cat <<'EOF' +Usage: ./demo.sh [--print] ACTION + +Actions: + prepare Build and package Pi, then register Egress Gate with OpenShell + serve Start Egress Gate + gateway Start the forked OpenShell gateway + launch Create the OpenAI provider and launch Pi in a managed sandbox + verify Confirm redaction and absence of original text in Pi session history + cleanup Delete the sandbox and provider, then remove the registration + all Print every action in order (requires --print) + +Use --print to show exact commands without running them: + ./demo.sh --print prepare + ./demo.sh --print all +EOF +} + +case "$action" in + prepare) prepare ;; + serve) serve ;; + gateway) gateway ;; + launch) launch ;; + verify) verify ;; + cleanup) cleanup ;; + all) + if ! $print_only; then + printf 'The all action is print-only. Run: ./demo.sh --print all\n' >&2 + exit 1 + fi + for step in prepare serve gateway launch verify cleanup; do + printf '\n# %s\n' "$step" + "$step" + done + ;; + help | --help | -h) usage ;; + *) + printf 'Unknown action: %s\n\n' "$action" >&2 + usage >&2 + exit 1 + ;; +esac diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py new file mode 100644 index 00000000..06c061dd --- /dev/null +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -0,0 +1,49 @@ +from __future__ import annotations + +import os +import subprocess +from pathlib import Path + + +def test_pi_example_can_print_every_command_without_running_it( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + pi_repo = tmp_path / "pi" + package_dir = pi_repo / "packages/coding-agent" + package_dir.mkdir(parents=True) + (package_dir / "package.json").write_text('{"version":"1.2.3"}') + openshell_repo = tmp_path / "OpenShell" + pack_dir = tmp_path / "pack" + runtime_dir = tmp_path / "runtime" + environment = os.environ | { + "PI_REPO": str(pi_repo), + "OPENSHELL_REPO": str(openshell_repo), + "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_EGRESS_PACK_DIR": str(pack_dir), + "PI_EGRESS_RUNTIME_DIR": str(runtime_dir), + } + + result = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=environment, + text=True, + ) + + assert "npm run build" in result.stdout + assert "add-gateway-registration" in result.stdout + assert "egress-gate --debug serve" in result.stdout + assert "mise run gateway" in result.stdout + assert "provider create" in result.stdout + assert "sandbox create" in result.stdout + assert "sandbox exec" in result.stdout + assert "REDACTED" in result.stdout + assert "DENY_THIS" in result.stdout + assert "REDACT_THIS" in result.stdout + assert "sandbox delete" in result.stdout + assert result.stderr == "" + assert not pack_dir.exists() + assert not runtime_dir.exists() From 2cd2b0357a09f2a0dacc83e64e722a59060b6430 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 20 Aug 2026 21:50:33 +0000 Subject: [PATCH 09/70] chore: add example license headers --- projects/egress-gate/examples/pi-attested-admission/demo.sh | 3 +++ projects/egress-gate/tests/test_pi_example_commands.py | 3 +++ 2 files changed, 6 insertions(+) diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 0ed5d354..63c9d457 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -1,4 +1,7 @@ #!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + set -euo pipefail diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 06c061dd..38380b61 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -1,3 +1,6 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + from __future__ import annotations import os From 774baf8344f393e17d94c4b82645c31e6737f258 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 24 Aug 2026 03:01:03 +0000 Subject: [PATCH 10/70] chore: ignore local planning files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 87fb3d08..99136889 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ temp/ *.temp *.bak .scratch/ +plans/ # Local planning artifacts /plans/ From db435d45021eeda59d5a850e70fb5ecb5bc0658f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 26 Aug 2026 04:42:08 +0000 Subject: [PATCH 11/70] docs(egress-gate): sync Pi example forks --- .../examples/pi-attested-admission/README.md | 31 ++++++++++++++----- .../examples/pi-attested-admission/demo.sh | 31 ++++++++++++++++++- .../tests/test_pi_example_commands.py | 4 +++ 3 files changed, 58 insertions(+), 8 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 9c3e70dd..8d5fdfe2 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -10,10 +10,10 @@ incur provider charges. ## Before you start -Use the matching branches: +Use these matching fork branches: -- [Pi user-message append hook PR](https://github.com/johnnygreco/pi/pull/1) -- [OpenShell managed admission PR](https://github.com/johnnygreco/OpenShell/pull/1) +- [Pi `johnny/before-user-message-commit`](https://github.com/johnnygreco/pi/tree/johnny/before-user-message-commit) +- [OpenShell `openshell/pi-egress-admission`](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) - [OpenShell Research integration branch](https://github.com/NVIDIA/OpenShell-Research/tree/johnny/pi-attested-admission) Install each repository's development prerequisites and export: @@ -31,6 +31,13 @@ The helper expects sibling checkouts named `pi`, `OpenShell`, and `OpenShell-Research`. For another layout, set `PI_REPO` and `OPENSHELL_REPO` to absolute paths. +If you do not already have the fork checkouts, clone them beside this repository: + +```shell +git clone --branch johnny/before-user-message-commit https://github.com/johnnygreco/pi.git ../pi +git clone --branch openshell/pi-egress-admission https://github.com/johnnygreco/OpenShell.git ../OpenShell +``` + From the `OpenShell-Research` checkout, change to the example directory. Run all remaining commands there: @@ -44,6 +51,15 @@ You can inspect every command before running anything: ./demo.sh --print all ``` +Update both fork checkouts to the latest commits on those branches: + +```shell +./demo.sh sync +``` + +`sync` uses fast-forward-only pulls and stops instead of merging divergent local +work. + ## Try it Build the Pi fork and register Egress Gate with OpenShell: @@ -114,9 +130,10 @@ any action to show its exact commands without executing them: ./demo.sh --print launch ``` -The actions are deliberately small: `prepare` builds and packages the Pi fork; -`serve` runs Egress Gate; `gateway` runs the matching OpenShell fork; and -`launch` creates the credential provider and sandbox. +The actions are deliberately small: `sync` updates the two fork branches; +`prepare` builds and packages the Pi fork; `serve` runs Egress Gate; `gateway` +runs the matching OpenShell fork; and `launch` creates the credential provider +and sandbox. ## Current scope @@ -136,4 +153,4 @@ sandbox and provider: ``` Then stop the OpenShell gateway and Egress Gate with `Ctrl-C`. To run the -example again, start from `./demo.sh prepare`. +example again, start from `./demo.sh sync`. diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 63c9d457..ef5d53f6 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -18,6 +18,8 @@ workspace_dir=$(cd -- "$egress_gate_dir/../../.." && pwd) pi_repo=${PI_REPO:-$workspace_dir/pi} openshell_repo=${OPENSHELL_REPO:-$workspace_dir/OpenShell} +pi_branch=johnny/before-user-message-commit +openshell_branch=openshell/pi-egress-admission host_ip=${EGRESS_GATE_HOST_IP:-YOUR_HOST_IPV4} pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} @@ -68,6 +70,28 @@ require_value() { fi } +require_branch() { + local repository=$1 + local expected=$2 + local actual + actual=$(git -C "$repository" branch --show-current) + if [[ $actual != "$expected" ]]; then + printf 'Expected %s to be on branch %s, but found %s.\n' "$repository" "$expected" "${actual:-detached HEAD}" >&2 + exit 1 + fi +} + +sync() { + if ! $print_only; then + require_directory "$pi_repo" "Pi checkout" + require_directory "$openshell_repo" "OpenShell checkout" + require_branch "$pi_repo" "$pi_branch" + require_branch "$openshell_repo" "$openshell_branch" + fi + run_in "$pi_repo" git pull --ff-only origin "$pi_branch" + run_in "$openshell_repo" git pull --ff-only origin "$openshell_branch" +} + pi_tarball() { require_file "$pi_repo/packages/coding-agent/package.json" "Pi coding-agent package" local version @@ -78,6 +102,7 @@ pi_tarball() { prepare() { if ! $print_only; then require_directory "$pi_repo" "Pi checkout" + require_branch "$pi_repo" "$pi_branch" require_value "${EGRESS_GATE_HOST_IP:-}" EGRESS_GATE_HOST_IP fi local tarball @@ -100,6 +125,7 @@ serve() { gateway() { if ! $print_only; then require_directory "$openshell_repo" "OpenShell checkout" + require_branch "$openshell_repo" "$openshell_branch" fi run_in "$openshell_repo" mise trust run_in "$openshell_repo" mise run gateway @@ -173,6 +199,7 @@ usage() { Usage: ./demo.sh [--print] ACTION Actions: + sync Update the Pi and OpenShell fork branches with fast-forward pulls prepare Build and package Pi, then register Egress Gate with OpenShell serve Start Egress Gate gateway Start the forked OpenShell gateway @@ -182,12 +209,14 @@ Actions: all Print every action in order (requires --print) Use --print to show exact commands without running them: + ./demo.sh --print sync ./demo.sh --print prepare ./demo.sh --print all EOF } case "$action" in + sync) sync ;; prepare) prepare ;; serve) serve ;; gateway) gateway ;; @@ -199,7 +228,7 @@ case "$action" in printf 'The all action is print-only. Run: ./demo.sh --print all\n' >&2 exit 1 fi - for step in prepare serve gateway launch verify cleanup; do + for step in sync prepare serve gateway launch verify cleanup; do printf '\n# %s\n' "$step" "$step" done diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 38380b61..033558fa 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -37,6 +37,10 @@ def test_pi_example_can_print_every_command_without_running_it( ) assert "npm run build" in result.stdout + assert ( + "git pull --ff-only origin johnny/before-user-message-commit" in result.stdout + ) + assert "git pull --ff-only origin openshell/pi-egress-admission" in result.stdout assert "add-gateway-registration" in result.stdout assert "egress-gate --debug serve" in result.stdout assert "mise run gateway" in result.stdout From 6930fec7693c9bb72470ee8daa9dc466c2aab10f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 26 Aug 2026 04:56:31 +0000 Subject: [PATCH 12/70] docs(egress-gate): streamline Pi example setup --- .../examples/pi-attested-admission/README.md | 31 +++---------------- .../examples/pi-attested-admission/demo.sh | 12 +++---- 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 8d5fdfe2..43617a42 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -51,23 +51,17 @@ You can inspect every command before running anything: ./demo.sh --print all ``` -Update both fork checkouts to the latest commits on those branches: - -```shell -./demo.sh sync -``` - -`sync` uses fast-forward-only pulls and stops instead of merging divergent local -work. - ## Try it -Build the Pi fork and register Egress Gate with OpenShell: +Update both fork branches, build Pi, and register Egress Gate with OpenShell: ```shell ./demo.sh prepare ``` +The updates use fast-forward-only pulls and stop instead of merging divergent +local work. + Keep Egress Gate running in one terminal: ```shell title="Terminal 1: Egress Gate" @@ -120,21 +114,6 @@ The output must contain `[REDACTED]` and must not contain `DENY_THIS` or 5. OpenShell checks the receipt before the model request leaves the sandbox and injects `OPENAI_API_KEY`; the key is never copied into the sandbox. -## Inspect individual commands - -The helper never requires you to trust hidden orchestration. Add `--print` to -any action to show its exact commands without executing them: - -```shell -./demo.sh --print prepare -./demo.sh --print launch -``` - -The actions are deliberately small: `sync` updates the two fork branches; -`prepare` builds and packages the Pi fork; `serve` runs Egress Gate; `gateway` -runs the matching OpenShell fork; and `launch` creates the credential provider -and sandbox. - ## Current scope This initial integration supports idle, text-only, direct OpenAI Chat @@ -153,4 +132,4 @@ sandbox and provider: ``` Then stop the OpenShell gateway and Egress Gate with `Ctrl-C`. To run the -example again, start from `./demo.sh sync`. +example again, start from `./demo.sh prepare`. diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index ef5d53f6..7d066dc8 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -81,7 +81,7 @@ require_branch() { fi } -sync() { +sync_forks() { if ! $print_only; then require_directory "$pi_repo" "Pi checkout" require_directory "$openshell_repo" "OpenShell checkout" @@ -100,9 +100,8 @@ pi_tarball() { } prepare() { + sync_forks if ! $print_only; then - require_directory "$pi_repo" "Pi checkout" - require_branch "$pi_repo" "$pi_branch" require_value "${EGRESS_GATE_HOST_IP:-}" EGRESS_GATE_HOST_IP fi local tarball @@ -199,8 +198,7 @@ usage() { Usage: ./demo.sh [--print] ACTION Actions: - sync Update the Pi and OpenShell fork branches with fast-forward pulls - prepare Build and package Pi, then register Egress Gate with OpenShell + prepare Update the forks, package Pi, and register Egress Gate with OpenShell serve Start Egress Gate gateway Start the forked OpenShell gateway launch Create the OpenAI provider and launch Pi in a managed sandbox @@ -209,14 +207,12 @@ Actions: all Print every action in order (requires --print) Use --print to show exact commands without running them: - ./demo.sh --print sync ./demo.sh --print prepare ./demo.sh --print all EOF } case "$action" in - sync) sync ;; prepare) prepare ;; serve) serve ;; gateway) gateway ;; @@ -228,7 +224,7 @@ case "$action" in printf 'The all action is print-only. Run: ./demo.sh --print all\n' >&2 exit 1 fi - for step in sync prepare serve gateway launch verify cleanup; do + for step in prepare serve gateway launch verify cleanup; do printf '\n# %s\n' "$step" "$step" done From 7e26c8828b1a7dd9df56add3d191ebf9a1a4f040 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 22:47:55 +0000 Subject: [PATCH 13/70] fix(egress-gate): isolate nested OpenShell checkout --- projects/egress-gate/examples/pi-attested-admission/demo.sh | 6 ++++-- projects/egress-gate/tests/test_pi_example_commands.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 7d066dc8..a669b5b2 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -126,8 +126,10 @@ gateway() { require_directory "$openshell_repo" "OpenShell checkout" require_branch "$openshell_repo" "$openshell_branch" fi - run_in "$openshell_repo" mise trust - run_in "$openshell_repo" mise run gateway + # A custom checkout may be nested below this uv project. Keep OpenShell's + # mise-pinned uv from inheriting Egress Gate's uv configuration. + run_in "$openshell_repo" env UV_NO_CONFIG=1 mise trust + run_in "$openshell_repo" env UV_NO_CONFIG=1 mise run gateway } launch() { diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 033558fa..47da0ac7 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -43,7 +43,7 @@ def test_pi_example_can_print_every_command_without_running_it( assert "git pull --ff-only origin openshell/pi-egress-admission" in result.stdout assert "add-gateway-registration" in result.stdout assert "egress-gate --debug serve" in result.stdout - assert "mise run gateway" in result.stdout + assert "env UV_NO_CONFIG=1 mise run gateway" in result.stdout assert "provider create" in result.stdout assert "sandbox create" in result.stdout assert "sandbox exec" in result.stdout From 910d83a49136d328d101438ffe7223811cfe0b81 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 23:34:10 -0400 Subject: [PATCH 14/70] feat(egress-gate): complete Pi attested admission example --- projects/egress-gate/.gitignore | 1 + .../pi-attested-admission/.env.example | 4 + .../examples/pi-attested-admission/README.md | 124 +++-- .../examples/pi-attested-admission/demo.sh | 459 ++++++++++++++++-- .../pi-attested-admission/models.json | 25 - .../openshell-input-admission.test.mjs | 109 +++++ .../openshell-input-admission.ts | 183 +++++-- .../pi-attested-admission/policy.yaml | 8 +- .../render-runtime-config.mjs | 162 +++++++ .../proto/supervisor_middleware.proto | 203 +++++++- .../src/egress_gate/admission/receipts.py | 16 - .../bindings/supervisor_middleware_pb2.py | 126 +++-- .../bindings/supervisor_middleware_pb2.pyi | 182 ++++++- .../supervisor_middleware_pb2_grpc.py | 59 ++- .../src/egress_gate/service/servicer.py | 4 +- .../tests/admission/test_admission.py | 58 ++- .../tests/test_pi_admission_extension.py | 20 + .../tests/test_pi_example_commands.py | 280 ++++++++++- 18 files changed, 1727 insertions(+), 296 deletions(-) create mode 100644 projects/egress-gate/.gitignore create mode 100644 projects/egress-gate/examples/pi-attested-admission/.env.example delete mode 100644 projects/egress-gate/examples/pi-attested-admission/models.json create mode 100644 projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs create mode 100644 projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs create mode 100644 projects/egress-gate/tests/test_pi_admission_extension.py diff --git a/projects/egress-gate/.gitignore b/projects/egress-gate/.gitignore new file mode 100644 index 00000000..3b9932d3 --- /dev/null +++ b/projects/egress-gate/.gitignore @@ -0,0 +1 @@ +.workspaces/ diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example new file mode 100644 index 00000000..981b7931 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -0,0 +1,4 @@ +EGRESS_GATE_HOST_IP=YOUR_HOST_IPV4 +PI_MODEL_BASE_URL=https://provider.example.com/v1 +PI_MODEL_ID=your-model-id +PI_MODEL_API_KEY=your-provider-key diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 43617a42..b7bc274c 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,12 +1,18 @@ -# Managed Pi deny-or-redact example +# Managed Pi attested-admission example -This example runs the forked Pi CLI inside OpenShell and sends its rendered -user submissions through Egress Gate. It makes real OpenAI API calls and may -incur provider charges. +This example runs the forked Pi CLI inside OpenShell and sends admitted user +submissions to a model endpoint you choose. The endpoint may be a hosted +provider, an internal gateway, or a local server. It must accept the OpenAI Chat +Completions request shape used by the current attestation adapter; it does not +need to be OpenAI. -- `DENY_THIS` is rejected before Pi writes it to session history or starts a - model turn. -- `REDACT_THIS` becomes `[REDACTED]` before Pi writes or sends it. +The example demonstrates two outcomes: + +- `DENY_THIS` is rejected before Pi records it or starts a model request. +- `REDACT_THIS` becomes `[REDACTED]` before Pi records or sends it. + +The redaction case makes one real request to your configured endpoint and may +incur charges from that provider. ## Before you start @@ -16,44 +22,66 @@ Use these matching fork branches: - [OpenShell `openshell/pi-egress-admission`](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) - [OpenShell Research integration branch](https://github.com/NVIDIA/OpenShell-Research/tree/johnny/pi-attested-admission) -Install each repository's development prerequisites and export: - -```shell -export OPENAI_API_KEY=your-key -export EGRESS_GATE_HOST_IP=192.168.1.20 -``` - -`EGRESS_GATE_HOST_IP` must be a non-loopback IPv4 address reachable by the -gateway and sandbox supervisors. `hostname -I` usually shows the available -addresses; choose the address for the host network shared with OpenShell. +You do not need to clone the Pi or OpenShell forks manually. The first +`./demo.sh prepare` clones both into the ignored local workspace +`projects/egress-gate/.workspaces/pi-attested-admission/`. Later runs update +them with fast-forward-only pulls, so the fork contents never appear as +OpenShell Research changes. To reuse a checkout elsewhere, set `PI_REPO` or +`OPENSHELL_REPO` to its absolute path. -The helper expects sibling checkouts named `pi`, `OpenShell`, and -`OpenShell-Research`. For another layout, set `PI_REPO` and `OPENSHELL_REPO` to -absolute paths. +The OpenShell gateway needs a running compute backend. On macOS, start Docker +Desktop and wait until `docker info` succeeds before running the gateway; +Podman is also supported. Building the gateway also requires Z3 (`brew install +z3` on macOS or `libz3-dev` on Debian and Ubuntu). The fork recommends `mise` +2026.4.25 or newer. -If you do not already have the fork checkouts, clone them beside this repository: +From the `OpenShell-Research` checkout, change to the example directory. Run +all remaining commands there: ```shell -git clone --branch johnny/before-user-message-commit https://github.com/johnnygreco/pi.git ../pi -git clone --branch openshell/pi-egress-admission https://github.com/johnnygreco/OpenShell.git ../OpenShell +cd projects/egress-gate/examples/pi-attested-admission ``` -From the `OpenShell-Research` checkout, change to the example directory. Run -all remaining commands there: +Create the local configuration file, replace every example value, and load it +into the current shell: ```shell -cd projects/egress-gate/examples/pi-attested-admission +cp .env.example .env +# Edit .env before continuing. +set -a +source .env +set +a ``` -You can inspect every command before running anything: +If the model endpoint does not require authentication, set +`PI_MODEL_API_KEY=unused`. Source `.env` again in each new terminal that runs +`demo.sh`. + +`EGRESS_GATE_HOST_IP` is the address OpenShell uses to reach Egress Gate on this +machine. It must be a reachable, non-loopback IPv4 address; do not use +`127.0.0.1`. `PI_MODEL_BASE_URL` is separate: it is the model endpoint Pi will +call. A model server running on this machine must likewise use a hostname or +address reachable from the sandbox rather than `localhost`. + +`demo.sh prepare` derives the endpoint policy and Pi model configuration from +these values. You do not need to edit `policy.yaml`. If required values are +missing or still contain placeholders, the script prints the configuration +steps and stops before performing any work. + +Preview the complete workflow before running anything: ```shell ./demo.sh --print all ``` -## Try it +The walkthrough lists the terminal sequence and configuration visible to the +current shell. To inspect the exact commands for one action, use its name—for +example, `./demo.sh --print prepare` or `./demo.sh --print launch`. -Update both fork branches, build Pi, and register Egress Gate with OpenShell: +## Run the example + +Prepare the forks, build Pi, generate the endpoint-specific runtime +configuration, and generate the Egress Gate registration used by Terminal 2: ```shell ./demo.sh prepare @@ -74,13 +102,24 @@ Start the matching OpenShell gateway in a second terminal: ./demo.sh gateway ``` -After the gateway reports that it is ready, launch the real Pi CLI from a -third terminal: +The example uses its own gateway name and passes it explicitly to every +OpenShell command. It does not depend on or change your globally selected +OpenShell gateway. + +After the gateway reports that it is ready, launch Pi from a third terminal: ```shell title="Terminal 3: managed Pi" ./demo.sh launch ``` +Each launch replaces the example's `pi-egress-demo` sandbox so the current Pi +runtime, extension, policy, and OpenShell supervisor are used together. + +The example registers an endpoint-specific provider profile and stores +`PI_MODEL_API_KEY` as its credential. Pi sees only an opaque placeholder; +OpenShell resolves it only when the admitted request is sent to the configured +model host and port. + At the Pi prompt, submit both of these in the same session: ```text @@ -91,8 +130,8 @@ Reply with exactly: DENY_THIS Reply with exactly: REDACT_THIS ``` -The first submission is denied without starting a model turn. The second makes -a real model call using `[REDACTED]`. Exit Pi, then inspect its persisted +The first submission is denied without starting a model request. The second +makes a request containing `[REDACTED]`. Exit Pi, then inspect its persisted session: ```shell @@ -110,17 +149,20 @@ The output must contain `[REDACTED]` and must not contain `DENY_THIS` or bridge. 3. Egress Gate applies `policy.yaml`: it either denies the submission or returns replacement text plus a short-lived receipt. -4. Pi appends only admitted or replacement text to session history. -5. OpenShell checks the receipt before the model request leaves the sandbox and - injects `OPENAI_API_KEY`; the key is never copied into the sandbox. +4. Pi records only admitted or replacement text. +5. Before each model request in that turn, including automatic requests after + tool calls, the extension obtains a fresh receipt for the active admitted + text. +6. As each request leaves the sandbox, Egress Gate verifies that its final user + text matches the receipt and OpenShell resolves the credential. ## Current scope -This initial integration supports idle, text-only, direct OpenAI Chat -Completions submissions. Images, queued input, retries, compaction, and -automatic continuations after tool calls are unsupported and fail closed. The -next comprehensive boundary is one receipt per provider request; it does not -require one Pi hook per message role. +The attestation adapter supports normal text turns, including tools, queued +steering and follow-up messages, and the automatic model continuations they +produce, using the OpenAI Chat Completions wire format. Providers with a +different native protocol and image inputs are not covered by this example and +fail closed. ## Cleanup diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index a669b5b2..dc11d777 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -14,23 +14,70 @@ fi action=${1:-help} script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) egress_gate_dir=$(cd -- "$script_dir/../.." && pwd) -workspace_dir=$(cd -- "$egress_gate_dir/../../.." && pwd) -pi_repo=${PI_REPO:-$workspace_dir/pi} -openshell_repo=${OPENSHELL_REPO:-$workspace_dir/OpenShell} +forks_dir=${PI_EGRESS_FORKS_DIR:-$egress_gate_dir/.workspaces/pi-attested-admission} +pi_repo=${PI_REPO:-$forks_dir/pi} +openshell_repo=${OPENSHELL_REPO:-$forks_dir/OpenShell} pi_branch=johnny/before-user-message-commit openshell_branch=openshell/pi-egress-admission +pi_remote=https://github.com/johnnygreco/pi.git +openshell_remote=https://github.com/johnnygreco/OpenShell.git host_ip=${EGRESS_GATE_HOST_IP:-YOUR_HOST_IPV4} +model_base_url=${PI_MODEL_BASE_URL:-YOUR_MODEL_BASE_URL} +model_id=${PI_MODEL_ID:-YOUR_MODEL_ID} pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} openshell_cli=$openshell_repo/scripts/bin/openshell +gateway_name=${PI_EGRESS_GATEWAY_NAME:-pi-egress-demo-gateway} +runtime_models=$runtime_dir/models.json +runtime_policy=$runtime_dir/policy.yaml +runtime_provider_profile=$runtime_dir/provider-profile.yaml +runtime_gateway_fragment=$runtime_dir/gateway-middleware.toml +z3_library_path_override=${Z3_LIBRARY_PATH_OVERRIDE:-} + +bold="" +green="" +yellow="" +blue="" +cyan="" +reset="" +if [[ ${NO_COLOR+x} != x && (${FORCE_COLOR:-0} == 1 || (-t 1 && ${TERM:-} != dumb)) ]]; then + bold=$'\033[1m' + green=$'\033[32m' + yellow=$'\033[33m' + blue=$'\033[34m' + cyan=$'\033[36m' + reset=$'\033[0m' +fi print_command() { local directory=$1 shift - printf '(cd %q &&' "$directory" - printf ' %q' "$@" - printf ')\n' + local argument + local column=2 + local token + printf ' %bworking directory%b: %s\n' "$cyan" "$reset" "$directory" + printf ' %bcommand%b:\n ' "$green" "$reset" + for argument in "$@"; do + printf -v token '%q' "$argument" + if ((column > 2 && column + ${#token} + 1 > 96)); then + printf ' \\\n ' + column=6 + fi + if ((column > 2)); then + printf ' ' + ((column += 1)) + fi + printf '%s' "$token" + ((column += ${#token})) + done + printf '\n' +} + +describe_printed_commands() { + if $print_only; then + printf '\n%b%s%b\n' "$bold$blue" "$1" "$reset" + fi } run_in() { @@ -61,15 +108,125 @@ require_directory() { fi } -require_value() { - local value=$1 - local name=$2 - if [[ -z $value ]]; then - printf 'Set %s before running this action.\n' "$name" >&2 +require_compute_backend() { + local requested_driver=${OPENSHELL_DRIVERS:-} + if [[ -n ${KUBERNETES_SERVICE_HOST:-} ]]; then + return + fi + if [[ -z $requested_driver || $requested_driver == podman ]]; then + if command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then + return + fi + fi + if [[ -z $requested_driver || $requested_driver == docker ]]; then + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + return + fi + fi + if [[ -n $requested_driver && $requested_driver != podman && $requested_driver != docker ]]; then + return + fi + + printf 'No running OpenShell compute backend was detected.\n' >&2 + printf 'Start Docker Desktop or Podman, wait until its info command succeeds, then retry:\n' >&2 + printf ' docker info\n' >&2 + printf ' # or: podman info\n' >&2 + printf 'For another supported backend, set OPENSHELL_DRIVERS before running gateway.\n' >&2 + exit 1 +} + +raise_gateway_open_file_limit() { + local target=10240 + local hard_limit + local soft_limit + hard_limit=$(ulimit -Hn) + soft_limit=$(ulimit -Sn) + if [[ $soft_limit == unlimited ]]; then + return + fi + if [[ $hard_limit != unlimited && $hard_limit -lt $target ]]; then + target=$hard_limit + fi + if ((soft_limit >= target)); then + return + fi + if ! ulimit -Sn "$target"; then + printf 'Could not raise the open-file limit from %s to %s for the OpenShell build.\n' \ + "$soft_limit" "$target" >&2 + printf 'Run `ulimit -n %s` in this terminal, then retry.\n' "$target" >&2 exit 1 fi } +require_gateway_z3() { + local z3_prefix + if [[ -n $z3_library_path_override ]]; then + return + fi + if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists z3; then + return + fi + case $(uname -s) in + Darwin) + if command -v brew >/dev/null 2>&1; then + z3_prefix=$(brew --prefix z3 2>/dev/null || true) + if [[ -f $z3_prefix/lib/libz3.dylib ]]; then + z3_library_path_override=$z3_prefix/lib + return + fi + fi + printf 'The OpenShell gateway build requires Z3. Install it, then retry:\n' >&2 + printf ' brew install z3\n' >&2 + ;; + Linux) + if command -v ldconfig >/dev/null 2>&1 && ldconfig -p 2>/dev/null | grep -q 'libz3\.so'; then + return + fi + printf 'The OpenShell gateway build requires the Z3 development library.\n' >&2 + printf 'On Debian or Ubuntu, install it with: sudo apt-get install libz3-dev\n' >&2 + ;; + *) + printf 'The OpenShell gateway build requires the Z3 native library.\n' >&2 + printf 'Install Z3 or set Z3_LIBRARY_PATH_OVERRIDE to its library directory.\n' >&2 + ;; + esac + exit 1 +} + +require_example_configuration() { + local missing=() + if [[ -z ${EGRESS_GATE_HOST_IP:-} || ${EGRESS_GATE_HOST_IP:-} == YOUR_HOST_IPV4 ]]; then + missing+=(EGRESS_GATE_HOST_IP) + fi + if [[ -z ${PI_MODEL_BASE_URL:-} || ${PI_MODEL_BASE_URL:-} == https://provider.example.com/v1 ]]; then + missing+=(PI_MODEL_BASE_URL) + fi + if [[ -z ${PI_MODEL_ID:-} || ${PI_MODEL_ID:-} == your-model-id ]]; then + missing+=(PI_MODEL_ID) + fi + if [[ -z ${PI_MODEL_API_KEY:-} || ${PI_MODEL_API_KEY:-} == your-provider-key ]]; then + missing+=(PI_MODEL_API_KEY) + fi + if ((${#missing[@]} == 0)); then + return + fi + + printf 'The Pi attested-admission example is not configured.\n' >&2 + printf 'Set these environment variables:\n' >&2 + printf ' %s\n' "${missing[@]}" >&2 + printf '\n' >&2 + printf 'Configure and load %s:\n' "$script_dir/.env" >&2 + printf ' cd %s\n' "$script_dir" >&2 + if [[ ! -f $script_dir/.env ]]; then + printf ' cp .env.example .env\n' >&2 + fi + printf ' # Edit .env and replace every example value.\n' >&2 + printf ' set -a\n' >&2 + printf ' source .env\n' >&2 + printf ' set +a\n' >&2 + exit 1 +} + require_branch() { local repository=$1 local expected=$2 @@ -81,78 +238,196 @@ require_branch() { fi } +ensure_checkout() { + local repository=$1 + local description=$2 + local remote=$3 + local branch=$4 + local parent + parent=$(dirname -- "$repository") + if $print_only; then + describe_printed_commands "$description (only when missing):" + print_command "$parent" git clone --branch "$branch" "$remote" "$repository" + return + fi + if [[ -e $repository && ! -d $repository/.git ]]; then + printf '%s path exists but is not a Git checkout: %s\n' "$description" "$repository" >&2 + exit 1 + fi + if [[ ! -d $repository/.git ]]; then + mkdir -p "$parent" + run_in "$parent" git clone --branch "$branch" "$remote" "$repository" + fi +} + sync_forks() { + ensure_checkout "$pi_repo" "Pi checkout" "$pi_remote" "$pi_branch" + ensure_checkout "$openshell_repo" "OpenShell checkout" "$openshell_remote" "$openshell_branch" if ! $print_only; then - require_directory "$pi_repo" "Pi checkout" - require_directory "$openshell_repo" "OpenShell checkout" require_branch "$pi_repo" "$pi_branch" require_branch "$openshell_repo" "$openshell_branch" fi - run_in "$pi_repo" git pull --ff-only origin "$pi_branch" - run_in "$openshell_repo" git pull --ff-only origin "$openshell_branch" + describe_printed_commands "Update the Pi fork:" + run_in "$pi_repo" git pull --no-rebase --ff-only origin "$pi_branch" + describe_printed_commands "Update the OpenShell fork:" + run_in "$openshell_repo" git pull --no-rebase --ff-only origin "$openshell_branch" } pi_tarball() { + if $print_only; then + printf '%s/earendil-works-pi-coding-agent-VERSION.tgz' "$pack_dir" + return + fi require_file "$pi_repo/packages/coding-agent/package.json" "Pi coding-agent package" local version version=$(node -p "require(process.argv[1]).version" "$pi_repo/packages/coding-agent/package.json") printf '%s/earendil-works-pi-coding-agent-%s.tgz' "$pack_dir" "$version" } +render_runtime_configuration() { + run_in "$script_dir" mkdir -p "$runtime_dir" + run_in "$script_dir" node render-runtime-config.mjs \ + --base-url "$model_base_url" \ + --model-id "$model_id" \ + --models-output "$runtime_models" \ + --policy-output "$runtime_policy" \ + --provider-profile-output "$runtime_provider_profile" \ + --middleware-endpoint "http://$host_ip:50051" \ + --gateway-output "$runtime_gateway_fragment" +} + prepare() { - sync_forks if ! $print_only; then - require_value "${EGRESS_GATE_HOST_IP:-}" EGRESS_GATE_HOST_IP + require_example_configuration fi + sync_forks local tarball tarball=$(pi_tarball) + describe_printed_commands "Build and package Pi:" run_in "$pi_repo" npm install --ignore-scripts run_in "$pi_repo" npm run build run_in "$pi_repo" mkdir -p "$pack_dir" "$runtime_dir" run_in "$pi_repo" npm pack --workspace @earendil-works/pi-coding-agent --pack-destination "$pack_dir" run_in "$pi_repo" npm install --prefix "$runtime_dir" --ignore-scripts "$tarball" - run_in "$egress_gate_dir" uv run egress-gate add-gateway-registration \ - --host-ip "$host_ip" --name pi-egress --port 50051 + describe_printed_commands "Generate the Pi model configuration and endpoint policy:" + render_runtime_configuration } serve() { + describe_printed_commands "Run Egress Gate and keep it open:" run_in "$egress_gate_dir" uv run egress-gate --debug serve \ --listen 0.0.0.0:50051 --timeout 4s --require-pi-receipt } gateway() { if ! $print_only; then + require_example_configuration + require_compute_backend + raise_gateway_open_file_limit + require_gateway_z3 require_directory "$openshell_repo" "OpenShell checkout" require_branch "$openshell_repo" "$openshell_branch" fi + describe_printed_commands "Refresh the gateway middleware registration fragment:" + render_runtime_configuration # A custom checkout may be nested below this uv project. Keep OpenShell's # mise-pinned uv from inheriting Egress Gate's uv configuration. + describe_printed_commands "Start the matching OpenShell gateway and keep it open:" run_in "$openshell_repo" env UV_NO_CONFIG=1 mise trust - run_in "$openshell_repo" env UV_NO_CONFIG=1 mise run gateway + local gateway_environment=( + env + UV_NO_CONFIG=1 + CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" + OPENSHELL_GATEWAY_NAME="$gateway_name" + OPENSHELL_GATEWAY_CONFIG_FRAGMENT="$runtime_gateway_fragment" + ) + if [[ -n $z3_library_path_override ]]; then + gateway_environment+=(Z3_LIBRARY_PATH_OVERRIDE="$z3_library_path_override") + fi + run_in "$openshell_repo" "${gateway_environment[@]}" mise run gateway +} + +ensure_model_provider() { + if $print_only; then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile import --file "$runtime_provider_profile" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ + --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY + return + fi + if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + provider profile export pi-attested-model >/dev/null 2>&1); then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile update pi-attested-model --file "$runtime_provider_profile" + else + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile import --file "$runtime_provider_profile" + fi + if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + provider get pi-model >/dev/null 2>&1); then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider delete pi-model + fi + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ + --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY +} + +delete_demo_sandbox_if_present() { + if ! $print_only && (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + sandbox list --names | grep -Fxq pi-egress-demo); then + printf 'Replacing existing sandbox pi-egress-demo with the current example runtime.\n' + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox delete pi-egress-demo + fi +} + +create_demo_sandbox() { + run_in "$script_dir" "$openshell_cli" --gateway "$gateway_name" sandbox create \ + --name pi-egress-demo \ + --from base \ + --provider pi-model \ + --policy "$runtime_policy" \ + --upload "$runtime_dir/node_modules:/sandbox/pi-runtime" \ + --upload "$script_dir/openshell-input-admission.ts:/sandbox/openshell-input-admission.ts" \ + --upload "$runtime_models:/sandbox/pi-agent/models.json" \ + --no-git-ignore \ + --detach } launch() { if ! $print_only; then + require_example_configuration require_file "$openshell_cli" "OpenShell CLI wrapper" require_file "$(pi_tarball)" "packed Pi coding-agent" - require_value "${OPENAI_API_KEY:-}" OPENAI_API_KEY + require_file "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" \ + "installed Pi CLI" fi - run_in "$openshell_repo" "$openshell_cli" provider create \ - --name pi-openai --type openai --credential OPENAI_API_KEY - run_in "$script_dir" "$openshell_cli" sandbox create \ - --name pi-egress-demo \ - --from base \ - --provider pi-openai \ - --policy policy.yaml \ - --upload "$runtime_dir:/sandbox/pi-runtime" \ - --upload "$script_dir/openshell-input-admission.ts:/sandbox/openshell-input-admission.ts" \ - --upload "$script_dir/models.json:/sandbox/pi-agent/models.json" \ - -- env PI_CODING_AGENT_DIR=/sandbox/pi-agent \ + describe_printed_commands "Refresh the endpoint-specific model, policy, and provider profile:" + render_runtime_configuration + if ! $print_only; then + require_file "$runtime_models" "generated Pi model configuration" + require_file "$runtime_policy" "generated OpenShell policy" + require_file "$runtime_provider_profile" "generated OpenShell provider profile" + fi + + describe_printed_commands "Remove an earlier example sandbox, if present:" + delete_demo_sandbox_if_present + describe_printed_commands "Register the endpoint-scoped model credential in OpenShell:" + ensure_model_provider + describe_printed_commands "Create a fresh sandbox and upload the Pi runtime:" + create_demo_sandbox + describe_printed_commands "Launch Pi interactively in the prepared sandbox:" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec --tty -n pi-egress-demo -- \ + env \ + PI_CODING_AGENT_DIR=/sandbox/pi-agent \ + PI_OFFLINE=1 \ + OPENSHELL_AGENT_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation \ node /sandbox/pi-runtime/node_modules/@earendil-works/pi-coding-agent/dist/cli.js \ - --provider openai-chat-completions \ - --model gpt-4o-mini \ + --provider attested-provider \ + --model "$model_id" \ --extension /sandbox/openshell-input-admission.ts \ --session-dir /sandbox/pi-sessions } @@ -165,20 +440,25 @@ verify() { local forbidden='DENY_THIS|REDACT_THIS' if $print_only; then - print_command "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + describe_printed_commands "Confirm that Pi saved the redacted text:" + print_command "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec -n pi-egress-demo -- \ grep -R -n -E "$redacted" /sandbox/pi-sessions - printf '! ' - print_command "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + describe_printed_commands "Confirm that Pi did not save either original marker (this command must find no matches):" + print_command "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec -n pi-egress-demo -- \ grep -R -n -E "$forbidden" /sandbox/pi-sessions return fi - if ! run_in "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + if ! run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec -n pi-egress-demo -- \ grep -R -n -E "$redacted" /sandbox/pi-sessions; then printf 'Verification failed: [REDACTED] was not found in Pi session history.\n' >&2 exit 1 fi - if run_in "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + if run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec -n pi-egress-demo -- \ grep -R -n -E "$forbidden" /sandbox/pi-sessions; then printf 'Verification failed: denied or unredacted input was found in Pi session history.\n' >&2 exit 1 @@ -190,30 +470,106 @@ cleanup() { if ! $print_only; then require_file "$openshell_cli" "OpenShell CLI wrapper" fi - run_in "$openshell_repo" "$openshell_cli" sandbox delete pi-egress-demo - run_in "$openshell_repo" "$openshell_cli" provider delete pi-openai - run_in "$egress_gate_dir" uv run egress-gate remove-gateway-registration --name pi-egress + describe_printed_commands "Delete the example sandbox:" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox delete pi-egress-demo + describe_printed_commands "Delete the example credential provider:" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider delete pi-model + describe_printed_commands "Delete the example provider profile:" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile delete pi-attested-model } usage() { + printf '%bUsage%b: ./demo.sh [--print] ACTION\n\n' "$bold$cyan" "$reset" + printf '%bActions%b:\n' "$bold$blue" "$reset" cat <<'EOF' -Usage: ./demo.sh [--print] ACTION -Actions: - prepare Update the forks, package Pi, and register Egress Gate with OpenShell + prepare Update the forks, package Pi, and generate the runtime configuration serve Start Egress Gate gateway Start the forked OpenShell gateway - launch Create the OpenAI provider and launch Pi in a managed sandbox + launch Attach the configured model credential and launch managed Pi verify Confirm redaction and absence of original text in Pi session history - cleanup Delete the sandbox and provider, then remove the registration - all Print every action in order (requires --print) + cleanup Delete the example sandbox and credential provider + all Show the concise workflow walkthrough (requires --print) +EOF -Use --print to show exact commands without running them: + printf '\n%bPreview before running%b:\n' "$bold$blue" "$reset" + cat <<'EOF' ./demo.sh --print prepare ./demo.sh --print all EOF } +print_plan() { + local configuration_status="ready" + local status_color="$green" + local credential_status="not set" + local displayed_host="$host_ip" + local displayed_model_base_url="$model_base_url" + local displayed_model_id="$model_id" + if [[ $displayed_host == YOUR_HOST_IPV4 ]]; then + displayed_host="not set" + configuration_status="incomplete — edit and source .env" + fi + if [[ $displayed_model_base_url == YOUR_MODEL_BASE_URL ]]; then + displayed_model_base_url="not set" + configuration_status="incomplete — edit and source .env" + fi + if [[ $displayed_model_id == YOUR_MODEL_ID ]]; then + displayed_model_id="not set" + configuration_status="incomplete — edit and source .env" + fi + if [[ -n ${PI_MODEL_API_KEY:-} && ${PI_MODEL_API_KEY:-} != your-provider-key ]]; then + credential_status="set (value hidden)" + else + configuration_status="incomplete — edit and source .env" + fi + if [[ $configuration_status != ready ]]; then + status_color="$yellow" + fi + cat <&2 exit 1 fi - for step in prepare serve gateway launch verify cleanup; do - printf '\n# %s\n' "$step" - "$step" - done + print_plan ;; help | --help | -h) usage ;; *) diff --git a/projects/egress-gate/examples/pi-attested-admission/models.json b/projects/egress-gate/examples/pi-attested-admission/models.json deleted file mode 100644 index 69c6f911..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/models.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "providers": { - "openai-chat-completions": { - "baseUrl": "https://api.openai.com/v1", - "api": "openai-completions", - "apiKey": "$OPENAI_API_KEY", - "models": [ - { - "id": "gpt-4o-mini", - "name": "GPT-4o mini (Chat Completions)", - "reasoning": false, - "input": ["text"], - "contextWindow": 128000, - "maxTokens": 16384, - "cost": { - "input": 0.15, - "output": 0.6, - "cacheRead": 0.075, - "cacheWrite": 0 - } - } - ] - } - } -} diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs new file mode 100644 index 00000000..4032541e --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import registerAdmission from "./openshell-input-admission.ts"; + +const BRIDGE_URL_ENV = "OPENSHELL_AGENT_CONVERSATION_URL"; +const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; + +function createHarness() { + const handlers = new Map(); + registerAdmission({ + on(event, handler) { + handlers.set(event, handler); + }, + }); + return handlers; +} + +function createContext(isIdle = true) { + return { + isIdle: () => isIdle, + sessionManager: { getSessionId: () => "session-1" }, + signal: new AbortController().signal, + ui: { notify: () => {} }, + }; +} + +function allowResponse(receipt) { + return new Response( + JSON.stringify({ + decision: "allow", + receipt: Array.from(new TextEncoder().encode(receipt)), + }), + { status: 200 }, + ); +} + +test("uses a fresh receipt for every provider request in one admitted turn", async () => { + const originalBridgeUrl = process.env[BRIDGE_URL_ENV]; + const originalFetch = globalThis.fetch; + const bridgeRequests = []; + process.env[BRIDGE_URL_ENV] = "http://bridge.test/admit"; + globalThis.fetch = async (_url, init) => { + bridgeRequests.push(JSON.parse(init.body)); + return allowResponse(`receipt-${bridgeRequests.length}`); + }; + + try { + const handlers = createHarness(); + const ctx = createContext(); + const append = await handlers.get("before_user_message_append")( + { text: "inspect the repository" }, + ctx, + ); + assert.equal(append, undefined); + + const firstHeaders = {}; + await handlers.get("before_provider_headers")({ headers: firstHeaders }, ctx); + assert.equal(firstHeaders[RECEIPT_HEADER], "receipt-1"); + + const continuationHeaders = {}; + await handlers.get("before_provider_headers")({ headers: continuationHeaders }, ctx); + assert.equal(continuationHeaders[RECEIPT_HEADER], "receipt-2"); + + assert.equal(bridgeRequests.length, 2); + assert.notEqual(bridgeRequests[0].submission_id, bridgeRequests[1].submission_id); + assert.deepEqual(bridgeRequests[0].request_body, bridgeRequests[1].request_body); + } finally { + globalThis.fetch = originalFetch; + if (originalBridgeUrl === undefined) delete process.env[BRIDGE_URL_ENV]; + else process.env[BRIDGE_URL_ENV] = originalBridgeUrl; + } +}); + +test("activates queued prompts only when Pi delivers them", async () => { + const originalBridgeUrl = process.env[BRIDGE_URL_ENV]; + const originalFetch = globalThis.fetch; + let receiptNumber = 0; + process.env[BRIDGE_URL_ENV] = "http://bridge.test/admit"; + globalThis.fetch = async () => allowResponse(`receipt-${++receiptNumber}`); + + try { + const handlers = createHarness(); + const idleContext = createContext(); + await handlers.get("before_user_message_append")({ text: "current turn" }, idleContext); + + const initialHeaders = {}; + await handlers.get("before_provider_headers")({ headers: initialHeaders }, idleContext); + assert.equal(initialHeaders[RECEIPT_HEADER], "receipt-1"); + + const streamingContext = createContext(false); + await handlers.get("before_user_message_append")({ text: "queued turn" }, streamingContext); + + const currentContinuationHeaders = {}; + await handlers.get("before_provider_headers")({ headers: currentContinuationHeaders }, idleContext); + assert.equal(currentContinuationHeaders[RECEIPT_HEADER], "receipt-3"); + + await handlers.get("message_start")({ + message: { role: "user", content: [{ type: "text", text: "queued turn" }] }, + }); + const queuedHeaders = {}; + await handlers.get("before_provider_headers")({ headers: queuedHeaders }, idleContext); + assert.equal(queuedHeaders[RECEIPT_HEADER], "receipt-2"); + } finally { + globalThis.fetch = originalFetch; + if (originalBridgeUrl === undefined) delete process.env[BRIDGE_URL_ENV]; + else process.env[BRIDGE_URL_ENV] = originalBridgeUrl; + } +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts index 58fb373e..1b5f120f 100644 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts @@ -2,86 +2,174 @@ * OpenShell direct-input admission for Pi. * * Load this extension explicitly with Pi's standard --extension option. It - * admits one idle, text-only user submission after rendering and before Pi - * persists it, then attaches the returned receipt to the first provider - * request. Steering, follow-ups, images, compaction, and post-tool - * continuations are unsupported and fail closed. + * admits each text-only user submission after rendering and before Pi + * persists it. Every provider request in the admitted turn receives a fresh + * receipt, including automatic continuations after tool calls. */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -const BRIDGE_URL_ENV = "OPENSHELL_PI_CONVERSATION_URL"; +const BRIDGE_URL_ENV = "OPENSHELL_AGENT_CONVERSATION_URL"; +const LEGACY_BRIDGE_URL_ENV = "OPENSHELL_PI_CONVERSATION_URL"; const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; const SCHEMA_VERSION = "openshell.pi-input.v1"; const MAX_RESPONSE_BYTES = 256 * 1024; const MAX_RECEIPT_BYTES = 8 * 1024; -interface BridgeResponse { - decision: "allow" | "deny"; - replacement_body?: number[]; - receipt?: number[]; - reason_code?: string; -} +type BridgeResponse = + | { decision: "allow"; replacement_body?: number[]; receipt: number[] } + | { decision: "deny"; reason_code?: string }; interface CandidateEnvelope { schema_version: typeof SCHEMA_VERSION; text: string; } +interface ActiveAdmission { + bridgeUrl: string; + sessionId: string; + envelope: CandidateEnvelope; +} + +interface PendingAdmission extends ActiveAdmission { + receipt: string; +} + +interface AdmissionResult { + envelope: CandidateEnvelope; + receipt: string; +} + export default function (pi: ExtensionAPI) { + let activeAdmission: ActiveAdmission | undefined; let pendingReceipt: string | undefined; + const queuedAdmissions: PendingAdmission[] = []; pi.on("before_user_message_append", async (event, ctx) => { + const isIdle = ctx.isIdle(); try { - pendingReceipt = undefined; - if (!ctx.isIdle() || event.images?.length) { - notifySafely(ctx, "OpenShell admission currently supports only idle, text-only prompts"); + if (isIdle) { + activeAdmission = undefined; + pendingReceipt = undefined; + } + if (event.images?.length) { + notifySafely(ctx, "OpenShell admission currently supports only text prompts"); return { action: "cancel" }; } - const bridgeUrl = process.env[BRIDGE_URL_ENV]; + const bridgeUrl = process.env[BRIDGE_URL_ENV] ?? process.env[LEGACY_BRIDGE_URL_ENV]; if (!bridgeUrl) throw new Error(`${BRIDGE_URL_ENV} is required for OpenShell admission`); const envelope: CandidateEnvelope = { schema_version: SCHEMA_VERSION, text: event.text }; - const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); - const response = await fetch(bridgeUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - harness_version: "extension-v1", - session_id: ctx.sessionManager.getSessionId(), - submission_id: crypto.randomUUID(), - request_body: Array.from(requestBody), - }), - signal: ctx.signal, - }); - if (!response.ok) throw new Error("OpenShell admission is unavailable"); - const encoded = new Uint8Array(await response.arrayBuffer()); - if (encoded.byteLength > MAX_RESPONSE_BYTES) throw new Error("OpenShell admission response is too large"); - const result = parseBridgeResponse(JSON.parse(new TextDecoder().decode(encoded))); - if (result.decision === "deny") { - notifySafely(ctx, `OpenShell denied the prompt (${result.reason_code ?? "policy_denied"})`); + const sessionId = ctx.sessionManager.getSessionId(); + const result = await requestAdmission(bridgeUrl, sessionId, envelope, ctx.signal); + if (result.response.decision === "deny") { + notifySafely(ctx, `OpenShell denied the prompt (${result.response.reason_code ?? "policy_denied"})`); return { action: "cancel" }; } - pendingReceipt = decodeReceipt(result.receipt); - if (!result.replacement_body) return; - const replacement = parseEnvelope(new Uint8Array(result.replacement_body)); - return { action: "transform", text: replacement.text }; + const admission = { bridgeUrl, sessionId, ...result.admission }; + if (isIdle) { + activeAdmission = admission; + pendingReceipt = admission.receipt; + } else { + queuedAdmissions.push(admission); + } + if (result.admission.envelope.text === event.text) return; + return { action: "transform", text: result.admission.envelope.text }; } catch { - pendingReceipt = undefined; + if (isIdle) { + activeAdmission = undefined; + pendingReceipt = undefined; + } notifySafely(ctx, "OpenShell admission is unavailable"); return { action: "cancel" }; } }); - pi.on("before_provider_headers", (event) => { - if (!pendingReceipt) throw new Error("OpenShell candidate admission receipt is missing"); + pi.on("message_start", (event) => { + const text = userMessageText(event.message); + if (text === undefined) return; + const index = queuedAdmissions.findIndex((admission) => admission.envelope.text === text); + if (index === -1) return; + const [admission] = queuedAdmissions.splice(index, 1); + activeAdmission = admission; + pendingReceipt = admission.receipt; + }); + + pi.on("before_provider_headers", async (event, ctx) => { if (Object.keys(event.headers).some((name) => name.toLowerCase() === RECEIPT_HEADER)) { throw new Error("OpenShell receipt header is reserved"); } - event.headers[RECEIPT_HEADER] = pendingReceipt; + if (!activeAdmission) throw new Error("OpenShell candidate admission context is missing"); + + let receipt = pendingReceipt; + if (!receipt) { + const result = await requestAdmission( + activeAdmission.bridgeUrl, + activeAdmission.sessionId, + activeAdmission.envelope, + ctx.signal, + ); + if (result.response.decision === "deny") { + throw new Error(`OpenShell denied the active prompt (${result.response.reason_code ?? "policy_denied"})`); + } + if (result.admission.envelope.text !== activeAdmission.envelope.text) { + throw new Error("OpenShell changed a prompt after Pi persisted it"); + } + receipt = result.admission.receipt; + } + + event.headers[RECEIPT_HEADER] = receipt; pendingReceipt = undefined; }); } +async function requestAdmission( + bridgeUrl: string, + sessionId: string, + envelope: CandidateEnvelope, + signal: AbortSignal, +): Promise< + | { response: Extract; admission?: never } + | { response: Extract; admission: AdmissionResult } +> { + const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); + const response = await fetch(bridgeUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + harness_version: "extension-v1", + session_id: sessionId, + submission_id: crypto.randomUUID(), + request_body: Array.from(requestBody), + }), + signal, + }); + if (!response.ok) throw new Error("OpenShell admission is unavailable"); + const encoded = new Uint8Array(await response.arrayBuffer()); + if (encoded.byteLength > MAX_RESPONSE_BYTES) throw new Error("OpenShell admission response is too large"); + const result = parseBridgeResponse(JSON.parse(new TextDecoder().decode(encoded))); + if (result.decision === "deny") return { response: result }; + + return { + response: result, + admission: { + receipt: decodeReceipt(result.receipt), + envelope: result.replacement_body ? parseEnvelope(new Uint8Array(result.replacement_body)) : envelope, + }, + }; +} + +function userMessageText(message: unknown): string | undefined { + if (!isRecord(message) || message.role !== "user" || !Array.isArray(message.content)) return undefined; + const text = message.content + .filter( + (part): part is { type: "text"; text: string } => + isRecord(part) && part.type === "text" && typeof part.text === "string", + ) + .map((part) => part.text) + .join("\n"); + return text || undefined; +} + function notifySafely(ctx: ExtensionContext, message: string): void { try { ctx.ui.notify(message, "warning"); @@ -103,10 +191,19 @@ function parseBridgeResponse(value: unknown): BridgeResponse { reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined, }; } - if (!isByteArray(value.receipt) || (value.replacement_body !== undefined && !isByteArray(value.replacement_body))) { + const receipt = value.receipt; + const replacementBody = value.replacement_body; + if (!isByteArray(receipt)) { throw new Error("OpenShell admission returned an invalid allow response"); } - return { decision: "allow", receipt: value.receipt, replacement_body: value.replacement_body }; + let replacement: number[] | undefined; + if (replacementBody !== undefined) { + if (!isByteArray(replacementBody)) { + throw new Error("OpenShell admission returned an invalid allow response"); + } + replacement = replacementBody; + } + return { decision: "allow", receipt, replacement_body: replacement }; } function parseEnvelope(body: Uint8Array): CandidateEnvelope { diff --git a/projects/egress-gate/examples/pi-attested-admission/policy.yaml b/projects/egress-gate/examples/pi-attested-admission/policy.yaml index 82e8fec5..8766f30a 100644 --- a/projects/egress-gate/examples/pi-attested-admission/policy.yaml +++ b/projects/egress-gate/examples/pi-attested-admission/policy.yaml @@ -11,10 +11,10 @@ process: run_as_group: sandbox network_policies: - openai: - name: OpenAI Chat Completions + model_provider: + name: Configured model endpoint endpoints: - - host: api.openai.com + - host: provider.example.com port: 443 protocol: rest enforcement: enforce @@ -61,4 +61,4 @@ network_middlewares: on_error: fail_closed endpoints: include: - - api.openai.com + - provider.example.com diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs new file mode 100644 index 00000000..cd424fb4 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync, writeFileSync } from "node:fs"; + +const options = parseOptions(process.argv.slice(2)); +const baseUrl = parseBaseUrl(options.get("base-url")); +const modelId = requireOption(options, "model-id"); +const modelsOutput = requireOption(options, "models-output"); +const policyOutput = requireOption(options, "policy-output"); +const providerProfileOutput = requireOption(options, "provider-profile-output"); +const gatewayOutput = requireOption(options, "gateway-output"); +const middlewareEndpoint = parseMiddlewareEndpoint( + options.get("middleware-endpoint"), +); +const endpointPort = baseUrl.port || (baseUrl.protocol === "https:" ? "443" : "80"); + +const models = { + providers: { + "attested-provider": { + baseUrl: baseUrl.toString().replace(/\/$/, ""), + api: "openai-completions", + apiKey: "$PI_MODEL_API_KEY", + models: [ + { + id: modelId, + name: modelId, + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 16384, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }, + }, +}; +writeFileSync(modelsOutput, `${JSON.stringify(models, null, 2)}\n`); + +const policyTemplate = readFileSync(new URL("policy.yaml", import.meta.url), "utf8"); +const policy = replaceExpected( + replaceExpected(policyTemplate, "provider.example.com", baseUrl.hostname, 2), + " port: 443", + ` port: ${endpointPort}`, + 1, +); +writeFileSync(policyOutput, policy); + +writeFileSync( + providerProfileOutput, + `id: pi-attested-model +display_name: Pi attested-admission model +description: Endpoint-scoped model credential for the Pi attested-admission example +category: inference +inference_capable: true +credentials: + - name: api_key + description: Model provider API key + env_vars: [PI_MODEL_API_KEY] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: ${JSON.stringify(baseUrl.hostname)} + port: ${endpointPort} + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/node, /usr/local/bin/node] +`, +); + +writeFileSync( + gatewayOutput, + `[[openshell.supervisor.middleware]] +name = "pi-egress" +grpc_endpoint = "${middlewareEndpoint}" +allow_insecure_transport = true +max_payload_bytes = 32768 +timeout = "30s" +`, +); + +function parseOptions(argumentsList) { + if (argumentsList.length % 2 !== 0) { + fail("Options must be passed as --name value pairs."); + } + const parsed = new Map(); + for (let index = 0; index < argumentsList.length; index += 2) { + const name = argumentsList[index]; + if (!name.startsWith("--")) { + fail(`Expected an option name, received: ${name}`); + } + parsed.set(name.slice(2), argumentsList[index + 1]); + } + return parsed; +} + +function requireOption(parsed, name) { + const value = parsed.get(name); + if (!value) { + fail(`Missing --${name}.`); + } + return value; +} + +function parseBaseUrl(value) { + const raw = value || fail("Missing --base-url."); + let parsed; + try { + parsed = new URL(raw); + } catch { + fail("--base-url must be an absolute HTTP or HTTPS URL."); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + fail("--base-url must use HTTP or HTTPS."); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + fail("--base-url must not contain credentials, a query, or a fragment."); + } + return parsed; +} + +function parseMiddlewareEndpoint(value) { + const raw = value || fail("Missing --middleware-endpoint."); + let parsed; + try { + parsed = new URL(raw); + } catch { + fail("--middleware-endpoint must be an absolute HTTP or HTTPS URL."); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + fail("--middleware-endpoint must use HTTP or HTTPS."); + } + if ( + parsed.username || + parsed.password || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + fail("--middleware-endpoint must contain only a scheme, host, and port."); + } + return parsed.toString().replace(/\/$/, ""); +} + +function replaceExpected(value, marker, replacement, expectedOccurrences) { + const occurrences = value.split(marker).length - 1; + if (occurrences !== expectedOccurrences) { + fail( + `Expected policy marker ${marker} ${expectedOccurrences} times; found ${occurrences}.`, + ); + } + return value.replaceAll(marker, replacement); +} + +function fail(message) { + console.error(message); + process.exit(1); +} diff --git a/projects/egress-gate/proto/supervisor_middleware.proto b/projects/egress-gate/proto/supervisor_middleware.proto index b30cb233..b388eec8 100644 --- a/projects/egress-gate/proto/supervisor_middleware.proto +++ b/projects/egress-gate/proto/supervisor_middleware.proto @@ -9,7 +9,8 @@ import "google/protobuf/empty.proto"; import "google/protobuf/struct.proto"; // SupervisorMiddleware lets an operator-run service inspect and transform -// sandbox HTTP egress or evaluate a supported agent-harness request. +// sandbox HTTP requests and client WebSocket text messages before OpenShell +// injects credentials, or evaluate a supported agent-harness request. service SupervisorMiddleware { // Describe returns the service manifest and declared bindings. rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); @@ -24,6 +25,16 @@ service SupervisorMiddleware { // EvaluateAgentConversation returns an allow, deny, or replacement decision for // one versioned, harness-native request before the harness commits or sends it. rpc EvaluateAgentConversation(AgentConversationEvaluation) returns (AgentConversationResult); + + // EvaluateWebSocketSession opens one ordered, phase-specific stream for a + // single middleware stage and WebSocket upgrade attempt. The current + // implementation supports client-to-upstream text messages at + // PRE_CREDENTIALS; PRE_RETURN is reserved for upstream-to-client messages. + // A request may go unanswered when the session terminates. For every opened + // stage stream, OpenShell attempts at most one session_end before closing the + // stream when its transport is still writable. + rpc EvaluateWebSocketSession(stream WebSocketSessionEvent) + returns (stream WebSocketSessionEventResult); } // MiddlewareManifest describes one middleware service and the bindings it @@ -38,27 +49,38 @@ message MiddlewareManifest { string service_version = 2; // Bindings exposed by this middleware service. repeated MiddlewareBinding bindings = 3; + // Exact JWT audience this service verifies on inbound OpenShell calls. + // After authenticated Describe succeeds, OpenShell rejects the registration + // unless this matches the operator-configured audience. A strict verifier may + // reject an incorrect audience before returning this manifest. Empty skips + // this post-authentication consistency check. + string expected_audience = 4; } // MiddlewareBinding declares one operation and phase supported by a service. message MiddlewareBinding { // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported evaluation phase. + // Supported evaluation phase. PR 1 supports PRE_CREDENTIALS. PRE_RETURN is + // reserved for the return-path follow-up and is rejected by current + // manifest validation. SupervisorMiddlewarePhase phase = 2; - // Maximum request or replacement body this binding can process. - uint64 max_body_bytes = 3; + // Maximum logical payload or replacement this binding can process. For + // HTTP_REQUEST and AGENT_CONVERSATION this is the request body; for + // WEBSOCKET_MESSAGE this is one complete message. Required for every + // payload-bearing operation. + uint64 max_payload_bytes = 3; // Optional binding-specific RPC timeout. Empty uses the operator-configured // service timeout, or the 500ms platform default when that is also omitted. // A non-empty value may shorten but cannot extend the operator timeout. // Values use an integer with an `ms` or `s` suffix and must be between // 10ms and 30s. string timeout = 4; - // Agent harness supported by an AGENT_CONVERSATION binding. Empty for HTTP_REQUEST. + // Agent harness supported by an AGENT_CONVERSATION binding. Empty otherwise. string harness = 5; - // Harness hook supported by an AGENT_CONVERSATION binding. Empty for HTTP_REQUEST. + // Harness hook supported by an AGENT_CONVERSATION binding. Empty otherwise. string hook = 6; - // Version of the harness-native request schema. Empty for HTTP_REQUEST. + // Version of the harness-native request schema. Empty otherwise. string schema_version = 7; } @@ -114,14 +136,153 @@ message HttpHeader { enum SupervisorMiddlewareOperation { SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; - SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION = 2; + SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE = 2; + SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION = 3; } // Ordered phase within a supervisor operation. enum SupervisorMiddlewarePhase { SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS = 1; - SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT = 2; + SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN = 2; + SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT = 3; +} + +// Why OpenShell is ending a middleware stream. +enum WebSocketSessionEndReason { + WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED = 0; + WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE = 1; + WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT = 2; + WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD = 3; + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; + WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR = 6; + WEB_SOCKET_SESSION_END_REASON_CANCELLATION = 7; + WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED = 8; + WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL = 9; + // The middleware stage voluntarily declined inspection during preflight. + // This is a successful stage-local outcome, not a cancellation or denial of + // the WebSocket upgrade. + WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED = 10; +} + +// WebSocketSessionEvent is one ordered event in a stage-local stream. +// Message sequence numbers identify logical messages session-wide. A stage +// receives a strictly increasing subset of those numbers; gaps are valid when +// session messages are not delivered to that stage. +message WebSocketSessionEvent { + oneof event { + WebSocketPreflight preflight = 1; + WebSocketSessionStart session_start = 2; + WebSocketMessage message = 3; + WebSocketSessionEnd session_end = 4; + } +} + +// WebSocketPreflight lets a service decline this upgrade before OpenShell +// contacts upstream. It deliberately excludes query data, arbitrary request +// headers, and message payloads. +message WebSocketPreflight { + string session_id = 1; + SupervisorMiddlewarePhase phase = 2; + RequestContext context = 3; + // Admitted HTTP WebSocket-upgrade target. The method is GET, query is always + // empty, and path never includes a query string. + HttpRequestTarget target = 4; + repeated string requested_subprotocols = 5; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 6; + google.protobuf.Struct config = 7; +} + +// WebSocketSessionStart reports bounded metadata known only after the +// upstream 101 response validates. Empty selected_subprotocol means none. +message WebSocketSessionStart { + string selected_subprotocol = 1; +} + +// WebSocketMessage contains one complete reconstructed logical message. +message WebSocketMessage { + // Session-global sequence starting at 1. Values delivered to one stage must + // strictly increase but need not be contiguous. Reject zero, duplicates, and + // regressions; accept gaps. + uint64 sequence = 1; + // One complete logical payload. Protobuf string decoding enforces UTF-8 for + // text messages. Raw frame mechanics are never exposed. Limited to 4 MiB by + // the platform and the binding-specific cap. + oneof payload { + string text = 2; + bytes binary = 3; + } +} + +// WebSocketSessionEnd is OpenShell's best-effort terminal notification for one +// opened stage stream. A stage receives at most one such notification. +message WebSocketSessionEnd { + WebSocketSessionEndReason reason = 1; +} + +// WebSocketPreflightAction is the service's one-time scoping decision. +enum WebSocketPreflightAction { + // Invalid response value handled according to the policy failure mode. + WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED = 0; + // Inspect this session after the upstream accepts the upgrade. + WEB_SOCKET_PREFLIGHT_ACTION_INSPECT = 1; + // Voluntarily decline inspection without denying the upgrade. This is a + // successful decision and does not engage on_error. + WEB_SOCKET_PREFLIGHT_ACTION_SKIP = 2; + // Authoritatively deny the upgrade before upstream contact. This is a + // successful decision and is enforced regardless of on_error. + WEB_SOCKET_PREFLIGHT_ACTION_DENY = 3; +} + +message WebSocketPreflightDecision { + WebSocketPreflightAction action = 1; + // Free-form service diagnostic. OpenShell never exposes this to the + // workload or security logs. Limited to 4 KiB before discarding. + string reason = 2; + // Optional stable machine-readable code for a deny decision. Because + // preflight runs before the HTTP upgrade completes, OpenShell may return + // this code to the requester. Codes follow the same format and 64-byte + // maximum as HttpRequestResult.reason_code. + string reason_code = 3; + // Audit-safe findings produced during preflight. At most 32 findings of at + // most 4 KiB encoded each are accepted. + repeated Finding findings = 4; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. + map metadata = 5; +} + +// WebSocketMessageResult contains the decision and optional replacement for +// one message. A replacement must use the same variant as the input payload. +message WebSocketMessageResult { + // Must exactly match the sequence of the corresponding WebSocketMessage. + uint64 sequence = 1; + Decision decision = 2; + // Absence preserves the input unchanged. Oneof presence distinguishes an + // empty replacement from no replacement, and string decoding enforces UTF-8. + oneof replacement { + string text = 3; + bytes binary = 4; + } + // Free-form service diagnostic. OpenShell never exposes this to the + // workload or security logs. Limited to 4 KiB before discarding. + string reason = 5; + // Optional stable machine-readable code for OCSF only. Unlike the HTTP + // reason_code, this value is never put in a WebSocket close frame. + string reason_code = 6; + repeated Finding findings = 7; + map metadata = 8; +} + +// WebSocketSessionEventResult is an evaluation result for a preflight or message +// event. Session start and end events do not produce results. +message WebSocketSessionEventResult { + oneof result { + WebSocketPreflightDecision preflight_decision = 1; + WebSocketMessageResult message_result = 2; + } } // RequestContext identifies the sandbox request being evaluated. @@ -132,11 +293,19 @@ message RequestContext { string sandbox_id = 2; // Workload process that originated the request, when available. Process originating_process = 3; + // Sandbox name that originated the request. For display and logging only. + // Names are workspace-scoped and may be reused for different sandbox + // instances, so consumers must use sandbox_id for authorization, persistence, + // durable correlation, and identity. + string sandbox_name = 4; + // Workspace the sandbox belongs to. For display and logging only; see the + // sandbox_name guidance above. + string workspace = 5; } // HttpRequestTarget describes the admitted HTTP destination and request target. message HttpRequestTarget { - // Request scheme, such as "http" or "https". + // Request scheme, such as "http", "https", "ws", or "wss". string scheme = 1; // Destination hostname selected by network policy. string host = 2; @@ -185,10 +354,7 @@ message AgentConversationEvaluation { string session_id = 7; string turn_id = 8; bytes request_body = 9; - string source = 10; - string delivery = 11; - string request_kind = 12; - optional uint32 candidate_index = 13; + reserved 10 to 13; } // AgentConversationResult carries the authority decision, an optional complete @@ -205,13 +371,16 @@ message AgentConversationResult { bool has_replacement_body = 10; } -// Decision controls whether OpenShell continues processing the request. +// Decision controls whether OpenShell continues processing the current +// evaluation unit. enum Decision { // Invalid response value handled according to the policy failure mode. DECISION_UNSPECIFIED = 0; - // Continue processing the request and apply any returned mutations. + // Continue processing the current request or message and apply any returned + // mutations. DECISION_ALLOW = 1; - // Deny the request before credentials are injected or data is sent upstream. + // Reject the current request or message. The operation-specific result + // defines the enclosing protocol behavior. DECISION_DENY = 2; } diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py index 6442fcbf..fc1d7c43 100644 --- a/projects/egress-gate/src/egress_gate/admission/receipts.py +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -46,12 +46,8 @@ class ReceiptClaimsV1(StrictDomainModel): submission_id: BoundedMetadataString receipt_id: str = Field(pattern=r"^[0-9a-f]{32}$") provider_adapter_schema: Literal["openai.chat-completions.v1"] - scheme: ScalarString host: ScalarString port: int = Field(ge=0, le=2**32 - 1) - method: ScalarString - path: ScalarString - query: ScalarString rendered_prompt_hash: str = Field(pattern=r"^[0-9a-f]{64}$") issued_at: int = Field(ge=0) expires_at: int = Field(ge=0) @@ -123,12 +119,8 @@ def issue( submission_id=provenance.submission_id, receipt_id=secrets.token_hex(16), provider_adapter_schema=context.provider_adapter_schema, - scheme=target.scheme, host=target.host, port=target.port, - method=target.method, - path=target.path, - query=target.query, rendered_prompt_hash=_prompt_hash(rendered_prompt), issued_at=issued_at, expires_at=issued_at + self._lifetime_seconds, @@ -178,12 +170,8 @@ def verify( policy_fingerprint, context.sandbox_id, context.provider_adapter_schema, - target.scheme, target.host, target.port, - target.method, - target.path, - target.query, _prompt_hash(rendered_prompt), ) actual = ( @@ -195,12 +183,8 @@ def verify( claims.policy_fingerprint, claims.sandbox_id, claims.provider_adapter_schema, - claims.scheme, claims.host, claims.port, - claims.method, - claims.path, - claims.query, claims.rendered_prompt_hash, ) if actual != expected: diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py index d0e51411..93d50eaf 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py @@ -26,63 +26,91 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"y\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\"\x81\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x16\n\x0emax_body_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"w\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xc9\x03\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0c\x12\x0e\n\x06source\x18\n \x01(\t\x12\x10\n\x08\x64\x65livery\x18\x0b \x01(\t\x12\x14\n\x0crequest_kind\x18\x0c \x01(\t\x12\x1c\n\x0f\x63\x61ndidate_index\x18\r \x01(\rH\x00\x88\x01\x01\x42\x12\n\x10_candidate_indexJ\x04\x08\x05\x10\x06\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xba\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x02*\xa8\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x02*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xd3\x03\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResultb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\x84\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xe5\x02\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0cJ\x04\x08\x05\x10\x06J\x04\x08\n\x10\x0e\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xf1\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x03*\xd4\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x03*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xda\x04\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'supervisor_middleware_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._loaded_options = None + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_options = b'8\001' + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._loaded_options = None + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._loaded_options = None _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_HTTPREQUESTRESULT_METADATAENTRY']._loaded_options = None _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=3108 - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=3294 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=3297 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=3465 - _globals['_DECISION']._serialized_start=3467 - _globals['_DECISION']._serialized_end=3542 - _globals['_EXISTINGHEADERACTION']._serialized_start=3545 - _globals['_EXISTINGHEADERACTION']._serialized_end=3713 - _globals['_MIDDLEWAREMANIFEST']._serialized_start=115 - _globals['_MIDDLEWAREMANIFEST']._serialized_end=236 - _globals['_MIDDLEWAREBINDING']._serialized_start=239 - _globals['_MIDDLEWAREBINDING']._serialized_end=496 - _globals['_VALIDATECONFIGREQUEST']._serialized_start=498 - _globals['_VALIDATECONFIGREQUEST']._serialized_end=587 - _globals['_VALIDATECONFIGRESPONSE']._serialized_start=589 - _globals['_VALIDATECONFIGRESPONSE']._serialized_end=644 - _globals['_HTTPREQUESTEVALUATION']._serialized_start=647 - _globals['_HTTPREQUESTEVALUATION']._serialized_end=989 - _globals['_HTTPHEADER']._serialized_start=991 - _globals['_HTTPHEADER']._serialized_end=1032 - _globals['_REQUESTCONTEXT']._serialized_start=1034 - _globals['_REQUESTCONTEXT']._serialized_end=1153 - _globals['_HTTPREQUESTTARGET']._serialized_start=1155 - _globals['_HTTPREQUESTTARGET']._serialized_end=1263 - _globals['_PROCESS']._serialized_start=1265 - _globals['_PROCESS']._serialized_end=1322 - _globals['_AGENTCONVERSATIONTARGET']._serialized_start=1325 - _globals['_AGENTCONVERSATIONTARGET']._serialized_end=1488 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=1491 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=1948 - _globals['_AGENTCONVERSATIONRESULT']._serialized_start=1951 - _globals['_AGENTCONVERSATIONRESULT']._serialized_end=2338 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2279 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2326 - _globals['_FINDING']._serialized_start=2340 - _globals['_FINDING']._serialized_end=2431 - _globals['_WRITEHEADER']._serialized_start=2433 - _globals['_WRITEHEADER']._serialized_end=2543 - _globals['_REMOVEHEADER']._serialized_start=2545 - _globals['_REMOVEHEADER']._serialized_end=2573 - _globals['_HEADERMUTATION']._serialized_start=2576 - _globals['_HEADERMUTATION']._serialized_end=2717 - _globals['_HTTPREQUESTRESULT']._serialized_start=2720 - _globals['_HTTPREQUESTRESULT']._serialized_end=3105 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2279 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2326 - _globals['_SUPERVISORMIDDLEWARE']._serialized_start=3716 - _globals['_SUPERVISORMIDDLEWARE']._serialized_end=4183 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=4828 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=5069 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=5072 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=5284 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=5287 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=5865 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=5868 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=6056 + _globals['_DECISION']._serialized_start=6058 + _globals['_DECISION']._serialized_end=6133 + _globals['_EXISTINGHEADERACTION']._serialized_start=6136 + _globals['_EXISTINGHEADERACTION']._serialized_end=6304 + _globals['_MIDDLEWAREMANIFEST']._serialized_start=116 + _globals['_MIDDLEWAREMANIFEST']._serialized_end=264 + _globals['_MIDDLEWAREBINDING']._serialized_start=267 + _globals['_MIDDLEWAREBINDING']._serialized_end=527 + _globals['_VALIDATECONFIGREQUEST']._serialized_start=529 + _globals['_VALIDATECONFIGREQUEST']._serialized_end=618 + _globals['_VALIDATECONFIGRESPONSE']._serialized_start=620 + _globals['_VALIDATECONFIGRESPONSE']._serialized_end=675 + _globals['_HTTPREQUESTEVALUATION']._serialized_start=678 + _globals['_HTTPREQUESTEVALUATION']._serialized_end=1020 + _globals['_HTTPHEADER']._serialized_start=1022 + _globals['_HTTPHEADER']._serialized_end=1063 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1066 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1368 + _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1371 + _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1694 + _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1696 + _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1749 + _globals['_WEBSOCKETMESSAGE']._serialized_start=1751 + _globals['_WEBSOCKETMESSAGE']._serialized_end=1832 + _globals['_WEBSOCKETSESSIONEND']._serialized_start=1834 + _globals['_WEBSOCKETSESSIONEND']._serialized_end=1923 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1926 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2244 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2197 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2244 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2247 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2610 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2197 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2244 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2613 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2810 + _globals['_REQUESTCONTEXT']._serialized_start=2813 + _globals['_REQUESTCONTEXT']._serialized_end=2973 + _globals['_HTTPREQUESTTARGET']._serialized_start=2975 + _globals['_HTTPREQUESTTARGET']._serialized_end=3083 + _globals['_PROCESS']._serialized_start=3085 + _globals['_PROCESS']._serialized_end=3142 + _globals['_AGENTCONVERSATIONTARGET']._serialized_start=3145 + _globals['_AGENTCONVERSATIONTARGET']._serialized_end=3308 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=3311 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=3668 + _globals['_AGENTCONVERSATIONRESULT']._serialized_start=3671 + _globals['_AGENTCONVERSATIONRESULT']._serialized_end=4058 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2197 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2244 + _globals['_FINDING']._serialized_start=4060 + _globals['_FINDING']._serialized_end=4151 + _globals['_WRITEHEADER']._serialized_start=4153 + _globals['_WRITEHEADER']._serialized_end=4263 + _globals['_REMOVEHEADER']._serialized_start=4265 + _globals['_REMOVEHEADER']._serialized_end=4293 + _globals['_HEADERMUTATION']._serialized_start=4296 + _globals['_HEADERMUTATION']._serialized_end=4437 + _globals['_HTTPREQUESTRESULT']._serialized_start=4440 + _globals['_HTTPREQUESTRESULT']._serialized_end=4825 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2197 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2244 + _globals['_SUPERVISORMIDDLEWARE']._serialized_start=6307 + _globals['_SUPERVISORMIDDLEWARE']._serialized_end=6909 # @@protoc_insertion_point(module_scope) diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi index accf5f19..aeea0f4f 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi @@ -13,14 +13,37 @@ class SupervisorMiddlewareOperation(int, metaclass=_enum_type_wrapper.EnumTypeWr __slots__ = () SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: _ClassVar[SupervisorMiddlewareOperation] + SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: _ClassVar[SupervisorMiddlewareOperation] class SupervisorMiddlewarePhase(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: _ClassVar[SupervisorMiddlewarePhase] SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: _ClassVar[SupervisorMiddlewarePhase] + SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: _ClassVar[SupervisorMiddlewarePhase] SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: _ClassVar[SupervisorMiddlewarePhase] +class WebSocketSessionEndReason(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_CANCELLATION: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: _ClassVar[WebSocketSessionEndReason] + +class WebSocketPreflightAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_INSPECT: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_SKIP: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_DENY: _ClassVar[WebSocketPreflightAction] + class Decision(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () DECISION_UNSPECIFIED: _ClassVar[Decision] @@ -35,10 +58,27 @@ class ExistingHeaderAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): EXISTING_HEADER_ACTION_SKIP: _ClassVar[ExistingHeaderAction] SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: SupervisorMiddlewareOperation +SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: SupervisorMiddlewarePhase +SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: SupervisorMiddlewarePhase +WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_CANCELLATION: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: WebSocketSessionEndReason +WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_INSPECT: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_SKIP: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_DENY: WebSocketPreflightAction DECISION_UNSPECIFIED: Decision DECISION_ALLOW: Decision DECISION_DENY: Decision @@ -48,32 +88,34 @@ EXISTING_HEADER_ACTION_OVERWRITE: ExistingHeaderAction EXISTING_HEADER_ACTION_SKIP: ExistingHeaderAction class MiddlewareManifest(_message.Message): - __slots__ = ("name", "service_version", "bindings") + __slots__ = ("name", "service_version", "bindings", "expected_audience") NAME_FIELD_NUMBER: _ClassVar[int] SERVICE_VERSION_FIELD_NUMBER: _ClassVar[int] BINDINGS_FIELD_NUMBER: _ClassVar[int] + EXPECTED_AUDIENCE_FIELD_NUMBER: _ClassVar[int] name: str service_version: str bindings: _containers.RepeatedCompositeFieldContainer[MiddlewareBinding] - def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ...) -> None: ... + expected_audience: str + def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ..., expected_audience: _Optional[str] = ...) -> None: ... class MiddlewareBinding(_message.Message): - __slots__ = ("operation", "phase", "max_body_bytes", "timeout", "harness", "hook", "schema_version") + __slots__ = ("operation", "phase", "max_payload_bytes", "timeout", "harness", "hook", "schema_version") OPERATION_FIELD_NUMBER: _ClassVar[int] PHASE_FIELD_NUMBER: _ClassVar[int] - MAX_BODY_BYTES_FIELD_NUMBER: _ClassVar[int] + MAX_PAYLOAD_BYTES_FIELD_NUMBER: _ClassVar[int] TIMEOUT_FIELD_NUMBER: _ClassVar[int] HARNESS_FIELD_NUMBER: _ClassVar[int] HOOK_FIELD_NUMBER: _ClassVar[int] SCHEMA_VERSION_FIELD_NUMBER: _ClassVar[int] operation: SupervisorMiddlewareOperation phase: SupervisorMiddlewarePhase - max_body_bytes: int + max_payload_bytes: int timeout: str harness: str hook: str schema_version: str - def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_body_bytes: _Optional[int] = ..., timeout: _Optional[str] = ..., harness: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ...) -> None: ... + def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_payload_bytes: _Optional[int] = ..., timeout: _Optional[str] = ..., harness: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ...) -> None: ... class ValidateConfigRequest(_message.Message): __slots__ = ("config", "middleware_name") @@ -117,15 +159,127 @@ class HttpHeader(_message.Message): value: str def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... +class WebSocketSessionEvent(_message.Message): + __slots__ = ("preflight", "session_start", "message", "session_end") + PREFLIGHT_FIELD_NUMBER: _ClassVar[int] + SESSION_START_FIELD_NUMBER: _ClassVar[int] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + SESSION_END_FIELD_NUMBER: _ClassVar[int] + preflight: WebSocketPreflight + session_start: WebSocketSessionStart + message: WebSocketMessage + session_end: WebSocketSessionEnd + def __init__(self, preflight: _Optional[_Union[WebSocketPreflight, _Mapping]] = ..., session_start: _Optional[_Union[WebSocketSessionStart, _Mapping]] = ..., message: _Optional[_Union[WebSocketMessage, _Mapping]] = ..., session_end: _Optional[_Union[WebSocketSessionEnd, _Mapping]] = ...) -> None: ... + +class WebSocketPreflight(_message.Message): + __slots__ = ("session_id", "phase", "context", "target", "requested_subprotocols", "middleware_name", "config") + SESSION_ID_FIELD_NUMBER: _ClassVar[int] + PHASE_FIELD_NUMBER: _ClassVar[int] + CONTEXT_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + REQUESTED_SUBPROTOCOLS_FIELD_NUMBER: _ClassVar[int] + MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + CONFIG_FIELD_NUMBER: _ClassVar[int] + session_id: str + phase: SupervisorMiddlewarePhase + context: RequestContext + target: HttpRequestTarget + requested_subprotocols: _containers.RepeatedScalarFieldContainer[str] + middleware_name: str + config: _struct_pb2.Struct + def __init__(self, session_id: _Optional[str] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., requested_subprotocols: _Optional[_Iterable[str]] = ..., middleware_name: _Optional[str] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ... + +class WebSocketSessionStart(_message.Message): + __slots__ = ("selected_subprotocol",) + SELECTED_SUBPROTOCOL_FIELD_NUMBER: _ClassVar[int] + selected_subprotocol: str + def __init__(self, selected_subprotocol: _Optional[str] = ...) -> None: ... + +class WebSocketMessage(_message.Message): + __slots__ = ("sequence", "text", "binary") + SEQUENCE_FIELD_NUMBER: _ClassVar[int] + TEXT_FIELD_NUMBER: _ClassVar[int] + BINARY_FIELD_NUMBER: _ClassVar[int] + sequence: int + text: str + binary: bytes + def __init__(self, sequence: _Optional[int] = ..., text: _Optional[str] = ..., binary: _Optional[bytes] = ...) -> None: ... + +class WebSocketSessionEnd(_message.Message): + __slots__ = ("reason",) + REASON_FIELD_NUMBER: _ClassVar[int] + reason: WebSocketSessionEndReason + def __init__(self, reason: _Optional[_Union[WebSocketSessionEndReason, str]] = ...) -> None: ... + +class WebSocketPreflightDecision(_message.Message): + __slots__ = ("action", "reason", "reason_code", "findings", "metadata") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + ACTION_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + action: WebSocketPreflightAction + reason: str + reason_code: str + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + def __init__(self, action: _Optional[_Union[WebSocketPreflightAction, str]] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class WebSocketMessageResult(_message.Message): + __slots__ = ("sequence", "decision", "text", "binary", "reason", "reason_code", "findings", "metadata") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + SEQUENCE_FIELD_NUMBER: _ClassVar[int] + DECISION_FIELD_NUMBER: _ClassVar[int] + TEXT_FIELD_NUMBER: _ClassVar[int] + BINARY_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + sequence: int + decision: Decision + text: str + binary: bytes + reason: str + reason_code: str + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + def __init__(self, sequence: _Optional[int] = ..., decision: _Optional[_Union[Decision, str]] = ..., text: _Optional[str] = ..., binary: _Optional[bytes] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class WebSocketSessionEventResult(_message.Message): + __slots__ = ("preflight_decision", "message_result") + PREFLIGHT_DECISION_FIELD_NUMBER: _ClassVar[int] + MESSAGE_RESULT_FIELD_NUMBER: _ClassVar[int] + preflight_decision: WebSocketPreflightDecision + message_result: WebSocketMessageResult + def __init__(self, preflight_decision: _Optional[_Union[WebSocketPreflightDecision, _Mapping]] = ..., message_result: _Optional[_Union[WebSocketMessageResult, _Mapping]] = ...) -> None: ... + class RequestContext(_message.Message): - __slots__ = ("request_id", "sandbox_id", "originating_process") + __slots__ = ("request_id", "sandbox_id", "originating_process", "sandbox_name", "workspace") REQUEST_ID_FIELD_NUMBER: _ClassVar[int] SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] ORIGINATING_PROCESS_FIELD_NUMBER: _ClassVar[int] + SANDBOX_NAME_FIELD_NUMBER: _ClassVar[int] + WORKSPACE_FIELD_NUMBER: _ClassVar[int] request_id: str sandbox_id: str originating_process: Process - def __init__(self, request_id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., originating_process: _Optional[_Union[Process, _Mapping]] = ...) -> None: ... + sandbox_name: str + workspace: str + def __init__(self, request_id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., originating_process: _Optional[_Union[Process, _Mapping]] = ..., sandbox_name: _Optional[str] = ..., workspace: _Optional[str] = ...) -> None: ... class HttpRequestTarget(_message.Message): __slots__ = ("scheme", "host", "port", "method", "path", "query") @@ -174,7 +328,7 @@ class AgentConversationTarget(_message.Message): def __init__(self, harness: _Optional[str] = ..., harness_version: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ..., scheme: _Optional[str] = ..., host: _Optional[str] = ..., port: _Optional[int] = ..., path: _Optional[str] = ...) -> None: ... class AgentConversationEvaluation(_message.Message): - __slots__ = ("phase", "context", "config", "target", "middleware_name", "session_id", "turn_id", "request_body", "source", "delivery", "request_kind", "candidate_index") + __slots__ = ("phase", "context", "config", "target", "middleware_name", "session_id", "turn_id", "request_body") PHASE_FIELD_NUMBER: _ClassVar[int] CONTEXT_FIELD_NUMBER: _ClassVar[int] CONFIG_FIELD_NUMBER: _ClassVar[int] @@ -183,10 +337,6 @@ class AgentConversationEvaluation(_message.Message): SESSION_ID_FIELD_NUMBER: _ClassVar[int] TURN_ID_FIELD_NUMBER: _ClassVar[int] REQUEST_BODY_FIELD_NUMBER: _ClassVar[int] - SOURCE_FIELD_NUMBER: _ClassVar[int] - DELIVERY_FIELD_NUMBER: _ClassVar[int] - REQUEST_KIND_FIELD_NUMBER: _ClassVar[int] - CANDIDATE_INDEX_FIELD_NUMBER: _ClassVar[int] phase: SupervisorMiddlewarePhase context: RequestContext config: _struct_pb2.Struct @@ -195,11 +345,7 @@ class AgentConversationEvaluation(_message.Message): session_id: str turn_id: str request_body: bytes - source: str - delivery: str - request_kind: str - candidate_index: int - def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[AgentConversationTarget, _Mapping]] = ..., middleware_name: _Optional[str] = ..., session_id: _Optional[str] = ..., turn_id: _Optional[str] = ..., request_body: _Optional[bytes] = ..., source: _Optional[str] = ..., delivery: _Optional[str] = ..., request_kind: _Optional[str] = ..., candidate_index: _Optional[int] = ...) -> None: ... + def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[AgentConversationTarget, _Mapping]] = ..., middleware_name: _Optional[str] = ..., session_id: _Optional[str] = ..., turn_id: _Optional[str] = ..., request_body: _Optional[bytes] = ...) -> None: ... class AgentConversationResult(_message.Message): __slots__ = ("decision", "reason", "attestation", "findings", "metadata", "reason_code", "replacement_body", "has_replacement_body") diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py index aab704aa..e3421f1c 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py @@ -28,7 +28,8 @@ class SupervisorMiddlewareStub: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress or evaluate a supported agent-harness request. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials, or evaluate a supported agent-harness request. """ def __init__(self, channel): @@ -57,11 +58,17 @@ def __init__(self, channel): request_serializer=supervisor__middleware__pb2.AgentConversationEvaluation.SerializeToString, response_deserializer=supervisor__middleware__pb2.AgentConversationResult.FromString, _registered_method=True) + self.EvaluateWebSocketSession = channel.stream_stream( + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', + request_serializer=supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + response_deserializer=supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + _registered_method=True) class SupervisorMiddlewareServicer: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress or evaluate a supported agent-harness request. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials, or evaluate a supported agent-harness request. """ def Describe(self, request, context): @@ -94,6 +101,19 @@ def EvaluateAgentConversation(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def EvaluateWebSocketSession(self, request_iterator, context): + """EvaluateWebSocketSession opens one ordered, phase-specific stream for a + single middleware stage and WebSocket upgrade attempt. The current + implementation supports client-to-upstream text messages at + PRE_CREDENTIALS; PRE_RETURN is reserved for upstream-to-client messages. + A request may go unanswered when the session terminates. For every opened + stage stream, OpenShell attempts at most one session_end before closing the + stream when its transport is still writable. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_SupervisorMiddlewareServicer_to_server(servicer, server): rpc_method_handlers = { @@ -117,6 +137,11 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): request_deserializer=supervisor__middleware__pb2.AgentConversationEvaluation.FromString, response_serializer=supervisor__middleware__pb2.AgentConversationResult.SerializeToString, ), + 'EvaluateWebSocketSession': grpc.stream_stream_rpc_method_handler( + servicer.EvaluateWebSocketSession, + request_deserializer=supervisor__middleware__pb2.WebSocketSessionEvent.FromString, + response_serializer=supervisor__middleware__pb2.WebSocketSessionEventResult.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'openshell.middleware.v1.SupervisorMiddleware', rpc_method_handlers) @@ -127,7 +152,8 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class SupervisorMiddleware: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress or evaluate a supported agent-harness request. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials, or evaluate a supported agent-harness request. """ @staticmethod @@ -237,3 +263,30 @@ def EvaluateAgentConversation(request, timeout, metadata, _registered_method=True) + + @staticmethod + def EvaluateWebSocketSession(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream( + request_iterator, + target, + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', + supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index 6a8ec786..a45fc9af 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -161,13 +161,13 @@ async def Describe( pb2.MiddlewareBinding( operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST, phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, - max_body_bytes=MAX_BODY_BYTES, + max_payload_bytes=MAX_BODY_BYTES, ), *( pb2.MiddlewareBinding( operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION, phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, - max_body_bytes=MAX_ADMISSION_BODY_BYTES, + max_payload_bytes=MAX_ADMISSION_BODY_BYTES, harness="pi", hook=hook.value, schema_version="openshell.pi-input.v1", diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index 567fa7d2..f36f0f04 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -126,6 +126,7 @@ def _admit_body( body: bytes, *, timeout: Timeout | None = None, + provider_target: HttpTarget | None = None, ): result = processor.process( HarnessAdmissionRequest( @@ -144,7 +145,7 @@ def _admit_body( harness_version="extension-v1", hook=AdmissionHook.RENDERED_PROMPT, schema_version="openshell.pi-input.v1", - provider_target=_target(), + provider_target=provider_target or _target(), provider_adapter_schema="openai.chat-completions.v1", ), timeout=timeout or Timeout.from_seconds(1), @@ -152,7 +153,12 @@ def _admit_body( return result -def _provider_request(prompt: str, receipt: bytes | None) -> HttpRequest: +def _provider_request( + prompt: str, + receipt: bytes | None, + *, + target: HttpTarget | None = None, +) -> HttpRequest: body = json.dumps( { "model": "fixture-model", @@ -178,7 +184,7 @@ def _provider_request(prompt: str, receipt: bytes | None) -> HttpRequest: headers.append(HttpHeader(name=RECEIPT_HEADER, value=receipt.decode("ascii"))) return HttpRequest( context=RequestContext(request_id="network-1", sandbox_id="sandbox-1"), - target=_target(), + target=target or _target(), headers=tuple(headers), body=body, ) @@ -203,6 +209,52 @@ def test_safe_rendered_prompt_receipt_authorizes_first_request_and_is_stripped() ] == [RECEIPT_HEADER] +def test_receipt_uses_stable_destination_across_tls_proxy_normalization() -> None: + admission, egress = _processors() + body = canonical_json_bytes( + PiInputV1(schema_version="openshell.pi-input.v1", text="safe rendered prompt") + ) + admitted = _admit_body( + admission, + body, + provider_target=HttpTarget( + scheme="https", + host="provider.test", + port=443, + method="POST", + path="", + query="", + ), + ) + assert admitted.receipt is not None + + normalized_target = HttpTarget( + scheme="http", + host="provider.test", + port=443, + method="POST", + path="/v1/chat/completions", + query="", + ) + wrong_host = egress.process( + _provider_request( + "safe rendered prompt", + admitted.receipt, + target=normalized_target.model_copy(update={"host": "other.test"}), + ), + timeout=Timeout.from_seconds(1), + ) + result = egress.process( + _provider_request( + "safe rendered prompt", admitted.receipt, target=normalized_target + ), + timeout=Timeout.from_seconds(1), + ) + + assert wrong_host.reason_code == "receipt_context_mismatch" + assert result.decision.value == "allow" + + def test_rendered_prompt_receipt_is_consumed_after_first_request() -> None: admission, egress = _processors() _, admitted = _admit(admission, "safe rendered prompt") diff --git a/projects/egress-gate/tests/test_pi_admission_extension.py b/projects/egress-gate/tests/test_pi_admission_extension.py new file mode 100644 index 00000000..d2bd43f9 --- /dev/null +++ b/projects/egress-gate/tests/test_pi_admission_extension.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def test_pi_admission_extension_renews_receipts_for_provider_continuations() -> None: + project_dir = Path(__file__).parents[1] + test_file = ( + project_dir + / "examples/pi-attested-admission/openshell-input-admission.test.mjs" + ) + + subprocess.run( + ["node", "--experimental-strip-types", "--test", str(test_file)], + check=True, + ) diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 47da0ac7..42941306 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -3,20 +3,21 @@ from __future__ import annotations +import json import os import subprocess +import tomllib from pathlib import Path +import yaml -def test_pi_example_can_print_every_command_without_running_it( + +def test_pi_example_can_print_each_action_without_running_it( tmp_path: Path, ) -> None: project_dir = Path(__file__).parents[1] script = project_dir / "examples/pi-attested-admission/demo.sh" pi_repo = tmp_path / "pi" - package_dir = pi_repo / "packages/coding-agent" - package_dir.mkdir(parents=True) - (package_dir / "package.json").write_text('{"version":"1.2.3"}') openshell_repo = tmp_path / "OpenShell" pack_dir = tmp_path / "pack" runtime_dir = tmp_path / "runtime" @@ -24,33 +25,268 @@ def test_pi_example_can_print_every_command_without_running_it( "PI_REPO": str(pi_repo), "OPENSHELL_REPO": str(openshell_repo), "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_MODEL_BASE_URL": "https://models.example.test/v1", + "PI_MODEL_ID": "example-model", "PI_EGRESS_PACK_DIR": str(pack_dir), "PI_EGRESS_RUNTIME_DIR": str(runtime_dir), } + results = [ + subprocess.run( + ["bash", str(script), "--print", action], + check=True, + capture_output=True, + env=environment, + text=True, + ) + for action in ("prepare", "serve", "gateway", "launch", "verify", "cleanup") + ] + output = "\n".join(result.stdout for result in results) + + assert "npm run build" in output + assert "earendil-works-pi-coding-agent-VERSION.tgz" in output + assert "git clone --branch johnny/before-user-message-commit" in output + assert "git clone --branch openshell/pi-egress-admission" in output + assert ( + "git pull --no-rebase --ff-only origin johnny/before-user-message-commit" + in output + ) + assert ( + "git pull --no-rebase --ff-only origin openshell/pi-egress-admission" in output + ) + assert "gateway-middleware.toml" in output + assert "OPENSHELL_GATEWAY_CONFIG_FRAGMENT=" in output + assert "render-runtime-config.mjs" in output + assert "https://models.example.test/v1" in output + assert "example-model" in output + assert "egress-gate --debug serve" in output + assert "CARGO_BUILD_JOBS=4" in output + assert "OPENSHELL_GATEWAY_NAME=pi-egress-demo-gateway" in output + assert "--gateway pi-egress-demo-gateway" in output + assert "provider create" in output + assert "provider profile import" in output + assert "--type pi-attested-model" in output + assert "PI_MODEL_API_KEY" in output + assert "OPENAI_API_KEY" not in output + assert "api.openai.com" not in output + assert "sandbox create" in output + assert "--detach" in output + assert "--no-git-ignore" in output + assert f"{runtime_dir}/node_modules:/sandbox/pi-runtime" in output + assert f"{runtime_dir}:/sandbox/pi-runtime" not in output + assert "sandbox exec" in output + assert "sandbox exec --tty" in output + assert "PI_OFFLINE=1" in output + assert "OPENSHELL_AGENT_CONVERSATION_URL=" in output + assert "REDACTED" in output + assert "DENY_THIS" in output + assert "REDACT_THIS" in output + assert "sandbox delete" in output + assert all(result.stderr == "" for result in results) + assert not pi_repo.exists() + assert not openshell_repo.exists() + assert not pack_dir.exists() + assert not runtime_dir.exists() + + +def test_pi_example_print_all_is_a_concise_walkthrough() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + result = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=os.environ + | { + "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_MODEL_BASE_URL": "https://models.example.test/v1", + "PI_MODEL_ID": "example-model", + "PI_MODEL_API_KEY": "secret-not-printed", + }, + text=True, + ) + + assert "Pi attested-admission walkthrough" in result.stdout + assert "Configuration visible to this shell" in result.stdout + assert "Model credential: set (value hidden)" in result.stdout + assert "1. prepare" in result.stdout + assert "7. cleanup" in result.stdout + assert "secret-not-printed" not in result.stdout + assert "working directory:" not in result.stdout + + +def test_pi_example_uses_terminal_colors_without_leaking_them_to_redirects() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + environment = { + name: value for name, value in os.environ.items() if name != "NO_COLOR" + } | {"FORCE_COLOR": "1"} + + colored = subprocess.run( ["bash", str(script), "--print", "all"], check=True, capture_output=True, env=environment, text=True, ) + uncolored = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=environment | {"NO_COLOR": "1"}, + text=True, + ) + assert "\x1b[36m" in colored.stdout + assert "\x1b[" not in uncolored.stdout - assert "npm run build" in result.stdout - assert ( - "git pull --ff-only origin johnny/before-user-message-commit" in result.stdout - ) - assert "git pull --ff-only origin openshell/pi-egress-admission" in result.stdout - assert "add-gateway-registration" in result.stdout - assert "egress-gate --debug serve" in result.stdout - assert "env UV_NO_CONFIG=1 mise run gateway" in result.stdout - assert "provider create" in result.stdout - assert "sandbox create" in result.stdout - assert "sandbox exec" in result.stdout - assert "REDACTED" in result.stdout - assert "DENY_THIS" in result.stdout - assert "REDACT_THIS" in result.stdout - assert "sandbox delete" in result.stdout - assert result.stderr == "" - assert not pack_dir.exists() - assert not runtime_dir.exists() + +def test_pi_example_defaults_to_an_ignored_external_workspace() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + + result = subprocess.run( + ["bash", str(script), "--print", "prepare"], + check=True, + capture_output=True, + env={ + name: value + for name, value in os.environ.items() + if name not in {"PI_REPO", "OPENSHELL_REPO", "PI_EGRESS_FORKS_DIR"} + }, + text=True, + ) + + workspace = project_dir / ".workspaces/pi-attested-admission" + assert str(workspace / "pi") in result.stdout + assert str(workspace / "OpenShell") in result.stdout + assert ".workspaces/" in (project_dir / ".gitignore").read_text().splitlines() + + +def test_pi_example_renders_provider_specific_runtime_configuration( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + example_dir = project_dir / "examples/pi-attested-admission" + models_output = tmp_path / "models.json" + policy_output = tmp_path / "policy.yaml" + provider_profile_output = tmp_path / "provider-profile.yaml" + gateway_output = tmp_path / "gateway-middleware.toml" + + subprocess.run( + [ + "node", + str(example_dir / "render-runtime-config.mjs"), + "--base-url", + "https://gateway.example.test:8443/models/v1", + "--model-id", + "custom-model", + "--models-output", + str(models_output), + "--policy-output", + str(policy_output), + "--provider-profile-output", + str(provider_profile_output), + "--middleware-endpoint", + "http://192.0.2.10:50051", + "--gateway-output", + str(gateway_output), + ], + check=True, + ) + + models = json.loads(models_output.read_text()) + provider = models["providers"]["attested-provider"] + assert provider["baseUrl"] == "https://gateway.example.test:8443/models/v1" + assert provider["api"] == "openai-completions" + assert provider["apiKey"] == "$PI_MODEL_API_KEY" + assert provider["models"][0]["id"] == "custom-model" + + provider_profile = yaml.safe_load(provider_profile_output.read_text()) + assert provider_profile["id"] == "pi-attested-model" + assert provider_profile["credentials"][0]["env_vars"] == ["PI_MODEL_API_KEY"] + assert provider_profile["endpoints"][0]["host"] == "gateway.example.test" + assert provider_profile["endpoints"][0]["port"] == 8443 + + policy = yaml.safe_load(policy_output.read_text()) + endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] + assert endpoint["host"] == "gateway.example.test" + assert endpoint["port"] == 8443 + middleware = policy["network_middlewares"]["pi_egress_gate"] + assert middleware["endpoints"]["include"] == ["gateway.example.test"] + + gateway_fragment = tomllib.loads(gateway_output.read_text()) + registration = gateway_fragment["openshell"]["supervisor"]["middleware"][0] + assert registration["name"] == "pi-egress" + assert registration["grpc_endpoint"] == "http://192.0.2.10:50051" + assert registration["allow_insecure_transport"] is True + assert registration["max_payload_bytes"] == 32 * 1024 + + +def test_pi_example_reports_all_missing_configuration_before_work( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + environment = { + name: value + for name, value in os.environ.items() + if name + not in { + "EGRESS_GATE_HOST_IP", + "PI_MODEL_BASE_URL", + "PI_MODEL_ID", + "PI_MODEL_API_KEY", + } + } + + result = subprocess.run( + ["bash", str(script), "prepare"], + capture_output=True, + cwd=tmp_path, + env=environment, + text=True, + ) + + assert result.returncode == 1 + assert result.stdout == "" + assert "The Pi attested-admission example is not configured." in result.stderr + assert "EGRESS_GATE_HOST_IP" in result.stderr + assert "PI_MODEL_BASE_URL" in result.stderr + assert "PI_MODEL_ID" in result.stderr + assert "PI_MODEL_API_KEY" in result.stderr + assert "source .env" in result.stderr + assert "git pull" not in result.stderr + + +def test_pi_example_reports_a_missing_compute_backend_before_mise( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + for command in ("docker", "podman"): + stub = tmp_path / command + stub.write_text("#!/bin/sh\nexit 1\n") + stub.chmod(0o755) + + result = subprocess.run( + ["bash", str(script), "gateway"], + capture_output=True, + env=os.environ + | { + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "OPENSHELL_DRIVERS": "", + "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_MODEL_BASE_URL": "https://models.example.test/v1", + "PI_MODEL_ID": "example-model", + "PI_MODEL_API_KEY": "test-key", + }, + text=True, + ) + + assert result.returncode == 1 + assert result.stdout == "" + assert "No running OpenShell compute backend was detected." in result.stderr + assert "docker info" in result.stderr + assert "podman info" in result.stderr + assert "mise" not in result.stderr From 636ed82a9c74ae08d07b7c37ec90149b247243a3 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 23:37:44 -0400 Subject: [PATCH 15/70] fix(egress-gate): recreate example provider profile on launch --- .../examples/pi-attested-admission/README.md | 5 +++-- .../examples/pi-attested-admission/demo.sh | 17 ++++++++++------- .../tests/test_pi_example_commands.py | 3 +++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index b7bc274c..4996eb49 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -112,8 +112,9 @@ After the gateway reports that it is ready, launch Pi from a third terminal: ./demo.sh launch ``` -Each launch replaces the example's `pi-egress-demo` sandbox so the current Pi -runtime, extension, policy, and OpenShell supervisor are used together. +Each launch replaces the example's `pi-egress-demo` sandbox, provider, and +custom provider profile so the current Pi runtime, extension, policy, endpoint, +and OpenShell supervisor are used together. The example registers an endpoint-specific provider profile and stores `PI_MODEL_API_KEY` as its credential. Pi sees only an opaque placeholder; diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index dc11d777..de40b532 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -350,6 +350,10 @@ gateway() { ensure_model_provider() { if $print_only; then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider delete pi-model + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile delete pi-attested-model run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ provider profile import --file "$runtime_provider_profile" run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ @@ -357,18 +361,17 @@ ensure_model_provider() { return fi if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ - provider profile export pi-attested-model >/dev/null 2>&1); then - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider profile update pi-attested-model --file "$runtime_provider_profile" - else + provider get pi-model >/dev/null 2>&1); then run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider profile import --file "$runtime_provider_profile" + provider delete pi-model fi if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ - provider get pi-model >/dev/null 2>&1); then + provider profile export pi-attested-model >/dev/null 2>&1); then run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider delete pi-model + provider profile delete pi-attested-model fi + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile import --file "$runtime_provider_profile" run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY } diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 42941306..92e6f775 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -65,6 +65,9 @@ def test_pi_example_can_print_each_action_without_running_it( assert "--gateway pi-egress-demo-gateway" in output assert "provider create" in output assert "provider profile import" in output + assert "provider profile delete pi-attested-model" in output + assert "provider delete pi-model" in output + assert "provider profile update" not in output assert "--type pi-attested-model" in output assert "PI_MODEL_API_KEY" in output assert "OPENAI_API_KEY" not in output From c4ad24360d2510fcb2df5b347c046b29bafd4c95 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 23:43:31 -0400 Subject: [PATCH 16/70] fix(egress-gate): redact model credentials from Pi tool output --- .../examples/pi-attested-admission/README.md | 9 ++++---- .../examples/pi-attested-admission/demo.sh | 10 +++++--- .../openshell-input-admission.test.mjs | 23 +++++++++++++++++++ .../openshell-input-admission.ts | 20 ++++++++++++++++ .../render-runtime-config.mjs | 4 ++-- .../tests/test_pi_example_commands.py | 6 ++--- 6 files changed, 60 insertions(+), 12 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 4996eb49..686d43a4 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -116,10 +116,11 @@ Each launch replaces the example's `pi-egress-demo` sandbox, provider, and custom provider profile so the current Pi runtime, extension, policy, endpoint, and OpenShell supervisor are used together. -The example registers an endpoint-specific provider profile and stores -`PI_MODEL_API_KEY` as its credential. Pi sees only an opaque placeholder; -OpenShell resolves it only when the admitted request is sent to the configured -model host and port. +The example registers an endpoint-specific provider profile using the host-side +`PI_MODEL_API_KEY`. Inside the sandbox it uses the distinct +`MODEL_PROVIDER_API_KEY` name so Pi's own `PI_*` diagnostics do not capture the +credential. The extension redacts accidental appearances in tool output, and +OpenShell blocks any credential-bearing request body from leaving the sandbox. At the Pi prompt, submit both of these in the same session: diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index de40b532..0c2a7a5f 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -29,6 +29,7 @@ pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} openshell_cli=$openshell_repo/scripts/bin/openshell gateway_name=${PI_EGRESS_GATEWAY_NAME:-pi-egress-demo-gateway} +model_credential_env=MODEL_PROVIDER_API_KEY runtime_models=$runtime_dir/models.json runtime_policy=$runtime_dir/policy.yaml runtime_provider_profile=$runtime_dir/provider-profile.yaml @@ -357,7 +358,7 @@ ensure_model_provider() { run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ provider profile import --file "$runtime_provider_profile" run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ - --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY + --name pi-model --type pi-attested-model --credential "$model_credential_env" return fi if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ @@ -372,8 +373,11 @@ ensure_model_provider() { fi run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ provider profile import --file "$runtime_provider_profile" - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ - --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY + ( + export MODEL_PROVIDER_API_KEY=$PI_MODEL_API_KEY + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ + --name pi-model --type pi-attested-model --credential "$model_credential_env" + ) } delete_demo_sandbox_if_present() { diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs index 4032541e..e9720c25 100644 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs @@ -107,3 +107,26 @@ test("activates queued prompts only when Pi delivers them", async () => { else process.env[BRIDGE_URL_ENV] = originalBridgeUrl; } }); + +test("redacts the model credential from tool output before Pi records it", async () => { + const credentialName = "MODEL_PROVIDER_API_KEY"; + const originalCredential = process.env[credentialName]; + const credential = "test-model-credential-123456"; + process.env[credentialName] = credential; + + try { + const handlers = createHarness(); + const result = await handlers.get("tool_result")({ + content: [{ type: "text", text: `MODEL_PROVIDER_API_KEY=${credential}\nPI_SESSION_ID=session-1` }], + }); + assert.deepEqual(result.content, [ + { + type: "text", + text: "MODEL_PROVIDER_API_KEY=[REDACTED_MODEL_CREDENTIAL]\nPI_SESSION_ID=session-1", + }, + ]); + } finally { + if (originalCredential === undefined) delete process.env[credentialName]; + else process.env[credentialName] = originalCredential; + } +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts index 1b5f120f..d153b95a 100644 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts @@ -14,6 +14,8 @@ const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; const SCHEMA_VERSION = "openshell.pi-input.v1"; const MAX_RESPONSE_BYTES = 256 * 1024; const MAX_RECEIPT_BYTES = 8 * 1024; +const REDACTED_CREDENTIAL = "[REDACTED_MODEL_CREDENTIAL]"; +const CREDENTIAL_ENV_NAMES = ["MODEL_PROVIDER_API_KEY", "PI_MODEL_API_KEY"]; type BridgeResponse = | { decision: "allow"; replacement_body?: number[]; receipt: number[] } @@ -43,6 +45,24 @@ export default function (pi: ExtensionAPI) { let activeAdmission: ActiveAdmission | undefined; let pendingReceipt: string | undefined; const queuedAdmissions: PendingAdmission[] = []; + const credentialValues = CREDENTIAL_ENV_NAMES.map((name) => process.env[name]).filter( + (value): value is string => typeof value === "string" && value.length >= 12, + ); + + pi.on("tool_result", (event) => { + let changed = false; + const content = event.content.map((part) => { + if (part.type !== "text") return part; + let text = part.text; + for (const credential of credentialValues) { + const redacted = text.replaceAll(credential, REDACTED_CREDENTIAL); + changed ||= redacted !== text; + text = redacted; + } + return text === part.text ? part : { ...part, text }; + }); + return changed ? { content } : undefined; + }); pi.on("before_user_message_append", async (event, ctx) => { const isIdle = ctx.isIdle(); diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs index cd424fb4..8d141840 100644 --- a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -20,7 +20,7 @@ const models = { "attested-provider": { baseUrl: baseUrl.toString().replace(/\/$/, ""), api: "openai-completions", - apiKey: "$PI_MODEL_API_KEY", + apiKey: "$MODEL_PROVIDER_API_KEY", models: [ { id: modelId, @@ -56,7 +56,7 @@ inference_capable: true credentials: - name: api_key description: Model provider API key - env_vars: [PI_MODEL_API_KEY] + env_vars: [MODEL_PROVIDER_API_KEY] required: true auth_style: bearer header_name: authorization diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 92e6f775..f2e9ac3d 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -69,7 +69,7 @@ def test_pi_example_can_print_each_action_without_running_it( assert "provider delete pi-model" in output assert "provider profile update" not in output assert "--type pi-attested-model" in output - assert "PI_MODEL_API_KEY" in output + assert "MODEL_PROVIDER_API_KEY" in output assert "OPENAI_API_KEY" not in output assert "api.openai.com" not in output assert "sandbox create" in output @@ -202,12 +202,12 @@ def test_pi_example_renders_provider_specific_runtime_configuration( provider = models["providers"]["attested-provider"] assert provider["baseUrl"] == "https://gateway.example.test:8443/models/v1" assert provider["api"] == "openai-completions" - assert provider["apiKey"] == "$PI_MODEL_API_KEY" + assert provider["apiKey"] == "$MODEL_PROVIDER_API_KEY" assert provider["models"][0]["id"] == "custom-model" provider_profile = yaml.safe_load(provider_profile_output.read_text()) assert provider_profile["id"] == "pi-attested-model" - assert provider_profile["credentials"][0]["env_vars"] == ["PI_MODEL_API_KEY"] + assert provider_profile["credentials"][0]["env_vars"] == ["MODEL_PROVIDER_API_KEY"] assert provider_profile["endpoints"][0]["host"] == "gateway.example.test" assert provider_profile["endpoints"][0]["port"] == 8443 From bbc03af229222f806c9a2135ac46eadf1ba57838 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 23:47:13 -0400 Subject: [PATCH 17/70] fix(egress-gate): distinguish credential placeholders from secrets --- .../examples/pi-attested-admission/README.md | 9 +++++---- .../examples/pi-attested-admission/demo.sh | 10 +++------- .../openshell-input-admission.test.mjs | 18 +++++++++--------- .../openshell-input-admission.ts | 10 +++++----- .../render-runtime-config.mjs | 4 ++-- .../tests/test_pi_example_commands.py | 6 +++--- 6 files changed, 27 insertions(+), 30 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 686d43a4..6cbac3b6 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -117,10 +117,11 @@ custom provider profile so the current Pi runtime, extension, policy, endpoint, and OpenShell supervisor are used together. The example registers an endpoint-specific provider profile using the host-side -`PI_MODEL_API_KEY`. Inside the sandbox it uses the distinct -`MODEL_PROVIDER_API_KEY` name so Pi's own `PI_*` diagnostics do not capture the -credential. The extension redacts accidental appearances in tool output, and -OpenShell blocks any credential-bearing request body from leaving the sandbox. +`PI_MODEL_API_KEY`. The real credential remains in OpenShell. Pi receives an +environment variable with the same name whose value is an opaque, +endpoint-bound OpenShell resolver placeholder. The extension redacts accidental +appearances of that placeholder in tool output; OpenShell resolves it in the +authorization header only for the configured model endpoint. At the Pi prompt, submit both of these in the same session: diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 0c2a7a5f..de40b532 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -29,7 +29,6 @@ pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} openshell_cli=$openshell_repo/scripts/bin/openshell gateway_name=${PI_EGRESS_GATEWAY_NAME:-pi-egress-demo-gateway} -model_credential_env=MODEL_PROVIDER_API_KEY runtime_models=$runtime_dir/models.json runtime_policy=$runtime_dir/policy.yaml runtime_provider_profile=$runtime_dir/provider-profile.yaml @@ -358,7 +357,7 @@ ensure_model_provider() { run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ provider profile import --file "$runtime_provider_profile" run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ - --name pi-model --type pi-attested-model --credential "$model_credential_env" + --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY return fi if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ @@ -373,11 +372,8 @@ ensure_model_provider() { fi run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ provider profile import --file "$runtime_provider_profile" - ( - export MODEL_PROVIDER_API_KEY=$PI_MODEL_API_KEY - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ - --name pi-model --type pi-attested-model --credential "$model_credential_env" - ) + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ + --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY } delete_demo_sandbox_if_present() { diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs index e9720c25..820f741f 100644 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs @@ -108,25 +108,25 @@ test("activates queued prompts only when Pi delivers them", async () => { } }); -test("redacts the model credential from tool output before Pi records it", async () => { - const credentialName = "MODEL_PROVIDER_API_KEY"; - const originalCredential = process.env[credentialName]; - const credential = "test-model-credential-123456"; - process.env[credentialName] = credential; +test("redacts the OpenShell credential placeholder before Pi records it", async () => { + const credentialName = "PI_MODEL_API_KEY"; + const originalPlaceholder = process.env[credentialName]; + const placeholder = "openshell:resolve:env:PI_MODEL_API_KEY:test-handle"; + process.env[credentialName] = placeholder; try { const handlers = createHarness(); const result = await handlers.get("tool_result")({ - content: [{ type: "text", text: `MODEL_PROVIDER_API_KEY=${credential}\nPI_SESSION_ID=session-1` }], + content: [{ type: "text", text: `PI_MODEL_API_KEY=${placeholder}\nPI_SESSION_ID=session-1` }], }); assert.deepEqual(result.content, [ { type: "text", - text: "MODEL_PROVIDER_API_KEY=[REDACTED_MODEL_CREDENTIAL]\nPI_SESSION_ID=session-1", + text: "PI_MODEL_API_KEY=[REDACTED_OPENSHELL_CREDENTIAL_PLACEHOLDER]\nPI_SESSION_ID=session-1", }, ]); } finally { - if (originalCredential === undefined) delete process.env[credentialName]; - else process.env[credentialName] = originalCredential; + if (originalPlaceholder === undefined) delete process.env[credentialName]; + else process.env[credentialName] = originalPlaceholder; } }); diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts index d153b95a..c47c29d6 100644 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts @@ -14,8 +14,8 @@ const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; const SCHEMA_VERSION = "openshell.pi-input.v1"; const MAX_RESPONSE_BYTES = 256 * 1024; const MAX_RECEIPT_BYTES = 8 * 1024; -const REDACTED_CREDENTIAL = "[REDACTED_MODEL_CREDENTIAL]"; -const CREDENTIAL_ENV_NAMES = ["MODEL_PROVIDER_API_KEY", "PI_MODEL_API_KEY"]; +const REDACTED_PLACEHOLDER = "[REDACTED_OPENSHELL_CREDENTIAL_PLACEHOLDER]"; +const PLACEHOLDER_ENV_NAMES = ["PI_MODEL_API_KEY", "MODEL_PROVIDER_API_KEY"]; type BridgeResponse = | { decision: "allow"; replacement_body?: number[]; receipt: number[] } @@ -45,7 +45,7 @@ export default function (pi: ExtensionAPI) { let activeAdmission: ActiveAdmission | undefined; let pendingReceipt: string | undefined; const queuedAdmissions: PendingAdmission[] = []; - const credentialValues = CREDENTIAL_ENV_NAMES.map((name) => process.env[name]).filter( + const credentialPlaceholders = PLACEHOLDER_ENV_NAMES.map((name) => process.env[name]).filter( (value): value is string => typeof value === "string" && value.length >= 12, ); @@ -54,8 +54,8 @@ export default function (pi: ExtensionAPI) { const content = event.content.map((part) => { if (part.type !== "text") return part; let text = part.text; - for (const credential of credentialValues) { - const redacted = text.replaceAll(credential, REDACTED_CREDENTIAL); + for (const placeholder of credentialPlaceholders) { + const redacted = text.replaceAll(placeholder, REDACTED_PLACEHOLDER); changed ||= redacted !== text; text = redacted; } diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs index 8d141840..cd424fb4 100644 --- a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -20,7 +20,7 @@ const models = { "attested-provider": { baseUrl: baseUrl.toString().replace(/\/$/, ""), api: "openai-completions", - apiKey: "$MODEL_PROVIDER_API_KEY", + apiKey: "$PI_MODEL_API_KEY", models: [ { id: modelId, @@ -56,7 +56,7 @@ inference_capable: true credentials: - name: api_key description: Model provider API key - env_vars: [MODEL_PROVIDER_API_KEY] + env_vars: [PI_MODEL_API_KEY] required: true auth_style: bearer header_name: authorization diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index f2e9ac3d..92e6f775 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -69,7 +69,7 @@ def test_pi_example_can_print_each_action_without_running_it( assert "provider delete pi-model" in output assert "provider profile update" not in output assert "--type pi-attested-model" in output - assert "MODEL_PROVIDER_API_KEY" in output + assert "PI_MODEL_API_KEY" in output assert "OPENAI_API_KEY" not in output assert "api.openai.com" not in output assert "sandbox create" in output @@ -202,12 +202,12 @@ def test_pi_example_renders_provider_specific_runtime_configuration( provider = models["providers"]["attested-provider"] assert provider["baseUrl"] == "https://gateway.example.test:8443/models/v1" assert provider["api"] == "openai-completions" - assert provider["apiKey"] == "$MODEL_PROVIDER_API_KEY" + assert provider["apiKey"] == "$PI_MODEL_API_KEY" assert provider["models"][0]["id"] == "custom-model" provider_profile = yaml.safe_load(provider_profile_output.read_text()) assert provider_profile["id"] == "pi-attested-model" - assert provider_profile["credentials"][0]["env_vars"] == ["MODEL_PROVIDER_API_KEY"] + assert provider_profile["credentials"][0]["env_vars"] == ["PI_MODEL_API_KEY"] assert provider_profile["endpoints"][0]["host"] == "gateway.example.test" assert provider_profile["endpoints"][0]["port"] == 8443 From 5fa533016c7942aec4b8be51470acc8c6ce8cb81 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 23:59:56 -0400 Subject: [PATCH 18/70] fix(egress-gate): allow maximum Pi request payloads --- .../examples/pi-attested-admission/render-runtime-config.mjs | 2 +- projects/egress-gate/tests/test_pi_example_commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs index cd424fb4..773d1b7e 100644 --- a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -78,7 +78,7 @@ writeFileSync( name = "pi-egress" grpc_endpoint = "${middlewareEndpoint}" allow_insecure_transport = true -max_payload_bytes = 32768 +max_payload_bytes = 4194304 timeout = "30s" `, ); diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 92e6f775..68edc5f4 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -223,7 +223,7 @@ def test_pi_example_renders_provider_specific_runtime_configuration( assert registration["name"] == "pi-egress" assert registration["grpc_endpoint"] == "http://192.0.2.10:50051" assert registration["allow_insecure_transport"] is True - assert registration["max_payload_bytes"] == 32 * 1024 + assert registration["max_payload_bytes"] == 4 * 1024 * 1024 def test_pi_example_reports_all_missing_configuration_before_work( From 7fabdeadfa613463b121b3af9405b4457a42bd3a Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Fri, 28 Aug 2026 12:54:49 -0400 Subject: [PATCH 19/70] feat(egress-gate): isolate managed Pi admission --- projects/egress-gate/README.md | 18 +- .../examples/pi-attested-admission/README.md | 83 ++-- .../examples/pi-attested-admission/demo.sh | 59 +-- .../managed-pi-admission.test.mjs | 101 ++++ .../managed-pi-admission.ts | 210 +++++++++ .../pi-attested-admission/managed-pi.ts | 65 +++ .../openshell-input-admission.test.mjs | 132 ------ .../openshell-input-admission.ts | 252 ---------- .../proto/supervisor_middleware.proto | 3 + .../src/egress_gate/admission/__init__.py | 23 +- .../src/egress_gate/admission/adapters.py | 143 +++++- .../src/egress_gate/admission/models.py | 30 +- .../src/egress_gate/admission/processor.py | 94 ++-- .../src/egress_gate/admission/receipts.py | 160 ++++++- .../bindings/supervisor_middleware_pb2.py | 128 ++--- .../bindings/supervisor_middleware_pb2.pyi | 6 +- projects/egress-gate/src/egress_gate/cli.py | 10 +- .../egress-gate/src/egress_gate/constants.py | 1 + .../src/egress_gate/service/server.py | 4 +- .../src/egress_gate/service/servicer.py | 45 +- .../tests/admission/test_admission.py | 443 +++++++++++------- .../tests/service/test_grpc_integration.py | 34 +- .../tests/service/test_servicer.py | 31 ++ projects/egress-gate/tests/test_cli.py | 8 +- ...ension.py => test_managed_pi_admission.py} | 5 +- .../tests/test_pi_example_commands.py | 12 +- 26 files changed, 1253 insertions(+), 847 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs create mode 100644 projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/managed-pi.ts delete mode 100644 projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs delete mode 100644 projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts rename projects/egress-gate/tests/{test_pi_admission_extension.py => test_managed_pi_admission.py} (68%) diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 940e1e28..a440e1b2 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -24,7 +24,7 @@ commands work from any directory and do not depend on repository-only files: egress-gate gates list egress-gate gates schema egress-gate validate --policy /absolute/path/to/your-policy.yaml -egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-receipt +egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-attestation ``` ## Source-checkout quickstart @@ -39,7 +39,7 @@ uv run egress-gate gates list uv run egress-gate gates schema uv run egress-gate validate \ --policy examples/regex-redaction/egress-gate-config.yaml -uv run egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-receipt +uv run egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-attestation uv run egress-gate evaluate \ --policy examples/regex-redaction/egress-gate-config.yaml \ --cases examples/regex-redaction/cases.yaml @@ -49,10 +49,10 @@ Use `0.0.0.0` only when the OpenShell supervisor must reach the service across network namespaces. The development server uses plaintext gRPC. Restrict its listen port to trusted networks. -The CLI requires managed Pi admission receipts by default, coupling receipt -issuance to provider egress verification. The general Gate quickstarts opt out -explicitly. Keep the default, or pass `--require-pi-receipt`, for managed Pi; -use `--no-require-pi-receipt` only for an intentionally unmanaged deployment. +The CLI requires managed Pi context attestations by default, coupling admission +to provider egress verification. The general Gate quickstarts opt out +explicitly. Keep the default, or pass `--require-pi-attestation`, for managed +Pi; use `--no-require-pi-attestation` only for an intentionally unmanaged deployment. See the [managed Pi example](examples/pi-attested-admission/README.md) for the matching Pi and OpenShell fork branches, startup contract, and current limits. @@ -94,7 +94,7 @@ need initialization, helper bases, or typed resources use the full class-based ```bash uv run egress-gate --registry my_gates:registry gates list -uv run egress-gate --registry my_gates:registry serve --no-require-pi-receipt +uv run egress-gate --registry my_gates:registry serve --no-require-pi-attestation ``` OpenShell owns interception, routing, and credential attachment. Egress Gate @@ -110,12 +110,12 @@ from egress_gate.service import EgressGateServer server = EgressGateServer( create_builtin_registry(), timeout_middleware_processing=10, - require_pi_receipt=False, + require_pi_attestation=False, ) server.serve_sync("127.0.0.1:50051") ``` -Make the `require_pi_receipt` choice explicit in programmatic deployments; set +Make the `require_pi_attestation` choice explicit in programmatic deployments; set it to `True` for managed Pi. In this unmanaged example, `timeout_middleware_processing` gives each evaluation 10 seconds. Omitting it uses the one-second service default. The value is expressed diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 6cbac3b6..a8ba3bac 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,15 +1,16 @@ # Managed Pi attested-admission example -This example runs the forked Pi CLI inside OpenShell and sends admitted user -submissions to a model endpoint you choose. The endpoint may be a hosted +This example runs a normal interactive Pi TUI inside OpenShell and sends +admitted conversation context to a model endpoint you choose. The endpoint may be a hosted provider, an internal gateway, or a local server. It must accept the OpenAI Chat Completions request shape used by the current attestation adapter; it does not need to be OpenAI. -The example demonstrates two outcomes: +The example demonstrates the same policy at both context boundaries: -- `DENY_THIS` is rejected before Pi records it or starts a model request. -- `REDACT_THIS` becomes `[REDACTED]` before Pi records or sends it. +- `DENY_THIS` is rejected before Pi adds a user message or tool result to its + live context. +- `REDACT_THIS` becomes `[REDACTED]` before Pi adds or sends it. The redaction case makes one real request to your configured endpoint and may incur charges from that provider. @@ -113,15 +114,13 @@ After the gateway reports that it is ready, launch Pi from a third terminal: ``` Each launch replaces the example's `pi-egress-demo` sandbox, provider, and -custom provider profile so the current Pi runtime, extension, policy, endpoint, -and OpenShell supervisor are used together. +custom provider profile so the current Pi runtime, managed harness, policy, +endpoint, and OpenShell supervisor are used together. The example registers an endpoint-specific provider profile using the host-side -`PI_MODEL_API_KEY`. The real credential remains in OpenShell. Pi receives an -environment variable with the same name whose value is an opaque, -endpoint-bound OpenShell resolver placeholder. The extension redacts accidental -appearances of that placeholder in tool output; OpenShell resolves it in the -authorization header only for the configured model endpoint. +`PI_MODEL_API_KEY`. The real credential remains in OpenShell. Pi receives only +an opaque, endpoint-bound resolver placeholder; OpenShell resolves it in the +authorization header for the configured model endpoint. At the Pi prompt, submit both of these in the same session: @@ -134,38 +133,52 @@ Reply with exactly: REDACT_THIS ``` The first submission is denied without starting a model request. The second -makes a request containing `[REDACTED]`. Exit Pi, then inspect its persisted -session: +makes a request containing `[REDACTED]`. -```shell -./demo.sh verify +To exercise tool-result admission without putting the marker in the user +message, ask Pi: + +```text +Use bash to print the concatenation of DENY_ and THIS, then tell me the output. ``` -The output must contain `[REDACTED]` and must not contain `DENY_THIS` or -`REDACT_THIS`. The command exits with an error if either check fails. +The tool runs, but its result is replaced by Pi's protocol-safe blocked result +before it enters live context. Repeat with `REDACT_` and `THIS` to see the tool +result admitted as `[REDACTED]`. + +This example deliberately uses Pi's in-memory session manager. The interactive +TUI, tools, queued messages, retries, and `/new` work normally during the run, +but the session is not written inside the sandbox and cannot be resumed after +Pi exits. That is the minimal isolation guarantee: unadmitted context cannot be +recovered from a workload-owned session file. ## How it works -1. Pi renders the user submission and calls its general-purpose - `before_user_message_append` extension hook. -2. The example extension sends that text to OpenShell's sandbox-local admission - bridge. -3. Egress Gate applies `policy.yaml`: it either denies the submission or - returns replacement text plus a short-lived receipt. -4. Pi records only admitted or replacement text. -5. Before each model request in that turn, including automatic requests after - tool calls, the extension obtains a fresh receipt for the active admitted - text. -6. As each request leaves the sandbox, Egress Gate verifies that its final user - text matches the receipt and OpenShell resolves the credential. +1. `managed-pi.ts` creates the regular Pi `InteractiveMode` with a mandatory SDK + `ContextAdmission` boundary and an in-memory session manager. It disables + dynamically loaded extensions, so project or user extensions cannot replace + this boundary. +2. Pi calls that boundary for each rendered user message and finalized tool + result before it queues, appends, or persists the value. +3. The adapter sends the exact context addition to OpenShell's sandbox-local + bridge. Egress Gate applies `policy.yaml` and returns allow, deny, or a + complete replacement. +4. OpenShell keeps the signed attestation and gives Pi only an opaque handle. + The adapter keeps handles in its private closure, outside Pi messages. +5. For each provider request or retry, Pi passes the exact outbound context to + the adapter. It selects the handle for the newest admitted user message or + tool result in that context. +6. OpenShell strips the handle, resolves the supervisor-held attestation, and + supplies it only to the configured Egress Gate middleware stage. Egress Gate + verifies the latest context addition and scans the complete provider request + before OpenShell resolves the model credential. ## Current scope -The attestation adapter supports normal text turns, including tools, queued -steering and follow-up messages, and the automatic model continuations they -produce, using the OpenAI Chat Completions wire format. Providers with a -different native protocol and image inputs are not covered by this example and -fail closed. +The attestation adapter supports normal text turns, text tool results, queued +steering and follow-up messages, retries, and automatic model continuations, +using the OpenAI Chat Completions wire format. Providers with a different native +protocol and image inputs are not covered by this example and fail closed. ## Cleanup diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index de40b532..576319f2 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -317,7 +317,7 @@ prepare() { serve() { describe_printed_commands "Run Egress Gate and keep it open:" run_in "$egress_gate_dir" uv run egress-gate --debug serve \ - --listen 0.0.0.0:50051 --timeout 4s --require-pi-receipt + --listen 0.0.0.0:50051 --timeout 4s --require-pi-attestation } gateway() { @@ -392,7 +392,8 @@ create_demo_sandbox() { --provider pi-model \ --policy "$runtime_policy" \ --upload "$runtime_dir/node_modules:/sandbox/pi-runtime" \ - --upload "$script_dir/openshell-input-admission.ts:/sandbox/openshell-input-admission.ts" \ + --upload "$script_dir/managed-pi.ts:/sandbox/pi-runtime/managed-pi.ts" \ + --upload "$script_dir/managed-pi-admission.ts:/sandbox/pi-runtime/managed-pi-admission.ts" \ --upload "$runtime_models:/sandbox/pi-agent/models.json" \ --no-git-ignore \ --detach @@ -403,8 +404,10 @@ launch() { require_example_configuration require_file "$openshell_cli" "OpenShell CLI wrapper" require_file "$(pi_tarball)" "packed Pi coding-agent" - require_file "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" \ - "installed Pi CLI" + require_file "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/index.js" \ + "installed Pi SDK" + require_file "$script_dir/managed-pi.ts" "managed Pi harness" + require_file "$script_dir/managed-pi-admission.ts" "managed Pi admission adapter" fi describe_printed_commands "Refresh the endpoint-specific model, policy, and provider profile:" @@ -427,46 +430,10 @@ launch() { env \ PI_CODING_AGENT_DIR=/sandbox/pi-agent \ PI_OFFLINE=1 \ + PI_MANAGED_PROVIDER=attested-provider \ + PI_MANAGED_MODEL="$model_id" \ OPENSHELL_AGENT_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation \ - node /sandbox/pi-runtime/node_modules/@earendil-works/pi-coding-agent/dist/cli.js \ - --provider attested-provider \ - --model "$model_id" \ - --extension /sandbox/openshell-input-admission.ts \ - --session-dir /sandbox/pi-sessions -} - -verify() { - if ! $print_only; then - require_file "$openshell_cli" "OpenShell CLI wrapper" - fi - local redacted='\[REDACTED\]' - local forbidden='DENY_THIS|REDACT_THIS' - - if $print_only; then - describe_printed_commands "Confirm that Pi saved the redacted text:" - print_command "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec -n pi-egress-demo -- \ - grep -R -n -E "$redacted" /sandbox/pi-sessions - describe_printed_commands "Confirm that Pi did not save either original marker (this command must find no matches):" - print_command "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec -n pi-egress-demo -- \ - grep -R -n -E "$forbidden" /sandbox/pi-sessions - return - fi - - if ! run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec -n pi-egress-demo -- \ - grep -R -n -E "$redacted" /sandbox/pi-sessions; then - printf 'Verification failed: [REDACTED] was not found in Pi session history.\n' >&2 - exit 1 - fi - if run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec -n pi-egress-demo -- \ - grep -R -n -E "$forbidden" /sandbox/pi-sessions; then - printf 'Verification failed: denied or unredacted input was found in Pi session history.\n' >&2 - exit 1 - fi - printf 'Verified: session history contains [REDACTED] and no original test markers.\n' + node --experimental-strip-types /sandbox/pi-runtime/managed-pi.ts } cleanup() { @@ -492,7 +459,6 @@ usage() { serve Start Egress Gate gateway Start the forked OpenShell gateway launch Attach the configured model credential and launch managed Pi - verify Confirm redaction and absence of original text in Pi session history cleanup Delete the example sandbox and credential provider all Show the concise workflow walkthrough (requires --print) EOF @@ -558,15 +524,13 @@ ${bold}${blue}Workflow${reset} ${green}5. test${reset} At the Pi prompt, submit: Reply with exactly: DENY_THIS Reply with exactly: REDACT_THIS - ${green}6. verify${reset} After exiting Pi, confirm only [REDACTED] was persisted. - ${green}7. cleanup${reset} Delete the sandbox and credential provider. + ${green}6. cleanup${reset} Delete the sandbox and credential provider. ${bold}${blue}Inspect exact commands${reset} ./demo.sh --print prepare ./demo.sh --print serve ./demo.sh --print gateway ./demo.sh --print launch - ./demo.sh --print verify ./demo.sh --print cleanup Run an action without --print when you are ready. @@ -578,7 +542,6 @@ case "$action" in serve) serve ;; gateway) gateway ;; launch) launch ;; - verify) verify ;; cleanup) cleanup ;; all) if ! $print_only; then diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs new file mode 100644 index 00000000..d5b9d61a --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createOpenShellContextAdmission } from "./managed-pi-admission.ts"; + +const HANDLE_HEADER = "x-openshell-agent-admission-handle"; + +function user(text, timestamp) { + return { role: "user", content: [{ type: "text", text }], timestamp }; +} + +test("selects the handle for the exact queued or retried provider context", async () => { + const bridgeRequests = []; + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { + const request = JSON.parse(init.body); + bridgeRequests.push(request); + const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.text}` })); + }); + const current = user("current turn", 1); + const queued = user("queued turn", 2); + + assert.deepEqual(await admission.admitUserMessage(current, { source: "interactive" }), { action: "allow" }); + assert.deepEqual(await admission.admitUserMessage(queued, { source: "interactive" }), { action: "allow" }); + + const currentHeaders = await admission.transformProviderHeaders({}, { messages: [current], tools: [] }); + const retryHeaders = await admission.transformProviderHeaders({}, { messages: [current], tools: [] }); + const queuedHeaders = await admission.transformProviderHeaders({}, { messages: [current, queued], tools: [] }); + + assert.equal(currentHeaders[HANDLE_HEADER], "handle:current turn"); + assert.equal(retryHeaders[HANDLE_HEADER], "handle:current turn"); + assert.equal(queuedHeaders[HANDLE_HEADER], "handle:queued turn"); + assert.deepEqual( + bridgeRequests.map((request) => [request.hook, request.schema_version]), + [ + ["rendered_prompt_admission", "openshell.pi-input.v1"], + ["rendered_prompt_admission", "openshell.pi-input.v1"], + ], + ); + assert.deepEqual(bridgeRequests.map((request) => request.session_id), ["session-123", "session-123"]); +}); + +test("uses an admitted replacement as the handle lookup key", async () => { + const replacement = new TextEncoder().encode( + JSON.stringify({ schema_version: "openshell.pi-input.v1", text: "[REDACTED]" }), + ); + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async () => + new Response( + JSON.stringify({ + decision: "allow", + handle: "replacement-handle", + replacement_body: Array.from(replacement), + }), + ), + ); + const original = user("secret", 1); + const admitted = await admission.admitUserMessage(original, { source: "interactive" }); + + assert.equal(admitted.action, "allow"); + assert.equal(admitted.message.content[0].text, "[REDACTED]"); + const headers = await admission.transformProviderHeaders( + {}, + { messages: [admitted.message], tools: [] }, + ); + assert.equal(headers[HANDLE_HEADER], "replacement-handle"); +}); + +test("bounds handles retained for a long-lived in-memory session", async () => { + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { + const request = JSON.parse(init.body); + const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.text}` })); + }); + const messages = Array.from({ length: 1025 }, (_, index) => user(`turn ${index}`, index)); + for (const message of messages) { + assert.equal((await admission.admitUserMessage(message, { source: "interactive" })).action, "allow"); + } + + await assert.rejects( + admission.transformProviderHeaders({}, { messages: [messages[0]], tools: [] }), + /OpenShell admission handle is missing/, + ); + const headers = await admission.transformProviderHeaders({}, { messages: [messages.at(-1)], tools: [] }); + assert.equal(headers[HANDLE_HEADER], "handle:turn 1024"); +}); + +test("uses the current session ID after a new in-memory session starts", async () => { + let sessionId = "session-1"; + const observedSessionIds = []; + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => sessionId, async (_url, init) => { + const request = JSON.parse(init.body); + observedSessionIds.push(request.session_id); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${request.session_id}` })); + }); + + await admission.admitUserMessage(user("first", 1), { source: "interactive" }); + sessionId = "session-2"; + await admission.admitUserMessage(user("after new", 2), { source: "interactive" }); + + assert.deepEqual(observedSessionIds, ["session-1", "session-2"]); +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts new file mode 100644 index 00000000..6ac44b13 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts @@ -0,0 +1,210 @@ +import type { + Context, + ImageContent, + ProviderHeaders, + TextContent, + ToolResultMessage, + UserMessage, +} from "@earendil-works/pi-ai/compat"; +import type { ContextAdmission } from "@earendil-works/pi-coding-agent"; + +const HANDLE_HEADER = "x-openshell-agent-admission-handle"; +const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; +// A byte encoded as a JSON array item can occupy four characters including its comma. +const MAX_BRIDGE_RESPONSE_BYTES = MAX_ADMISSION_BYTES * 4 + 64 * 1024; +const MAX_HANDLE_ENTRIES = 1024; + +type ContentBlock = TextContent | ImageContent; +type UserEnvelope = { schema_version: "openshell.pi-input.v1"; text: string }; +type ToolResultEnvelope = { + schema_version: "openshell.pi-tool-result.v1"; + tool_call_id: string; + tool_name: string; + content: ContentBlock[]; + is_error: boolean; +}; +type AdmissionEnvelope = UserEnvelope | ToolResultEnvelope; +type BridgeResult = + | { decision: "deny"; reason_code?: string } + | { decision: "allow"; handle: string; replacement_body?: number[] }; + +export function createOpenShellContextAdmission( + bridgeUrl: string, + getSessionId: () => string, + fetchRequest: typeof fetch = fetch, +): ContextAdmission { + const handles = new Map(); + + async function requestAdmission( + hook: "rendered_prompt_admission" | "tool_result_admission", + envelope: AdmissionEnvelope, + ): Promise { + const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); + if (requestBody.byteLength > MAX_ADMISSION_BYTES) { + throw new Error("OpenShell admission request is too large"); + } + const response = await fetchRequest(bridgeUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + harness_version: "sdk-v1", + hook, + schema_version: envelope.schema_version, + session_id: getSessionId(), + submission_id: crypto.randomUUID(), + request_body: Array.from(requestBody), + }), + }); + if (!response.ok) throw new Error("OpenShell admission is unavailable"); + const encoded = new Uint8Array(await response.arrayBuffer()); + if (encoded.byteLength > MAX_BRIDGE_RESPONSE_BYTES) throw new Error("OpenShell admission response is too large"); + return parseBridgeResult(JSON.parse(new TextDecoder().decode(encoded))); + } + + return { + async admitUserMessage(message) { + const envelope = userEnvelope(message); + if (!envelope) { + return { action: "deny", reason: "Image inputs are not supported by this managed Pi example" }; + } + const result = await requestAdmission("rendered_prompt_admission", envelope); + if (result.decision === "deny") return denied(result.reason_code); + const admittedEnvelope = result.replacement_body + ? parseUserEnvelope(new Uint8Array(result.replacement_body)) + : envelope; + const admittedMessage: UserMessage = { + ...message, + content: replaceUserText(message.content, admittedEnvelope.text), + }; + rememberHandle(handles, messageKey(admittedMessage), result.handle); + return admittedEnvelope.text === envelope.text + ? { action: "allow" } + : { action: "allow", message: admittedMessage }; + }, + + async admitToolResult(message) { + const envelope = toolResultEnvelope(message); + const result = await requestAdmission("tool_result_admission", envelope); + if (result.decision === "deny") return denied(result.reason_code); + const admittedEnvelope = result.replacement_body + ? parseToolResultEnvelope(new Uint8Array(result.replacement_body)) + : envelope; + const admittedMessage: ToolResultMessage = { + ...message, + content: admittedEnvelope.content, + }; + rememberHandle(handles, messageKey(admittedMessage), result.handle); + return result.replacement_body + ? { action: "allow", message: admittedMessage } + : { action: "allow" }; + }, + + async transformProviderHeaders(headers: ProviderHeaders, context: Context) { + if (Object.keys(headers).some((name) => name.toLowerCase() === HANDLE_HEADER)) { + throw new Error("OpenShell admission handle header is reserved"); + } + for (let index = context.messages.length - 1; index >= 0; index -= 1) { + const message = context.messages[index]; + if (message.role !== "user" && message.role !== "toolResult") continue; + const handle = handles.get(messageKey(message)); + if (handle) return { ...headers, [HANDLE_HEADER]: handle }; + } + throw new Error("OpenShell admission handle is missing for the outbound context"); + }, + }; +} + +function userEnvelope(message: UserMessage): UserEnvelope | undefined { + if (typeof message.content === "string") { + return { schema_version: "openshell.pi-input.v1", text: message.content }; + } + if (message.content.some((block) => block.type === "image")) return undefined; + return { + schema_version: "openshell.pi-input.v1", + text: message.content.map((block) => (block as TextContent).text).join("\n"), + }; +} + +function toolResultEnvelope(message: ToolResultMessage): ToolResultEnvelope { + return { + schema_version: "openshell.pi-tool-result.v1", + tool_call_id: message.toolCallId, + tool_name: message.toolName, + content: message.content, + is_error: message.isError, + }; +} + +function messageKey(message: UserMessage | ToolResultMessage): string { + return JSON.stringify(message.role === "user" ? userEnvelope(message) : toolResultEnvelope(message)); +} + +function rememberHandle(handles: Map, key: string, handle: string): void { + handles.delete(key); + handles.set(key, handle); + if (handles.size > MAX_HANDLE_ENTRIES) { + const oldest = handles.keys().next().value; + if (oldest !== undefined) handles.delete(oldest); + } +} + +function replaceUserText(content: UserMessage["content"], text: string): UserMessage["content"] { + return typeof content === "string" ? text : [{ type: "text", text }]; +} + +function denied(reasonCode?: string): { action: "deny"; reason: string } { + return { + action: "deny", + reason: reasonCode ? `OpenShell denied this context addition (${reasonCode})` : "OpenShell denied this context addition", + }; +} + +function parseBridgeResult(value: unknown): BridgeResult { + if (!isRecord(value) || (value.decision !== "allow" && value.decision !== "deny")) { + throw new Error("OpenShell admission returned an invalid response"); + } + if (value.decision === "deny") { + return { decision: "deny", reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined }; + } + if (typeof value.handle !== "string" || !value.handle || value.handle.length > 1024) { + throw new Error("OpenShell admission returned an invalid handle"); + } + if ( + value.replacement_body !== undefined && + (!isByteArray(value.replacement_body) || value.replacement_body.length > MAX_ADMISSION_BYTES) + ) { + throw new Error("OpenShell admission returned an invalid replacement"); + } + return { decision: "allow", handle: value.handle, replacement_body: value.replacement_body }; +} + +function parseUserEnvelope(body: Uint8Array): UserEnvelope { + const value: unknown = JSON.parse(new TextDecoder().decode(body)); + if (!isRecord(value) || value.schema_version !== "openshell.pi-input.v1" || typeof value.text !== "string") { + throw new Error("OpenShell admission returned an invalid user replacement"); + } + return { schema_version: "openshell.pi-input.v1", text: value.text }; +} + +function parseToolResultEnvelope(body: Uint8Array): ToolResultEnvelope { + const value: unknown = JSON.parse(new TextDecoder().decode(body)); + if ( + !isRecord(value) || + value.schema_version !== "openshell.pi-tool-result.v1" || + typeof value.tool_call_id !== "string" || + typeof value.tool_name !== "string" || + !Array.isArray(value.content) || + typeof value.is_error !== "boolean" + ) { + throw new Error("OpenShell admission returned an invalid tool-result replacement"); + } + return value as ToolResultEnvelope; +} + +function isByteArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts b/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts new file mode 100644 index 00000000..c71988d9 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts @@ -0,0 +1,65 @@ +/** Normal interactive Pi with mandatory OpenShell context admission. */ +import { + type CreateAgentSessionRuntimeFactory, + InteractiveMode, + ModelRuntime, + SessionManager, + createAgentSessionFromServices, + createAgentSessionRuntime, + createAgentSessionServices, +} from "@earendil-works/pi-coding-agent"; +import { createOpenShellContextAdmission } from "./managed-pi-admission.ts"; + +async function main(): Promise { + const bridgeUrl = process.env.OPENSHELL_AGENT_CONVERSATION_URL; + const agentDir = process.env.PI_CODING_AGENT_DIR; + const provider = process.env.PI_MANAGED_PROVIDER; + const modelId = process.env.PI_MANAGED_MODEL; + if (!bridgeUrl || !agentDir || !provider || !modelId) { + throw new Error( + "OPENSHELL_AGENT_CONVERSATION_URL, PI_CODING_AGENT_DIR, PI_MANAGED_PROVIDER, and PI_MANAGED_MODEL are required", + ); + } + + const sessionManager = SessionManager.inMemory(process.cwd()); + const contextAdmission = createOpenShellContextAdmission(bridgeUrl, () => sessionManager.getSessionId()); + const modelRuntime = await ModelRuntime.create({ + authPath: `${agentDir}/auth.json`, + modelsPath: `${agentDir}/models.json`, + refreshOnCreate: false, + }); + const model = modelRuntime.getModel(provider, modelId); + if (!model) throw new Error(`Model ${provider}/${modelId} was not found`); + + const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => { + const services = await createAgentSessionServices({ + cwd, + agentDir, + modelRuntime, + resourceLoaderOptions: { noExtensions: true }, + }); + return { + ...(await createAgentSessionFromServices({ + services, + sessionManager, + sessionStartEvent, + model, + thinkingLevel: "off", + contextAdmission, + })), + services, + diagnostics: services.diagnostics, + }; + }; + const runtime = await createAgentSessionRuntime(createRuntime, { + cwd: process.cwd(), + agentDir, + sessionManager, + }); + await new InteractiveMode(runtime, { startupDiagnostics: [...runtime.diagnostics] }).run(); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs deleted file mode 100644 index 820f741f..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs +++ /dev/null @@ -1,132 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import registerAdmission from "./openshell-input-admission.ts"; - -const BRIDGE_URL_ENV = "OPENSHELL_AGENT_CONVERSATION_URL"; -const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; - -function createHarness() { - const handlers = new Map(); - registerAdmission({ - on(event, handler) { - handlers.set(event, handler); - }, - }); - return handlers; -} - -function createContext(isIdle = true) { - return { - isIdle: () => isIdle, - sessionManager: { getSessionId: () => "session-1" }, - signal: new AbortController().signal, - ui: { notify: () => {} }, - }; -} - -function allowResponse(receipt) { - return new Response( - JSON.stringify({ - decision: "allow", - receipt: Array.from(new TextEncoder().encode(receipt)), - }), - { status: 200 }, - ); -} - -test("uses a fresh receipt for every provider request in one admitted turn", async () => { - const originalBridgeUrl = process.env[BRIDGE_URL_ENV]; - const originalFetch = globalThis.fetch; - const bridgeRequests = []; - process.env[BRIDGE_URL_ENV] = "http://bridge.test/admit"; - globalThis.fetch = async (_url, init) => { - bridgeRequests.push(JSON.parse(init.body)); - return allowResponse(`receipt-${bridgeRequests.length}`); - }; - - try { - const handlers = createHarness(); - const ctx = createContext(); - const append = await handlers.get("before_user_message_append")( - { text: "inspect the repository" }, - ctx, - ); - assert.equal(append, undefined); - - const firstHeaders = {}; - await handlers.get("before_provider_headers")({ headers: firstHeaders }, ctx); - assert.equal(firstHeaders[RECEIPT_HEADER], "receipt-1"); - - const continuationHeaders = {}; - await handlers.get("before_provider_headers")({ headers: continuationHeaders }, ctx); - assert.equal(continuationHeaders[RECEIPT_HEADER], "receipt-2"); - - assert.equal(bridgeRequests.length, 2); - assert.notEqual(bridgeRequests[0].submission_id, bridgeRequests[1].submission_id); - assert.deepEqual(bridgeRequests[0].request_body, bridgeRequests[1].request_body); - } finally { - globalThis.fetch = originalFetch; - if (originalBridgeUrl === undefined) delete process.env[BRIDGE_URL_ENV]; - else process.env[BRIDGE_URL_ENV] = originalBridgeUrl; - } -}); - -test("activates queued prompts only when Pi delivers them", async () => { - const originalBridgeUrl = process.env[BRIDGE_URL_ENV]; - const originalFetch = globalThis.fetch; - let receiptNumber = 0; - process.env[BRIDGE_URL_ENV] = "http://bridge.test/admit"; - globalThis.fetch = async () => allowResponse(`receipt-${++receiptNumber}`); - - try { - const handlers = createHarness(); - const idleContext = createContext(); - await handlers.get("before_user_message_append")({ text: "current turn" }, idleContext); - - const initialHeaders = {}; - await handlers.get("before_provider_headers")({ headers: initialHeaders }, idleContext); - assert.equal(initialHeaders[RECEIPT_HEADER], "receipt-1"); - - const streamingContext = createContext(false); - await handlers.get("before_user_message_append")({ text: "queued turn" }, streamingContext); - - const currentContinuationHeaders = {}; - await handlers.get("before_provider_headers")({ headers: currentContinuationHeaders }, idleContext); - assert.equal(currentContinuationHeaders[RECEIPT_HEADER], "receipt-3"); - - await handlers.get("message_start")({ - message: { role: "user", content: [{ type: "text", text: "queued turn" }] }, - }); - const queuedHeaders = {}; - await handlers.get("before_provider_headers")({ headers: queuedHeaders }, idleContext); - assert.equal(queuedHeaders[RECEIPT_HEADER], "receipt-2"); - } finally { - globalThis.fetch = originalFetch; - if (originalBridgeUrl === undefined) delete process.env[BRIDGE_URL_ENV]; - else process.env[BRIDGE_URL_ENV] = originalBridgeUrl; - } -}); - -test("redacts the OpenShell credential placeholder before Pi records it", async () => { - const credentialName = "PI_MODEL_API_KEY"; - const originalPlaceholder = process.env[credentialName]; - const placeholder = "openshell:resolve:env:PI_MODEL_API_KEY:test-handle"; - process.env[credentialName] = placeholder; - - try { - const handlers = createHarness(); - const result = await handlers.get("tool_result")({ - content: [{ type: "text", text: `PI_MODEL_API_KEY=${placeholder}\nPI_SESSION_ID=session-1` }], - }); - assert.deepEqual(result.content, [ - { - type: "text", - text: "PI_MODEL_API_KEY=[REDACTED_OPENSHELL_CREDENTIAL_PLACEHOLDER]\nPI_SESSION_ID=session-1", - }, - ]); - } finally { - if (originalPlaceholder === undefined) delete process.env[credentialName]; - else process.env[credentialName] = originalPlaceholder; - } -}); diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts deleted file mode 100644 index c47c29d6..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * OpenShell direct-input admission for Pi. - * - * Load this extension explicitly with Pi's standard --extension option. It - * admits each text-only user submission after rendering and before Pi - * persists it. Every provider request in the admitted turn receives a fresh - * receipt, including automatic continuations after tool calls. - */ -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; - -const BRIDGE_URL_ENV = "OPENSHELL_AGENT_CONVERSATION_URL"; -const LEGACY_BRIDGE_URL_ENV = "OPENSHELL_PI_CONVERSATION_URL"; -const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; -const SCHEMA_VERSION = "openshell.pi-input.v1"; -const MAX_RESPONSE_BYTES = 256 * 1024; -const MAX_RECEIPT_BYTES = 8 * 1024; -const REDACTED_PLACEHOLDER = "[REDACTED_OPENSHELL_CREDENTIAL_PLACEHOLDER]"; -const PLACEHOLDER_ENV_NAMES = ["PI_MODEL_API_KEY", "MODEL_PROVIDER_API_KEY"]; - -type BridgeResponse = - | { decision: "allow"; replacement_body?: number[]; receipt: number[] } - | { decision: "deny"; reason_code?: string }; - -interface CandidateEnvelope { - schema_version: typeof SCHEMA_VERSION; - text: string; -} - -interface ActiveAdmission { - bridgeUrl: string; - sessionId: string; - envelope: CandidateEnvelope; -} - -interface PendingAdmission extends ActiveAdmission { - receipt: string; -} - -interface AdmissionResult { - envelope: CandidateEnvelope; - receipt: string; -} - -export default function (pi: ExtensionAPI) { - let activeAdmission: ActiveAdmission | undefined; - let pendingReceipt: string | undefined; - const queuedAdmissions: PendingAdmission[] = []; - const credentialPlaceholders = PLACEHOLDER_ENV_NAMES.map((name) => process.env[name]).filter( - (value): value is string => typeof value === "string" && value.length >= 12, - ); - - pi.on("tool_result", (event) => { - let changed = false; - const content = event.content.map((part) => { - if (part.type !== "text") return part; - let text = part.text; - for (const placeholder of credentialPlaceholders) { - const redacted = text.replaceAll(placeholder, REDACTED_PLACEHOLDER); - changed ||= redacted !== text; - text = redacted; - } - return text === part.text ? part : { ...part, text }; - }); - return changed ? { content } : undefined; - }); - - pi.on("before_user_message_append", async (event, ctx) => { - const isIdle = ctx.isIdle(); - try { - if (isIdle) { - activeAdmission = undefined; - pendingReceipt = undefined; - } - if (event.images?.length) { - notifySafely(ctx, "OpenShell admission currently supports only text prompts"); - return { action: "cancel" }; - } - const bridgeUrl = process.env[BRIDGE_URL_ENV] ?? process.env[LEGACY_BRIDGE_URL_ENV]; - if (!bridgeUrl) throw new Error(`${BRIDGE_URL_ENV} is required for OpenShell admission`); - const envelope: CandidateEnvelope = { schema_version: SCHEMA_VERSION, text: event.text }; - const sessionId = ctx.sessionManager.getSessionId(); - const result = await requestAdmission(bridgeUrl, sessionId, envelope, ctx.signal); - if (result.response.decision === "deny") { - notifySafely(ctx, `OpenShell denied the prompt (${result.response.reason_code ?? "policy_denied"})`); - return { action: "cancel" }; - } - - const admission = { bridgeUrl, sessionId, ...result.admission }; - if (isIdle) { - activeAdmission = admission; - pendingReceipt = admission.receipt; - } else { - queuedAdmissions.push(admission); - } - if (result.admission.envelope.text === event.text) return; - return { action: "transform", text: result.admission.envelope.text }; - } catch { - if (isIdle) { - activeAdmission = undefined; - pendingReceipt = undefined; - } - notifySafely(ctx, "OpenShell admission is unavailable"); - return { action: "cancel" }; - } - }); - - pi.on("message_start", (event) => { - const text = userMessageText(event.message); - if (text === undefined) return; - const index = queuedAdmissions.findIndex((admission) => admission.envelope.text === text); - if (index === -1) return; - const [admission] = queuedAdmissions.splice(index, 1); - activeAdmission = admission; - pendingReceipt = admission.receipt; - }); - - pi.on("before_provider_headers", async (event, ctx) => { - if (Object.keys(event.headers).some((name) => name.toLowerCase() === RECEIPT_HEADER)) { - throw new Error("OpenShell receipt header is reserved"); - } - if (!activeAdmission) throw new Error("OpenShell candidate admission context is missing"); - - let receipt = pendingReceipt; - if (!receipt) { - const result = await requestAdmission( - activeAdmission.bridgeUrl, - activeAdmission.sessionId, - activeAdmission.envelope, - ctx.signal, - ); - if (result.response.decision === "deny") { - throw new Error(`OpenShell denied the active prompt (${result.response.reason_code ?? "policy_denied"})`); - } - if (result.admission.envelope.text !== activeAdmission.envelope.text) { - throw new Error("OpenShell changed a prompt after Pi persisted it"); - } - receipt = result.admission.receipt; - } - - event.headers[RECEIPT_HEADER] = receipt; - pendingReceipt = undefined; - }); -} - -async function requestAdmission( - bridgeUrl: string, - sessionId: string, - envelope: CandidateEnvelope, - signal: AbortSignal, -): Promise< - | { response: Extract; admission?: never } - | { response: Extract; admission: AdmissionResult } -> { - const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); - const response = await fetch(bridgeUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - harness_version: "extension-v1", - session_id: sessionId, - submission_id: crypto.randomUUID(), - request_body: Array.from(requestBody), - }), - signal, - }); - if (!response.ok) throw new Error("OpenShell admission is unavailable"); - const encoded = new Uint8Array(await response.arrayBuffer()); - if (encoded.byteLength > MAX_RESPONSE_BYTES) throw new Error("OpenShell admission response is too large"); - const result = parseBridgeResponse(JSON.parse(new TextDecoder().decode(encoded))); - if (result.decision === "deny") return { response: result }; - - return { - response: result, - admission: { - receipt: decodeReceipt(result.receipt), - envelope: result.replacement_body ? parseEnvelope(new Uint8Array(result.replacement_body)) : envelope, - }, - }; -} - -function userMessageText(message: unknown): string | undefined { - if (!isRecord(message) || message.role !== "user" || !Array.isArray(message.content)) return undefined; - const text = message.content - .filter( - (part): part is { type: "text"; text: string } => - isRecord(part) && part.type === "text" && typeof part.text === "string", - ) - .map((part) => part.text) - .join("\n"); - return text || undefined; -} - -function notifySafely(ctx: ExtensionContext, message: string): void { - try { - ctx.ui.notify(message, "warning"); - } catch { - // Admission remains fail closed when a UI implementation cannot notify. - } -} - -function parseBridgeResponse(value: unknown): BridgeResponse { - if (!isRecord(value) || (value.decision !== "allow" && value.decision !== "deny")) { - throw new Error("OpenShell admission returned an invalid response"); - } - if (value.decision === "deny") { - if (value.receipt !== undefined || value.replacement_body !== undefined) { - throw new Error("OpenShell admission returned an invalid denial"); - } - return { - decision: "deny", - reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined, - }; - } - const receipt = value.receipt; - const replacementBody = value.replacement_body; - if (!isByteArray(receipt)) { - throw new Error("OpenShell admission returned an invalid allow response"); - } - let replacement: number[] | undefined; - if (replacementBody !== undefined) { - if (!isByteArray(replacementBody)) { - throw new Error("OpenShell admission returned an invalid allow response"); - } - replacement = replacementBody; - } - return { decision: "allow", receipt, replacement_body: replacement }; -} - -function parseEnvelope(body: Uint8Array): CandidateEnvelope { - const value: unknown = JSON.parse(new TextDecoder().decode(body)); - if (!isRecord(value) || value.schema_version !== SCHEMA_VERSION || typeof value.text !== "string") { - throw new Error("OpenShell admission returned an invalid replacement"); - } - return { schema_version: SCHEMA_VERSION, text: value.text }; -} - -function decodeReceipt(value: number[] | undefined): string { - if (!value || value.length === 0 || value.length > MAX_RECEIPT_BYTES) { - throw new Error("OpenShell admission receipt is invalid"); - } - const receipt = new TextDecoder("ascii", { fatal: true }).decode(new Uint8Array(value)); - if (!/^[\x21-\x7e]+$/.test(receipt)) throw new Error("OpenShell admission receipt is invalid"); - return receipt; -} - -function isByteArray(value: unknown): value is number[] { - return Array.isArray(value) && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object"; -} diff --git a/projects/egress-gate/proto/supervisor_middleware.proto b/projects/egress-gate/proto/supervisor_middleware.proto index b388eec8..a51a57ba 100644 --- a/projects/egress-gate/proto/supervisor_middleware.proto +++ b/projects/egress-gate/proto/supervisor_middleware.proto @@ -122,6 +122,9 @@ message HttpRequestEvaluation { bytes body = 6; // Built-in middleware name or operator-owned registration name. string middleware_name = 7; + // Supervisor-resolved agent attestation for this middleware stage. The + // workload cannot set or observe these bytes. Limited to 8 KiB. + bytes agent_attestation = 8; } // HttpHeader is one request header line. diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py index b60830a8..ec0004b5 100644 --- a/projects/egress-gate/src/egress_gate/admission/__init__.py +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -4,10 +4,15 @@ """First-class harness admission and attested-egress APIs.""" from egress_gate.admission.adapters import ( + AttestedCandidate, HarnessAdapter, HarnessAdapterRegistry, OpenAIChatCompletionsV1Adapter, + PiImageContentV1, PiInputV1, + PiTextContentV1, + PiToolResultV1, + PiToolResultV1Adapter, PiV1Adapter, PreparedHarnessRequest, ProviderAdapterRegistry, @@ -30,21 +35,29 @@ PI_HARNESS_VERSION, AdmissionDecision, AdmissionHook, + AdmissionProvenance, HarnessAdmissionContext, HarnessAdmissionRequest, HarnessAdmissionResult, - PromptProvenance, ) from egress_gate.admission.processor import ( RECEIPT_HEADER, AttestedEgressProcessor, HarnessAdmissionProcessor, ) -from egress_gate.admission.receipts import ReceiptAuthority, ReceiptClaimsV1 +from egress_gate.admission.receipts import ( + AgentAttestationClaimsV1, + ReceiptAuthority, + ReceiptClaimsV1, + ReceiptVerificationError, +) __all__ = [ "AdmissionDecision", "AdmissionHook", + "AdmissionProvenance", + "AgentAttestationClaimsV1", + "AttestedCandidate", "AttestedEgressProcessor", "CanonicalFunctionCallV1", "CanonicalGenerationV1", @@ -59,11 +72,14 @@ "HarnessAdmissionRequest", "HarnessAdmissionResult", "MAX_ADMISSION_BODY_BYTES", - "PromptProvenance", "PI_HARNESS_VERSION", "ModelRequestV1", "OpenAIChatCompletionsV1Adapter", "PiInputV1", + "PiImageContentV1", + "PiTextContentV1", + "PiToolResultV1", + "PiToolResultV1Adapter", "PiV1Adapter", "PreparedHarnessRequest", "ProviderAdapterRegistry", @@ -71,6 +87,7 @@ "RECEIPT_HEADER", "ReceiptAuthority", "ReceiptClaimsV1", + "ReceiptVerificationError", "canonical_json_bytes", "create_pi_adapter_registry", "create_provider_adapter_registry", diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index 80af00eb..68634b10 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -6,7 +6,7 @@ from __future__ import annotations import json -from typing import Literal, Protocol +from typing import Literal, Protocol, TypeAlias from pydantic import ( Field, @@ -52,19 +52,52 @@ class ProviderShapeError(ValueError): class PiInputV1(StrictDomainModel): - """Rendered text submitted by the pinned Pi extension.""" + """Rendered text submitted by the managed Pi harness.""" schema_version: Literal["openshell.pi-input.v1"] text: ScalarString +class PiTextContentV1(StrictDomainModel): + """One Pi text content block.""" + + type: Literal["text"] + text: ScalarString + + +class PiImageContentV1(StrictDomainModel): + """One Pi image content block.""" + + type: Literal["image"] + data: ScalarString + mimeType: ScalarString + + +class PiToolResultV1(StrictDomainModel): + """Provider-relevant fields from one Pi tool-result message.""" + + schema_version: Literal["openshell.pi-tool-result.v1"] + tool_call_id: ScalarString + tool_name: ScalarString + content: tuple[PiTextContentV1 | PiImageContentV1, ...] + is_error: bool + + @field_validator("content", mode="before") + @classmethod + def _content_is_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +AttestedCandidate: TypeAlias = PiInputV1 | CanonicalMessageV1 + + class PreparedHarnessRequest: """Parsed Pi request plus its canonical Gate projection.""" def __init__( self, *, - native: PiInputV1, + native: PiInputV1 | PiToolResultV1, projected_body: bytes, original_body: bytes, ) -> None: @@ -89,7 +122,7 @@ def validate_result( projected_body: bytes, context: HarnessAdmissionContext, timeout: Timeout, - ) -> tuple[bytes | None, PiInputV1]: ... + ) -> tuple[bytes | None, AttestedCandidate]: ... class PiV1Adapter: @@ -125,6 +158,54 @@ def validate_result( return replacement, updated +class PiToolResultV1Adapter: + """Strict adapter for Pi tool-result content blocks.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: + native = _parse_pi_tool_result(request.request_body, timeout) + _tool_result_attested_candidate(native) + return PreparedHarnessRequest( + native=native, + projected_body=canonical_json_bytes(native), + original_body=request.request_body, + ) + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, AttestedCandidate]: + updated = _parse_pi_tool_result(projected_body, timeout) + if not isinstance(prepared.native, PiToolResultV1): + raise AdmissionMutationError("tool-result admission state is invalid") + immutable_before = ( + prepared.native.schema_version, + prepared.native.tool_call_id, + prepared.native.tool_name, + prepared.native.is_error, + ) + immutable_after = ( + updated.schema_version, + updated.tool_call_id, + updated.tool_name, + updated.is_error, + ) + if immutable_after != immutable_before: + raise AdmissionMutationError("admission changed tool-result metadata") + encoded = canonical_json_bytes(updated) + replacement = ( + None if encoded == canonical_json_bytes(prepared.native) else encoded + ) + return replacement, _tool_result_attested_candidate(updated) + + class HarnessAdapterRegistry: """Small explicit registry for supported harness admission shapes.""" @@ -248,7 +329,9 @@ def canonicalize( self, request: HttpRequest, timeout: Timeout ) -> ModelRequestV1: ... - def rendered_prompt(self, request: HttpRequest, timeout: Timeout) -> PiInputV1: ... + def latest_attested_candidate( + self, request: HttpRequest, timeout: Timeout + ) -> AttestedCandidate: ... class OpenAIChatCompletionsV1Adapter: @@ -304,8 +387,10 @@ def canonicalize(self, request: HttpRequest, timeout: Timeout) -> ModelRequestV1 ), ) - def rendered_prompt(self, request: HttpRequest, timeout: Timeout) -> PiInputV1: - """Extract the last user text from the first provider request.""" + def latest_attested_candidate( + self, request: HttpRequest, timeout: Timeout + ) -> AttestedCandidate: + """Extract the latest user or tool context addition.""" canonical = self.canonicalize(request, timeout) for message in reversed(canonical.messages): if message.role is CanonicalRole.USER and message.content is not None: @@ -313,7 +398,11 @@ def rendered_prompt(self, request: HttpRequest, timeout: Timeout) -> PiInputV1: schema_version="openshell.pi-input.v1", text=message.content, ) - raise ProviderShapeError("provider request has no user prompt") + if message.role is CanonicalRole.TOOL and message.content is not None: + if message.tool_call_id is None: + raise ProviderShapeError("provider tool result has no call ID") + return message + raise ProviderShapeError("provider request has no attested context addition") class ProviderAdapterRegistry: @@ -343,6 +432,12 @@ def create_pi_adapter_registry() -> HarnessAdapterRegistry: "openshell.pi-input.v1", PiV1Adapter(), ) + registry.register( + "pi", + AdmissionHook.TOOL_RESULT, + "openshell.pi-tool-result.v1", + PiToolResultV1Adapter(), + ) return registry @@ -366,6 +461,32 @@ def _parse_pi_body(body: bytes, timeout: Timeout) -> PiInputV1: return parsed +def _parse_pi_tool_result(body: bytes, timeout: Timeout) -> PiToolResultV1: + value = _load_json(body, AdmissionShapeError, timeout) + try: + parsed = _PI_TOOL_RESULT_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi tool-result body is unsupported") from None + if not isinstance(parsed, PiToolResultV1): + raise AdmissionShapeError("Pi tool-result body is unsupported") + return parsed + + +def _tool_result_attested_candidate( + result: PiToolResultV1, +) -> CanonicalMessageV1: + if any(block.type == "image" for block in result.content): + raise AdmissionShapeError("Pi tool-result images are unsupported") + text = "\n".join( + block.text for block in result.content if isinstance(block, PiTextContentV1) + ) + return CanonicalMessageV1( + role=CanonicalRole.TOOL, + content=text or "(no tool output)", + tool_call_id=result.tool_call_id, + ) + + def _load_json(body: bytes, error_type: type[ValueError], timeout: Timeout) -> object: try: JsonDocument.parse(body, timeout=timeout) @@ -411,16 +532,22 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 _PI_ADAPTER = TypeAdapter(PiInputV1) +_PI_TOOL_RESULT_ADAPTER = TypeAdapter(PiToolResultV1) _PROVIDER_ADAPTER = TypeAdapter(_ProviderRequest) __all__ = [ "AdmissionMutationError", "AdmissionShapeError", + "AttestedCandidate", "HarnessAdapter", "HarnessAdapterRegistry", "OpenAIChatCompletionsV1Adapter", "PiInputV1", + "PiImageContentV1", + "PiTextContentV1", + "PiToolResultV1", + "PiToolResultV1Adapter", "PiV1Adapter", "PreparedHarnessRequest", "ProviderAdapterRegistry", diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py index 9cfbe944..2dc2743a 100644 --- a/projects/egress-gate/src/egress_gate/admission/models.py +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -16,14 +16,15 @@ from egress_gate.result import ReasonCode, SourcedFinding from egress_gate.string_validators import BoundedMetadataString, ScalarString -MAX_ADMISSION_BODY_BYTES = 32 * 1024 -PI_HARNESS_VERSION = "extension-v1" +MAX_ADMISSION_BODY_BYTES = 4 * 1024 * 1024 +PI_HARNESS_VERSION = "sdk-v1" class AdmissionHook(StrEnum): """Supported Pi admission boundaries.""" RENDERED_PROMPT = "rendered_prompt_admission" + TOOL_RESULT = "tool_result_admission" class AdmissionDecision(StrEnum): @@ -34,19 +35,18 @@ class AdmissionDecision(StrEnum): DENY = "deny" -class PromptProvenance(StrictDomainModel): - """Request-local correlation assertions for one rendered submission.""" +class AdmissionProvenance(StrictDomainModel): + """Request-local correlation assertions for one context addition.""" - kind: Literal["rendered_prompt"] session_id: BoundedMetadataString submission_id: BoundedMetadataString class HarnessAdmissionRequest(StrictDomainModel): - """One complete harness-native rendered prompt.""" + """One complete harness-native context addition.""" request_body: bytes = Field(max_length=MAX_ADMISSION_BODY_BYTES, repr=False) - provenance: PromptProvenance + provenance: AdmissionProvenance class HarnessAdmissionContext(StrictDomainModel): @@ -56,9 +56,9 @@ class HarnessAdmissionContext(StrictDomainModel): sandbox_id: BoundedMetadataString middleware_name: BoundedMetadataString harness: Literal["pi"] - harness_version: Literal["extension-v1"] + harness_version: Literal["extension-v1", "sdk-v1"] hook: AdmissionHook - schema_version: Literal["openshell.pi-input.v1"] + schema_version: Literal["openshell.pi-input.v1", "openshell.pi-tool-result.v1"] provider_target: HttpTarget provider_adapter_schema: Literal["openai.chat-completions.v1"] @@ -73,7 +73,7 @@ class HarnessAdmissionResult(StrictDomainModel): max_length=MAX_ADMISSION_BODY_BYTES, repr=False, ) - receipt: bytes | None = Field( + attestation: bytes | None = Field( default=None, min_length=1, max_length=8 * 1024, @@ -90,8 +90,8 @@ def _decision_contract_is_consistent(self) -> HarnessAdmissionResult: if self.decision is AdmissionDecision.DENY: if self.reason_code is None: raise ValueError("denial requires a reason code") - if self.replacement_body is not None or self.receipt is not None: - raise ValueError("denial cannot carry a replacement or receipt") + if self.replacement_body is not None or self.attestation is not None: + raise ValueError("denial cannot carry a replacement or attestation") else: if self.reason_code is not None: raise ValueError("allow decisions cannot carry a reason code") @@ -105,18 +105,18 @@ def _decision_contract_is_consistent(self) -> HarnessAdmissionResult: and self.replacement_body is not None ): raise ValueError("allow decisions cannot carry a replacement body") - if self.receipt is None: - raise ValueError("admission requires a receipt") + if self.attestation is None: + raise ValueError("admission requires an attestation") return self __all__ = [ "AdmissionDecision", "AdmissionHook", + "AdmissionProvenance", "HarnessAdmissionContext", "HarnessAdmissionRequest", "HarnessAdmissionResult", "MAX_ADMISSION_BODY_BYTES", - "PromptProvenance", "PI_HARNESS_VERSION", ] diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py index 7aec8c0c..3c40c691 100644 --- a/projects/egress-gate/src/egress_gate/admission/processor.py +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -13,10 +13,15 @@ AdmissionMutationError, AdmissionShapeError, HarnessAdapterRegistry, + PiInputV1, ProviderAdapterRegistry, ProviderShapeError, ) -from egress_gate.admission.canonical import canonical_json_bytes +from egress_gate.admission.canonical import ( + CanonicalMessageV1, + CanonicalRole, + canonical_json_bytes, +) from egress_gate.admission.models import ( MAX_ADMISSION_BODY_BYTES, AdmissionDecision, @@ -31,9 +36,7 @@ EnforcementPoint, HarnessAdmissionMetadata, HttpRequest, - RemoveHeaderMutation, RequestContext, - RequestMutations, ) from egress_gate.request_processor import RequestProcessor, apply_request_mutations from egress_gate.result import ( @@ -71,7 +74,7 @@ def readiness(self) -> dict[str, str]: "admission_schema": "openshell.pi-input.v1", "canonicalization": "canonical-json.v1", "provider_adapter": "openai.chat-completions.v1", - "receipt_version": "egress-receipt.v1", + "attestation_version": "agent-attestation.v1", "key_id": self._receipt_authority.key_id, "policy_fingerprint": self._policy_fingerprint, } @@ -124,7 +127,7 @@ def process( if replacement is not None and len(replacement) > MAX_ADMISSION_BODY_BYTES: raise AdmissionMutationError("admission replacement body is too large") timeout.raise_if_expired() - receipt = self._receipt_authority.issue( + attestation = self._receipt_authority.issue_attestation( rendered_prompt, context, request.provenance, @@ -139,7 +142,7 @@ def process( else AdmissionDecision.ALLOW ), replacement_body=replacement, - receipt=receipt, + attestation=attestation, findings=gate_result.findings, policy_fingerprint=self._policy_fingerprint, ) @@ -162,7 +165,7 @@ def _deny(self, reason_code: str, hook: AdmissionHook) -> HarnessAdmissionResult class AttestedEgressProcessor: - """Verify a receipt, run network Gates, and reject prompt divergence.""" + """Verify trusted agent attestation and reject context divergence.""" def __init__( self, @@ -171,7 +174,7 @@ def __init__( receipt_authority: ReceiptAuthority, *, middleware_name: str, - harness_version: Literal["extension-v1"], + harness_version: Literal["sdk-v1"], ) -> None: fingerprint = request_processor.policy_fingerprint if not fingerprint: @@ -184,70 +187,65 @@ def __init__( self._provider_adapter_schema = "openai.chat-completions.v1" self._policy_fingerprint = fingerprint - def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: + def process( + self, + request: HttpRequest, + *, + agent_attestation: bytes, + timeout: Timeout, + ) -> EgressResult: """Deny any unattested or semantically changed provider request.""" if request.context.enforcement_point is not EnforcementPoint.NETWORK_EGRESS: return self._deny("network_context_invalid") - receipt_headers = tuple( - header - for header in request.headers - if header.name.lower() == RECEIPT_HEADER - ) - if len(receipt_headers) != 1: - reason = "receipt_missing" if not receipt_headers else "receipt_duplicate" - return self._deny(reason) - stripped = request.model_copy( - update={ - "headers": tuple( - header - for header in request.headers - if header.name.lower() != RECEIPT_HEADER - ) - } - ) + if any(header.name.lower() == RECEIPT_HEADER for header in request.headers): + return self._deny("reserved_receipt_header") + if not agent_attestation: + return self._deny("attestation_missing") try: adapter = self._provider_adapters.resolve(self._provider_adapter_schema) - rendered_prompt = adapter.rendered_prompt(stripped, timeout) + candidate = adapter.latest_attested_candidate(request, timeout) timeout.raise_if_expired() + if isinstance(candidate, PiInputV1): + hook = AdmissionHook.RENDERED_PROMPT + schema_version = "openshell.pi-input.v1" + elif ( + isinstance(candidate, CanonicalMessageV1) + and candidate.role is CanonicalRole.TOOL + ): + hook = AdmissionHook.TOOL_RESULT + schema_version = "openshell.pi-tool-result.v1" + else: + raise ProviderShapeError("provider context addition is unsupported") context = HarnessAdmissionContext( request_id=request.context.request_id, sandbox_id=request.context.sandbox_id, middleware_name=self._middleware_name, harness="pi", harness_version=self._harness_version, - hook=AdmissionHook.RENDERED_PROMPT, - schema_version="openshell.pi-input.v1", + hook=hook, + schema_version=schema_version, provider_target=request.target, provider_adapter_schema="openai.chat-completions.v1", ) - self._receipt_authority.verify( - receipt_headers[0].value.encode("ascii"), - rendered_prompt, + self._receipt_authority.verify_attestation( + agent_attestation, + candidate, context, policy_fingerprint=self._policy_fingerprint, ) timeout.raise_if_expired() - gate_result = self._request_processor.process(stripped, timeout=timeout) + gate_result = self._request_processor.process(request, timeout=timeout) timeout.raise_if_expired() if gate_result.decision is EgressDecision.DENY: return gate_result final_request = apply_request_mutations( - stripped, gate_result.request_mutations + request, gate_result.request_mutations ) - final_prompt = adapter.rendered_prompt(final_request, timeout) - if canonical_json_bytes(final_prompt) != canonical_json_bytes( - rendered_prompt - ): + final_candidate = adapter.latest_attested_candidate(final_request, timeout) + if canonical_json_bytes(final_candidate) != canonical_json_bytes(candidate): return self._deny("semantic_mutation_denied") timeout.raise_if_expired() - mutations = RequestMutations( - replacement_body=gate_result.request_mutations.replacement_body, - header_mutations=gate_result.request_mutations.header_mutations - + (RemoveHeaderMutation(kind="remove", name=RECEIPT_HEADER),), - ) - return gate_result.model_copy(update={"request_mutations": mutations}) - except UnicodeEncodeError: - return self._deny("receipt_malformed") + return gate_result except ReceiptVerificationError as error: return self._deny(error.reason_code) except TimeoutExpiredError: @@ -264,8 +262,8 @@ def _deny(self, reason_code: str) -> EgressResult: decision=EgressDecision.DENY, decision_source=GateDecisionSource( kind=DecisionSourceKind.GATE, - gate_name="receipt-verifier", - gate_type="receipt-verifier", + gate_name="agent-attestation-verifier", + gate_type="agent-attestation-verifier", ), reason_code=reason_code, policy_fingerprint=self._policy_fingerprint, diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py index fc1d7c43..4aa09aac 100644 --- a/projects/egress-gate/src/egress_gate/admission/receipts.py +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -19,12 +19,12 @@ ) from pydantic import Field, ValidationError -from egress_gate.admission.adapters import PiInputV1 +from egress_gate.admission.adapters import AttestedCandidate, PiInputV1 from egress_gate.admission.canonical import canonical_json_bytes from egress_gate.admission.models import ( AdmissionHook, + AdmissionProvenance, HarnessAdmissionContext, - PromptProvenance, ) from egress_gate.base import StrictDomainModel from egress_gate.string_validators import BoundedMetadataString, ScalarString @@ -54,6 +54,30 @@ class ReceiptClaimsV1(StrictDomainModel): key_id: str = Field(pattern=r"^[0-9a-f]{16}$") +class AgentAttestationClaimsV1(StrictDomainModel): + """Supervisor-only proof that the latest context addition was admitted.""" + + attestation_version: Literal["agent-attestation.v1"] = "agent-attestation.v1" + canonicalization_version: Literal["canonical-json.v1"] = "canonical-json.v1" + harness: Literal["pi"] + harness_version: Literal["sdk-v1"] + harness_schema: Literal["openshell.pi-input.v1", "openshell.pi-tool-result.v1"] + hook: Literal["rendered_prompt_admission", "tool_result_admission"] + middleware_binding: BoundedMetadataString + policy_fingerprint: ScalarString + sandbox_id: BoundedMetadataString + session_id: BoundedMetadataString + submission_id: BoundedMetadataString + attestation_id: str = Field(pattern=r"^[0-9a-f]{32}$") + provider_adapter_schema: Literal["openai.chat-completions.v1"] + host: ScalarString + port: int = Field(ge=0, le=2**32 - 1) + candidate_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + issued_at: int = Field(ge=0) + expires_at: int = Field(ge=0) + key_id: str = Field(pattern=r"^[0-9a-f]{16}$") + + class ReceiptVerificationError(ValueError): """A bounded receipt verification failure.""" @@ -87,6 +111,7 @@ def __init__( self._allowed_clock_skew_seconds = allowed_clock_skew_seconds self._consumed_receipts: dict[str, int] = {} self._consumed_receipts_lock = threading.Lock() + self._attestation_lifetime_seconds = 300 @property def key_id(self) -> str: @@ -97,7 +122,7 @@ def issue( self, rendered_prompt: PiInputV1, context: HarnessAdmissionContext, - provenance: PromptProvenance, + provenance: AdmissionProvenance, *, policy_fingerprint: str, now: int | None = None, @@ -105,6 +130,11 @@ def issue( """Issue one opaque receipt after final admission validation.""" if context.hook is not AdmissionHook.RENDERED_PROMPT: raise ValueError("receipts may be issued only for rendered prompts") + if ( + context.harness_version != "extension-v1" + or context.schema_version != "openshell.pi-input.v1" + ): + raise ValueError("receipt context is unsupported") issued_at = _now_seconds() if now is None else now target = context.provider_target claims = ReceiptClaimsV1( @@ -130,6 +160,43 @@ def issue( signature = self._private_key.sign(payload) return b"eg1." + _encode(payload) + b"." + _encode(signature) + def issue_attestation( + self, + candidate: AttestedCandidate, + context: HarnessAdmissionContext, + provenance: AdmissionProvenance, + *, + policy_fingerprint: str, + now: int | None = None, + ) -> bytes: + """Issue a retry-safe proof retained by the OpenShell supervisor.""" + if context.harness_version != "sdk-v1": + raise ValueError("agent attestation context is unsupported") + issued_at = _now_seconds() if now is None else now + target = context.provider_target + claims = AgentAttestationClaimsV1( + harness=context.harness, + harness_version=context.harness_version, + harness_schema=context.schema_version, + hook=context.hook.value, + middleware_binding=context.middleware_name, + policy_fingerprint=policy_fingerprint, + sandbox_id=context.sandbox_id, + session_id=provenance.session_id, + submission_id=provenance.submission_id, + attestation_id=secrets.token_hex(16), + provider_adapter_schema=context.provider_adapter_schema, + host=target.host, + port=target.port, + candidate_hash=_candidate_hash(candidate), + issued_at=issued_at, + expires_at=issued_at + self._attestation_lifetime_seconds, + key_id=self._key_id, + ) + payload = canonical_json_bytes(claims) + signature = self._private_key.sign(payload) + return b"ag1." + _encode(payload) + b"." + _encode(signature) + def verify( self, receipt: bytes, @@ -200,11 +267,76 @@ def verify( self._consumed_receipts[claims.receipt_id] = claims.expires_at return claims + def verify_attestation( + self, + attestation: bytes, + candidate: AttestedCandidate, + context: HarnessAdmissionContext, + *, + policy_fingerprint: str, + now: int | None = None, + ) -> AgentAttestationClaimsV1: + """Verify a supervisor-supplied context-addition attestation.""" + payload, signature = _decode_token( + attestation, prefix=b"ag1", malformed_reason="attestation_malformed" + ) + try: + self._public_key.verify(signature, payload) + except InvalidSignature: + raise ReceiptVerificationError("attestation_signature_invalid") from None + try: + claims = AgentAttestationClaimsV1.model_validate_json(payload, strict=True) + except ValidationError: + raise ReceiptVerificationError("attestation_malformed") from None + if canonical_json_bytes(claims) != payload: + raise ReceiptVerificationError("attestation_malformed") + current = _now_seconds() if now is None else now + if claims.key_id != self._key_id: + raise ReceiptVerificationError("attestation_key_mismatch") + if claims.issued_at > current + self._allowed_clock_skew_seconds: + raise ReceiptVerificationError("attestation_not_yet_valid") + if claims.expires_at <= current or claims.expires_at <= claims.issued_at: + raise ReceiptVerificationError("attestation_expired") + target = context.provider_target + expected = ( + context.harness, + context.harness_version, + context.schema_version, + context.hook.value, + context.middleware_name, + policy_fingerprint, + context.sandbox_id, + context.provider_adapter_schema, + target.host, + target.port, + _candidate_hash(candidate), + ) + actual = ( + claims.harness, + claims.harness_version, + claims.harness_schema, + claims.hook, + claims.middleware_binding, + claims.policy_fingerprint, + claims.sandbox_id, + claims.provider_adapter_schema, + claims.host, + claims.port, + claims.candidate_hash, + ) + if actual != expected: + raise ReceiptVerificationError("attestation_context_mismatch") + return claims + def _prompt_hash(rendered_prompt: PiInputV1) -> str: return hashlib.sha256(canonical_json_bytes(rendered_prompt)).hexdigest() +def _candidate_hash(candidate: AttestedCandidate) -> str: + return hashlib.sha256(canonical_json_bytes(candidate)).hexdigest() + + def _encode(value: bytes) -> bytes: return base64.urlsafe_b64encode(value).rstrip(b"=") @@ -218,12 +350,21 @@ def _decode(value: bytes) -> bytes: def _decode_receipt(receipt: bytes) -> tuple[bytes, bytes]: - if len(receipt) > 8 * 1024: - raise ReceiptVerificationError("receipt_malformed") - parts = receipt.split(b".") - if len(parts) != 3 or parts[0] != b"eg1" or not parts[1] or not parts[2]: - raise ReceiptVerificationError("receipt_malformed") - return _decode(parts[1]), _decode(parts[2]) + return _decode_token(receipt, prefix=b"eg1", malformed_reason="receipt_malformed") + + +def _decode_token( + value: bytes, *, prefix: bytes, malformed_reason: str +) -> tuple[bytes, bytes]: + if len(value) > 8 * 1024: + raise ReceiptVerificationError(malformed_reason) + parts = value.split(b".") + if len(parts) != 3 or parts[0] != prefix or not parts[1] or not parts[2]: + raise ReceiptVerificationError(malformed_reason) + try: + return _decode(parts[1]), _decode(parts[2]) + except ReceiptVerificationError: + raise ReceiptVerificationError(malformed_reason) from None def _now_seconds() -> int: @@ -231,6 +372,7 @@ def _now_seconds() -> int: __all__ = [ + "AgentAttestationClaimsV1", "ReceiptAuthority", "ReceiptClaimsV1", "ReceiptVerificationError", diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py index 93d50eaf..a611b35d 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\x84\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xe5\x02\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0cJ\x04\x08\x05\x10\x06J\x04\x08\n\x10\x0e\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xf1\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x03*\xd4\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x03*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xda\x04\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\x84\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xf1\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\x12\x19\n\x11\x61gent_attestation\x18\x08 \x01(\x0c\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xe5\x02\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0cJ\x04\x08\x05\x10\x06J\x04\x08\n\x10\x0e\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xf1\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x03*\xd4\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x03*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xda\x04\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -41,18 +41,18 @@ _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_HTTPREQUESTRESULT_METADATAENTRY']._loaded_options = None _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=4828 - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=5069 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=5072 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=5284 - _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=5287 - _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=5865 - _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=5868 - _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=6056 - _globals['_DECISION']._serialized_start=6058 - _globals['_DECISION']._serialized_end=6133 - _globals['_EXISTINGHEADERACTION']._serialized_start=6136 - _globals['_EXISTINGHEADERACTION']._serialized_end=6304 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=4855 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=5096 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=5099 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=5311 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=5314 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=5892 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=5895 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=6083 + _globals['_DECISION']._serialized_start=6085 + _globals['_DECISION']._serialized_end=6160 + _globals['_EXISTINGHEADERACTION']._serialized_start=6163 + _globals['_EXISTINGHEADERACTION']._serialized_end=6331 _globals['_MIDDLEWAREMANIFEST']._serialized_start=116 _globals['_MIDDLEWAREMANIFEST']._serialized_end=264 _globals['_MIDDLEWAREBINDING']._serialized_start=267 @@ -62,55 +62,55 @@ _globals['_VALIDATECONFIGRESPONSE']._serialized_start=620 _globals['_VALIDATECONFIGRESPONSE']._serialized_end=675 _globals['_HTTPREQUESTEVALUATION']._serialized_start=678 - _globals['_HTTPREQUESTEVALUATION']._serialized_end=1020 - _globals['_HTTPHEADER']._serialized_start=1022 - _globals['_HTTPHEADER']._serialized_end=1063 - _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1066 - _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1368 - _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1371 - _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1694 - _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1696 - _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1749 - _globals['_WEBSOCKETMESSAGE']._serialized_start=1751 - _globals['_WEBSOCKETMESSAGE']._serialized_end=1832 - _globals['_WEBSOCKETSESSIONEND']._serialized_start=1834 - _globals['_WEBSOCKETSESSIONEND']._serialized_end=1923 - _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1926 - _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2244 - _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2197 - _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2244 - _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2247 - _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2610 - _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2197 - _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2244 - _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2613 - _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2810 - _globals['_REQUESTCONTEXT']._serialized_start=2813 - _globals['_REQUESTCONTEXT']._serialized_end=2973 - _globals['_HTTPREQUESTTARGET']._serialized_start=2975 - _globals['_HTTPREQUESTTARGET']._serialized_end=3083 - _globals['_PROCESS']._serialized_start=3085 - _globals['_PROCESS']._serialized_end=3142 - _globals['_AGENTCONVERSATIONTARGET']._serialized_start=3145 - _globals['_AGENTCONVERSATIONTARGET']._serialized_end=3308 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=3311 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=3668 - _globals['_AGENTCONVERSATIONRESULT']._serialized_start=3671 - _globals['_AGENTCONVERSATIONRESULT']._serialized_end=4058 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2197 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2244 - _globals['_FINDING']._serialized_start=4060 - _globals['_FINDING']._serialized_end=4151 - _globals['_WRITEHEADER']._serialized_start=4153 - _globals['_WRITEHEADER']._serialized_end=4263 - _globals['_REMOVEHEADER']._serialized_start=4265 - _globals['_REMOVEHEADER']._serialized_end=4293 - _globals['_HEADERMUTATION']._serialized_start=4296 - _globals['_HEADERMUTATION']._serialized_end=4437 - _globals['_HTTPREQUESTRESULT']._serialized_start=4440 - _globals['_HTTPREQUESTRESULT']._serialized_end=4825 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2197 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2244 - _globals['_SUPERVISORMIDDLEWARE']._serialized_start=6307 - _globals['_SUPERVISORMIDDLEWARE']._serialized_end=6909 + _globals['_HTTPREQUESTEVALUATION']._serialized_end=1047 + _globals['_HTTPHEADER']._serialized_start=1049 + _globals['_HTTPHEADER']._serialized_end=1090 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1093 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1395 + _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1398 + _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1721 + _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1723 + _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1776 + _globals['_WEBSOCKETMESSAGE']._serialized_start=1778 + _globals['_WEBSOCKETMESSAGE']._serialized_end=1859 + _globals['_WEBSOCKETSESSIONEND']._serialized_start=1861 + _globals['_WEBSOCKETSESSIONEND']._serialized_end=1950 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1953 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2271 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2224 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2271 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2274 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2637 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2224 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2271 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2640 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2837 + _globals['_REQUESTCONTEXT']._serialized_start=2840 + _globals['_REQUESTCONTEXT']._serialized_end=3000 + _globals['_HTTPREQUESTTARGET']._serialized_start=3002 + _globals['_HTTPREQUESTTARGET']._serialized_end=3110 + _globals['_PROCESS']._serialized_start=3112 + _globals['_PROCESS']._serialized_end=3169 + _globals['_AGENTCONVERSATIONTARGET']._serialized_start=3172 + _globals['_AGENTCONVERSATIONTARGET']._serialized_end=3335 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=3338 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=3695 + _globals['_AGENTCONVERSATIONRESULT']._serialized_start=3698 + _globals['_AGENTCONVERSATIONRESULT']._serialized_end=4085 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2224 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2271 + _globals['_FINDING']._serialized_start=4087 + _globals['_FINDING']._serialized_end=4178 + _globals['_WRITEHEADER']._serialized_start=4180 + _globals['_WRITEHEADER']._serialized_end=4290 + _globals['_REMOVEHEADER']._serialized_start=4292 + _globals['_REMOVEHEADER']._serialized_end=4320 + _globals['_HEADERMUTATION']._serialized_start=4323 + _globals['_HEADERMUTATION']._serialized_end=4464 + _globals['_HTTPREQUESTRESULT']._serialized_start=4467 + _globals['_HTTPREQUESTRESULT']._serialized_end=4852 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2224 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2271 + _globals['_SUPERVISORMIDDLEWARE']._serialized_start=6334 + _globals['_SUPERVISORMIDDLEWARE']._serialized_end=6936 # @@protoc_insertion_point(module_scope) diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi index aeea0f4f..9549a7f1 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi @@ -134,7 +134,7 @@ class ValidateConfigResponse(_message.Message): def __init__(self, valid: _Optional[bool] = ..., reason: _Optional[str] = ...) -> None: ... class HttpRequestEvaluation(_message.Message): - __slots__ = ("phase", "context", "config", "target", "headers", "body", "middleware_name") + __slots__ = ("phase", "context", "config", "target", "headers", "body", "middleware_name", "agent_attestation") PHASE_FIELD_NUMBER: _ClassVar[int] CONTEXT_FIELD_NUMBER: _ClassVar[int] CONFIG_FIELD_NUMBER: _ClassVar[int] @@ -142,6 +142,7 @@ class HttpRequestEvaluation(_message.Message): HEADERS_FIELD_NUMBER: _ClassVar[int] BODY_FIELD_NUMBER: _ClassVar[int] MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + AGENT_ATTESTATION_FIELD_NUMBER: _ClassVar[int] phase: SupervisorMiddlewarePhase context: RequestContext config: _struct_pb2.Struct @@ -149,7 +150,8 @@ class HttpRequestEvaluation(_message.Message): headers: _containers.RepeatedCompositeFieldContainer[HttpHeader] body: bytes middleware_name: str - def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., body: _Optional[bytes] = ..., middleware_name: _Optional[str] = ...) -> None: ... + agent_attestation: bytes + def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., body: _Optional[bytes] = ..., middleware_name: _Optional[str] = ..., agent_attestation: _Optional[bytes] = ...) -> None: ... class HttpHeader(_message.Message): __slots__ = ("name", "value") diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 1194d37e..50d06952 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -167,13 +167,13 @@ def serve( ), ), ] = f"{DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING:g}s", - require_pi_receipt: Annotated[ + require_pi_attestation: Annotated[ bool, typer.Option( - "--require-pi-receipt/--no-require-pi-receipt", + "--require-pi-attestation/--no-require-pi-attestation", help=( - "Require and verify a matching Pi rendered-prompt receipt " - "on HTTP egress. Enabled by default; disable only for an " + "Require a supervisor-held Pi context attestation on HTTP " + "egress. Enabled by default; disable only for an " "explicitly unmanaged deployment." ), ), @@ -220,7 +220,7 @@ def serve( EgressGateServer( options.registry, timeout_middleware_processing=timeout_middleware_processing, - require_pi_receipt=require_pi_receipt, + require_pi_attestation=require_pi_attestation, ).serve_sync(listen) except EgressGateError as error: _render_egress_error("Egress Gate could not start", error) diff --git a/projects/egress-gate/src/egress_gate/constants.py b/projects/egress-gate/src/egress_gate/constants.py index a854ef9b..3dde4144 100644 --- a/projects/egress-gate/src/egress_gate/constants.py +++ b/projects/egress-gate/src/egress_gate/constants.py @@ -79,6 +79,7 @@ MAX_PROTO_TARGET_BYTES = 32 * 1024 MAX_PROTO_HEADERS = 128 MAX_PROTO_HEADERS_BYTES = 64 * 1024 +MAX_AGENT_ATTESTATION_BYTES = 8 * 1024 PROTOBUF_ENVELOPE_ALLOWANCE_BYTES = 1024 * 1024 MAX_RECEIVE_MESSAGE_BYTES = MAX_BODY_BYTES + PROTOBUF_ENVELOPE_ALLOWANCE_BYTES diff --git a/projects/egress-gate/src/egress_gate/service/server.py b/projects/egress-gate/src/egress_gate/service/server.py index dca0f249..924b16bd 100644 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ b/projects/egress-gate/src/egress_gate/service/server.py @@ -34,12 +34,12 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - require_pi_receipt: bool = False, + require_pi_attestation: bool = False, ) -> None: self._middleware = EgressGateMiddleware( registry, timeout_middleware_processing=timeout_middleware_processing, - require_pi_receipt=require_pi_receipt, + require_pi_attestation=require_pi_attestation, ) def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index a45fc9af..645ab4b4 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -24,11 +24,11 @@ RECEIPT_HEADER, AdmissionDecision, AdmissionHook, + AdmissionProvenance, AttestedEgressProcessor, HarnessAdmissionContext, HarnessAdmissionProcessor, HarnessAdmissionRequest, - PromptProvenance, ReceiptAuthority, create_pi_adapter_registry, create_provider_adapter_registry, @@ -41,6 +41,7 @@ DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, LIMIT_REASON, LIMIT_REASON_CODE, + MAX_AGENT_ATTESTATION_BYTES, MAX_BODY_BYTES, MAX_CONCURRENT_PROCESSING, MAX_PROTO_CONFIG_BYTES, @@ -97,13 +98,17 @@ def _require_pi_harness(value: str) -> Literal["pi"]: raise ValueError("invalid admission harness") -def _require_pi_schema(value: str) -> Literal["openshell.pi-input.v1"]: - if value == "openshell.pi-input.v1": +def _require_pi_schema( + value: str, hook: AdmissionHook +) -> Literal["openshell.pi-input.v1", "openshell.pi-tool-result.v1"]: + if hook is AdmissionHook.RENDERED_PROMPT and value == "openshell.pi-input.v1": + return value + if hook is AdmissionHook.TOOL_RESULT and value == "openshell.pi-tool-result.v1": return value raise ValueError("invalid admission schema") -def _require_pi_harness_version(value: str) -> Literal["extension-v1"]: +def _require_pi_harness_version(value: str) -> Literal["sdk-v1"]: if value == PI_HARNESS_VERSION: return value raise ValueError("invalid Pi harness version") @@ -117,7 +122,7 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - require_pi_receipt: bool = False, + require_pi_attestation: bool = False, ) -> None: registry.configuration_json_schema() self._registry = registry @@ -126,7 +131,7 @@ def __init__( ) self._policy = _ActivePolicy(registry) self._receipt_authority = ReceiptAuthority() - self._require_pi_receipt = require_pi_receipt + self._require_pi_attestation = require_pi_attestation self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING) self._processing_executor = ThreadPoolExecutor( max_workers=MAX_CONCURRENT_PROCESSING, @@ -170,10 +175,14 @@ async def Describe( max_payload_bytes=MAX_ADMISSION_BODY_BYTES, harness="pi", hook=hook.value, - schema_version="openshell.pi-input.v1", + schema_version=( + "openshell.pi-input.v1" + if hook is AdmissionHook.RENDERED_PROMPT + else "openshell.pi-tool-result.v1" + ), ) for hook in AdmissionHook - if self._require_pi_receipt + if self._require_pi_attestation ), ], ) @@ -221,7 +230,7 @@ def _evaluate_agent_admission( timeout: Timeout, ) -> pb2.AgentConversationResult: try: - if not self._require_pi_receipt: + if not self._require_pi_attestation: raise ValueError("agent admission is disabled") if request.phase != pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: raise ValueError("invalid admission phase") @@ -236,8 +245,7 @@ def _evaluate_agent_admission( path=request.target.path, query="", ) - provenance = PromptProvenance( - kind="rendered_prompt", + provenance = AdmissionProvenance( session_id=request.session_id, submission_id=request.turn_id, ) @@ -262,7 +270,9 @@ def _evaluate_agent_admission( request.target.harness_version ), hook=hook, - schema_version=_require_pi_schema(request.target.schema_version), + schema_version=_require_pi_schema( + request.target.schema_version, hook + ), provider_target=target, provider_adapter_schema="openai.chat-completions.v1", ), @@ -275,7 +285,7 @@ def _evaluate_agent_admission( else pb2.DECISION_ALLOW ), reason_code=result.reason_code or "", - attestation=result.receipt or b"", + attestation=result.attestation or b"", replacement_body=result.replacement_body or b"", has_replacement_body=result.replacement_body is not None, ) @@ -399,14 +409,18 @@ def _prepare_and_process( values, timeout=timeout, ) - if self._require_pi_receipt: + if self._require_pi_attestation: return AttestedEgressProcessor( processor, create_provider_adapter_registry(), self._receipt_authority, middleware_name=request.middleware_name, harness_version=PI_HARNESS_VERSION, - ).process(domain_request, timeout=timeout) + ).process( + domain_request, + agent_attestation=request.agent_attestation, + timeout=timeout, + ) if any( header.name.lower() == RECEIPT_HEADER for header in domain_request.headers ): @@ -622,6 +636,7 @@ def _validate_evaluation_envelope(request: pb2.HttpRequestEvaluation) -> None: or request.target.ByteSize() > MAX_PROTO_TARGET_BYTES or len(request.headers) > MAX_PROTO_HEADERS or _encoded_headers_size(request.headers) > MAX_PROTO_HEADERS_BYTES + or len(request.agent_attestation) > MAX_AGENT_ATTESTATION_BYTES ): raise EgressGateError(ErrorCode.REQUEST_ENVELOPE_INVALID) diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index f36f0f04..b8bc5da4 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -1,23 +1,30 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Conformance tests for rendered-prompt admission and attested egress.""" +"""Conformance tests for managed Pi context admission and attested egress.""" from __future__ import annotations import json +from typing import Literal + +import pytest from egress_gate.admission import ( + MAX_ADMISSION_BODY_BYTES, RECEIPT_HEADER, AdmissionDecision, AdmissionHook, + AdmissionProvenance, AttestedEgressProcessor, HarnessAdmissionContext, HarnessAdmissionProcessor, HarnessAdmissionRequest, PiInputV1, - PromptProvenance, + PiTextContentV1, + PiToolResultV1, ReceiptAuthority, + ReceiptVerificationError, canonical_json_bytes, create_pi_adapter_registry, create_provider_adapter_registry, @@ -32,7 +39,7 @@ def _processors( *, replacement_template: str = "[REDACTED]" -) -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor]: +) -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor, ReceiptAuthority]: registry = create_builtin_registry() config = registry.validate_config( { @@ -98,15 +105,16 @@ def _processors( create_provider_adapter_registry(), authority, middleware_name="pi-egress", - harness_version="extension-v1", + harness_version="sdk-v1", ), + authority, ) -def _target() -> HttpTarget: +def _target(*, host: str = "provider.test") -> HttpTarget: return HttpTarget( scheme="https", - host="provider.test", + host=host, port=443, method="POST", path="/v1/chat/completions", @@ -114,58 +122,112 @@ def _target() -> HttpTarget: ) -def _admit(processor: HarnessAdmissionProcessor, text: str): - body = canonical_json_bytes( - PiInputV1(schema_version="openshell.pi-input.v1", text=text) +def _context( + hook: AdmissionHook, + *, + harness_version: Literal["extension-v1", "sdk-v1"] = "sdk-v1", + target: HttpTarget | None = None, +) -> HarnessAdmissionContext: + schema = ( + "openshell.pi-input.v1" + if hook is AdmissionHook.RENDERED_PROMPT + else "openshell.pi-tool-result.v1" + ) + return HarnessAdmissionContext( + request_id="admission-1", + sandbox_id="sandbox-1", + middleware_name="pi-egress", + harness="pi", + harness_version=harness_version, + hook=hook, + schema_version=schema, + provider_target=target or _target(), + provider_adapter_schema="openai.chat-completions.v1", ) - return body, _admit_body(processor, body) -def _admit_body( +def _admit( processor: HarnessAdmissionProcessor, - body: bytes, + value: PiInputV1 | PiToolResultV1, *, + target: HttpTarget | None = None, timeout: Timeout | None = None, - provider_target: HttpTarget | None = None, ): - result = processor.process( + hook = ( + AdmissionHook.RENDERED_PROMPT + if isinstance(value, PiInputV1) + else AdmissionHook.TOOL_RESULT + ) + return processor.process( HarnessAdmissionRequest( - request_body=body, - provenance=PromptProvenance( - kind="rendered_prompt", - session_id="session-1", - submission_id="submission-1", + request_body=canonical_json_bytes(value), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" ), ), - HarnessAdmissionContext( - request_id="admission-1", - sandbox_id="sandbox-1", - middleware_name="pi-egress", - harness="pi", - harness_version="extension-v1", - hook=AdmissionHook.RENDERED_PROMPT, - schema_version="openshell.pi-input.v1", - provider_target=provider_target or _target(), - provider_adapter_schema="openai.chat-completions.v1", - ), + _context(hook, target=target), timeout=timeout or Timeout.from_seconds(1), ) - return result + + +def _user(text: str) -> PiInputV1: + return PiInputV1(schema_version="openshell.pi-input.v1", text=text) + + +def _tool_result(text: str, *, image: bool = False) -> PiToolResultV1: + content: list[dict[str, object]] = ( + [{"type": "image", "data": "AA==", "mimeType": "image/png"}] + if image + else [{"type": "text", "text": text}] + ) + return PiToolResultV1.model_validate( + { + "schema_version": "openshell.pi-tool-result.v1", + "tool_call_id": "call-1", + "tool_name": "read", + "content": content, + "is_error": False, + }, + strict=True, + ) def _provider_request( prompt: str, - receipt: bytes | None, *, + tool_result: str | None = None, + headers: tuple[HttpHeader, ...] = (), target: HttpTarget | None = None, ) -> HttpRequest: + messages: list[dict[str, object]] = [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": prompt}, + ] + if tool_result is not None: + messages.extend( + [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "content": tool_result, + "tool_call_id": "call-1", + }, + ] + ) body = json.dumps( { "model": "fixture-model", - "messages": [ - {"role": "system", "content": "fixture system prompt"}, - {"role": "user", "content": prompt}, - ], + "messages": messages, "tools": [], "tool_choice": "auto", "temperature": 0, @@ -179,45 +241,59 @@ def _provider_request( separators=(",", ":"), sort_keys=True, ).encode() - headers = [HttpHeader(name="content-type", value="application/json")] - if receipt is not None: - headers.append(HttpHeader(name=RECEIPT_HEADER, value=receipt.decode("ascii"))) return HttpRequest( context=RequestContext(request_id="network-1", sandbox_id="sandbox-1"), target=target or _target(), - headers=tuple(headers), + headers=(HttpHeader(name="content-type", value="application/json"),) + headers, body=body, ) -def test_safe_rendered_prompt_receipt_authorizes_first_request_and_is_stripped() -> ( - None +def _egress( + processor: AttestedEgressProcessor, + request: HttpRequest, + attestation: bytes | None, ): - admission, egress = _processors() - _, admitted = _admit(admission, "safe rendered prompt") - - assert admitted.decision is AdmissionDecision.ALLOW - assert admitted.receipt is not None - result = egress.process( - _provider_request("safe rendered prompt", admitted.receipt), + return processor.process( + request, + agent_attestation=attestation or b"", timeout=Timeout.from_seconds(1), ) - assert result.decision.value == "allow" - assert [ - mutation.name for mutation in result.request_mutations.header_mutations - ] == [RECEIPT_HEADER] +def test_user_attestation_authorizes_retries_without_entering_request_headers() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe rendered prompt")) -def test_receipt_uses_stable_destination_across_tls_proxy_normalization() -> None: - admission, egress = _processors() - body = canonical_json_bytes( - PiInputV1(schema_version="openshell.pi-input.v1", text="safe rendered prompt") - ) - admitted = _admit_body( + assert admitted.decision is AdmissionDecision.ALLOW + assert admitted.attestation is not None + request = _provider_request("safe rendered prompt") + first = _egress(egress, request, admitted.attestation) + retry = _egress(egress, request, admitted.attestation) + + assert first.decision.value == "allow" + assert retry.decision.value == "allow" + assert first.request_mutations.header_mutations == () + + +def test_changed_or_unattested_user_context_fails_closed() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe rendered prompt")) + assert admitted.attestation is not None + + changed = _egress(egress, _provider_request("changed prompt"), admitted.attestation) + missing = _egress(egress, _provider_request("safe rendered prompt"), None) + + assert changed.reason_code == "attestation_context_mismatch" + assert missing.reason_code == "attestation_missing" + + +def test_attestation_uses_stable_destination_across_tls_proxy_normalization() -> None: + admission, egress, _ = _processors() + admitted = _admit( admission, - body, - provider_target=HttpTarget( + _user("safe"), + target=HttpTarget( scheme="https", host="provider.test", port=443, @@ -226,9 +302,8 @@ def test_receipt_uses_stable_destination_across_tls_proxy_normalization() -> Non query="", ), ) - assert admitted.receipt is not None - - normalized_target = HttpTarget( + assert admitted.attestation is not None + normalized = HttpTarget( scheme="http", host="provider.test", port=443, @@ -236,150 +311,152 @@ def test_receipt_uses_stable_destination_across_tls_proxy_normalization() -> Non path="/v1/chat/completions", query="", ) - wrong_host = egress.process( - _provider_request( - "safe rendered prompt", - admitted.receipt, - target=normalized_target.model_copy(update={"host": "other.test"}), - ), - timeout=Timeout.from_seconds(1), + + allowed = _egress( + egress, + _provider_request("safe", target=normalized), + admitted.attestation, ) - result = egress.process( + wrong_host = _egress( + egress, _provider_request( - "safe rendered prompt", admitted.receipt, target=normalized_target + "safe", target=normalized.model_copy(update={"host": "other.test"}) ), - timeout=Timeout.from_seconds(1), + admitted.attestation, ) - assert wrong_host.reason_code == "receipt_context_mismatch" - assert result.decision.value == "allow" - - -def test_rendered_prompt_receipt_is_consumed_after_first_request() -> None: - admission, egress = _processors() - _, admitted = _admit(admission, "safe rendered prompt") - assert admitted.receipt is not None - request = _provider_request("safe rendered prompt", admitted.receipt) - - first = egress.process(request, timeout=Timeout.from_seconds(1)) - replay = egress.process(request, timeout=Timeout.from_seconds(1)) - - assert first.decision.value == "allow" - assert replay.decision.value == "deny" - assert replay.reason_code == "receipt_replayed" - - -def test_denial_returns_no_receipt_or_replacement() -> None: - admission, _ = _processors() - _, denied = _admit(admission, f"do not persist {DENY_TEXT}") - - assert denied.decision is AdmissionDecision.DENY - assert denied.receipt is None - assert denied.replacement_body is None + assert allowed.decision.value == "allow" + assert wrong_host.reason_code == "attestation_context_mismatch" -def test_redaction_receipt_binds_only_the_replacement() -> None: - admission, egress = _processors() - original = f"hide {REDACT_TEXT} please" - _, admitted = _admit(admission, original) +def test_user_redaction_attests_only_the_replacement() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user(f"hide {REDACT_TEXT} please")) assert admitted.decision is AdmissionDecision.REPLACE - assert admitted.receipt is not None + assert admitted.attestation is not None assert admitted.replacement_body is not None replacement = PiInputV1.model_validate_json( admitted.replacement_body, strict=True ).text + assert replacement == "hide [REDACTED] please" assert ( - egress.process( - _provider_request(original, admitted.receipt), - timeout=Timeout.from_seconds(1), - ).reason_code - == "receipt_context_mismatch" - ) - assert ( - egress.process( - _provider_request(replacement, admitted.receipt), - timeout=Timeout.from_seconds(1), + _egress( + egress, _provider_request(replacement), admitted.attestation ).decision.value == "allow" ) + assert ( + _egress( + egress, + _provider_request(f"hide {REDACT_TEXT} please"), + admitted.attestation, + ).reason_code + == "attestation_context_mismatch" + ) + +def test_tool_result_is_admitted_before_persistence_and_attested_at_egress() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _tool_result("safe tool output")) -def test_oversized_redaction_fails_before_receipt_issuance() -> None: - admission, _ = _processors(replacement_template="x" * 1024) + assert admitted.decision is AdmissionDecision.ALLOW + assert admitted.attestation is not None + matching = _egress( + egress, + _provider_request("inspect", tool_result="safe tool output"), + admitted.attestation, + ) + changed = _egress( + egress, + _provider_request("inspect", tool_result="changed tool output"), + admitted.attestation, + ) - _, denied = _admit(admission, REDACT_TEXT * 33) + assert matching.decision.value == "allow" + assert changed.reason_code == "attestation_context_mismatch" - assert denied.decision is AdmissionDecision.DENY - assert denied.reason_code == "admission_contract_invalid" - assert denied.receipt is None +def test_tool_result_denial_redaction_and_images_fail_closed() -> None: + admission, _, _ = _processors() -def test_changed_prompt_and_unattested_continuation_fail_closed() -> None: - admission, egress = _processors() - _, admitted = _admit(admission, "safe rendered prompt") - assert admitted.receipt is not None + denied = _admit(admission, _tool_result(DENY_TEXT)) + redacted = _admit(admission, _tool_result(REDACT_TEXT)) + image = _admit(admission, _tool_result("", image=True)) - changed = egress.process( - _provider_request("changed prompt", admitted.receipt), - timeout=Timeout.from_seconds(1), - ) - continuation = egress.process( - _provider_request("safe rendered prompt", None), - timeout=Timeout.from_seconds(1), + assert denied.decision is AdmissionDecision.DENY + assert denied.attestation is None + assert redacted.decision is AdmissionDecision.REPLACE + assert redacted.replacement_body is not None + redacted_tool_result = PiToolResultV1.model_validate_json( + redacted.replacement_body, strict=True ) - - assert changed.reason_code == "receipt_context_mismatch" - assert continuation.reason_code == "receipt_missing" + assert isinstance(redacted_tool_result.content[0], PiTextContentV1) + assert redacted_tool_result.content[0].text == "[REDACTED]" + assert image.decision is AdmissionDecision.DENY + assert image.reason_code == "admission_contract_invalid" -def test_malformed_and_duplicate_admission_json_are_contract_errors() -> None: - admission, _ = _processors() +def test_denial_returns_no_attestation_or_replacement() -> None: + admission, _, _ = _processors() - malformed = _admit_body(admission, b"{") - duplicate = _admit_body( - admission, - b'{"schema_version":"openshell.pi-input.v1",' - b'"schema_version":"openshell.pi-input.v1","text":"safe"}', - ) + denied = _admit(admission, _user(f"do not persist {DENY_TEXT}")) - assert malformed.reason_code == "admission_contract_invalid" - assert duplicate.reason_code == "admission_contract_invalid" + assert denied.decision is AdmissionDecision.DENY + assert denied.attestation is None + assert denied.replacement_body is None -def test_admission_json_limits_and_deadlines_remain_availability_errors() -> None: - admission, _ = _processors() - over_depth = b"[" * 129 + b"0" + b"]" * 129 +def test_oversized_redaction_attempt_fails_before_attestation_issuance() -> None: + admission, _, _ = _processors(replacement_template="x" * 1024) - limited = _admit_body(admission, over_depth) - expired = _admit_body(admission, b"{}", timeout=Timeout(deadline=0.0)) + denied = _admit( + admission, + _user(REDACT_TEXT * (MAX_ADMISSION_BODY_BYTES // 1024 + 1)), + ) - assert limited.reason_code == "admission_unavailable" - assert expired.reason_code == "admission_unavailable" + assert denied.decision is AdmissionDecision.DENY + assert denied.reason_code == "egress_gate_limit_exceeded" + assert denied.attestation is None -def test_provider_malformed_json_is_an_unsupported_shape() -> None: - admission, egress = _processors() - _, admitted = _admit(admission, "safe rendered prompt") - assert admitted.receipt is not None - malformed = _provider_request("safe rendered prompt", admitted.receipt).model_copy( - update={"body": b"{"} +def test_malformed_duplicate_and_expired_admission_fail_closed() -> None: + admission, _, _ = _processors() + provenance = AdmissionProvenance( + session_id="session-1", submission_id="submission-1" ) - result = egress.process(malformed, timeout=Timeout.from_seconds(1)) + def admit_body(body: bytes, timeout: Timeout | None = None): + return admission.process( + HarnessAdmissionRequest(request_body=body, provenance=provenance), + _context(AdmissionHook.RENDERED_PROMPT), + timeout=timeout or Timeout.from_seconds(1), + ) - assert result.reason_code == "provider_shape_unsupported" + malformed = admit_body(b"{") + duplicate = admit_body( + b'{"schema_version":"openshell.pi-input.v1",' + b'"schema_version":"openshell.pi-input.v1","text":"safe"}' + ) + over_depth = admit_body(b"[" * 129 + b"0" + b"]" * 129) + expired = admit_body(b"{}", Timeout(deadline=0.0)) + assert malformed.reason_code == "admission_contract_invalid" + assert duplicate.reason_code == "admission_contract_invalid" + assert over_depth.reason_code == "admission_unavailable" + assert expired.reason_code == "admission_unavailable" -def test_direct_openai_reasoning_effort_is_supported() -> None: - admission, egress = _processors() - _, admitted = _admit(admission, "safe rendered prompt") - assert admitted.receipt is not None - request = _provider_request("safe rendered prompt", admitted.receipt) + +def test_provider_shape_validation_and_optional_reasoning_field_are_preserved() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe")) + assert admitted.attestation is not None + request = _provider_request("safe") + malformed = request.model_copy(update={"body": b"{"}) provider_body = json.loads(request.body) provider_body["reasoning_effort"] = "medium" - request = request.model_copy( + with_reasoning = request.model_copy( update={ "body": json.dumps( provider_body, @@ -390,6 +467,38 @@ def test_direct_openai_reasoning_effort_is_supported() -> None: } ) - result = egress.process(request, timeout=Timeout.from_seconds(1)) + malformed_result = _egress(egress, malformed, admitted.attestation) + reasoning_result = _egress(egress, with_reasoning, admitted.attestation) + + assert malformed_result.reason_code == "provider_shape_unsupported" + assert reasoning_result.decision.value == "allow" + + +def test_workload_receipt_header_is_reserved_in_managed_flow() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe")) + assert admitted.attestation is not None + request = _provider_request( + "safe", + headers=(HttpHeader(name=RECEIPT_HEADER, value="eg1.untrusted"),), + ) + + result = _egress(egress, request, admitted.attestation) + + assert result.reason_code == "reserved_receipt_header" + + +def test_legacy_workload_receipts_remain_one_use() -> None: + _, _, authority = _processors() + context = _context(AdmissionHook.RENDERED_PROMPT, harness_version="extension-v1") + provenance = AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ) + prompt = _user("safe") + receipt = authority.issue( + prompt, context, provenance, policy_fingerprint="policy", now=100 + ) - assert result.decision.value == "allow" + authority.verify(receipt, prompt, context, policy_fingerprint="policy", now=100) + with pytest.raises(ReceiptVerificationError, match="receipt_replayed"): + authority.verify(receipt, prompt, context, policy_fingerprint="policy", now=100) diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index 95714ef4..654f1888 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -159,7 +159,7 @@ async def test_generated_stub_round_trip_covers_manifest_and_gate_actions() -> N @pytest.mark.asyncio -async def test_generated_stub_issues_a_rendered_prompt_receipt() -> None: +async def test_generated_stub_issues_a_rendered_prompt_attestation() -> None: body = canonical_json_bytes( PiInputV1(schema_version="openshell.pi-input.v1", text="safe") ) @@ -169,7 +169,7 @@ async def test_generated_stub_issues_a_rendered_prompt_receipt() -> None: config=_config(action_kind="detect"), target=pb2.AgentConversationTarget( harness="pi", - harness_version="extension-v1", + harness_version="sdk-v1", hook="rendered_prompt_admission", schema_version="openshell.pi-input.v1", scheme="https", @@ -183,19 +183,19 @@ async def test_generated_stub_issues_a_rendered_prompt_receipt() -> None: request_body=body, ) middleware = EgressGateMiddleware( - create_builtin_registry(), require_pi_receipt=True + create_builtin_registry(), require_pi_attestation=True ) async with _running_stub(middleware) as (stub, _): response = await stub.EvaluateAgentConversation(request) assert response.decision == pb2.DECISION_ALLOW - assert response.attestation.startswith(b"eg1.") + assert response.attestation.startswith(b"ag1.") assert response.has_replacement_body is False assert response.metadata["admission_schema"] == "openshell.pi-input.v1" @pytest.mark.asyncio -async def test_agent_admission_is_unavailable_when_receipt_enforcement_is_off() -> None: +async def test_agent_admission_is_unavailable_when_managed_mode_is_off() -> None: request = pb2.AgentConversationEvaluation( phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT ) @@ -208,7 +208,7 @@ async def test_agent_admission_is_unavailable_when_receipt_enforcement_is_off() @pytest.mark.asyncio -async def test_admission_receipt_is_verified_and_stripped_for_http_egress() -> None: +async def test_trusted_agent_attestation_is_verified_for_http_egress() -> None: pi_body = canonical_json_bytes( PiInputV1(schema_version="openshell.pi-input.v1", text="safe") ) @@ -218,7 +218,7 @@ async def test_admission_receipt_is_verified_and_stripped_for_http_egress() -> N config=_config(action_kind="detect"), target=pb2.AgentConversationTarget( harness="pi", - harness_version="extension-v1", + harness_version="sdk-v1", hook="rendered_prompt_admission", schema_version="openshell.pi-input.v1", scheme="https", @@ -249,7 +249,7 @@ async def test_admission_receipt_is_verified_and_stripped_for_http_egress() -> N separators=(",", ":"), ).encode() middleware = EgressGateMiddleware( - create_builtin_registry(), require_pi_receipt=True + create_builtin_registry(), require_pi_attestation=True ) async with _running_stub(middleware) as (stub, _): admitted = await stub.EvaluateAgentConversation(admission) @@ -258,15 +258,10 @@ async def test_admission_receipt_is_verified_and_stripped_for_http_egress() -> N network.target.host = "provider.invalid" network.target.path = "/v1/chat/completions" network.middleware_name = "pi-egress" - network.headers.extend( - [ - pb2.HttpHeader(name="content-type", value="application/json"), - pb2.HttpHeader( - name="x-openshell-middleware-egress-receipt", - value=admitted.attestation.decode("ascii"), - ), - ] + network.headers.append( + pb2.HttpHeader(name="content-type", value="application/json") ) + network.agent_attestation = admitted.attestation allowed = await stub.EvaluateHttpRequest(network) missing = _evaluation(provider_body, action_kind="detect") missing.target.host = "provider.invalid" @@ -278,12 +273,9 @@ async def test_admission_receipt_is_verified_and_stripped_for_http_egress() -> N denied = await stub.EvaluateHttpRequest(missing) assert allowed.decision == pb2.DECISION_ALLOW - assert ( - allowed.header_mutations[0].remove.name - == "x-openshell-middleware-egress-receipt" - ) + assert not allowed.header_mutations assert denied.decision == pb2.DECISION_DENY - assert denied.reason_code == "receipt_missing" + assert denied.reason_code == "attestation_missing" @pytest.mark.asyncio diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 22cfe86d..54b9ec85 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -24,6 +24,7 @@ DEFAULT_DENY_REASON_CODE, LIMIT_REASON, LIMIT_REASON_CODE, + MAX_AGENT_ATTESTATION_BYTES, MAX_BODY_BYTES, MAX_PROTO_CONFIG_BYTES, MAX_PROTO_CONTEXT_BYTES, @@ -130,6 +131,29 @@ def test_manifest_leaves_the_gateway_rpc_timeout_to_the_operator() -> None: assert manifest.bindings[0].timeout == "" +def test_managed_manifest_advertises_exact_user_and_tool_result_bindings() -> None: + middleware = EgressGateMiddleware( + create_builtin_registry(), require_pi_attestation=True + ) + try: + manifest = asyncio.run(middleware.Describe(object(), Mock())) + finally: + asyncio.run(middleware.close()) + + agent_bindings = [ + binding + for binding in manifest.bindings + if binding.operation == pb2.SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION + ] + assert [ + (binding.harness, binding.hook, binding.schema_version) + for binding in agent_bindings + ] == [ + ("pi", "rendered_prompt_admission", "openshell.pi-input.v1"), + ("pi", "tool_result_admission", "openshell.pi-tool-result.v1"), + ] + + def test_copied_proto_remains_the_current_five_field_finding_contract() -> None: evaluation = pb2.HttpRequestEvaluation() finding = pb2.Finding() @@ -220,6 +244,13 @@ def test_evaluation_enforces_exact_encoded_transport_boundaries() -> None: with pytest.raises(EgressGateError): servicer_module._validate_evaluation_envelope(request) + request = _request(body=b"") + request.agent_attestation = b"x" * MAX_AGENT_ATTESTATION_BYTES + servicer_module._validate_evaluation_envelope(request) + request.agent_attestation += b"x" + with pytest.raises(EgressGateError): + servicer_module._validate_evaluation_envelope(request) + def test_request_adapter_builds_the_full_domain_request() -> None: domain = servicer_module._request_from_proto(_request(b"bytes")) diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 9be29c16..4ac136e0 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -74,9 +74,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, - require_pi_receipt: bool = False, + require_pi_attestation: bool = False, ) -> None: - del registry, require_pi_receipt + del registry, require_pi_attestation self.timeout_middleware_processing = timeout_middleware_processing def serve_sync(self, listen: str) -> None: @@ -547,9 +547,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, - require_pi_receipt: bool = False, + require_pi_attestation: bool = False, ) -> None: - del registry, timeout_middleware_processing, require_pi_receipt + del registry, timeout_middleware_processing, require_pi_attestation def serve_sync(self, listen: str) -> None: calls.append(listen) diff --git a/projects/egress-gate/tests/test_pi_admission_extension.py b/projects/egress-gate/tests/test_managed_pi_admission.py similarity index 68% rename from projects/egress-gate/tests/test_pi_admission_extension.py rename to projects/egress-gate/tests/test_managed_pi_admission.py index d2bd43f9..bf81fd9f 100644 --- a/projects/egress-gate/tests/test_pi_admission_extension.py +++ b/projects/egress-gate/tests/test_managed_pi_admission.py @@ -7,11 +7,10 @@ from pathlib import Path -def test_pi_admission_extension_renews_receipts_for_provider_continuations() -> None: +def test_managed_pi_admission_maps_handles_to_exact_provider_context() -> None: project_dir = Path(__file__).parents[1] test_file = ( - project_dir - / "examples/pi-attested-admission/openshell-input-admission.test.mjs" + project_dir / "examples/pi-attested-admission/managed-pi-admission.test.mjs" ) subprocess.run( diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 68edc5f4..08f0e611 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -39,7 +39,7 @@ def test_pi_example_can_print_each_action_without_running_it( env=environment, text=True, ) - for action in ("prepare", "serve", "gateway", "launch", "verify", "cleanup") + for action in ("prepare", "serve", "gateway", "launch", "cleanup") ] output = "\n".join(result.stdout for result in results) @@ -81,9 +81,11 @@ def test_pi_example_can_print_each_action_without_running_it( assert "sandbox exec --tty" in output assert "PI_OFFLINE=1" in output assert "OPENSHELL_AGENT_CONVERSATION_URL=" in output - assert "REDACTED" in output - assert "DENY_THIS" in output - assert "REDACT_THIS" in output + assert "managed-pi.ts:/sandbox/pi-runtime/managed-pi.ts" in output + assert ( + "managed-pi-admission.ts:/sandbox/pi-runtime/managed-pi-admission.ts" in output + ) + assert "--extension" not in output assert "sandbox delete" in output assert all(result.stderr == "" for result in results) assert not pi_repo.exists() @@ -114,7 +116,7 @@ def test_pi_example_print_all_is_a_concise_walkthrough() -> None: assert "Configuration visible to this shell" in result.stdout assert "Model credential: set (value hidden)" in result.stdout assert "1. prepare" in result.stdout - assert "7. cleanup" in result.stdout + assert "6. cleanup" in result.stdout assert "secret-not-printed" not in result.stdout assert "working directory:" not in result.stdout From a7d436b11b344c07023702ad5972d1e0204b97bc Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 31 Aug 2026 21:55:17 -0400 Subject: [PATCH 20/70] refactor(egress-gate): run admitted Pi through standard CLI --- .../examples/pi-attested-admission/README.md | 66 ++++++---- .../examples/pi-attested-admission/demo.sh | 60 +++++++-- .../managed-pi-admission.test.mjs | 122 ++++++++++++++++-- .../managed-pi-admission.ts | 88 ++++++++----- .../pi-attested-admission/managed-pi.ts | 67 +++------- .../render-runtime-config.mjs | 3 +- .../tests/test_pi_example_commands.py | 32 ++++- 7 files changed, 301 insertions(+), 137 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index a8ba3bac..dabff943 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,4 +1,4 @@ -# Managed Pi attested-admission example +# Pi attested-admission example This example runs a normal interactive Pi TUI inside OpenShell and sends admitted conversation context to a model endpoint you choose. The endpoint may be a hosted @@ -54,10 +54,16 @@ source .env set +a ``` +`set -a` makes assignments loaded by `source .env` available to commands run +from this shell; `set +a` restores the shell's default behavior afterward. + If the model endpoint does not require authentication, set `PI_MODEL_API_KEY=unused`. Source `.env` again in each new terminal that runs `demo.sh`. +The generated Pi model enables Pi's normal reasoning controls and sends the +OpenAI-compatible `reasoning_effort` request field to the configured endpoint. + `EGRESS_GATE_HOST_IP` is the address OpenShell uses to reach Egress Gate on this machine. It must be a reachable, non-loopback IPv4 address; do not use `127.0.0.1`. `PI_MODEL_BASE_URL` is separate: it is the model endpoint Pi will @@ -81,8 +87,9 @@ example, `./demo.sh --print prepare` or `./demo.sh --print launch`. ## Run the example -Prepare the forks, build Pi, generate the endpoint-specific runtime -configuration, and generate the Egress Gate registration used by Terminal 2: +Prepare the forks, build and package the locally modified Pi agent core and +coding agent, generate the endpoint-specific runtime configuration, and +generate the Egress Gate registration used by Terminal 2: ```shell ./demo.sh prepare @@ -109,18 +116,25 @@ OpenShell gateway. After the gateway reports that it is ready, launch Pi from a third terminal: -```shell title="Terminal 3: managed Pi" +```shell title="Terminal 3: Pi" ./demo.sh launch ``` -Each launch replaces the example's `pi-egress-demo` sandbox, provider, and -custom provider profile so the current Pi runtime, managed harness, policy, -endpoint, and OpenShell supervisor are used together. +This starts the standard Pi CLI, with OpenShell admission inserted immediately +before each provider request. Each launch replaces the example's +`pi-egress-demo` sandbox, provider, and custom provider profile so the current +Pi runtime, admission adapter, policy, endpoint, and OpenShell supervisor are +used together. The example registers an endpoint-specific provider profile using the host-side `PI_MODEL_API_KEY`. The real credential remains in OpenShell. Pi receives only -an opaque, endpoint-bound resolver placeholder; OpenShell resolves it in the -authorization header for the configured model endpoint. +an opaque, endpoint-bound resolver placeholder. The launcher removes that +placeholder before starting Pi and passes it once over a private file +descriptor. The thin launcher reads and closes the descriptor before the TUI or +tools start, supplies the resolver to Pi's non-persistent runtime credential +store, installs mandatory admission, and delegates the rest of startup to Pi's +standard CLI. OpenShell resolves the placeholder in the authorization header +for the configured model endpoint. At the Pi prompt, submit both of these in the same session: @@ -146,18 +160,19 @@ The tool runs, but its result is replaced by Pi's protocol-safe blocked result before it enters live context. Repeat with `REDACT_` and `THIS` to see the tool result admitted as `[REDACTED]`. -This example deliberately uses Pi's in-memory session manager. The interactive -TUI, tools, queued messages, retries, and `/new` work normally during the run, -but the session is not written inside the sandbox and cannot be resumed after -Pi exits. That is the minimal isolation guarantee: unadmitted context cannot be -recovered from a workload-owned session file. +Pi uses its standard session manager and JSONL session location, and exposes +the active path to tools as `PI_SESSION_FILE`. Admission runs before a user +message or tool result reaches that history. Each `launch` replaces the +disposable demo sandbox, so copy out anything you want to retain before ending +the run. ## How it works -1. `managed-pi.ts` creates the regular Pi `InteractiveMode` with a mandatory SDK - `ContextAdmission` boundary and an in-memory session manager. It disables - dynamically loaded extensions, so project or user extensions cannot replace - this boundary. +1. `managed-pi.ts` calls Pi's standard `main()` with two runtime hooks: one + installs the opaque credential resolver and one creates the mandatory SDK + `ContextAdmission` boundary for each normal Pi session. The launch command + uses Pi's standard `--no-extensions` option, so project or user extensions + cannot replace this boundary. 2. Pi calls that boundary for each rendered user message and finalized tool result before it queues, appends, or persists the value. 3. The adapter sends the exact context addition to OpenShell's sandbox-local @@ -165,9 +180,11 @@ recovered from a workload-owned session file. complete replacement. 4. OpenShell keeps the signed attestation and gives Pi only an opaque handle. The adapter keeps handles in its private closure, outside Pi messages. -5. For each provider request or retry, Pi passes the exact outbound context to - the adapter. It selects the handle for the newest admitted user message or - tool result in that context. +5. Immediately before every provider request, Pi passes the exact outbound + context through admission. This includes normal turns, retries, compaction, + branch summaries, and contexts restored from a prior session. The adapter + applies any replacement and obtains a fresh handle for the newest user + message or tool result in that exact context. 6. OpenShell strips the handle, resolves the supervisor-held attestation, and supplies it only to the configured Egress Gate middleware stage. Egress Gate verifies the latest context addition and scans the complete provider request @@ -176,9 +193,10 @@ recovered from a workload-owned session file. ## Current scope The attestation adapter supports normal text turns, text tool results, queued -steering and follow-up messages, retries, and automatic model continuations, -using the OpenAI Chat Completions wire format. Providers with a different native -protocol and image inputs are not covered by this example and fail closed. +steering and follow-up messages, retries, automatic model continuations, +compaction, branch summaries, and restored sessions using the OpenAI Chat +Completions wire format. Providers with a different native protocol and image +inputs are not covered by this example and fail closed. ## Cleanup diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 576319f2..24f07baa 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -99,6 +99,18 @@ require_file() { fi } +require_file_contains() { + local path=$1 + local expected_text=$2 + local description=$3 + require_file "$path" "$description" + if ! grep -Fq -- "$expected_text" "$path"; then + printf '%s is missing the required admission hook: %s\n' "$description" "$path" >&2 + printf 'Run `./demo.sh prepare` to rebuild the local Pi runtime.\n' >&2 + exit 1 + fi +} + require_directory() { local path=$1 local description=$2 @@ -273,15 +285,17 @@ sync_forks() { run_in "$openshell_repo" git pull --no-rebase --ff-only origin "$openshell_branch" } -pi_tarball() { +pi_package_tarball() { + local package_directory=$1 + local archive_name=$2 if $print_only; then - printf '%s/earendil-works-pi-coding-agent-VERSION.tgz' "$pack_dir" + printf '%s/%s-VERSION.tgz' "$pack_dir" "$archive_name" return fi - require_file "$pi_repo/packages/coding-agent/package.json" "Pi coding-agent package" + require_file "$package_directory/package.json" "Pi package" local version - version=$(node -p "require(process.argv[1]).version" "$pi_repo/packages/coding-agent/package.json") - printf '%s/earendil-works-pi-coding-agent-%s.tgz' "$pack_dir" "$version" + version=$(node -p "require(process.argv[1]).version" "$package_directory/package.json") + printf '%s/%s-%s.tgz' "$pack_dir" "$archive_name" "$version" } render_runtime_configuration() { @@ -301,15 +315,18 @@ prepare() { require_example_configuration fi sync_forks - local tarball - tarball=$(pi_tarball) + local agent_tarball + local coding_agent_tarball + agent_tarball=$(pi_package_tarball "$pi_repo/packages/agent" "earendil-works-pi-agent-core") + coding_agent_tarball=$(pi_package_tarball "$pi_repo/packages/coding-agent" "earendil-works-pi-coding-agent") describe_printed_commands "Build and package Pi:" run_in "$pi_repo" npm install --ignore-scripts run_in "$pi_repo" npm run build run_in "$pi_repo" mkdir -p "$pack_dir" "$runtime_dir" + run_in "$pi_repo" npm pack --workspace @earendil-works/pi-agent-core --pack-destination "$pack_dir" run_in "$pi_repo" npm pack --workspace @earendil-works/pi-coding-agent --pack-destination "$pack_dir" - run_in "$pi_repo" npm install --prefix "$runtime_dir" --ignore-scripts "$tarball" + run_in "$pi_repo" npm install --prefix "$runtime_dir" --ignore-scripts "$agent_tarball" "$coding_agent_tarball" describe_printed_commands "Generate the Pi model configuration and endpoint policy:" render_runtime_configuration } @@ -403,11 +420,26 @@ launch() { if ! $print_only; then require_example_configuration require_file "$openshell_cli" "OpenShell CLI wrapper" - require_file "$(pi_tarball)" "packed Pi coding-agent" + require_file "$(pi_package_tarball "$pi_repo/packages/agent" "earendil-works-pi-agent-core")" \ + "packed Pi agent core" + require_file "$(pi_package_tarball "$pi_repo/packages/coding-agent" "earendil-works-pi-coding-agent")" \ + "packed Pi coding-agent" + require_file_contains \ + "$runtime_dir/node_modules/@earendil-works/pi-agent-core/dist/agent-loop.js" \ + "beforeToolResultAppend" \ + "installed Pi agent core" require_file "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/index.js" \ "installed Pi SDK" - require_file "$script_dir/managed-pi.ts" "managed Pi harness" - require_file "$script_dir/managed-pi-admission.ts" "managed Pi admission adapter" + require_file_contains \ + "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/main.js" \ + "configureModelRuntime" \ + "installed Pi CLI" + require_file_contains \ + "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/main.js" \ + "createContextAdmission" \ + "installed Pi CLI" + require_file "$script_dir/managed-pi.ts" "Pi launcher" + require_file "$script_dir/managed-pi-admission.ts" "Pi admission adapter" fi describe_printed_commands "Refresh the endpoint-specific model, policy, and provider profile:" @@ -433,7 +465,7 @@ launch() { PI_MANAGED_PROVIDER=attested-provider \ PI_MANAGED_MODEL="$model_id" \ OPENSHELL_AGENT_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation \ - node --experimental-strip-types /sandbox/pi-runtime/managed-pi.ts + bash -c 'exec env -u PI_MODEL_API_KEY node --experimental-strip-types /sandbox/pi-runtime/managed-pi.ts --no-extensions --provider "$PI_MANAGED_PROVIDER" --model "$PI_MANAGED_MODEL" 3<<<"$PI_MODEL_API_KEY"' } cleanup() { @@ -458,7 +490,7 @@ usage() { prepare Update the forks, package Pi, and generate the runtime configuration serve Start Egress Gate gateway Start the forked OpenShell gateway - launch Attach the configured model credential and launch managed Pi + launch Attach the configured model credential and launch Pi cleanup Delete the example sandbox and credential provider all Show the concise workflow walkthrough (requires --print) EOF @@ -520,7 +552,7 @@ ${bold}${blue}Workflow${reset} and gateway middleware configuration. ${green}2. serve${reset} Start Egress Gate in Terminal 1 and leave it running. ${green}3. gateway${reset} Start the OpenShell gateway in Terminal 2 and leave it running. - ${green}4. launch${reset} Create the credential provider and launch managed Pi in Terminal 3. + ${green}4. launch${reset} Create the credential provider and launch Pi in Terminal 3. ${green}5. test${reset} At the Pi prompt, submit: Reply with exactly: DENY_THIS Reply with exactly: REDACT_THIS diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs index d5b9d61a..46112a9c 100644 --- a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs @@ -9,6 +9,23 @@ function user(text, timestamp) { return { role: "user", content: [{ type: "text", text }], timestamp }; } +function toolResult(text, isError = false) { + return { + role: "toolResult", + toolCallId: "call-1", + toolName: "bash", + content: [{ type: "text", text }], + isError, + timestamp: 2, + }; +} + +async function admittedProviderContext(admission, context) { + const result = await admission.admitProviderContext(context); + assert.equal(result.action, "allow"); + return result.context ?? context; +} + test("selects the handle for the exact queued or retried provider context", async () => { const bridgeRequests = []; const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { @@ -23,9 +40,12 @@ test("selects the handle for the exact queued or retried provider context", asyn assert.deepEqual(await admission.admitUserMessage(current, { source: "interactive" }), { action: "allow" }); assert.deepEqual(await admission.admitUserMessage(queued, { source: "interactive" }), { action: "allow" }); - const currentHeaders = await admission.transformProviderHeaders({}, { messages: [current], tools: [] }); - const retryHeaders = await admission.transformProviderHeaders({}, { messages: [current], tools: [] }); - const queuedHeaders = await admission.transformProviderHeaders({}, { messages: [current, queued], tools: [] }); + const currentContext = await admittedProviderContext(admission, { messages: [current], tools: [] }); + const currentHeaders = await admission.transformProviderHeaders({}, currentContext); + const retryContext = await admittedProviderContext(admission, { messages: [current], tools: [] }); + const retryHeaders = await admission.transformProviderHeaders({}, retryContext); + const queuedContext = await admittedProviderContext(admission, { messages: [current, queued], tools: [] }); + const queuedHeaders = await admission.transformProviderHeaders({}, queuedContext); assert.equal(currentHeaders[HANDLE_HEADER], "handle:current turn"); assert.equal(retryHeaders[HANDLE_HEADER], "handle:current turn"); @@ -35,9 +55,15 @@ test("selects the handle for the exact queued or retried provider context", asyn [ ["rendered_prompt_admission", "openshell.pi-input.v1"], ["rendered_prompt_admission", "openshell.pi-input.v1"], + ["rendered_prompt_admission", "openshell.pi-input.v1"], + ["rendered_prompt_admission", "openshell.pi-input.v1"], + ["rendered_prompt_admission", "openshell.pi-input.v1"], ], ); - assert.deepEqual(bridgeRequests.map((request) => request.session_id), ["session-123", "session-123"]); + assert.deepEqual( + bridgeRequests.map((request) => request.session_id), + ["session-123", "session-123", "session-123", "session-123", "session-123"], + ); }); test("uses an admitted replacement as the handle lookup key", async () => { @@ -58,14 +84,90 @@ test("uses an admitted replacement as the handle lookup key", async () => { assert.equal(admitted.action, "allow"); assert.equal(admitted.message.content[0].text, "[REDACTED]"); - const headers = await admission.transformProviderHeaders( - {}, - { messages: [admitted.message], tools: [] }, - ); + const context = await admittedProviderContext(admission, { messages: [admitted.message], tools: [] }); + const headers = await admission.transformProviderHeaders({}, context); assert.equal(headers[HANDLE_HEADER], "replacement-handle"); }); -test("bounds handles retained for a long-lived in-memory session", async () => { +test("selects the handle for an admitted failed tool result", async () => { + const observedHooks = []; + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { + const request = JSON.parse(init.body); + observedHooks.push(request.hook); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${request.hook}` })); + }); + const prompt = user("run a command", 1); + const failed = toolResult("(no output)\n\nCommand exited with code 2", true); + + assert.equal((await admission.admitUserMessage(prompt, { source: "interactive" })).action, "allow"); + assert.equal((await admission.admitToolResult(failed)).action, "allow"); + const context = await admittedProviderContext(admission, { messages: [prompt, failed], tools: [] }); + const headers = await admission.transformProviderHeaders({}, context); + + assert.equal(headers[HANDLE_HEADER], "handle:tool_result_admission"); + assert.deepEqual(observedHooks, ["rendered_prompt_admission", "tool_result_admission", "tool_result_admission"]); +}); + +test("admits and replaces a generated provider-only context", async () => { + const observedTexts = []; + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { + const request = JSON.parse(init.body); + const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + observedTexts.push(envelope.text); + const replacement = new TextEncoder().encode( + JSON.stringify({ schema_version: "openshell.pi-input.v1", text: "admitted summary context" }), + ); + return new Response( + JSON.stringify({ + decision: "allow", + handle: "summary-handle", + replacement_body: Array.from(replacement), + }), + ); + }); + const generated = { messages: [user("generated summary context", 1)], tools: [] }; + + const context = await admittedProviderContext(admission, generated); + const headers = await admission.transformProviderHeaders({}, context); + + assert.deepEqual(observedTexts, ["generated summary context"]); + assert.equal(context.messages[0].content[0].text, "admitted summary context"); + assert.equal(headers[HANDLE_HEADER], "summary-handle"); +}); + +test("fails closed when a generated provider-only context is denied", async () => { + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async () => + new Response(JSON.stringify({ decision: "deny", reason_code: "policy_denied" })), + ); + + const result = await admission.admitProviderContext({ + messages: [user("generated summary context", 1)], + tools: [], + }); + + assert.deepEqual(result, { + action: "deny", + reason: "OpenShell denied this context addition (policy_denied)", + }); +}); + +test("re-admits provider context restored without an in-memory handle", async () => { + let requests = 0; + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async () => { + requests++; + return new Response(JSON.stringify({ decision: "allow", handle: "restored-handle" })); + }); + const restored = { messages: [user("restored session message", 1)], tools: [] }; + + await assert.rejects(admission.transformProviderHeaders({}, restored), /admission handle is missing/); + const context = await admittedProviderContext(admission, restored); + const headers = await admission.transformProviderHeaders({}, context); + + assert.equal(requests, 1); + assert.equal(headers[HANDLE_HEADER], "restored-handle"); +}); + +test("bounds handles retained for a long-lived session", async () => { const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { const request = JSON.parse(init.body); const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); @@ -84,7 +186,7 @@ test("bounds handles retained for a long-lived in-memory session", async () => { assert.equal(headers[HANDLE_HEADER], "handle:turn 1024"); }); -test("uses the current session ID after a new in-memory session starts", async () => { +test("uses the current session ID after a new session starts", async () => { let sessionId = "session-1"; const observedSessionIds = []; const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => sessionId, async (_url, init) => { diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts index 6ac44b13..998008b4 100644 --- a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts @@ -6,7 +6,7 @@ import type { ToolResultMessage, UserMessage, } from "@earendil-works/pi-ai/compat"; -import type { ContextAdmission } from "@earendil-works/pi-coding-agent"; +import type { ContextAdmission, ContextAdmissionResult } from "@earendil-works/pi-coding-agent"; const HANDLE_HEADER = "x-openshell-agent-admission-handle"; const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; @@ -61,42 +61,60 @@ export function createOpenShellContextAdmission( return parseBridgeResult(JSON.parse(new TextDecoder().decode(encoded))); } + async function admitUserMessage(message: UserMessage): Promise> { + const envelope = userEnvelope(message); + if (!envelope) { + return { action: "deny", reason: "Image inputs are not supported by this managed Pi example" }; + } + const result = await requestAdmission("rendered_prompt_admission", envelope); + if (result.decision === "deny") return denied(result.reason_code); + const admittedEnvelope = result.replacement_body + ? parseUserEnvelope(new Uint8Array(result.replacement_body)) + : envelope; + const admittedMessage: UserMessage = { + ...message, + content: replaceUserText(message.content, admittedEnvelope.text), + }; + rememberHandle(handles, messageKey(admittedMessage), result.handle); + return admittedEnvelope.text === envelope.text + ? { action: "allow" } + : { action: "allow", message: admittedMessage }; + } + + async function admitToolResult(message: ToolResultMessage): Promise> { + const envelope = toolResultEnvelope(message); + const result = await requestAdmission("tool_result_admission", envelope); + if (result.decision === "deny") return denied(result.reason_code); + const admittedEnvelope = result.replacement_body + ? parseToolResultEnvelope(new Uint8Array(result.replacement_body)) + : envelope; + const admittedMessage: ToolResultMessage = { + ...message, + content: admittedEnvelope.content, + }; + rememberHandle(handles, messageKey(admittedMessage), result.handle); + return result.replacement_body + ? { action: "allow", message: admittedMessage } + : { action: "allow" }; + } + return { - async admitUserMessage(message) { - const envelope = userEnvelope(message); - if (!envelope) { - return { action: "deny", reason: "Image inputs are not supported by this managed Pi example" }; - } - const result = await requestAdmission("rendered_prompt_admission", envelope); - if (result.decision === "deny") return denied(result.reason_code); - const admittedEnvelope = result.replacement_body - ? parseUserEnvelope(new Uint8Array(result.replacement_body)) - : envelope; - const admittedMessage: UserMessage = { - ...message, - content: replaceUserText(message.content, admittedEnvelope.text), - }; - rememberHandle(handles, messageKey(admittedMessage), result.handle); - return admittedEnvelope.text === envelope.text - ? { action: "allow" } - : { action: "allow", message: admittedMessage }; - }, + admitUserMessage, + admitToolResult, - async admitToolResult(message) { - const envelope = toolResultEnvelope(message); - const result = await requestAdmission("tool_result_admission", envelope); - if (result.decision === "deny") return denied(result.reason_code); - const admittedEnvelope = result.replacement_body - ? parseToolResultEnvelope(new Uint8Array(result.replacement_body)) - : envelope; - const admittedMessage: ToolResultMessage = { - ...message, - content: admittedEnvelope.content, - }; - rememberHandle(handles, messageKey(admittedMessage), result.handle); - return result.replacement_body - ? { action: "allow", message: admittedMessage } - : { action: "allow" }; + async admitProviderContext(context) { + for (let index = context.messages.length - 1; index >= 0; index -= 1) { + const message = context.messages[index]; + if (message.role !== "user" && message.role !== "toolResult") continue; + const result = + message.role === "user" ? await admitUserMessage(message) : await admitToolResult(message); + if (result.action === "deny") return result; + if (!result.message) return { action: "allow" }; + const messages = [...context.messages]; + messages[index] = result.message; + return { action: "allow", context: { ...context, messages } }; + } + return { action: "deny", reason: "Provider context has no user message or tool result to admit" }; }, async transformProviderHeaders(headers: ProviderHeaders, context: Context) { diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts b/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts index c71988d9..3ac98d26 100644 --- a/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts +++ b/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts @@ -1,65 +1,28 @@ -/** Normal interactive Pi with mandatory OpenShell context admission. */ -import { - type CreateAgentSessionRuntimeFactory, - InteractiveMode, - ModelRuntime, - SessionManager, - createAgentSessionFromServices, - createAgentSessionRuntime, - createAgentSessionServices, -} from "@earendil-works/pi-coding-agent"; +/** Standard Pi CLI with mandatory OpenShell context admission. */ +import { closeSync, readFileSync } from "node:fs"; +import { main } from "@earendil-works/pi-coding-agent"; import { createOpenShellContextAdmission } from "./managed-pi-admission.ts"; -async function main(): Promise { +async function run(): Promise { const bridgeUrl = process.env.OPENSHELL_AGENT_CONVERSATION_URL; - const agentDir = process.env.PI_CODING_AGENT_DIR; const provider = process.env.PI_MANAGED_PROVIDER; - const modelId = process.env.PI_MANAGED_MODEL; - if (!bridgeUrl || !agentDir || !provider || !modelId) { - throw new Error( - "OPENSHELL_AGENT_CONVERSATION_URL, PI_CODING_AGENT_DIR, PI_MANAGED_PROVIDER, and PI_MANAGED_MODEL are required", - ); + if (!bridgeUrl || !provider) { + throw new Error("OPENSHELL_AGENT_CONVERSATION_URL and PI_MANAGED_PROVIDER are required"); } - const sessionManager = SessionManager.inMemory(process.cwd()); - const contextAdmission = createOpenShellContextAdmission(bridgeUrl, () => sessionManager.getSessionId()); - const modelRuntime = await ModelRuntime.create({ - authPath: `${agentDir}/auth.json`, - modelsPath: `${agentDir}/models.json`, - refreshOnCreate: false, - }); - const model = modelRuntime.getModel(provider, modelId); - if (!model) throw new Error(`Model ${provider}/${modelId} was not found`); + const modelApiKey = readFileSync(3, "utf8").replace(/\n$/u, ""); + closeSync(3); - const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => { - const services = await createAgentSessionServices({ - cwd, - agentDir, - modelRuntime, - resourceLoaderOptions: { noExtensions: true }, - }); - return { - ...(await createAgentSessionFromServices({ - services, - sessionManager, - sessionStartEvent, - model, - thinkingLevel: "off", - contextAdmission, - })), - services, - diagnostics: services.diagnostics, - }; - }; - const runtime = await createAgentSessionRuntime(createRuntime, { - cwd: process.cwd(), - agentDir, - sessionManager, + await main(process.argv.slice(2), { + configureModelRuntime: async (modelRuntime) => { + await modelRuntime.setRuntimeApiKey(provider, modelApiKey); + }, + createContextAdmission: (sessionManager) => + createOpenShellContextAdmission(bridgeUrl, () => sessionManager.getSessionId()), }); - await new InteractiveMode(runtime, { startupDiagnostics: [...runtime.diagnostics] }).run(); } -main().catch((error: unknown) => { +run().catch((error: unknown) => { console.error(error); process.exitCode = 1; }); diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs index 773d1b7e..392d2791 100644 --- a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -25,11 +25,12 @@ const models = { { id: modelId, name: modelId, - reasoning: false, + reasoning: true, input: ["text"], contextWindow: 128000, maxTokens: 16384, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: { supportsReasoningEffort: true }, }, ], }, diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 08f0e611..b7b4ca57 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -44,7 +44,10 @@ def test_pi_example_can_print_each_action_without_running_it( output = "\n".join(result.stdout for result in results) assert "npm run build" in output + assert "earendil-works-pi-agent-core-VERSION.tgz" in output assert "earendil-works-pi-coding-agent-VERSION.tgz" in output + assert "npm pack --workspace @earendil-works/pi-agent-core" in output + assert "npm pack --workspace @earendil-works/pi-coding-agent" in output assert "git clone --branch johnny/before-user-message-commit" in output assert "git clone --branch openshell/pi-egress-admission" in output assert ( @@ -80,12 +83,15 @@ def test_pi_example_can_print_each_action_without_running_it( assert "sandbox exec" in output assert "sandbox exec --tty" in output assert "PI_OFFLINE=1" in output + assert "--no-extensions" in output + assert "--provider" in output + assert "--model" in output assert "OPENSHELL_AGENT_CONVERSATION_URL=" in output assert "managed-pi.ts:/sandbox/pi-runtime/managed-pi.ts" in output assert ( "managed-pi-admission.ts:/sandbox/pi-runtime/managed-pi-admission.ts" in output ) - assert "--extension" not in output + assert "--extension " not in output assert "sandbox delete" in output assert all(result.stderr == "" for result in results) assert not pi_repo.exists() @@ -93,6 +99,28 @@ def test_pi_example_can_print_each_action_without_running_it( assert not pack_dir.exists() assert not runtime_dir.exists() + managed_pi = ( + project_dir / "examples/pi-attested-admission/managed-pi.ts" + ).read_text() + assert "main(process.argv.slice(2)" in managed_pi + assert "SessionManager" not in managed_pi + assert "ModelRuntime.create" not in managed_pi + assert "InteractiveMode" not in managed_pi + assert "thinkingLevel" not in managed_pi + assert 'readFileSync(3, "utf8")' in managed_pi + assert "closeSync(3)" in managed_pi + assert "process.env.PI_MODEL_API_KEY" not in managed_pi + assert "await modelRuntime.setRuntimeApiKey(provider, modelApiKey)" in managed_pi + assert "configureModelRuntime" in managed_pi + assert "createContextAdmission" in managed_pi + + demo_script = (project_dir / "examples/pi-attested-admission/demo.sh").read_text() + assert '"beforeToolResultAppend"' in demo_script + assert "exec env -u PI_MODEL_API_KEY node" in demo_script + assert '3<<<"$PI_MODEL_API_KEY"' in demo_script + assert '"configureModelRuntime"' in demo_script + assert '"createContextAdmission"' in demo_script + def test_pi_example_print_all_is_a_concise_walkthrough() -> None: project_dir = Path(__file__).parents[1] @@ -206,6 +234,8 @@ def test_pi_example_renders_provider_specific_runtime_configuration( assert provider["api"] == "openai-completions" assert provider["apiKey"] == "$PI_MODEL_API_KEY" assert provider["models"][0]["id"] == "custom-model" + assert provider["models"][0]["reasoning"] is True + assert provider["models"][0]["compat"] == {"supportsReasoningEffort": True} provider_profile = yaml.safe_load(provider_profile_output.read_text()) assert provider_profile["id"] == "pi-attested-model" From 4b5513baca2e7a5b20f0ea2b2af3f5f875f45a8d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 31 Aug 2026 22:38:05 -0400 Subject: [PATCH 21/70] Raise Pi example model token limits --- .../examples/pi-attested-admission/render-runtime-config.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs index 392d2791..08f16627 100644 --- a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -27,8 +27,8 @@ const models = { name: modelId, reasoning: true, input: ["text"], - contextWindow: 128000, - maxTokens: 16384, + contextWindow: 262144, + maxTokens: 32768, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, compat: { supportsReasoningEffort: true }, }, From af23a9c3947afb31fb673b0456e6e4b2e54c1860 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 31 Aug 2026 22:50:02 -0400 Subject: [PATCH 22/70] Configure three Pi example models --- .../pi-attested-admission/.env.example | 2 +- .../examples/pi-attested-admission/README.md | 29 +++++-- .../render-runtime-config.mjs | 80 ++++++++++++++++--- .../tests/test_pi_example_commands.py | 37 +++++++-- 4 files changed, 121 insertions(+), 27 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example index 981b7931..79be50cc 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.env.example +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -1,4 +1,4 @@ EGRESS_GATE_HOST_IP=YOUR_HOST_IPV4 PI_MODEL_BASE_URL=https://provider.example.com/v1 -PI_MODEL_ID=your-model-id +PI_MODEL_ID=nvidia/qwen/qwen3.8-flash-next PI_MODEL_API_KEY=your-provider-key diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index dabff943..2d36ec52 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,10 +1,9 @@ # Pi attested-admission example This example runs a normal interactive Pi TUI inside OpenShell and sends -admitted conversation context to a model endpoint you choose. The endpoint may be a hosted -provider, an internal gateway, or a local server. It must accept the OpenAI Chat -Completions request shape used by the current attestation adapter; it does not -need to be OpenAI. +admitted conversation context to one model endpoint. The endpoint may be a +hosted provider, an internal gateway, or a local server. One endpoint-scoped +provider and credential serve all configured models. The example demonstrates the same policy at both context boundaries: @@ -61,8 +60,20 @@ If the model endpoint does not require authentication, set `PI_MODEL_API_KEY=unused`. Source `.env` again in each new terminal that runs `demo.sh`. -The generated Pi model enables Pi's normal reasoning controls and sends the -OpenAI-compatible `reasoning_effort` request field to the configured endpoint. +The generated Pi catalog contains these models: + +| Model ID | Pi transport | +| --- | --- | +| `azure/anthropic/claude-opus-5` | OpenAI Chat Completions | +| `azure/openai/gpt-5.6-sol` | OpenAI Responses | +| `nvidia/qwen/qwen3.8-flash-next` | OpenAI Chat Completions | + +`PI_MODEL_ID` selects the model Pi starts with and must be one of those IDs. +Use Pi's normal model picker to switch among all three without creating another +OpenShell provider. Qwen uses Chat Completions reasoning controls, and GPT-5.6 +Sol uses Responses reasoning. The endpoint's Opus 5 alias currently rejects +explicit adaptive-thinking controls, so it runs with the endpoint's default +thinking behavior. `EGRESS_GATE_HOST_IP` is the address OpenShell uses to reach Egress Gate on this machine. It must be a reachable, non-loopback IPv4 address; do not use @@ -195,8 +206,10 @@ the run. The attestation adapter supports normal text turns, text tool results, queued steering and follow-up messages, retries, automatic model continuations, compaction, branch summaries, and restored sessions using the OpenAI Chat -Completions wire format. Providers with a different native protocol and image -inputs are not covered by this example and fail closed. +Completions wire format. The catalog includes GPT-5.6 Sol so the shared-provider +configuration is complete, but its Responses requests are not yet covered by +the attestation adapter and fail closed. Image inputs are likewise outside this +example's current scope. ## Cleanup diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs index 08f16627..be1e02c6 100644 --- a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -15,24 +15,78 @@ const middlewareEndpoint = parseMiddlewareEndpoint( ); const endpointPort = baseUrl.port || (baseUrl.protocol === "https:" ? "443" : "80"); +const configuredModels = [ + { + id: "azure/anthropic/claude-opus-5", + name: "Claude Opus 5", + api: "openai-completions", + reasoning: false, + input: ["text"], + contextWindow: 1000000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: { + maxTokensField: "max_tokens", + supportsDeveloperRole: false, + supportsReasoningEffort: false, + }, + }, + { + id: "azure/openai/gpt-5.6-sol", + name: "GPT-5.6 Sol", + api: "openai-responses", + reasoning: true, + thinkingLevelMap: { + off: "none", + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: "xhigh", + max: "max", + }, + input: ["text"], + contextWindow: 1050000, + maxTokens: 128000, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + { + id: "nvidia/qwen/qwen3.8-flash-next", + name: "Qwen3.8 Flash Next", + api: "openai-completions", + reasoning: true, + thinkingLevelMap: { + minimal: "low", + low: "low", + medium: "medium", + high: "high", + xhigh: "high", + max: "high", + }, + input: ["text"], + contextWindow: 262144, + maxTokens: 32768, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: { + maxTokensField: "max_tokens", + supportsDeveloperRole: false, + supportsReasoningEffort: true, + thinkingFormat: "qwen", + }, + }, +]; + +if (!configuredModels.some((model) => model.id === modelId)) { + const configuredModelIds = configuredModels.map((model) => model.id).join(", "); + fail(`--model-id must select one of: ${configuredModelIds}`); +} + const models = { providers: { "attested-provider": { baseUrl: baseUrl.toString().replace(/\/$/, ""), - api: "openai-completions", apiKey: "$PI_MODEL_API_KEY", - models: [ - { - id: modelId, - name: modelId, - reasoning: true, - input: ["text"], - contextWindow: 262144, - maxTokens: 32768, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { supportsReasoningEffort: true }, - }, - ], + models: configuredModels, }, }, }; diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index b7b4ca57..0a33ebce 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -213,7 +213,7 @@ def test_pi_example_renders_provider_specific_runtime_configuration( "--base-url", "https://gateway.example.test:8443/models/v1", "--model-id", - "custom-model", + "nvidia/qwen/qwen3.8-flash-next", "--models-output", str(models_output), "--policy-output", @@ -231,11 +231,38 @@ def test_pi_example_renders_provider_specific_runtime_configuration( models = json.loads(models_output.read_text()) provider = models["providers"]["attested-provider"] assert provider["baseUrl"] == "https://gateway.example.test:8443/models/v1" - assert provider["api"] == "openai-completions" assert provider["apiKey"] == "$PI_MODEL_API_KEY" - assert provider["models"][0]["id"] == "custom-model" - assert provider["models"][0]["reasoning"] is True - assert provider["models"][0]["compat"] == {"supportsReasoningEffort": True} + assert "api" not in provider + configured_models = {model["id"]: model for model in provider["models"]} + assert set(configured_models) == { + "azure/anthropic/claude-opus-5", + "azure/openai/gpt-5.6-sol", + "nvidia/qwen/qwen3.8-flash-next", + } + + opus = configured_models["azure/anthropic/claude-opus-5"] + assert opus["api"] == "openai-completions" + assert opus["reasoning"] is False + assert opus["contextWindow"] == 1_000_000 + assert opus["maxTokens"] == 128_000 + + gpt = configured_models["azure/openai/gpt-5.6-sol"] + assert gpt["api"] == "openai-responses" + assert gpt["reasoning"] is True + assert gpt["contextWindow"] == 1_050_000 + assert gpt["maxTokens"] == 128_000 + + qwen = configured_models["nvidia/qwen/qwen3.8-flash-next"] + assert qwen["api"] == "openai-completions" + assert qwen["reasoning"] is True + assert qwen["contextWindow"] == 262_144 + assert qwen["maxTokens"] == 32_768 + assert qwen["compat"] == { + "maxTokensField": "max_tokens", + "supportsDeveloperRole": False, + "supportsReasoningEffort": True, + "thinkingFormat": "qwen", + } provider_profile = yaml.safe_load(provider_profile_output.read_text()) assert provider_profile["id"] == "pi-attested-model" From 3dd5e0430b296da0a6746690a06a713f703202ed Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 31 Aug 2026 23:03:28 -0400 Subject: [PATCH 23/70] Use standard Pi models configuration --- .../pi-attested-admission/.env.example | 3 +- .../examples/pi-attested-admission/README.md | 38 +++--- .../examples/pi-attested-admission/demo.sh | 64 +++++----- .../pi-attested-admission/models.json | 68 +++++++++++ .../render-runtime-config.mjs | 114 +++++------------- .../tests/test_pi_example_commands.py | 51 +++++--- 6 files changed, 189 insertions(+), 149 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/models.json diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example index 79be50cc..ef3b0279 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.env.example +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -1,4 +1,3 @@ EGRESS_GATE_HOST_IP=YOUR_HOST_IPV4 -PI_MODEL_BASE_URL=https://provider.example.com/v1 -PI_MODEL_ID=nvidia/qwen/qwen3.8-flash-next +PI_MODELS_PATH=./models.json PI_MODEL_API_KEY=your-provider-key diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 2d36ec52..8209f05c 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -60,7 +60,10 @@ If the model endpoint does not require authentication, set `PI_MODEL_API_KEY=unused`. Source `.env` again in each new terminal that runs `demo.sh`. -The generated Pi catalog contains these models: +`PI_MODELS_PATH` points to a standard Pi `models.json`. Relative paths are +resolved from this example directory. The checked-in [models.json](models.json) +defines one `attested-provider`, its endpoint and credential reference, and +these models: | Model ID | Pi transport | | --- | --- | @@ -68,23 +71,26 @@ The generated Pi catalog contains these models: | `azure/openai/gpt-5.6-sol` | OpenAI Responses | | `nvidia/qwen/qwen3.8-flash-next` | OpenAI Chat Completions | -`PI_MODEL_ID` selects the model Pi starts with and must be one of those IDs. -Use Pi's normal model picker to switch among all three without creating another -OpenShell provider. Qwen uses Chat Completions reasoning controls, and GPT-5.6 -Sol uses Responses reasoning. The endpoint's Opus 5 alias currently rejects -explicit adaptive-thinking controls, so it runs with the endpoint's default -thinking behavior. +Pi starts with the first model in the file. Use Pi's normal model picker to +switch among all three without creating another OpenShell provider. Qwen uses +Chat Completions reasoning controls, and GPT-5.6 Sol uses Responses reasoning. +The endpoint's Opus 5 alias currently rejects explicit adaptive-thinking +controls, so it runs with the endpoint's default thinking behavior. + +To use another compatible endpoint or catalog, copy `models.json`, edit it using +Pi's documented JSON format, and set `PI_MODELS_PATH` to that file. Keep all +models under the one provider used by this example. `EGRESS_GATE_HOST_IP` is the address OpenShell uses to reach Egress Gate on this machine. It must be a reachable, non-loopback IPv4 address; do not use -`127.0.0.1`. `PI_MODEL_BASE_URL` is separate: it is the model endpoint Pi will -call. A model server running on this machine must likewise use a hostname or -address reachable from the sandbox rather than `localhost`. +`127.0.0.1`. The provider's `baseUrl` in `models.json` is the model endpoint Pi +will call. A model server running on this machine must likewise use a hostname +or address reachable from the sandbox rather than `localhost`. -`demo.sh prepare` derives the endpoint policy and Pi model configuration from -these values. You do not need to edit `policy.yaml`. If required values are -missing or still contain placeholders, the script prints the configuration -steps and stops before performing any work. +`demo.sh prepare` uploads the model file unchanged and derives the endpoint +policy from its provider `baseUrl`. You do not need to edit `policy.yaml`. If +required values are missing or still contain placeholders, the script prints +the configuration steps and stops before performing any work. Preview the complete workflow before running anything: @@ -99,8 +105,8 @@ example, `./demo.sh --print prepare` or `./demo.sh --print launch`. ## Run the example Prepare the forks, build and package the locally modified Pi agent core and -coding agent, generate the endpoint-specific runtime configuration, and -generate the Egress Gate registration used by Terminal 2: +coding agent, and generate the endpoint-specific OpenShell and Egress Gate +configuration used by Terminal 2: ```shell ./demo.sh prepare diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 24f07baa..4b02f943 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -23,16 +23,22 @@ openshell_branch=openshell/pi-egress-admission pi_remote=https://github.com/johnnygreco/pi.git openshell_remote=https://github.com/johnnygreco/OpenShell.git host_ip=${EGRESS_GATE_HOST_IP:-YOUR_HOST_IPV4} -model_base_url=${PI_MODEL_BASE_URL:-YOUR_MODEL_BASE_URL} -model_id=${PI_MODEL_ID:-YOUR_MODEL_ID} +models_path_value=${PI_MODELS_PATH:-YOUR_MODELS_PATH} +if [[ $models_path_value == /* || $models_path_value == YOUR_MODELS_PATH ]]; then + models_path=$models_path_value +else + models_path=$script_dir/${models_path_value#./} +fi +model_provider=MODEL_PROVIDER_FROM_CONFIG +model_id=FIRST_MODEL_FROM_CONFIG pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} openshell_cli=$openshell_repo/scripts/bin/openshell gateway_name=${PI_EGRESS_GATEWAY_NAME:-pi-egress-demo-gateway} -runtime_models=$runtime_dir/models.json runtime_policy=$runtime_dir/policy.yaml runtime_provider_profile=$runtime_dir/provider-profile.yaml runtime_gateway_fragment=$runtime_dir/gateway-middleware.toml +runtime_model_selection=$runtime_dir/model-selection.json z3_library_path_override=${Z3_LIBRARY_PATH_OVERRIDE:-} bold="" @@ -210,16 +216,14 @@ require_example_configuration() { if [[ -z ${EGRESS_GATE_HOST_IP:-} || ${EGRESS_GATE_HOST_IP:-} == YOUR_HOST_IPV4 ]]; then missing+=(EGRESS_GATE_HOST_IP) fi - if [[ -z ${PI_MODEL_BASE_URL:-} || ${PI_MODEL_BASE_URL:-} == https://provider.example.com/v1 ]]; then - missing+=(PI_MODEL_BASE_URL) - fi - if [[ -z ${PI_MODEL_ID:-} || ${PI_MODEL_ID:-} == your-model-id ]]; then - missing+=(PI_MODEL_ID) + if [[ -z ${PI_MODELS_PATH:-} || ${PI_MODELS_PATH:-} == YOUR_MODELS_PATH ]]; then + missing+=(PI_MODELS_PATH) fi if [[ -z ${PI_MODEL_API_KEY:-} || ${PI_MODEL_API_KEY:-} == your-provider-key ]]; then missing+=(PI_MODEL_API_KEY) fi if ((${#missing[@]} == 0)); then + require_file "$models_path" "Pi model configuration" return fi @@ -301,13 +305,23 @@ pi_package_tarball() { render_runtime_configuration() { run_in "$script_dir" mkdir -p "$runtime_dir" run_in "$script_dir" node render-runtime-config.mjs \ - --base-url "$model_base_url" \ - --model-id "$model_id" \ - --models-output "$runtime_models" \ + --models-path "$models_path" \ --policy-output "$runtime_policy" \ --provider-profile-output "$runtime_provider_profile" \ --middleware-endpoint "http://$host_ip:50051" \ - --gateway-output "$runtime_gateway_fragment" + --gateway-output "$runtime_gateway_fragment" \ + --selection-output "$runtime_model_selection" +} + +load_model_selection() { + if $print_only; then + return + fi + require_file "$runtime_model_selection" "generated model selection" + model_provider=$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).providerId" \ + "$runtime_model_selection") + model_id=$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).modelId" \ + "$runtime_model_selection") } prepare() { @@ -327,7 +341,7 @@ prepare() { run_in "$pi_repo" npm pack --workspace @earendil-works/pi-agent-core --pack-destination "$pack_dir" run_in "$pi_repo" npm pack --workspace @earendil-works/pi-coding-agent --pack-destination "$pack_dir" run_in "$pi_repo" npm install --prefix "$runtime_dir" --ignore-scripts "$agent_tarball" "$coding_agent_tarball" - describe_printed_commands "Generate the Pi model configuration and endpoint policy:" + describe_printed_commands "Generate the endpoint policy from the Pi model configuration:" render_runtime_configuration } @@ -411,7 +425,7 @@ create_demo_sandbox() { --upload "$runtime_dir/node_modules:/sandbox/pi-runtime" \ --upload "$script_dir/managed-pi.ts:/sandbox/pi-runtime/managed-pi.ts" \ --upload "$script_dir/managed-pi-admission.ts:/sandbox/pi-runtime/managed-pi-admission.ts" \ - --upload "$runtime_models:/sandbox/pi-agent/models.json" \ + --upload "$models_path:/sandbox/pi-agent/models.json" \ --no-git-ignore \ --detach } @@ -442,13 +456,13 @@ launch() { require_file "$script_dir/managed-pi-admission.ts" "Pi admission adapter" fi - describe_printed_commands "Refresh the endpoint-specific model, policy, and provider profile:" + describe_printed_commands "Refresh the endpoint policy and provider profile from models.json:" render_runtime_configuration if ! $print_only; then - require_file "$runtime_models" "generated Pi model configuration" require_file "$runtime_policy" "generated OpenShell policy" require_file "$runtime_provider_profile" "generated OpenShell provider profile" fi + load_model_selection describe_printed_commands "Remove an earlier example sandbox, if present:" delete_demo_sandbox_if_present @@ -507,18 +521,13 @@ print_plan() { local status_color="$green" local credential_status="not set" local displayed_host="$host_ip" - local displayed_model_base_url="$model_base_url" - local displayed_model_id="$model_id" + local displayed_models_path="$models_path" if [[ $displayed_host == YOUR_HOST_IPV4 ]]; then displayed_host="not set" configuration_status="incomplete — edit and source .env" fi - if [[ $displayed_model_base_url == YOUR_MODEL_BASE_URL ]]; then - displayed_model_base_url="not set" - configuration_status="incomplete — edit and source .env" - fi - if [[ $displayed_model_id == YOUR_MODEL_ID ]]; then - displayed_model_id="not set" + if [[ $displayed_models_path == YOUR_MODELS_PATH ]]; then + displayed_models_path="not set" configuration_status="incomplete — edit and source .env" fi if [[ -n ${PI_MODEL_API_KEY:-} && ${PI_MODEL_API_KEY:-} != your-provider-key ]]; then @@ -539,8 +548,7 @@ its exact commands. ${bold}${blue}Configuration visible to this shell${reset} Status: ${status_color}${configuration_status}${reset} Egress Gate host: $displayed_host - Model endpoint: $displayed_model_base_url - Model: $displayed_model_id + Pi models file: $displayed_models_path Model credential: $credential_status ${bold}${blue}Local fork workspace${reset} @@ -548,8 +556,8 @@ ${bold}${blue}Local fork workspace${reset} prepare clones missing Pi and OpenShell forks here. The directory is ignored by Git. ${bold}${blue}Workflow${reset} - ${green}1. prepare${reset} Clone or update the forks, build Pi, and generate the model, policy, - and gateway middleware configuration. + ${green}1. prepare${reset} Clone or update the forks, build Pi, and derive the endpoint policy + and gateway middleware configuration from models.json. ${green}2. serve${reset} Start Egress Gate in Terminal 1 and leave it running. ${green}3. gateway${reset} Start the OpenShell gateway in Terminal 2 and leave it running. ${green}4. launch${reset} Create the credential provider and launch Pi in Terminal 3. diff --git a/projects/egress-gate/examples/pi-attested-admission/models.json b/projects/egress-gate/examples/pi-attested-admission/models.json new file mode 100644 index 00000000..58d7fd33 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/models.json @@ -0,0 +1,68 @@ +{ + "providers": { + "attested-provider": { + "baseUrl": "https://inference-api.nvidia.com/v1", + "apiKey": "$PI_MODEL_API_KEY", + "models": [ + { + "id": "azure/anthropic/claude-opus-5", + "name": "Claude Opus 5", + "api": "openai-completions", + "reasoning": false, + "input": ["text"], + "contextWindow": 1000000, + "maxTokens": 128000, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "compat": { + "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsReasoningEffort": false + } + }, + { + "id": "azure/openai/gpt-5.6-sol", + "name": "GPT-5.6 Sol", + "api": "openai-responses", + "reasoning": true, + "thinkingLevelMap": { + "off": "none", + "minimal": "low", + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "xhigh", + "max": "max" + }, + "input": ["text"], + "contextWindow": 1050000, + "maxTokens": 128000, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } + }, + { + "id": "nvidia/qwen/qwen3.8-flash-next", + "name": "Qwen3.8 Flash Next", + "api": "openai-completions", + "reasoning": true, + "thinkingLevelMap": { + "minimal": "low", + "low": "low", + "medium": "medium", + "high": "high", + "xhigh": "high", + "max": "high" + }, + "input": ["text"], + "contextWindow": 262144, + "maxTokens": 32768, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "compat": { + "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsReasoningEffort": true, + "thinkingFormat": "qwen" + } + } + ] + } + } +} diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs index be1e02c6..1265cfa9 100644 --- a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -4,93 +4,18 @@ import { readFileSync, writeFileSync } from "node:fs"; const options = parseOptions(process.argv.slice(2)); -const baseUrl = parseBaseUrl(options.get("base-url")); -const modelId = requireOption(options, "model-id"); -const modelsOutput = requireOption(options, "models-output"); +const modelsPath = requireOption(options, "models-path"); const policyOutput = requireOption(options, "policy-output"); const providerProfileOutput = requireOption(options, "provider-profile-output"); const gatewayOutput = requireOption(options, "gateway-output"); +const selectionOutput = requireOption(options, "selection-output"); const middlewareEndpoint = parseMiddlewareEndpoint( options.get("middleware-endpoint"), ); +const { providerId, modelId, baseUrl } = readModelSelection(modelsPath); const endpointPort = baseUrl.port || (baseUrl.protocol === "https:" ? "443" : "80"); -const configuredModels = [ - { - id: "azure/anthropic/claude-opus-5", - name: "Claude Opus 5", - api: "openai-completions", - reasoning: false, - input: ["text"], - contextWindow: 1000000, - maxTokens: 128000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { - maxTokensField: "max_tokens", - supportsDeveloperRole: false, - supportsReasoningEffort: false, - }, - }, - { - id: "azure/openai/gpt-5.6-sol", - name: "GPT-5.6 Sol", - api: "openai-responses", - reasoning: true, - thinkingLevelMap: { - off: "none", - minimal: "low", - low: "low", - medium: "medium", - high: "high", - xhigh: "xhigh", - max: "max", - }, - input: ["text"], - contextWindow: 1050000, - maxTokens: 128000, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - }, - { - id: "nvidia/qwen/qwen3.8-flash-next", - name: "Qwen3.8 Flash Next", - api: "openai-completions", - reasoning: true, - thinkingLevelMap: { - minimal: "low", - low: "low", - medium: "medium", - high: "high", - xhigh: "high", - max: "high", - }, - input: ["text"], - contextWindow: 262144, - maxTokens: 32768, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { - maxTokensField: "max_tokens", - supportsDeveloperRole: false, - supportsReasoningEffort: true, - thinkingFormat: "qwen", - }, - }, -]; - -if (!configuredModels.some((model) => model.id === modelId)) { - const configuredModelIds = configuredModels.map((model) => model.id).join(", "); - fail(`--model-id must select one of: ${configuredModelIds}`); -} - -const models = { - providers: { - "attested-provider": { - baseUrl: baseUrl.toString().replace(/\/$/, ""), - apiKey: "$PI_MODEL_API_KEY", - models: configuredModels, - }, - }, -}; -writeFileSync(modelsOutput, `${JSON.stringify(models, null, 2)}\n`); +writeFileSync(selectionOutput, `${JSON.stringify({ providerId, modelId }, null, 2)}\n`); const policyTemplate = readFileSync(new URL("policy.yaml", import.meta.url), "utf8"); const policy = replaceExpected( @@ -161,19 +86,42 @@ function requireOption(parsed, name) { return value; } +function readModelSelection(path) { + let config; + try { + config = JSON.parse(readFileSync(path, "utf8")); + } catch (error) { + fail(`Unable to read Pi model configuration ${path}: ${error.message}`); + } + const providers = Object.entries(config?.providers || {}); + if (providers.length !== 1) { + fail("The example requires exactly one provider in the Pi model configuration."); + } + const [providerId, provider] = providers[0]; + const modelId = provider?.models?.[0]?.id; + if (!modelId) { + fail(`Provider ${providerId} must contain at least one model.`); + } + return { + providerId, + modelId, + baseUrl: parseBaseUrl(provider.baseUrl), + }; +} + function parseBaseUrl(value) { - const raw = value || fail("Missing --base-url."); + const raw = value || fail("The Pi model provider must define baseUrl."); let parsed; try { parsed = new URL(raw); } catch { - fail("--base-url must be an absolute HTTP or HTTPS URL."); + fail("The Pi model provider baseUrl must be an absolute HTTP or HTTPS URL."); } if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - fail("--base-url must use HTTP or HTTPS."); + fail("The Pi model provider baseUrl must use HTTP or HTTPS."); } if (parsed.username || parsed.password || parsed.search || parsed.hash) { - fail("--base-url must not contain credentials, a query, or a fragment."); + fail("The Pi model provider baseUrl must not contain credentials, a query, or a fragment."); } return parsed; } diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 0a33ebce..af1ebc46 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -21,12 +21,12 @@ def test_pi_example_can_print_each_action_without_running_it( openshell_repo = tmp_path / "OpenShell" pack_dir = tmp_path / "pack" runtime_dir = tmp_path / "runtime" + models_path = project_dir / "examples/pi-attested-admission/models.json" environment = os.environ | { "PI_REPO": str(pi_repo), "OPENSHELL_REPO": str(openshell_repo), "EGRESS_GATE_HOST_IP": "192.0.2.10", - "PI_MODEL_BASE_URL": "https://models.example.test/v1", - "PI_MODEL_ID": "example-model", + "PI_MODELS_PATH": str(models_path), "PI_EGRESS_PACK_DIR": str(pack_dir), "PI_EGRESS_RUNTIME_DIR": str(runtime_dir), } @@ -60,8 +60,7 @@ def test_pi_example_can_print_each_action_without_running_it( assert "gateway-middleware.toml" in output assert "OPENSHELL_GATEWAY_CONFIG_FRAGMENT=" in output assert "render-runtime-config.mjs" in output - assert "https://models.example.test/v1" in output - assert "example-model" in output + assert str(models_path) in output assert "egress-gate --debug serve" in output assert "CARGO_BUILD_JOBS=4" in output assert "OPENSHELL_GATEWAY_NAME=pi-egress-demo-gateway" in output @@ -133,8 +132,9 @@ def test_pi_example_print_all_is_a_concise_walkthrough() -> None: env=os.environ | { "EGRESS_GATE_HOST_IP": "192.0.2.10", - "PI_MODEL_BASE_URL": "https://models.example.test/v1", - "PI_MODEL_ID": "example-model", + "PI_MODELS_PATH": str( + project_dir / "examples/pi-attested-admission/models.json" + ), "PI_MODEL_API_KEY": "secret-not-printed", }, text=True, @@ -201,21 +201,24 @@ def test_pi_example_renders_provider_specific_runtime_configuration( ) -> None: project_dir = Path(__file__).parents[1] example_dir = project_dir / "examples/pi-attested-admission" - models_output = tmp_path / "models.json" + models_path = tmp_path / "models.json" policy_output = tmp_path / "policy.yaml" provider_profile_output = tmp_path / "provider-profile.yaml" gateway_output = tmp_path / "gateway-middleware.toml" + selection_output = tmp_path / "model-selection.json" + models = json.loads((example_dir / "models.json").read_text()) + models["providers"]["attested-provider"]["baseUrl"] = ( + "https://gateway.example.test:8443/models/v1" + ) + models_path.write_text(json.dumps(models)) + original_models = models_path.read_text() subprocess.run( [ "node", str(example_dir / "render-runtime-config.mjs"), - "--base-url", - "https://gateway.example.test:8443/models/v1", - "--model-id", - "nvidia/qwen/qwen3.8-flash-next", - "--models-output", - str(models_output), + "--models-path", + str(models_path), "--policy-output", str(policy_output), "--provider-profile-output", @@ -224,11 +227,14 @@ def test_pi_example_renders_provider_specific_runtime_configuration( "http://192.0.2.10:50051", "--gateway-output", str(gateway_output), + "--selection-output", + str(selection_output), ], check=True, ) - models = json.loads(models_output.read_text()) + assert models_path.read_text() == original_models + models = json.loads(models_path.read_text()) provider = models["providers"]["attested-provider"] assert provider["baseUrl"] == "https://gateway.example.test:8443/models/v1" assert provider["apiKey"] == "$PI_MODEL_API_KEY" @@ -264,6 +270,12 @@ def test_pi_example_renders_provider_specific_runtime_configuration( "thinkingFormat": "qwen", } + selection = json.loads(selection_output.read_text()) + assert selection == { + "providerId": "attested-provider", + "modelId": "azure/anthropic/claude-opus-5", + } + provider_profile = yaml.safe_load(provider_profile_output.read_text()) assert provider_profile["id"] == "pi-attested-model" assert provider_profile["credentials"][0]["env_vars"] == ["PI_MODEL_API_KEY"] @@ -296,8 +308,7 @@ def test_pi_example_reports_all_missing_configuration_before_work( if name not in { "EGRESS_GATE_HOST_IP", - "PI_MODEL_BASE_URL", - "PI_MODEL_ID", + "PI_MODELS_PATH", "PI_MODEL_API_KEY", } } @@ -314,8 +325,7 @@ def test_pi_example_reports_all_missing_configuration_before_work( assert result.stdout == "" assert "The Pi attested-admission example is not configured." in result.stderr assert "EGRESS_GATE_HOST_IP" in result.stderr - assert "PI_MODEL_BASE_URL" in result.stderr - assert "PI_MODEL_ID" in result.stderr + assert "PI_MODELS_PATH" in result.stderr assert "PI_MODEL_API_KEY" in result.stderr assert "source .env" in result.stderr assert "git pull" not in result.stderr @@ -339,8 +349,9 @@ def test_pi_example_reports_a_missing_compute_backend_before_mise( "PATH": f"{tmp_path}:{os.environ['PATH']}", "OPENSHELL_DRIVERS": "", "EGRESS_GATE_HOST_IP": "192.0.2.10", - "PI_MODEL_BASE_URL": "https://models.example.test/v1", - "PI_MODEL_ID": "example-model", + "PI_MODELS_PATH": str( + project_dir / "examples/pi-attested-admission/models.json" + ), "PI_MODEL_API_KEY": "test-key", }, text=True, From cf45225679744073449ebb3082a0ee07e6224fd1 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 31 Aug 2026 23:24:14 -0400 Subject: [PATCH 24/70] refactor(egress-gate): run configured Pi entrypoint --- .../examples/pi-attested-admission/README.md | 62 +++-- .../examples/pi-attested-admission/demo.sh | 75 ++---- .../gateway-middleware.toml.example | 6 + .../managed-pi-admission.test.mjs | 203 ---------------- .../managed-pi-admission.ts | 228 ------------------ .../pi-attested-admission/managed-pi.ts | 28 --- .../pi-attested-admission/models.json | 2 +- .../pi-attested-admission/policy.yaml | 4 +- .../provider-profile.yaml | 22 ++ .../render-runtime-config.mjs | 165 ------------- .../pi-attested-admission/settings.json | 5 + .../tests/test_managed_pi_admission.py | 19 -- .../tests/test_pi_example_commands.py | 108 +++------ 13 files changed, 121 insertions(+), 806 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/gateway-middleware.toml.example delete mode 100644 projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs delete mode 100644 projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts delete mode 100644 projects/egress-gate/examples/pi-attested-admission/managed-pi.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/provider-profile.yaml delete mode 100644 projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs create mode 100644 projects/egress-gate/examples/pi-attested-admission/settings.json delete mode 100644 projects/egress-gate/tests/test_managed_pi_admission.py diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 8209f05c..f28f2a1a 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,9 +1,8 @@ # Pi attested-admission example -This example runs a normal interactive Pi TUI inside OpenShell and sends -admitted conversation context to one model endpoint. The endpoint may be a -hosted provider, an internal gateway, or a local server. One endpoint-scoped -provider and credential serve all configured models. +This example runs the normal forked Pi CLI inside OpenShell and sends admitted +conversation context to the configured NVIDIA inference endpoint. One +endpoint-scoped provider and credential serve all configured models. The example demonstrates the same policy at both context boundaries: @@ -71,15 +70,17 @@ these models: | `azure/openai/gpt-5.6-sol` | OpenAI Responses | | `nvidia/qwen/qwen3.8-flash-next` | OpenAI Chat Completions | -Pi starts with the first model in the file. Use Pi's normal model picker to -switch among all three without creating another OpenShell provider. Qwen uses +Pi starts with the default in [settings.json](settings.json). Use Pi's normal +model picker to switch among all three without creating another OpenShell provider. Qwen uses Chat Completions reasoning controls, and GPT-5.6 Sol uses Responses reasoning. The endpoint's Opus 5 alias currently rejects explicit adaptive-thinking controls, so it runs with the endpoint's default thinking behavior. -To use another compatible endpoint or catalog, copy `models.json`, edit it using -Pi's documented JSON format, and set `PI_MODELS_PATH` to that file. Keep all -models under the one provider used by this example. +To use another catalog for the same endpoint, copy `models.json`, edit it using +Pi's documented JSON format, and set `PI_MODELS_PATH` to that file. OpenShell +pins network and credential access independently of Pi. To change endpoints, +update the matching host and port explicitly in `models.json`, `policy.yaml`, +and `provider-profile.yaml`. `EGRESS_GATE_HOST_IP` is the address OpenShell uses to reach Egress Gate on this machine. It must be a reachable, non-loopback IPv4 address; do not use @@ -87,10 +88,11 @@ machine. It must be a reachable, non-loopback IPv4 address; do not use will call. A model server running on this machine must likewise use a hostname or address reachable from the sandbox rather than `localhost`. -`demo.sh prepare` uploads the model file unchanged and derives the endpoint -policy from its provider `baseUrl`. You do not need to edit `policy.yaml`. If -required values are missing or still contain placeholders, the script prints -the configuration steps and stops before performing any work. +The example checks in ordinary Pi and OpenShell configuration files. It uploads +`models.json` and `settings.json` unchanged. The only host-specific output is a +copy of `gateway-middleware.toml.example` with `EGRESS_GATE_HOST_IP` substituted +for its documented placeholder. If required values are missing, the script +prints the configuration steps and stops before performing any work. Preview the complete workflow before running anything: @@ -104,9 +106,8 @@ example, `./demo.sh --print prepare` or `./demo.sh --print launch`. ## Run the example -Prepare the forks, build and package the locally modified Pi agent core and -coding agent, and generate the endpoint-specific OpenShell and Egress Gate -configuration used by Terminal 2: +Prepare the forks and build and package the locally modified Pi agent core and +coding agent: ```shell ./demo.sh prepare @@ -137,21 +138,19 @@ After the gateway reports that it is ready, launch Pi from a third terminal: ./demo.sh launch ``` -This starts the standard Pi CLI, with OpenShell admission inserted immediately -before each provider request. Each launch replaces the example's +This executes the fork's normal `pi` entrypoint. The explicit +`PI_OPENSHELL_CONTEXT_ADMISSION=1` setting makes its built-in OpenShell +admission boundary mandatory for the session. Each launch replaces the example's `pi-egress-demo` sandbox, provider, and custom provider profile so the current Pi runtime, admission adapter, policy, endpoint, and OpenShell supervisor are used together. The example registers an endpoint-specific provider profile using the host-side -`PI_MODEL_API_KEY`. The real credential remains in OpenShell. Pi receives only -an opaque, endpoint-bound resolver placeholder. The launcher removes that -placeholder before starting Pi and passes it once over a private file -descriptor. The thin launcher reads and closes the descriptor before the TUI or -tools start, supplies the resolver to Pi's non-persistent runtime credential -store, installs mandatory admission, and delegates the rest of startup to Pi's -standard CLI. OpenShell resolves the placeholder in the authorization header -for the configured model endpoint. +`PI_MODEL_API_KEY`. Its `delivery: proxy` setting keeps the credential and any +resolver placeholder out of the sandbox. Pi sends the non-secret placeholder +declared by `models.json`; after admission and middleware processing succeed, +the OpenShell supervisor replaces that authorization header with the real, +endpoint-bound credential immediately before forwarding the request. At the Pi prompt, submit both of these in the same session: @@ -185,11 +184,10 @@ the run. ## How it works -1. `managed-pi.ts` calls Pi's standard `main()` with two runtime hooks: one - installs the opaque credential resolver and one creates the mandatory SDK - `ContextAdmission` boundary for each normal Pi session. The launch command - uses Pi's standard `--no-extensions` option, so project or user extensions - cannot replace this boundary. +1. The forked `pi` entrypoint sees `PI_OPENSHELL_CONTEXT_ADMISSION=1` and installs + its built-in mandatory `ContextAdmission` boundary. The launch command uses + Pi's standard `--no-extensions` option, so project or user extensions cannot + replace this boundary. 2. Pi calls that boundary for each rendered user message and finalized tool result before it queues, appends, or persists the value. 3. The adapter sends the exact context addition to OpenShell's sandbox-local @@ -205,7 +203,7 @@ the run. 6. OpenShell strips the handle, resolves the supervisor-held attestation, and supplies it only to the configured Egress Gate middleware stage. Egress Gate verifies the latest context addition and scans the complete provider request - before OpenShell resolves the model credential. + before OpenShell injects the proxy-delivered model credential. ## Current scope diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 4b02f943..0ceb1b07 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -29,16 +29,15 @@ if [[ $models_path_value == /* || $models_path_value == YOUR_MODELS_PATH ]]; the else models_path=$script_dir/${models_path_value#./} fi -model_provider=MODEL_PROVIDER_FROM_CONFIG -model_id=FIRST_MODEL_FROM_CONFIG pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} openshell_cli=$openshell_repo/scripts/bin/openshell gateway_name=${PI_EGRESS_GATEWAY_NAME:-pi-egress-demo-gateway} -runtime_policy=$runtime_dir/policy.yaml -runtime_provider_profile=$runtime_dir/provider-profile.yaml +runtime_policy=$script_dir/policy.yaml +runtime_provider_profile=$script_dir/provider-profile.yaml runtime_gateway_fragment=$runtime_dir/gateway-middleware.toml -runtime_model_selection=$runtime_dir/model-selection.json +gateway_fragment_template=$script_dir/gateway-middleware.toml.example +pi_settings=$script_dir/settings.json z3_library_path_override=${Z3_LIBRARY_PATH_OVERRIDE:-} bold="" @@ -302,26 +301,17 @@ pi_package_tarball() { printf '%s/%s-%s.tgz' "$pack_dir" "$archive_name" "$version" } -render_runtime_configuration() { - run_in "$script_dir" mkdir -p "$runtime_dir" - run_in "$script_dir" node render-runtime-config.mjs \ - --models-path "$models_path" \ - --policy-output "$runtime_policy" \ - --provider-profile-output "$runtime_provider_profile" \ - --middleware-endpoint "http://$host_ip:50051" \ - --gateway-output "$runtime_gateway_fragment" \ - --selection-output "$runtime_model_selection" -} - -load_model_selection() { +prepare_gateway_configuration() { if $print_only; then + describe_printed_commands "Write the one host-specific gateway setting:" + printf ' %bsource%b: %s\n' "$cyan" "$reset" "$gateway_fragment_template" + printf ' %boutput%b: %s\n' "$green" "$reset" "$runtime_gateway_fragment" + printf ' Replace YOUR_HOST_IPV4 with %s.\n' "$host_ip" return fi - require_file "$runtime_model_selection" "generated model selection" - model_provider=$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).providerId" \ - "$runtime_model_selection") - model_id=$(node -p "JSON.parse(require('fs').readFileSync(process.argv[1], 'utf8')).modelId" \ - "$runtime_model_selection") + require_file "$gateway_fragment_template" "gateway middleware configuration template" + mkdir -p "$runtime_dir" + sed "s/YOUR_HOST_IPV4/$host_ip/" "$gateway_fragment_template" >"$runtime_gateway_fragment" } prepare() { @@ -341,8 +331,6 @@ prepare() { run_in "$pi_repo" npm pack --workspace @earendil-works/pi-agent-core --pack-destination "$pack_dir" run_in "$pi_repo" npm pack --workspace @earendil-works/pi-coding-agent --pack-destination "$pack_dir" run_in "$pi_repo" npm install --prefix "$runtime_dir" --ignore-scripts "$agent_tarball" "$coding_agent_tarball" - describe_printed_commands "Generate the endpoint policy from the Pi model configuration:" - render_runtime_configuration } serve() { @@ -360,8 +348,7 @@ gateway() { require_directory "$openshell_repo" "OpenShell checkout" require_branch "$openshell_repo" "$openshell_branch" fi - describe_printed_commands "Refresh the gateway middleware registration fragment:" - render_runtime_configuration + prepare_gateway_configuration # A custom checkout may be nested below this uv project. Keep OpenShell's # mise-pinned uv from inheriting Egress Gate's uv configuration. describe_printed_commands "Start the matching OpenShell gateway and keep it open:" @@ -423,9 +410,8 @@ create_demo_sandbox() { --provider pi-model \ --policy "$runtime_policy" \ --upload "$runtime_dir/node_modules:/sandbox/pi-runtime" \ - --upload "$script_dir/managed-pi.ts:/sandbox/pi-runtime/managed-pi.ts" \ - --upload "$script_dir/managed-pi-admission.ts:/sandbox/pi-runtime/managed-pi-admission.ts" \ --upload "$models_path:/sandbox/pi-agent/models.json" \ + --upload "$pi_settings:/sandbox/pi-agent/settings.json" \ --no-git-ignore \ --detach } @@ -442,27 +428,14 @@ launch() { "$runtime_dir/node_modules/@earendil-works/pi-agent-core/dist/agent-loop.js" \ "beforeToolResultAppend" \ "installed Pi agent core" - require_file "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/index.js" \ - "installed Pi SDK" - require_file_contains \ - "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/main.js" \ - "configureModelRuntime" \ - "installed Pi CLI" require_file_contains \ - "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/main.js" \ - "createContextAdmission" \ + "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" \ + "PI_OPENSHELL_CONTEXT_ADMISSION" \ "installed Pi CLI" - require_file "$script_dir/managed-pi.ts" "Pi launcher" - require_file "$script_dir/managed-pi-admission.ts" "Pi admission adapter" - fi - - describe_printed_commands "Refresh the endpoint policy and provider profile from models.json:" - render_runtime_configuration - if ! $print_only; then - require_file "$runtime_policy" "generated OpenShell policy" - require_file "$runtime_provider_profile" "generated OpenShell provider profile" + require_file "$pi_settings" "Pi settings" + require_file "$runtime_policy" "OpenShell sandbox policy" + require_file "$runtime_provider_profile" "OpenShell provider profile" fi - load_model_selection describe_printed_commands "Remove an earlier example sandbox, if present:" delete_demo_sandbox_if_present @@ -476,10 +449,9 @@ launch() { env \ PI_CODING_AGENT_DIR=/sandbox/pi-agent \ PI_OFFLINE=1 \ - PI_MANAGED_PROVIDER=attested-provider \ - PI_MANAGED_MODEL="$model_id" \ + PI_OPENSHELL_CONTEXT_ADMISSION=1 \ OPENSHELL_AGENT_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation \ - bash -c 'exec env -u PI_MODEL_API_KEY node --experimental-strip-types /sandbox/pi-runtime/managed-pi.ts --no-extensions --provider "$PI_MANAGED_PROVIDER" --model "$PI_MANAGED_MODEL" 3<<<"$PI_MODEL_API_KEY"' + /sandbox/pi-runtime/node_modules/.bin/pi --no-extensions } cleanup() { @@ -501,7 +473,7 @@ usage() { printf '%bActions%b:\n' "$bold$blue" "$reset" cat <<'EOF' - prepare Update the forks, package Pi, and generate the runtime configuration + prepare Update the forks and package Pi serve Start Egress Gate gateway Start the forked OpenShell gateway launch Attach the configured model credential and launch Pi @@ -556,8 +528,7 @@ ${bold}${blue}Local fork workspace${reset} prepare clones missing Pi and OpenShell forks here. The directory is ignored by Git. ${bold}${blue}Workflow${reset} - ${green}1. prepare${reset} Clone or update the forks, build Pi, and derive the endpoint policy - and gateway middleware configuration from models.json. + ${green}1. prepare${reset} Clone or update the forks and build the configured Pi fork. ${green}2. serve${reset} Start Egress Gate in Terminal 1 and leave it running. ${green}3. gateway${reset} Start the OpenShell gateway in Terminal 2 and leave it running. ${green}4. launch${reset} Create the credential provider and launch Pi in Terminal 3. diff --git a/projects/egress-gate/examples/pi-attested-admission/gateway-middleware.toml.example b/projects/egress-gate/examples/pi-attested-admission/gateway-middleware.toml.example new file mode 100644 index 00000000..66c7d565 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/gateway-middleware.toml.example @@ -0,0 +1,6 @@ +[[openshell.supervisor.middleware]] +name = "pi-egress" +grpc_endpoint = "http://YOUR_HOST_IPV4:50051" +allow_insecure_transport = true +max_payload_bytes = 4194304 +timeout = "30s" diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs deleted file mode 100644 index 46112a9c..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs +++ /dev/null @@ -1,203 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import { createOpenShellContextAdmission } from "./managed-pi-admission.ts"; - -const HANDLE_HEADER = "x-openshell-agent-admission-handle"; - -function user(text, timestamp) { - return { role: "user", content: [{ type: "text", text }], timestamp }; -} - -function toolResult(text, isError = false) { - return { - role: "toolResult", - toolCallId: "call-1", - toolName: "bash", - content: [{ type: "text", text }], - isError, - timestamp: 2, - }; -} - -async function admittedProviderContext(admission, context) { - const result = await admission.admitProviderContext(context); - assert.equal(result.action, "allow"); - return result.context ?? context; -} - -test("selects the handle for the exact queued or retried provider context", async () => { - const bridgeRequests = []; - const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { - const request = JSON.parse(init.body); - bridgeRequests.push(request); - const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); - return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.text}` })); - }); - const current = user("current turn", 1); - const queued = user("queued turn", 2); - - assert.deepEqual(await admission.admitUserMessage(current, { source: "interactive" }), { action: "allow" }); - assert.deepEqual(await admission.admitUserMessage(queued, { source: "interactive" }), { action: "allow" }); - - const currentContext = await admittedProviderContext(admission, { messages: [current], tools: [] }); - const currentHeaders = await admission.transformProviderHeaders({}, currentContext); - const retryContext = await admittedProviderContext(admission, { messages: [current], tools: [] }); - const retryHeaders = await admission.transformProviderHeaders({}, retryContext); - const queuedContext = await admittedProviderContext(admission, { messages: [current, queued], tools: [] }); - const queuedHeaders = await admission.transformProviderHeaders({}, queuedContext); - - assert.equal(currentHeaders[HANDLE_HEADER], "handle:current turn"); - assert.equal(retryHeaders[HANDLE_HEADER], "handle:current turn"); - assert.equal(queuedHeaders[HANDLE_HEADER], "handle:queued turn"); - assert.deepEqual( - bridgeRequests.map((request) => [request.hook, request.schema_version]), - [ - ["rendered_prompt_admission", "openshell.pi-input.v1"], - ["rendered_prompt_admission", "openshell.pi-input.v1"], - ["rendered_prompt_admission", "openshell.pi-input.v1"], - ["rendered_prompt_admission", "openshell.pi-input.v1"], - ["rendered_prompt_admission", "openshell.pi-input.v1"], - ], - ); - assert.deepEqual( - bridgeRequests.map((request) => request.session_id), - ["session-123", "session-123", "session-123", "session-123", "session-123"], - ); -}); - -test("uses an admitted replacement as the handle lookup key", async () => { - const replacement = new TextEncoder().encode( - JSON.stringify({ schema_version: "openshell.pi-input.v1", text: "[REDACTED]" }), - ); - const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async () => - new Response( - JSON.stringify({ - decision: "allow", - handle: "replacement-handle", - replacement_body: Array.from(replacement), - }), - ), - ); - const original = user("secret", 1); - const admitted = await admission.admitUserMessage(original, { source: "interactive" }); - - assert.equal(admitted.action, "allow"); - assert.equal(admitted.message.content[0].text, "[REDACTED]"); - const context = await admittedProviderContext(admission, { messages: [admitted.message], tools: [] }); - const headers = await admission.transformProviderHeaders({}, context); - assert.equal(headers[HANDLE_HEADER], "replacement-handle"); -}); - -test("selects the handle for an admitted failed tool result", async () => { - const observedHooks = []; - const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { - const request = JSON.parse(init.body); - observedHooks.push(request.hook); - return new Response(JSON.stringify({ decision: "allow", handle: `handle:${request.hook}` })); - }); - const prompt = user("run a command", 1); - const failed = toolResult("(no output)\n\nCommand exited with code 2", true); - - assert.equal((await admission.admitUserMessage(prompt, { source: "interactive" })).action, "allow"); - assert.equal((await admission.admitToolResult(failed)).action, "allow"); - const context = await admittedProviderContext(admission, { messages: [prompt, failed], tools: [] }); - const headers = await admission.transformProviderHeaders({}, context); - - assert.equal(headers[HANDLE_HEADER], "handle:tool_result_admission"); - assert.deepEqual(observedHooks, ["rendered_prompt_admission", "tool_result_admission", "tool_result_admission"]); -}); - -test("admits and replaces a generated provider-only context", async () => { - const observedTexts = []; - const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { - const request = JSON.parse(init.body); - const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); - observedTexts.push(envelope.text); - const replacement = new TextEncoder().encode( - JSON.stringify({ schema_version: "openshell.pi-input.v1", text: "admitted summary context" }), - ); - return new Response( - JSON.stringify({ - decision: "allow", - handle: "summary-handle", - replacement_body: Array.from(replacement), - }), - ); - }); - const generated = { messages: [user("generated summary context", 1)], tools: [] }; - - const context = await admittedProviderContext(admission, generated); - const headers = await admission.transformProviderHeaders({}, context); - - assert.deepEqual(observedTexts, ["generated summary context"]); - assert.equal(context.messages[0].content[0].text, "admitted summary context"); - assert.equal(headers[HANDLE_HEADER], "summary-handle"); -}); - -test("fails closed when a generated provider-only context is denied", async () => { - const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async () => - new Response(JSON.stringify({ decision: "deny", reason_code: "policy_denied" })), - ); - - const result = await admission.admitProviderContext({ - messages: [user("generated summary context", 1)], - tools: [], - }); - - assert.deepEqual(result, { - action: "deny", - reason: "OpenShell denied this context addition (policy_denied)", - }); -}); - -test("re-admits provider context restored without an in-memory handle", async () => { - let requests = 0; - const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async () => { - requests++; - return new Response(JSON.stringify({ decision: "allow", handle: "restored-handle" })); - }); - const restored = { messages: [user("restored session message", 1)], tools: [] }; - - await assert.rejects(admission.transformProviderHeaders({}, restored), /admission handle is missing/); - const context = await admittedProviderContext(admission, restored); - const headers = await admission.transformProviderHeaders({}, context); - - assert.equal(requests, 1); - assert.equal(headers[HANDLE_HEADER], "restored-handle"); -}); - -test("bounds handles retained for a long-lived session", async () => { - const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { - const request = JSON.parse(init.body); - const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); - return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.text}` })); - }); - const messages = Array.from({ length: 1025 }, (_, index) => user(`turn ${index}`, index)); - for (const message of messages) { - assert.equal((await admission.admitUserMessage(message, { source: "interactive" })).action, "allow"); - } - - await assert.rejects( - admission.transformProviderHeaders({}, { messages: [messages[0]], tools: [] }), - /OpenShell admission handle is missing/, - ); - const headers = await admission.transformProviderHeaders({}, { messages: [messages.at(-1)], tools: [] }); - assert.equal(headers[HANDLE_HEADER], "handle:turn 1024"); -}); - -test("uses the current session ID after a new session starts", async () => { - let sessionId = "session-1"; - const observedSessionIds = []; - const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => sessionId, async (_url, init) => { - const request = JSON.parse(init.body); - observedSessionIds.push(request.session_id); - return new Response(JSON.stringify({ decision: "allow", handle: `handle:${request.session_id}` })); - }); - - await admission.admitUserMessage(user("first", 1), { source: "interactive" }); - sessionId = "session-2"; - await admission.admitUserMessage(user("after new", 2), { source: "interactive" }); - - assert.deepEqual(observedSessionIds, ["session-1", "session-2"]); -}); diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts deleted file mode 100644 index 998008b4..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts +++ /dev/null @@ -1,228 +0,0 @@ -import type { - Context, - ImageContent, - ProviderHeaders, - TextContent, - ToolResultMessage, - UserMessage, -} from "@earendil-works/pi-ai/compat"; -import type { ContextAdmission, ContextAdmissionResult } from "@earendil-works/pi-coding-agent"; - -const HANDLE_HEADER = "x-openshell-agent-admission-handle"; -const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; -// A byte encoded as a JSON array item can occupy four characters including its comma. -const MAX_BRIDGE_RESPONSE_BYTES = MAX_ADMISSION_BYTES * 4 + 64 * 1024; -const MAX_HANDLE_ENTRIES = 1024; - -type ContentBlock = TextContent | ImageContent; -type UserEnvelope = { schema_version: "openshell.pi-input.v1"; text: string }; -type ToolResultEnvelope = { - schema_version: "openshell.pi-tool-result.v1"; - tool_call_id: string; - tool_name: string; - content: ContentBlock[]; - is_error: boolean; -}; -type AdmissionEnvelope = UserEnvelope | ToolResultEnvelope; -type BridgeResult = - | { decision: "deny"; reason_code?: string } - | { decision: "allow"; handle: string; replacement_body?: number[] }; - -export function createOpenShellContextAdmission( - bridgeUrl: string, - getSessionId: () => string, - fetchRequest: typeof fetch = fetch, -): ContextAdmission { - const handles = new Map(); - - async function requestAdmission( - hook: "rendered_prompt_admission" | "tool_result_admission", - envelope: AdmissionEnvelope, - ): Promise { - const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); - if (requestBody.byteLength > MAX_ADMISSION_BYTES) { - throw new Error("OpenShell admission request is too large"); - } - const response = await fetchRequest(bridgeUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - harness_version: "sdk-v1", - hook, - schema_version: envelope.schema_version, - session_id: getSessionId(), - submission_id: crypto.randomUUID(), - request_body: Array.from(requestBody), - }), - }); - if (!response.ok) throw new Error("OpenShell admission is unavailable"); - const encoded = new Uint8Array(await response.arrayBuffer()); - if (encoded.byteLength > MAX_BRIDGE_RESPONSE_BYTES) throw new Error("OpenShell admission response is too large"); - return parseBridgeResult(JSON.parse(new TextDecoder().decode(encoded))); - } - - async function admitUserMessage(message: UserMessage): Promise> { - const envelope = userEnvelope(message); - if (!envelope) { - return { action: "deny", reason: "Image inputs are not supported by this managed Pi example" }; - } - const result = await requestAdmission("rendered_prompt_admission", envelope); - if (result.decision === "deny") return denied(result.reason_code); - const admittedEnvelope = result.replacement_body - ? parseUserEnvelope(new Uint8Array(result.replacement_body)) - : envelope; - const admittedMessage: UserMessage = { - ...message, - content: replaceUserText(message.content, admittedEnvelope.text), - }; - rememberHandle(handles, messageKey(admittedMessage), result.handle); - return admittedEnvelope.text === envelope.text - ? { action: "allow" } - : { action: "allow", message: admittedMessage }; - } - - async function admitToolResult(message: ToolResultMessage): Promise> { - const envelope = toolResultEnvelope(message); - const result = await requestAdmission("tool_result_admission", envelope); - if (result.decision === "deny") return denied(result.reason_code); - const admittedEnvelope = result.replacement_body - ? parseToolResultEnvelope(new Uint8Array(result.replacement_body)) - : envelope; - const admittedMessage: ToolResultMessage = { - ...message, - content: admittedEnvelope.content, - }; - rememberHandle(handles, messageKey(admittedMessage), result.handle); - return result.replacement_body - ? { action: "allow", message: admittedMessage } - : { action: "allow" }; - } - - return { - admitUserMessage, - admitToolResult, - - async admitProviderContext(context) { - for (let index = context.messages.length - 1; index >= 0; index -= 1) { - const message = context.messages[index]; - if (message.role !== "user" && message.role !== "toolResult") continue; - const result = - message.role === "user" ? await admitUserMessage(message) : await admitToolResult(message); - if (result.action === "deny") return result; - if (!result.message) return { action: "allow" }; - const messages = [...context.messages]; - messages[index] = result.message; - return { action: "allow", context: { ...context, messages } }; - } - return { action: "deny", reason: "Provider context has no user message or tool result to admit" }; - }, - - async transformProviderHeaders(headers: ProviderHeaders, context: Context) { - if (Object.keys(headers).some((name) => name.toLowerCase() === HANDLE_HEADER)) { - throw new Error("OpenShell admission handle header is reserved"); - } - for (let index = context.messages.length - 1; index >= 0; index -= 1) { - const message = context.messages[index]; - if (message.role !== "user" && message.role !== "toolResult") continue; - const handle = handles.get(messageKey(message)); - if (handle) return { ...headers, [HANDLE_HEADER]: handle }; - } - throw new Error("OpenShell admission handle is missing for the outbound context"); - }, - }; -} - -function userEnvelope(message: UserMessage): UserEnvelope | undefined { - if (typeof message.content === "string") { - return { schema_version: "openshell.pi-input.v1", text: message.content }; - } - if (message.content.some((block) => block.type === "image")) return undefined; - return { - schema_version: "openshell.pi-input.v1", - text: message.content.map((block) => (block as TextContent).text).join("\n"), - }; -} - -function toolResultEnvelope(message: ToolResultMessage): ToolResultEnvelope { - return { - schema_version: "openshell.pi-tool-result.v1", - tool_call_id: message.toolCallId, - tool_name: message.toolName, - content: message.content, - is_error: message.isError, - }; -} - -function messageKey(message: UserMessage | ToolResultMessage): string { - return JSON.stringify(message.role === "user" ? userEnvelope(message) : toolResultEnvelope(message)); -} - -function rememberHandle(handles: Map, key: string, handle: string): void { - handles.delete(key); - handles.set(key, handle); - if (handles.size > MAX_HANDLE_ENTRIES) { - const oldest = handles.keys().next().value; - if (oldest !== undefined) handles.delete(oldest); - } -} - -function replaceUserText(content: UserMessage["content"], text: string): UserMessage["content"] { - return typeof content === "string" ? text : [{ type: "text", text }]; -} - -function denied(reasonCode?: string): { action: "deny"; reason: string } { - return { - action: "deny", - reason: reasonCode ? `OpenShell denied this context addition (${reasonCode})` : "OpenShell denied this context addition", - }; -} - -function parseBridgeResult(value: unknown): BridgeResult { - if (!isRecord(value) || (value.decision !== "allow" && value.decision !== "deny")) { - throw new Error("OpenShell admission returned an invalid response"); - } - if (value.decision === "deny") { - return { decision: "deny", reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined }; - } - if (typeof value.handle !== "string" || !value.handle || value.handle.length > 1024) { - throw new Error("OpenShell admission returned an invalid handle"); - } - if ( - value.replacement_body !== undefined && - (!isByteArray(value.replacement_body) || value.replacement_body.length > MAX_ADMISSION_BYTES) - ) { - throw new Error("OpenShell admission returned an invalid replacement"); - } - return { decision: "allow", handle: value.handle, replacement_body: value.replacement_body }; -} - -function parseUserEnvelope(body: Uint8Array): UserEnvelope { - const value: unknown = JSON.parse(new TextDecoder().decode(body)); - if (!isRecord(value) || value.schema_version !== "openshell.pi-input.v1" || typeof value.text !== "string") { - throw new Error("OpenShell admission returned an invalid user replacement"); - } - return { schema_version: "openshell.pi-input.v1", text: value.text }; -} - -function parseToolResultEnvelope(body: Uint8Array): ToolResultEnvelope { - const value: unknown = JSON.parse(new TextDecoder().decode(body)); - if ( - !isRecord(value) || - value.schema_version !== "openshell.pi-tool-result.v1" || - typeof value.tool_call_id !== "string" || - typeof value.tool_name !== "string" || - !Array.isArray(value.content) || - typeof value.is_error !== "boolean" - ) { - throw new Error("OpenShell admission returned an invalid tool-result replacement"); - } - return value as ToolResultEnvelope; -} - -function isByteArray(value: unknown): value is number[] { - return Array.isArray(value) && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object"; -} diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts b/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts deleted file mode 100644 index 3ac98d26..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts +++ /dev/null @@ -1,28 +0,0 @@ -/** Standard Pi CLI with mandatory OpenShell context admission. */ -import { closeSync, readFileSync } from "node:fs"; -import { main } from "@earendil-works/pi-coding-agent"; -import { createOpenShellContextAdmission } from "./managed-pi-admission.ts"; - -async function run(): Promise { - const bridgeUrl = process.env.OPENSHELL_AGENT_CONVERSATION_URL; - const provider = process.env.PI_MANAGED_PROVIDER; - if (!bridgeUrl || !provider) { - throw new Error("OPENSHELL_AGENT_CONVERSATION_URL and PI_MANAGED_PROVIDER are required"); - } - - const modelApiKey = readFileSync(3, "utf8").replace(/\n$/u, ""); - closeSync(3); - - await main(process.argv.slice(2), { - configureModelRuntime: async (modelRuntime) => { - await modelRuntime.setRuntimeApiKey(provider, modelApiKey); - }, - createContextAdmission: (sessionManager) => - createOpenShellContextAdmission(bridgeUrl, () => sessionManager.getSessionId()), - }); -} - -run().catch((error: unknown) => { - console.error(error); - process.exitCode = 1; -}); diff --git a/projects/egress-gate/examples/pi-attested-admission/models.json b/projects/egress-gate/examples/pi-attested-admission/models.json index 58d7fd33..5da24a7f 100644 --- a/projects/egress-gate/examples/pi-attested-admission/models.json +++ b/projects/egress-gate/examples/pi-attested-admission/models.json @@ -2,7 +2,7 @@ "providers": { "attested-provider": { "baseUrl": "https://inference-api.nvidia.com/v1", - "apiKey": "$PI_MODEL_API_KEY", + "apiKey": "openshell-proxy", "models": [ { "id": "azure/anthropic/claude-opus-5", diff --git a/projects/egress-gate/examples/pi-attested-admission/policy.yaml b/projects/egress-gate/examples/pi-attested-admission/policy.yaml index 8766f30a..1bdc86b5 100644 --- a/projects/egress-gate/examples/pi-attested-admission/policy.yaml +++ b/projects/egress-gate/examples/pi-attested-admission/policy.yaml @@ -14,7 +14,7 @@ network_policies: model_provider: name: Configured model endpoint endpoints: - - host: provider.example.com + - host: inference-api.nvidia.com port: 443 protocol: rest enforcement: enforce @@ -61,4 +61,4 @@ network_middlewares: on_error: fail_closed endpoints: include: - - provider.example.com + - inference-api.nvidia.com diff --git a/projects/egress-gate/examples/pi-attested-admission/provider-profile.yaml b/projects/egress-gate/examples/pi-attested-admission/provider-profile.yaml new file mode 100644 index 00000000..8980d48d --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/provider-profile.yaml @@ -0,0 +1,22 @@ +id: pi-attested-model +display_name: Pi attested-admission model +description: Endpoint-scoped model credential for the Pi attested-admission example +category: inference +inference_capable: true +credentials: + - name: api_key + description: Model provider API key + env_vars: [PI_MODEL_API_KEY] + required: true + delivery: proxy + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: inference-api.nvidia.com + port: 443 + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/node, /usr/local/bin/node] diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs deleted file mode 100644 index 1265cfa9..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs +++ /dev/null @@ -1,165 +0,0 @@ -// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -// SPDX-License-Identifier: Apache-2.0 - -import { readFileSync, writeFileSync } from "node:fs"; - -const options = parseOptions(process.argv.slice(2)); -const modelsPath = requireOption(options, "models-path"); -const policyOutput = requireOption(options, "policy-output"); -const providerProfileOutput = requireOption(options, "provider-profile-output"); -const gatewayOutput = requireOption(options, "gateway-output"); -const selectionOutput = requireOption(options, "selection-output"); -const middlewareEndpoint = parseMiddlewareEndpoint( - options.get("middleware-endpoint"), -); -const { providerId, modelId, baseUrl } = readModelSelection(modelsPath); -const endpointPort = baseUrl.port || (baseUrl.protocol === "https:" ? "443" : "80"); - -writeFileSync(selectionOutput, `${JSON.stringify({ providerId, modelId }, null, 2)}\n`); - -const policyTemplate = readFileSync(new URL("policy.yaml", import.meta.url), "utf8"); -const policy = replaceExpected( - replaceExpected(policyTemplate, "provider.example.com", baseUrl.hostname, 2), - " port: 443", - ` port: ${endpointPort}`, - 1, -); -writeFileSync(policyOutput, policy); - -writeFileSync( - providerProfileOutput, - `id: pi-attested-model -display_name: Pi attested-admission model -description: Endpoint-scoped model credential for the Pi attested-admission example -category: inference -inference_capable: true -credentials: - - name: api_key - description: Model provider API key - env_vars: [PI_MODEL_API_KEY] - required: true - auth_style: bearer - header_name: authorization -discovery: - credentials: [api_key] -endpoints: - - host: ${JSON.stringify(baseUrl.hostname)} - port: ${endpointPort} - protocol: rest - access: read-write - enforcement: enforce -binaries: [/usr/bin/node, /usr/local/bin/node] -`, -); - -writeFileSync( - gatewayOutput, - `[[openshell.supervisor.middleware]] -name = "pi-egress" -grpc_endpoint = "${middlewareEndpoint}" -allow_insecure_transport = true -max_payload_bytes = 4194304 -timeout = "30s" -`, -); - -function parseOptions(argumentsList) { - if (argumentsList.length % 2 !== 0) { - fail("Options must be passed as --name value pairs."); - } - const parsed = new Map(); - for (let index = 0; index < argumentsList.length; index += 2) { - const name = argumentsList[index]; - if (!name.startsWith("--")) { - fail(`Expected an option name, received: ${name}`); - } - parsed.set(name.slice(2), argumentsList[index + 1]); - } - return parsed; -} - -function requireOption(parsed, name) { - const value = parsed.get(name); - if (!value) { - fail(`Missing --${name}.`); - } - return value; -} - -function readModelSelection(path) { - let config; - try { - config = JSON.parse(readFileSync(path, "utf8")); - } catch (error) { - fail(`Unable to read Pi model configuration ${path}: ${error.message}`); - } - const providers = Object.entries(config?.providers || {}); - if (providers.length !== 1) { - fail("The example requires exactly one provider in the Pi model configuration."); - } - const [providerId, provider] = providers[0]; - const modelId = provider?.models?.[0]?.id; - if (!modelId) { - fail(`Provider ${providerId} must contain at least one model.`); - } - return { - providerId, - modelId, - baseUrl: parseBaseUrl(provider.baseUrl), - }; -} - -function parseBaseUrl(value) { - const raw = value || fail("The Pi model provider must define baseUrl."); - let parsed; - try { - parsed = new URL(raw); - } catch { - fail("The Pi model provider baseUrl must be an absolute HTTP or HTTPS URL."); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - fail("The Pi model provider baseUrl must use HTTP or HTTPS."); - } - if (parsed.username || parsed.password || parsed.search || parsed.hash) { - fail("The Pi model provider baseUrl must not contain credentials, a query, or a fragment."); - } - return parsed; -} - -function parseMiddlewareEndpoint(value) { - const raw = value || fail("Missing --middleware-endpoint."); - let parsed; - try { - parsed = new URL(raw); - } catch { - fail("--middleware-endpoint must be an absolute HTTP or HTTPS URL."); - } - if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { - fail("--middleware-endpoint must use HTTP or HTTPS."); - } - if ( - parsed.username || - parsed.password || - parsed.pathname !== "/" || - parsed.search || - parsed.hash - ) { - fail("--middleware-endpoint must contain only a scheme, host, and port."); - } - return parsed.toString().replace(/\/$/, ""); -} - -function replaceExpected(value, marker, replacement, expectedOccurrences) { - const occurrences = value.split(marker).length - 1; - if (occurrences !== expectedOccurrences) { - fail( - `Expected policy marker ${marker} ${expectedOccurrences} times; found ${occurrences}.`, - ); - } - return value.replaceAll(marker, replacement); -} - -function fail(message) { - console.error(message); - process.exit(1); -} diff --git a/projects/egress-gate/examples/pi-attested-admission/settings.json b/projects/egress-gate/examples/pi-attested-admission/settings.json new file mode 100644 index 00000000..603697d9 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/settings.json @@ -0,0 +1,5 @@ +{ + "defaultProvider": "attested-provider", + "defaultModel": "azure/anthropic/claude-opus-5", + "defaultThinkingLevel": "high" +} diff --git a/projects/egress-gate/tests/test_managed_pi_admission.py b/projects/egress-gate/tests/test_managed_pi_admission.py deleted file mode 100644 index bf81fd9f..00000000 --- a/projects/egress-gate/tests/test_managed_pi_admission.py +++ /dev/null @@ -1,19 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -from __future__ import annotations - -import subprocess -from pathlib import Path - - -def test_managed_pi_admission_maps_handles_to_exact_provider_context() -> None: - project_dir = Path(__file__).parents[1] - test_file = ( - project_dir / "examples/pi-attested-admission/managed-pi-admission.test.mjs" - ) - - subprocess.run( - ["node", "--experimental-strip-types", "--test", str(test_file)], - check=True, - ) diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index af1ebc46..51c8e30f 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -59,7 +59,8 @@ def test_pi_example_can_print_each_action_without_running_it( ) assert "gateway-middleware.toml" in output assert "OPENSHELL_GATEWAY_CONFIG_FRAGMENT=" in output - assert "render-runtime-config.mjs" in output + assert "gateway-middleware.toml.example" in output + assert "render-runtime-config.mjs" not in output assert str(models_path) in output assert "egress-gate --debug serve" in output assert "CARGO_BUILD_JOBS=4" in output @@ -79,17 +80,16 @@ def test_pi_example_can_print_each_action_without_running_it( assert "--no-git-ignore" in output assert f"{runtime_dir}/node_modules:/sandbox/pi-runtime" in output assert f"{runtime_dir}:/sandbox/pi-runtime" not in output + assert f"{models_path}:/sandbox/pi-agent/models.json" in output + assert "settings.json:/sandbox/pi-agent/settings.json" in output assert "sandbox exec" in output assert "sandbox exec --tty" in output assert "PI_OFFLINE=1" in output assert "--no-extensions" in output - assert "--provider" in output - assert "--model" in output + assert "/sandbox/pi-runtime/node_modules/.bin/pi --no-extensions" in output + assert "PI_OPENSHELL_CONTEXT_ADMISSION=1" in output assert "OPENSHELL_AGENT_CONVERSATION_URL=" in output - assert "managed-pi.ts:/sandbox/pi-runtime/managed-pi.ts" in output - assert ( - "managed-pi-admission.ts:/sandbox/pi-runtime/managed-pi-admission.ts" in output - ) + assert "managed-pi" not in output assert "--extension " not in output assert "sandbox delete" in output assert all(result.stderr == "" for result in results) @@ -98,27 +98,11 @@ def test_pi_example_can_print_each_action_without_running_it( assert not pack_dir.exists() assert not runtime_dir.exists() - managed_pi = ( - project_dir / "examples/pi-attested-admission/managed-pi.ts" - ).read_text() - assert "main(process.argv.slice(2)" in managed_pi - assert "SessionManager" not in managed_pi - assert "ModelRuntime.create" not in managed_pi - assert "InteractiveMode" not in managed_pi - assert "thinkingLevel" not in managed_pi - assert 'readFileSync(3, "utf8")' in managed_pi - assert "closeSync(3)" in managed_pi - assert "process.env.PI_MODEL_API_KEY" not in managed_pi - assert "await modelRuntime.setRuntimeApiKey(provider, modelApiKey)" in managed_pi - assert "configureModelRuntime" in managed_pi - assert "createContextAdmission" in managed_pi - demo_script = (project_dir / "examples/pi-attested-admission/demo.sh").read_text() assert '"beforeToolResultAppend"' in demo_script - assert "exec env -u PI_MODEL_API_KEY node" in demo_script - assert '3<<<"$PI_MODEL_API_KEY"' in demo_script - assert '"configureModelRuntime"' in demo_script - assert '"createContextAdmission"' in demo_script + assert "exec env -u PI_MODEL_API_KEY node" not in demo_script + assert '3<<<"$PI_MODEL_API_KEY"' not in demo_script + assert "render-runtime-config.mjs" not in demo_script def test_pi_example_print_all_is_a_concise_walkthrough() -> None: @@ -196,48 +180,13 @@ def test_pi_example_defaults_to_an_ignored_external_workspace() -> None: assert ".workspaces/" in (project_dir / ".gitignore").read_text().splitlines() -def test_pi_example_renders_provider_specific_runtime_configuration( - tmp_path: Path, -) -> None: +def test_pi_example_uses_standard_checked_in_configuration() -> None: project_dir = Path(__file__).parents[1] example_dir = project_dir / "examples/pi-attested-admission" - models_path = tmp_path / "models.json" - policy_output = tmp_path / "policy.yaml" - provider_profile_output = tmp_path / "provider-profile.yaml" - gateway_output = tmp_path / "gateway-middleware.toml" - selection_output = tmp_path / "model-selection.json" models = json.loads((example_dir / "models.json").read_text()) - models["providers"]["attested-provider"]["baseUrl"] = ( - "https://gateway.example.test:8443/models/v1" - ) - models_path.write_text(json.dumps(models)) - original_models = models_path.read_text() - - subprocess.run( - [ - "node", - str(example_dir / "render-runtime-config.mjs"), - "--models-path", - str(models_path), - "--policy-output", - str(policy_output), - "--provider-profile-output", - str(provider_profile_output), - "--middleware-endpoint", - "http://192.0.2.10:50051", - "--gateway-output", - str(gateway_output), - "--selection-output", - str(selection_output), - ], - check=True, - ) - - assert models_path.read_text() == original_models - models = json.loads(models_path.read_text()) provider = models["providers"]["attested-provider"] - assert provider["baseUrl"] == "https://gateway.example.test:8443/models/v1" - assert provider["apiKey"] == "$PI_MODEL_API_KEY" + assert provider["baseUrl"] == "https://inference-api.nvidia.com/v1" + assert provider["apiKey"] == "openshell-proxy" assert "api" not in provider configured_models = {model["id"]: model for model in provider["models"]} assert set(configured_models) == { @@ -270,26 +219,33 @@ def test_pi_example_renders_provider_specific_runtime_configuration( "thinkingFormat": "qwen", } - selection = json.loads(selection_output.read_text()) - assert selection == { - "providerId": "attested-provider", - "modelId": "azure/anthropic/claude-opus-5", + settings = json.loads((example_dir / "settings.json").read_text()) + assert settings == { + "defaultProvider": "attested-provider", + "defaultModel": "azure/anthropic/claude-opus-5", + "defaultThinkingLevel": "high", } - provider_profile = yaml.safe_load(provider_profile_output.read_text()) + provider_profile = yaml.safe_load( + (example_dir / "provider-profile.yaml").read_text() + ) assert provider_profile["id"] == "pi-attested-model" assert provider_profile["credentials"][0]["env_vars"] == ["PI_MODEL_API_KEY"] - assert provider_profile["endpoints"][0]["host"] == "gateway.example.test" - assert provider_profile["endpoints"][0]["port"] == 8443 + assert provider_profile["credentials"][0]["delivery"] == "proxy" + assert provider_profile["endpoints"][0]["host"] == "inference-api.nvidia.com" + assert provider_profile["endpoints"][0]["port"] == 443 - policy = yaml.safe_load(policy_output.read_text()) + policy = yaml.safe_load((example_dir / "policy.yaml").read_text()) endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] - assert endpoint["host"] == "gateway.example.test" - assert endpoint["port"] == 8443 + assert endpoint["host"] == "inference-api.nvidia.com" + assert endpoint["port"] == 443 middleware = policy["network_middlewares"]["pi_egress_gate"] - assert middleware["endpoints"]["include"] == ["gateway.example.test"] + assert middleware["endpoints"]["include"] == ["inference-api.nvidia.com"] - gateway_fragment = tomllib.loads(gateway_output.read_text()) + gateway_template = (example_dir / "gateway-middleware.toml.example").read_text() + gateway_fragment = tomllib.loads( + gateway_template.replace("YOUR_HOST_IPV4", "192.0.2.10") + ) registration = gateway_fragment["openshell"]["supervisor"]["middleware"][0] assert registration["name"] == "pi-egress" assert registration["grpc_endpoint"] == "http://192.0.2.10:50051" From 8e3a38d813d399e2f1ca84aba77f160a1fecb620 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 31 Aug 2026 23:30:35 -0400 Subject: [PATCH 25/70] fix(egress-gate): preserve standard Pi extensions --- .../egress-gate/examples/pi-attested-admission/README.md | 5 ++--- projects/egress-gate/examples/pi-attested-admission/demo.sh | 2 +- projects/egress-gate/tests/test_pi_example_commands.py | 4 ++-- 3 files changed, 5 insertions(+), 6 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index f28f2a1a..7b6c0f01 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -185,9 +185,8 @@ the run. ## How it works 1. The forked `pi` entrypoint sees `PI_OPENSHELL_CONTEXT_ADMISSION=1` and installs - its built-in mandatory `ContextAdmission` boundary. The launch command uses - Pi's standard `--no-extensions` option, so project or user extensions cannot - replace this boundary. + its built-in mandatory `ContextAdmission` boundary. Pi otherwise starts + normally, including its standard project and user extension discovery. 2. Pi calls that boundary for each rendered user message and finalized tool result before it queues, appends, or persists the value. 3. The adapter sends the exact context addition to OpenShell's sandbox-local diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 0ceb1b07..956b9ae1 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -451,7 +451,7 @@ launch() { PI_OFFLINE=1 \ PI_OPENSHELL_CONTEXT_ADMISSION=1 \ OPENSHELL_AGENT_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation \ - /sandbox/pi-runtime/node_modules/.bin/pi --no-extensions + /sandbox/pi-runtime/node_modules/.bin/pi } cleanup() { diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 51c8e30f..a7368aeb 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -85,8 +85,8 @@ def test_pi_example_can_print_each_action_without_running_it( assert "sandbox exec" in output assert "sandbox exec --tty" in output assert "PI_OFFLINE=1" in output - assert "--no-extensions" in output - assert "/sandbox/pi-runtime/node_modules/.bin/pi --no-extensions" in output + assert "--no-extensions" not in output + assert "/sandbox/pi-runtime/node_modules/.bin/pi" in output assert "PI_OPENSHELL_CONTEXT_ADMISSION=1" in output assert "OPENSHELL_AGENT_CONVERSATION_URL=" in output assert "managed-pi" not in output From 9a5585455a15b0289b150cbad8aee46ee796c82b Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 1 Sep 2026 10:50:01 -0400 Subject: [PATCH 26/70] feat(egress-gate): attest OpenAI Responses context --- .../src/egress_gate/admission/__init__.py | 2 + .../src/egress_gate/admission/adapters.py | 334 +++++++++++++++++- .../src/egress_gate/admission/models.py | 2 +- .../src/egress_gate/admission/processor.py | 7 +- .../src/egress_gate/admission/receipts.py | 4 +- .../src/egress_gate/service/servicer.py | 2 +- .../fixtures/pi-openai-completions.json | 44 +++ .../fixtures/pi-openai-responses.json | 81 +++++ .../tests/admission/test_admission.py | 188 ++++++++-- 9 files changed, 629 insertions(+), 35 deletions(-) create mode 100644 projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json create mode 100644 projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py index ec0004b5..5949b8ee 100644 --- a/projects/egress-gate/src/egress_gate/admission/__init__.py +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -8,6 +8,7 @@ HarnessAdapter, HarnessAdapterRegistry, OpenAIChatCompletionsV1Adapter, + OpenAIResponsesV1Adapter, PiImageContentV1, PiInputV1, PiTextContentV1, @@ -75,6 +76,7 @@ "PI_HARNESS_VERSION", "ModelRequestV1", "OpenAIChatCompletionsV1Adapter", + "OpenAIResponsesV1Adapter", "PiInputV1", "PiImageContentV1", "PiTextContentV1", diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index 68634b10..cb9bfcaa 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -279,7 +279,13 @@ class _ProviderFunctionDefinition(StrictDomainModel): name: ScalarString description: ScalarString parameters: dict[str, object] - strict: bool + strict: bool | None = None + + @model_validator(mode="after") + def _optional_strict_is_not_null(self) -> _ProviderFunctionDefinition: + if "strict" in self.model_fields_set and self.strict is None: + raise ValueError("provider function strict cannot be null") + return self class _ProviderTool(StrictDomainModel): @@ -306,19 +312,186 @@ class _ProviderRequest(StrictDomainModel): tools: tuple[_ProviderTool, ...] = () tool_choice: Literal["auto", "none", "required"] | _ProviderNamedToolChoice = "auto" temperature: int | float | None = Field(default=None, allow_inf_nan=False) - max_completion_tokens: int = Field(ge=1) + max_completion_tokens: int | None = Field(default=None, ge=1) + max_tokens: int | None = Field(default=None, ge=1) stream: Literal[True] stream_options: _ProviderStreamOptions - store: Literal[False] + store: Literal[False] | None = None prompt_cache_key: ScalarString | None = None prompt_cache_retention: Literal["24h"] | None = None reasoning_effort: ScalarString | None = None + enable_thinking: bool | None = None @field_validator("messages", "tools", mode="before") @classmethod def _provider_collections_are_tuples(cls, value: object) -> object: return tuple(value) if isinstance(value, list | tuple) else value + @model_validator(mode="after") + def _compatibility_fields_have_one_representation(self) -> _ProviderRequest: + if (self.max_completion_tokens is None) == (self.max_tokens is None): + raise ValueError("provider request requires exactly one max-token field") + for field_name in ("store", "enable_thinking"): + if ( + field_name in self.model_fields_set + and getattr(self, field_name) is None + ): + raise ValueError(f"provider request {field_name} cannot be null") + return self + + @property + def output_token_limit(self) -> int: + value = self.max_completion_tokens or self.max_tokens + if value is None: + raise ValueError("provider request has no max-token field") + return value + + +class _ResponsesInputText(StrictDomainModel): + type: Literal["input_text"] + text: ScalarString + + +class _ResponsesOutputText(StrictDomainModel): + type: Literal["output_text"] + text: ScalarString + annotations: tuple[object, ...] + + @field_validator("annotations", mode="before") + @classmethod + def _annotations_are_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +class _ResponsesInputMessage(StrictDomainModel): + role: Literal["system", "developer", "user"] + content: ScalarString | tuple[_ResponsesInputText, ...] + type: Literal["message"] | None = None + + @field_validator("content", mode="before") + @classmethod + def _content_is_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + @model_validator(mode="after") + def _optional_type_is_not_null(self) -> _ResponsesInputMessage: + if "type" in self.model_fields_set and self.type is None: + raise ValueError("Responses input message type cannot be null") + return self + + +class _ResponsesAssistantMessage(StrictDomainModel): + type: Literal["message"] + role: Literal["assistant"] + content: tuple[_ResponsesOutputText, ...] + status: Literal["completed"] + id: ScalarString + phase: Literal["commentary", "final_answer"] | None = None + + @field_validator("content", mode="before") + @classmethod + def _content_is_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +class _ResponsesFunctionCall(StrictDomainModel): + type: Literal["function_call"] + call_id: ScalarString + name: ScalarString + arguments: ScalarString + id: ScalarString | None = None + namespace: ScalarString | None = None + + +class _ResponsesFunctionCallOutput(StrictDomainModel): + type: Literal["function_call_output"] + call_id: ScalarString + output: ScalarString | tuple[_ResponsesInputText, ...] + + @field_validator("output", mode="before") + @classmethod + def _output_is_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +class _ResponsesReasoningSummary(StrictDomainModel): + type: Literal["summary_text"] + text: ScalarString + + +class _ResponsesReasoningContent(StrictDomainModel): + type: Literal["reasoning_text"] + text: ScalarString + + +class _ResponsesReasoning(StrictDomainModel): + type: Literal["reasoning"] + id: ScalarString + summary: tuple[_ResponsesReasoningSummary, ...] + content: tuple[_ResponsesReasoningContent, ...] | None = None + encrypted_content: ScalarString | None = None + status: Literal["in_progress", "completed", "incomplete"] | None = None + + @field_validator("summary", "content", mode="before") + @classmethod + def _sequences_are_tuples(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +_ResponsesInputItem: TypeAlias = ( + _ResponsesInputMessage + | _ResponsesAssistantMessage + | _ResponsesFunctionCall + | _ResponsesFunctionCallOutput + | _ResponsesReasoning +) + + +class _ResponsesTool(StrictDomainModel): + type: Literal["function"] + name: ScalarString + description: ScalarString + parameters: dict[str, object] + strict: bool | None = None + + +class _ResponsesNamedToolChoice(StrictDomainModel): + type: Literal["function"] + name: ScalarString + + +class _ResponsesReasoningOptions(StrictDomainModel): + effort: ScalarString + summary: Literal["auto", "detailed", "concise"] | None = None + + +class _ResponsesPromptCacheOptions(StrictDomainModel): + mode: Literal["explicit"] + + +class _ResponsesRequest(StrictDomainModel): + model: ScalarString + input: tuple[_ResponsesInputItem, ...] + stream: Literal[True] + store: Literal[False] + max_output_tokens: int = Field(ge=1) + tools: tuple[_ResponsesTool, ...] = () + tool_choice: Literal["auto", "none", "required"] | _ResponsesNamedToolChoice = ( + "auto" + ) + temperature: int | float | None = Field(default=None, allow_inf_nan=False) + prompt_cache_key: ScalarString | None = None + prompt_cache_retention: Literal["24h"] | None = None + prompt_cache_options: _ResponsesPromptCacheOptions | None = None + reasoning: _ResponsesReasoningOptions | None = None + include: tuple[Literal["reasoning.encrypted_content"], ...] = () + service_tier: Literal["auto", "default", "flex", "scale", "priority"] | None = None + + @field_validator("input", "tools", "include", mode="before") + @classmethod + def _collections_are_tuples(cls, value: object) -> object: + return tuple(value) if isinstance(value, list | tuple) else value + class ProviderRequestAdapter(Protocol): """Validate and project a provider request for rendered-prompt extraction.""" @@ -383,7 +556,7 @@ def canonicalize(self, request: HttpRequest, timeout: Timeout) -> ModelRequestV1 tool_choice=tool_choice, generation=CanonicalGenerationV1( temperature=provider.temperature, - max_tokens=provider.max_completion_tokens, + max_tokens=provider.output_token_limit, ), ) @@ -405,6 +578,97 @@ def latest_attested_candidate( raise ProviderShapeError("provider request has no attested context addition") +class OpenAIResponsesV1Adapter: + """Pinned OpenAI-compatible Responses request adapter.""" + + schema_version = "openai.responses.v1" + + def canonicalize(self, request: HttpRequest, timeout: Timeout) -> ModelRequestV1: + provider = self._parse(request, timeout) + messages: list[CanonicalMessageV1] = [] + for item in provider.input: + if isinstance(item, _ResponsesInputMessage): + messages.append( + CanonicalMessageV1( + role=CanonicalRole(item.role), + content=_responses_text(item.content), + ) + ) + elif isinstance(item, _ResponsesAssistantMessage): + messages.append( + CanonicalMessageV1( + role=CanonicalRole.ASSISTANT, + content="\n".join(block.text for block in item.content), + ) + ) + elif isinstance(item, _ResponsesFunctionCall): + messages.append( + CanonicalMessageV1( + role=CanonicalRole.ASSISTANT, + content=None, + tool_calls=( + CanonicalFunctionCallV1( + id=item.call_id, + name=item.name, + arguments=item.arguments, + ), + ), + ) + ) + elif isinstance(item, _ResponsesFunctionCallOutput): + messages.append(_responses_tool_result(item)) + tools = tuple( + CanonicalToolV1( + name=item.name, + description=item.description, + input_schema=item.parameters, + ) + for item in provider.tools + ) + if isinstance(provider.tool_choice, str): + tool_choice = CanonicalToolChoiceV1(mode=provider.tool_choice) + else: + tool_choice = CanonicalToolChoiceV1( + mode="function", function_name=provider.tool_choice.name + ) + return ModelRequestV1( + model=provider.model, + messages=tuple(messages), + tools=tools, + tool_choice=tool_choice, + generation=CanonicalGenerationV1( + temperature=provider.temperature, + max_tokens=provider.max_output_tokens, + ), + ) + + def latest_attested_candidate( + self, request: HttpRequest, timeout: Timeout + ) -> AttestedCandidate: + """Extract the latest user or function-call output context addition.""" + provider = self._parse(request, timeout) + for item in reversed(provider.input): + if isinstance(item, _ResponsesInputMessage) and item.role == "user": + return PiInputV1( + schema_version="openshell.pi-input.v1", + text=_responses_text(item.content), + ) + if isinstance(item, _ResponsesFunctionCallOutput): + return _responses_tool_result(item) + raise ProviderShapeError("provider request has no attested context addition") + + def _parse(self, request: HttpRequest, timeout: Timeout) -> _ResponsesRequest: + _validate_json_request(request) + value = _load_json(request.body, ProviderShapeError, timeout) + try: + provider = _RESPONSES_PROVIDER_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise ProviderShapeError("provider request body is unsupported") from None + if not isinstance(provider, _ResponsesRequest): + raise ProviderShapeError("provider request body is unsupported") + return provider + + class ProviderAdapterRegistry: """Explicit versioned provider-adapter registry.""" @@ -422,6 +686,19 @@ def resolve(self, schema_version: str) -> ProviderRequestAdapter: except KeyError: raise ProviderShapeError("provider adapter is unsupported") from None + def resolve_request( + self, request: HttpRequest, timeout: Timeout + ) -> ProviderRequestAdapter: + """Select the adapter from the mutually exclusive top-level request shape.""" + value = _load_json(request.body, ProviderShapeError, timeout) + if not isinstance(value, dict): + raise ProviderShapeError("provider request body is unsupported") + if "messages" in value and "input" not in value: + return self.resolve(OpenAIChatCompletionsV1Adapter.schema_version) + if "input" in value and "messages" not in value: + return self.resolve(OpenAIResponsesV1Adapter.schema_version) + raise ProviderShapeError("provider request body is unsupported") + def create_pi_adapter_registry() -> HarnessAdapterRegistry: """Return the built-in Pi v1 admission registry.""" @@ -442,9 +719,10 @@ def create_pi_adapter_registry() -> HarnessAdapterRegistry: def create_provider_adapter_registry() -> ProviderAdapterRegistry: - """Return the built-in OpenAI Chat Completions provider registry.""" + """Return the built-in OpenAI provider-request registry.""" registry = ProviderAdapterRegistry() registry.register(OpenAIChatCompletionsV1Adapter()) + registry.register(OpenAIResponsesV1Adapter()) return registry @@ -483,10 +761,46 @@ def _tool_result_attested_candidate( return CanonicalMessageV1( role=CanonicalRole.TOOL, content=text or "(no tool output)", - tool_call_id=result.tool_call_id, + tool_call_id=_provider_tool_call_id(result.tool_call_id), ) +def _validate_json_request(request: HttpRequest) -> None: + if request.target.method.upper() != "POST": + raise ProviderShapeError("provider request method is unsupported") + content_types = [ + header.value.strip().lower() + for header in request.headers + if header.name.lower() == "content-type" + ] + if content_types != ["application/json"]: + raise ProviderShapeError("provider request requires one JSON content type") + if any(header.name.lower() == "content-encoding" for header in request.headers): + raise ProviderShapeError("provider request content encoding is unsupported") + + +def _responses_text(value: ScalarString | tuple[_ResponsesInputText, ...]) -> str: + if isinstance(value, str): + return value + if not value: + raise ProviderShapeError("provider message content cannot be empty") + return "\n".join(block.text for block in value) + + +def _responses_tool_result( + item: _ResponsesFunctionCallOutput, +) -> CanonicalMessageV1: + return CanonicalMessageV1( + role=CanonicalRole.TOOL, + content=_responses_text(item.output), + tool_call_id=_provider_tool_call_id(item.call_id), + ) + + +def _provider_tool_call_id(value: str) -> str: + return value.split("|", 1)[0] + + def _load_json(body: bytes, error_type: type[ValueError], timeout: Timeout) -> object: try: JsonDocument.parse(body, timeout=timeout) @@ -519,7 +833,11 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 role=CanonicalRole(item.role), content=content, name=item.name, - tool_call_id=item.tool_call_id, + tool_call_id=( + _provider_tool_call_id(item.tool_call_id) + if item.tool_call_id is not None + else None + ), tool_calls=tuple( CanonicalFunctionCallV1( id=call.id, @@ -534,6 +852,7 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 _PI_ADAPTER = TypeAdapter(PiInputV1) _PI_TOOL_RESULT_ADAPTER = TypeAdapter(PiToolResultV1) _PROVIDER_ADAPTER = TypeAdapter(_ProviderRequest) +_RESPONSES_PROVIDER_ADAPTER = TypeAdapter(_ResponsesRequest) __all__ = [ @@ -543,6 +862,7 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 "HarnessAdapter", "HarnessAdapterRegistry", "OpenAIChatCompletionsV1Adapter", + "OpenAIResponsesV1Adapter", "PiInputV1", "PiImageContentV1", "PiTextContentV1", diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py index 2dc2743a..16e635f9 100644 --- a/projects/egress-gate/src/egress_gate/admission/models.py +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -60,7 +60,7 @@ class HarnessAdmissionContext(StrictDomainModel): hook: AdmissionHook schema_version: Literal["openshell.pi-input.v1", "openshell.pi-tool-result.v1"] provider_target: HttpTarget - provider_adapter_schema: Literal["openai.chat-completions.v1"] + provider_adapter_schema: Literal["openai.request.v1"] class HarnessAdmissionResult(StrictDomainModel): diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py index 3c40c691..bcbc7c0b 100644 --- a/projects/egress-gate/src/egress_gate/admission/processor.py +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -73,7 +73,7 @@ def readiness(self) -> dict[str, str]: return { "admission_schema": "openshell.pi-input.v1", "canonicalization": "canonical-json.v1", - "provider_adapter": "openai.chat-completions.v1", + "provider_adapter": "openai.request.v1", "attestation_version": "agent-attestation.v1", "key_id": self._receipt_authority.key_id, "policy_fingerprint": self._policy_fingerprint, @@ -184,7 +184,6 @@ def __init__( self._receipt_authority = receipt_authority self._middleware_name = middleware_name self._harness_version = harness_version - self._provider_adapter_schema = "openai.chat-completions.v1" self._policy_fingerprint = fingerprint def process( @@ -202,7 +201,7 @@ def process( if not agent_attestation: return self._deny("attestation_missing") try: - adapter = self._provider_adapters.resolve(self._provider_adapter_schema) + adapter = self._provider_adapters.resolve_request(request, timeout) candidate = adapter.latest_attested_candidate(request, timeout) timeout.raise_if_expired() if isinstance(candidate, PiInputV1): @@ -225,7 +224,7 @@ def process( hook=hook, schema_version=schema_version, provider_target=request.target, - provider_adapter_schema="openai.chat-completions.v1", + provider_adapter_schema="openai.request.v1", ) self._receipt_authority.verify_attestation( agent_attestation, diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py index 4aa09aac..5283b443 100644 --- a/projects/egress-gate/src/egress_gate/admission/receipts.py +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -45,7 +45,7 @@ class ReceiptClaimsV1(StrictDomainModel): session_id: BoundedMetadataString submission_id: BoundedMetadataString receipt_id: str = Field(pattern=r"^[0-9a-f]{32}$") - provider_adapter_schema: Literal["openai.chat-completions.v1"] + provider_adapter_schema: Literal["openai.request.v1"] host: ScalarString port: int = Field(ge=0, le=2**32 - 1) rendered_prompt_hash: str = Field(pattern=r"^[0-9a-f]{64}$") @@ -69,7 +69,7 @@ class AgentAttestationClaimsV1(StrictDomainModel): session_id: BoundedMetadataString submission_id: BoundedMetadataString attestation_id: str = Field(pattern=r"^[0-9a-f]{32}$") - provider_adapter_schema: Literal["openai.chat-completions.v1"] + provider_adapter_schema: Literal["openai.request.v1"] host: ScalarString port: int = Field(ge=0, le=2**32 - 1) candidate_hash: str = Field(pattern=r"^[0-9a-f]{64}$") diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index 645ab4b4..6470ba87 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -274,7 +274,7 @@ def _evaluate_agent_admission( request.target.schema_version, hook ), provider_target=target, - provider_adapter_schema="openai.chat-completions.v1", + provider_adapter_schema="openai.request.v1", ), timeout=timeout, ) diff --git a/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json b/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json new file mode 100644 index 00000000..6e1fc010 --- /dev/null +++ b/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json @@ -0,0 +1,44 @@ +{ + "opus": { + "model": "azure/anthropic/claude-opus-5", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "safe"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "max_tokens": 128000, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}} + } + } + ] + }, + "qwen": { + "model": "nvidia/qwen/qwen3.8-flash-next", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "safe"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "max_tokens": 32768, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}} + } + } + ], + "enable_thinking": true, + "reasoning_effort": "high" + } +} diff --git a/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json b/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json new file mode 100644 index 00000000..e5f99168 --- /dev/null +++ b/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json @@ -0,0 +1,81 @@ +{ + "user_request": { + "model": "fixture-model", + "input": [ + {"role": "developer", "content": "fixture system prompt"}, + { + "role": "user", + "content": [{"type": "input_text", "text": "safe"}] + } + ], + "stream": true, + "prompt_cache_key": "session-1", + "store": false, + "max_output_tokens": 128, + "tools": [ + { + "type": "function", + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}} + } + ], + "reasoning": {"effort": "high", "summary": "auto"}, + "include": ["reasoning.encrypted_content"] + }, + "tool_result_request": { + "model": "fixture-model", + "input": [ + {"role": "developer", "content": "fixture system prompt"}, + { + "role": "user", + "content": [{"type": "input_text", "text": "use the tool"}] + }, + { + "type": "reasoning", + "id": "rs-1", + "summary": [{"type": "summary_text", "text": "summary"}], + "encrypted_content": "encrypted", + "status": "completed" + }, + { + "type": "message", + "role": "assistant", + "content": [ + { + "type": "output_text", + "text": "I will read it.", + "annotations": [] + } + ], + "status": "completed", + "id": "msg-1" + }, + { + "type": "function_call", + "call_id": "call-1", + "name": "read", + "arguments": "{}" + }, + { + "type": "function_call_output", + "call_id": "call-1", + "output": "safe tool output" + } + ], + "stream": true, + "prompt_cache_key": "session-1", + "store": false, + "max_output_tokens": 128, + "tools": [ + { + "type": "function", + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}} + } + ], + "reasoning": {"effort": "high", "summary": "auto"}, + "include": ["reasoning.encrypted_content"] + } +} diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index b8bc5da4..bdda274e 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -6,6 +6,7 @@ from __future__ import annotations import json +from pathlib import Path from typing import Literal import pytest @@ -35,6 +36,15 @@ DENY_TEXT = "DENY_THIS" REDACT_TEXT = "REDACT_THIS" +_PI_RESPONSES_FIXTURES = json.loads( + (Path(__file__).parent / "fixtures/pi-openai-responses.json").read_text() +) +_PI_CHAT_FIXTURES = json.loads( + (Path(__file__).parent / "fixtures/pi-openai-completions.json").read_text() +) +# These payloads were captured at Pi's fake-fetch boundary from its native +# openai-responses and openai-completions stream functions. They intentionally +# preserve the serializer output rather than restating it through test builders. def _processors( @@ -142,7 +152,7 @@ def _context( hook=hook, schema_version=schema, provider_target=target or _target(), - provider_adapter_schema="openai.chat-completions.v1", + provider_adapter_schema="openai.request.v1", ) @@ -174,7 +184,9 @@ def _user(text: str) -> PiInputV1: return PiInputV1(schema_version="openshell.pi-input.v1", text=text) -def _tool_result(text: str, *, image: bool = False) -> PiToolResultV1: +def _tool_result( + text: str, *, image: bool = False, tool_call_id: str = "call-1" +) -> PiToolResultV1: content: list[dict[str, object]] = ( [{"type": "image", "data": "AA==", "mimeType": "image/png"}] if image @@ -183,7 +195,7 @@ def _tool_result(text: str, *, image: bool = False) -> PiToolResultV1: return PiToolResultV1.model_validate( { "schema_version": "openshell.pi-tool-result.v1", - "tool_call_id": "call-1", + "tool_call_id": tool_call_id, "tool_name": "read", "content": content, "is_error": False, @@ -199,12 +211,10 @@ def _provider_request( headers: tuple[HttpHeader, ...] = (), target: HttpTarget | None = None, ) -> HttpRequest: - messages: list[dict[str, object]] = [ - {"role": "system", "content": "fixture system prompt"}, - {"role": "user", "content": prompt}, - ] + provider_body = json.loads(json.dumps(_PI_CHAT_FIXTURES["opus"])) + provider_body["messages"][1]["content"] = prompt if tool_result is not None: - messages.extend( + provider_body["messages"].extend( [ { "role": "assistant", @@ -225,18 +235,7 @@ def _provider_request( ] ) body = json.dumps( - { - "model": "fixture-model", - "messages": messages, - "tools": [], - "tool_choice": "auto", - "temperature": 0, - "max_completion_tokens": 128, - "stream": True, - "stream_options": {"include_usage": True}, - "store": False, - "prompt_cache_key": "session-1", - }, + provider_body, ensure_ascii=False, separators=(",", ":"), sort_keys=True, @@ -249,6 +248,30 @@ def _provider_request( ) +def _responses_request( + prompt: str, + *, + tool_result: str | None = None, +) -> HttpRequest: + fixture_name = "tool_result_request" if tool_result is not None else "user_request" + provider_body = json.loads(json.dumps(_PI_RESPONSES_FIXTURES[fixture_name])) + provider_body["input"][1]["content"][0]["text"] = prompt + if tool_result is not None: + provider_body["input"][-1]["output"] = tool_result + body = json.dumps( + provider_body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + return HttpRequest( + context=RequestContext(request_id="network-1", sandbox_id="sandbox-1"), + target=_target().model_copy(update={"path": "/v1/responses"}), + headers=(HttpHeader(name="content-type", value="application/json"),), + body=body, + ) + + def _egress( processor: AttestedEgressProcessor, request: HttpRequest, @@ -276,6 +299,100 @@ def test_user_attestation_authorizes_retries_without_entering_request_headers() assert first.request_mutations.header_mutations == () +@pytest.mark.parametrize("fixture_name", ["opus", "qwen"]) +def test_configured_pi_chat_serializations_are_attested(fixture_name: str) -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe")) + assert admitted.attestation is not None + request = _provider_request("safe").model_copy( + update={ + "body": json.dumps( + _PI_CHAT_FIXTURES[fixture_name], + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + + result = _egress(egress, request, admitted.attestation) + + assert result.decision.value == "allow" + + +def test_user_attestation_authorizes_responses_requests_and_retries() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe")) + assert admitted.attestation is not None + request = _responses_request("safe") + + first = _egress(egress, request, admitted.attestation) + retry = _egress(egress, request, admitted.attestation) + + assert first.decision.value == "allow" + assert retry.decision.value == "allow" + + +def test_changed_responses_context_fails_closed() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe")) + assert admitted.attestation is not None + + result = _egress(egress, _responses_request("changed prompt"), admitted.attestation) + + assert result.reason_code == "attestation_context_mismatch" + + +@pytest.mark.parametrize( + "mutation", + [ + lambda body: body.update({"messages": []}), + lambda body: body["input"].append( + {"role": "user", "content": [{"type": "input_image"}]} + ), + lambda body: body.pop("max_output_tokens"), + ], +) +def test_mixed_or_malformed_responses_shapes_fail_closed(mutation) -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe")) + assert admitted.attestation is not None + request = _responses_request("safe") + body = json.loads(request.body) + mutation(body) + malformed = request.model_copy( + update={ + "body": json.dumps( + body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + + result = _egress(egress, malformed, admitted.attestation) + + assert result.reason_code == "provider_shape_unsupported" + + +def test_tool_result_attestation_authorizes_responses_call_id_projection() -> None: + admission, egress, _ = _processors() + admitted = _admit( + admission, + _tool_result("safe tool output", tool_call_id="call-1|fc-1"), + ) + assert admitted.attestation is not None + + result = _egress( + egress, + _responses_request("use the tool", tool_result="safe tool output"), + admitted.attestation, + ) + + assert result.decision.value == "allow" + + def test_changed_or_unattested_user_context_fails_closed() -> None: admission, egress, _ = _processors() admitted = _admit(admission, _user("safe rendered prompt")) @@ -474,6 +591,37 @@ def test_provider_shape_validation_and_optional_reasoning_field_are_preserved() assert reasoning_result.decision.value == "allow" +@pytest.mark.parametrize( + "mutation", + [ + lambda body: body.update({"max_completion_tokens": 128}), + lambda body: body.update({"store": None}), + lambda body: body["tools"][0]["function"].update({"strict": None}), + ], +) +def test_mixed_or_null_chat_compatibility_fields_fail_closed(mutation) -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe")) + assert admitted.attestation is not None + request = _provider_request("safe") + body = json.loads(request.body) + mutation(body) + malformed = request.model_copy( + update={ + "body": json.dumps( + body, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode() + } + ) + + result = _egress(egress, malformed, admitted.attestation) + + assert result.reason_code == "provider_shape_unsupported" + + def test_workload_receipt_header_is_reserved_in_managed_flow() -> None: admission, egress, _ = _processors() admitted = _admit(admission, _user("safe")) From f8fc01ea866332e280b5910ee121ae4b9494b81b Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 1 Sep 2026 10:50:06 -0400 Subject: [PATCH 27/70] refactor(egress-gate): run standard persistent Pi demo --- .../pi-attested-admission/.env.example | 1 + .../examples/pi-attested-admission/README.md | 69 ++++++++++------ .../examples/pi-attested-admission/demo.sh | 81 ++++++++++++++----- .../pi-attested-admission/sandbox/Dockerfile | 11 +++ .../pi-attested-admission/settings.json | 2 +- .../tests/test_pi_example_commands.py | 62 ++++++++++++-- 6 files changed, 176 insertions(+), 50 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example index ef3b0279..c9909dc3 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.env.example +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -1,3 +1,4 @@ EGRESS_GATE_HOST_IP=YOUR_HOST_IPV4 PI_MODELS_PATH=./models.json PI_MODEL_API_KEY=your-provider-key +PI_WORKSPACE_PATH=/absolute/path/to/your/project diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 7b6c0f01..b49ff75a 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -56,8 +56,14 @@ set +a from this shell; `set +a` restores the shell's default behavior afterward. If the model endpoint does not require authentication, set -`PI_MODEL_API_KEY=unused`. Source `.env` again in each new terminal that runs -`demo.sh`. +`PI_MODEL_API_KEY=unused`. Source `.env` in the terminals that run `gateway` and +`reset`; the other actions do not consume the credential. + +`PI_WORKSPACE_PATH` is the absolute path of the project you want Pi to work on. +The reset step uploads its contents to `/sandbox/workspace` using the project's normal +`.gitignore` rules. Pi starts in that directory, so project instructions, +extensions, skills, prompts, and session grouping follow its ordinary +current-directory behavior. `PI_MODELS_PATH` points to a standard Pi `models.json`. Relative paths are resolved from this example directory. The checked-in [models.json](models.json) @@ -70,9 +76,10 @@ these models: | `azure/openai/gpt-5.6-sol` | OpenAI Responses | | `nvidia/qwen/qwen3.8-flash-next` | OpenAI Chat Completions | -Pi starts with the default in [settings.json](settings.json). Use Pi's normal -model picker to switch among all three without creating another OpenShell provider. Qwen uses -Chat Completions reasoning controls, and GPT-5.6 Sol uses Responses reasoning. +Pi starts with the reasoning-capable Qwen model and `high` thinking from +[settings.json](settings.json). Use Pi's normal model picker to switch among all +three without creating another OpenShell provider. Qwen uses Chat Completions +reasoning controls, and GPT-5.6 Sol uses Responses reasoning. The endpoint's Opus 5 alias currently rejects explicit adaptive-thinking controls, so it runs with the endpoint's default thinking behavior. @@ -89,10 +96,11 @@ will call. A model server running on this machine must likewise use a hostname or address reachable from the sandbox rather than `localhost`. The example checks in ordinary Pi and OpenShell configuration files. It uploads -`models.json` and `settings.json` unchanged. The only host-specific output is a -copy of `gateway-middleware.toml.example` with `EGRESS_GATE_HOST_IP` substituted -for its documented placeholder. If required values are missing, the script -prints the configuration steps and stops before performing any work. +`models.json` and `settings.json` unchanged to Pi's standard +`~/.pi/agent` directory. The only generated configuration is a copy of +`gateway-middleware.toml.example` with `EGRESS_GATE_HOST_IP` substituted for its +documented placeholder. If an action needs configuration that is missing, the +script prints the values required by that action and stops before doing work. Preview the complete workflow before running anything: @@ -132,7 +140,16 @@ The example uses its own gateway name and passes it explicitly to every OpenShell command. It does not depend on or change your globally selected OpenShell gateway. -After the gateway reports that it is ready, launch Pi from a third terminal: +After the gateway reports that it is ready, create the demo sandbox from a +third terminal. `reset` is deliberately named: it deletes any prior demo +sandbox and its sessions before uploading the current runtime, configuration, +and workspace. + +```shell title="Terminal 3: Pi" +./demo.sh reset +``` + +Then launch Pi: ```shell title="Terminal 3: Pi" ./demo.sh launch @@ -140,10 +157,19 @@ After the gateway reports that it is ready, launch Pi from a third terminal: This executes the fork's normal `pi` entrypoint. The explicit `PI_OPENSHELL_CONTEXT_ADMISSION=1` setting makes its built-in OpenShell -admission boundary mandatory for the session. Each launch replaces the example's -`pi-egress-demo` sandbox, provider, and custom provider profile so the current -Pi runtime, admission adapter, policy, endpoint, and OpenShell supervisor are -used together. +admission boundary mandatory for the session. `launch` only enters the existing +sandbox; it does not replace the sandbox or Pi's state. Exit and run `launch` +again to use Pi's normal `/resume` flow and persistent JSONL sessions. Run +`reset` only when you intentionally want a fresh sandbox or need to apply a new +runtime, policy, model configuration, credential, or workspace snapshot. + +The sandbox image adds the `fd` and `rg` executables used by Pi's standard +`find` and `grep` tools. Pi itself still comes from the prepared fork package, +and starts without a wrapper or restrictive CLI flags. Its standard user and +project resource discovery, extension loading, tools, model picker, thinking +controls, compaction, and session manager remain active. OpenShell's filesystem +and network policy still apply to every process in the sandbox; arbitrary +package downloads are intentionally outside this endpoint-focused example. The example registers an endpoint-specific provider profile using the host-side `PI_MODEL_API_KEY`. Its `delivery: proxy` setting keeps the credential and any @@ -178,9 +204,8 @@ result admitted as `[REDACTED]`. Pi uses its standard session manager and JSONL session location, and exposes the active path to tools as `PI_SESSION_FILE`. Admission runs before a user -message or tool result reaches that history. Each `launch` replaces the -disposable demo sandbox, so copy out anything you want to retain before ending -the run. +message or tool result reaches that history. `launch` preserves the history; +`reset` and `cleanup` delete it with the sandbox. ## How it works @@ -209,10 +234,8 @@ the run. The attestation adapter supports normal text turns, text tool results, queued steering and follow-up messages, retries, automatic model continuations, compaction, branch summaries, and restored sessions using the OpenAI Chat -Completions wire format. The catalog includes GPT-5.6 Sol so the shared-provider -configuration is complete, but its Responses requests are not yet covered by -the attestation adapter and fail closed. Image inputs are likewise outside this -example's current scope. +Completions and Responses wire formats. Image inputs are outside this example's +current scope and fail closed. ## Cleanup @@ -223,5 +246,5 @@ sandbox and provider: ./demo.sh cleanup ``` -Then stop the OpenShell gateway and Egress Gate with `Ctrl-C`. To run the -example again, start from `./demo.sh prepare`. +Then stop the OpenShell gateway and Egress Gate with `Ctrl-C`. For another +session in the same prepared sandbox, use `./demo.sh launch` instead of cleanup. diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 956b9ae1..3f21ee63 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -29,6 +29,7 @@ if [[ $models_path_value == /* || $models_path_value == YOUR_MODELS_PATH ]]; the else models_path=$script_dir/${models_path_value#./} fi +workspace_path=${PI_WORKSPACE_PATH:-YOUR_WORKSPACE_PATH} pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} openshell_cli=$openshell_repo/scripts/bin/openshell @@ -210,7 +211,16 @@ require_gateway_z3() { exit 1 } -require_example_configuration() { +require_host_configuration() { + if [[ -n ${EGRESS_GATE_HOST_IP:-} && ${EGRESS_GATE_HOST_IP:-} != YOUR_HOST_IPV4 ]]; then + return + fi + printf 'Set EGRESS_GATE_HOST_IP in %s and source it before starting the gateway.\n' \ + "$script_dir/.env" >&2 + exit 1 +} + +require_setup_configuration() { local missing=() if [[ -z ${EGRESS_GATE_HOST_IP:-} || ${EGRESS_GATE_HOST_IP:-} == YOUR_HOST_IPV4 ]]; then missing+=(EGRESS_GATE_HOST_IP) @@ -221,8 +231,12 @@ require_example_configuration() { if [[ -z ${PI_MODEL_API_KEY:-} || ${PI_MODEL_API_KEY:-} == your-provider-key ]]; then missing+=(PI_MODEL_API_KEY) fi + if [[ -z ${PI_WORKSPACE_PATH:-} || ${PI_WORKSPACE_PATH:-} == /absolute/path/to/your/project ]]; then + missing+=(PI_WORKSPACE_PATH) + fi if ((${#missing[@]} == 0)); then require_file "$models_path" "Pi model configuration" + require_directory "$workspace_path" "Pi workspace" return fi @@ -315,9 +329,6 @@ prepare_gateway_configuration() { } prepare() { - if ! $print_only; then - require_example_configuration - fi sync_forks local agent_tarball local coding_agent_tarball @@ -326,7 +337,7 @@ prepare() { describe_printed_commands "Build and package Pi:" run_in "$pi_repo" npm install --ignore-scripts - run_in "$pi_repo" npm run build + run_in "$pi_repo" npm run build:offline run_in "$pi_repo" mkdir -p "$pack_dir" "$runtime_dir" run_in "$pi_repo" npm pack --workspace @earendil-works/pi-agent-core --pack-destination "$pack_dir" run_in "$pi_repo" npm pack --workspace @earendil-works/pi-coding-agent --pack-destination "$pack_dir" @@ -341,7 +352,7 @@ serve() { gateway() { if ! $print_only; then - require_example_configuration + require_host_configuration require_compute_backend raise_gateway_open_file_limit require_gateway_z3 @@ -395,6 +406,11 @@ ensure_model_provider() { } delete_demo_sandbox_if_present() { + if $print_only; then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox delete pi-egress-demo + return + fi if ! $print_only && (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ sandbox list --names | grep -Fxq pi-egress-demo); then printf 'Replacing existing sandbox pi-egress-demo with the current example runtime.\n' @@ -406,19 +422,22 @@ delete_demo_sandbox_if_present() { create_demo_sandbox() { run_in "$script_dir" "$openshell_cli" --gateway "$gateway_name" sandbox create \ --name pi-egress-demo \ - --from base \ + --from "$script_dir/sandbox" \ --provider pi-model \ --policy "$runtime_policy" \ --upload "$runtime_dir/node_modules:/sandbox/pi-runtime" \ - --upload "$models_path:/sandbox/pi-agent/models.json" \ - --upload "$pi_settings:/sandbox/pi-agent/settings.json" \ + --upload "$models_path:/sandbox/.pi/agent/models.json" \ + --upload "$pi_settings:/sandbox/.pi/agent/settings.json" \ --no-git-ignore \ --detach + describe_printed_commands "Upload the selected workspace with its .gitignore rules:" + run_in "$workspace_path" "$openshell_cli" --gateway "$gateway_name" sandbox upload \ + pi-egress-demo . /sandbox/workspace } -launch() { +reset_demo() { if ! $print_only; then - require_example_configuration + require_setup_configuration require_file "$openshell_cli" "OpenShell CLI wrapper" require_file "$(pi_package_tarball "$pi_repo/packages/agent" "earendil-works-pi-agent-core")" \ "packed Pi agent core" @@ -443,12 +462,27 @@ launch() { ensure_model_provider describe_printed_commands "Create a fresh sandbox and upload the Pi runtime:" create_demo_sandbox + printf 'The demo sandbox is ready. Run: ./demo.sh launch\n' +} + +require_demo_sandbox() { + if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + sandbox list --names | grep -Fxq pi-egress-demo); then + return + fi + printf 'The pi-egress-demo sandbox does not exist. Create it with: ./demo.sh reset\n' >&2 + exit 1 +} + +launch() { + if ! $print_only; then + require_file "$openshell_cli" "OpenShell CLI wrapper" + require_demo_sandbox + fi describe_printed_commands "Launch Pi interactively in the prepared sandbox:" run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec --tty -n pi-egress-demo -- \ + sandbox exec --tty -n pi-egress-demo --workdir /sandbox/workspace -- \ env \ - PI_CODING_AGENT_DIR=/sandbox/pi-agent \ - PI_OFFLINE=1 \ PI_OPENSHELL_CONTEXT_ADMISSION=1 \ OPENSHELL_AGENT_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation \ /sandbox/pi-runtime/node_modules/.bin/pi @@ -476,7 +510,8 @@ usage() { prepare Update the forks and package Pi serve Start Egress Gate gateway Start the forked OpenShell gateway - launch Attach the configured model credential and launch Pi + reset Recreate the demo sandbox and configure its model credential + launch Start Pi in the existing sandbox without deleting its sessions cleanup Delete the example sandbox and credential provider all Show the concise workflow walkthrough (requires --print) EOF @@ -494,6 +529,7 @@ print_plan() { local credential_status="not set" local displayed_host="$host_ip" local displayed_models_path="$models_path" + local displayed_workspace="not set" if [[ $displayed_host == YOUR_HOST_IPV4 ]]; then displayed_host="not set" configuration_status="incomplete — edit and source .env" @@ -507,6 +543,11 @@ print_plan() { else configuration_status="incomplete — edit and source .env" fi + if [[ $workspace_path != YOUR_WORKSPACE_PATH && $workspace_path != /absolute/path/to/your/project ]]; then + displayed_workspace="$workspace_path" + else + configuration_status="incomplete — edit and source .env" + fi if [[ $configuration_status != ready ]]; then status_color="$yellow" fi @@ -521,6 +562,7 @@ ${bold}${blue}Configuration visible to this shell${reset} Status: ${status_color}${configuration_status}${reset} Egress Gate host: $displayed_host Pi models file: $displayed_models_path + Pi workspace: $displayed_workspace Model credential: $credential_status ${bold}${blue}Local fork workspace${reset} @@ -531,16 +573,18 @@ ${bold}${blue}Workflow${reset} ${green}1. prepare${reset} Clone or update the forks and build the configured Pi fork. ${green}2. serve${reset} Start Egress Gate in Terminal 1 and leave it running. ${green}3. gateway${reset} Start the OpenShell gateway in Terminal 2 and leave it running. - ${green}4. launch${reset} Create the credential provider and launch Pi in Terminal 3. - ${green}5. test${reset} At the Pi prompt, submit: + ${green}4. reset${reset} Recreate the sandbox once and upload the selected workspace. + ${green}5. launch${reset} Start Pi in Terminal 3. Later launches preserve its sessions. + ${green}6. test${reset} At the Pi prompt, submit: Reply with exactly: DENY_THIS Reply with exactly: REDACT_THIS - ${green}6. cleanup${reset} Delete the sandbox and credential provider. + ${green}7. cleanup${reset} Delete the sandbox and credential provider. ${bold}${blue}Inspect exact commands${reset} ./demo.sh --print prepare ./demo.sh --print serve ./demo.sh --print gateway + ./demo.sh --print reset ./demo.sh --print launch ./demo.sh --print cleanup @@ -552,6 +596,7 @@ case "$action" in prepare) prepare ;; serve) serve ;; gateway) gateway ;; + reset) reset_demo ;; launch) launch ;; cleanup) cleanup ;; all) diff --git a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile new file mode 100644 index 00000000..b168725a --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile @@ -0,0 +1,11 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +FROM ghcr.io/nvidia/openshell-community/sandboxes/pi:latest + +USER root +RUN apt-get update \ + && apt-get install -y --no-install-recommends fd-find ripgrep \ + && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && rm -rf /var/lib/apt/lists/* +USER sandbox diff --git a/projects/egress-gate/examples/pi-attested-admission/settings.json b/projects/egress-gate/examples/pi-attested-admission/settings.json index 603697d9..3b1578da 100644 --- a/projects/egress-gate/examples/pi-attested-admission/settings.json +++ b/projects/egress-gate/examples/pi-attested-admission/settings.json @@ -1,5 +1,5 @@ { "defaultProvider": "attested-provider", - "defaultModel": "azure/anthropic/claude-opus-5", + "defaultModel": "nvidia/qwen/qwen3.8-flash-next", "defaultThinkingLevel": "high" } diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index a7368aeb..8c5c89d5 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -29,6 +29,7 @@ def test_pi_example_can_print_each_action_without_running_it( "PI_MODELS_PATH": str(models_path), "PI_EGRESS_PACK_DIR": str(pack_dir), "PI_EGRESS_RUNTIME_DIR": str(runtime_dir), + "PI_WORKSPACE_PATH": str(tmp_path / "workspace"), } results = [ @@ -39,11 +40,11 @@ def test_pi_example_can_print_each_action_without_running_it( env=environment, text=True, ) - for action in ("prepare", "serve", "gateway", "launch", "cleanup") + for action in ("prepare", "serve", "gateway", "reset", "launch", "cleanup") ] output = "\n".join(result.stdout for result in results) - assert "npm run build" in output + assert "npm run build:offline" in output assert "earendil-works-pi-agent-core-VERSION.tgz" in output assert "earendil-works-pi-coding-agent-VERSION.tgz" in output assert "npm pack --workspace @earendil-works/pi-agent-core" in output @@ -76,15 +77,20 @@ def test_pi_example_can_print_each_action_without_running_it( assert "OPENAI_API_KEY" not in output assert "api.openai.com" not in output assert "sandbox create" in output + assert "--from" in output + assert "pi-attested-admission/sandbox" in output assert "--detach" in output assert "--no-git-ignore" in output assert f"{runtime_dir}/node_modules:/sandbox/pi-runtime" in output assert f"{runtime_dir}:/sandbox/pi-runtime" not in output - assert f"{models_path}:/sandbox/pi-agent/models.json" in output - assert "settings.json:/sandbox/pi-agent/settings.json" in output + assert f"{models_path}:/sandbox/.pi/agent/models.json" in output + assert "settings.json:/sandbox/.pi/agent/settings.json" in output + assert "sandbox upload" in output + assert "/sandbox/workspace" in output assert "sandbox exec" in output assert "sandbox exec --tty" in output - assert "PI_OFFLINE=1" in output + assert "PI_OFFLINE=1" not in output + assert "PI_CODING_AGENT_DIR=" not in output assert "--no-extensions" not in output assert "/sandbox/pi-runtime/node_modules/.bin/pi" in output assert "PI_OPENSHELL_CONTEXT_ADMISSION=1" in output @@ -98,6 +104,17 @@ def test_pi_example_can_print_each_action_without_running_it( assert not pack_dir.exists() assert not runtime_dir.exists() + reset_output = results[3].stdout + normalized_reset_output = " ".join(reset_output.replace("\\\n", " ").split()) + assert f"working directory: {tmp_path / 'workspace'}" in reset_output + assert ( + "sandbox upload pi-egress-demo . /sandbox/workspace" in normalized_reset_output + ) + assert ( + f"sandbox upload pi-egress-demo {tmp_path / 'workspace'}" + not in normalized_reset_output + ) + demo_script = (project_dir / "examples/pi-attested-admission/demo.sh").read_text() assert '"beforeToolResultAppend"' in demo_script assert "exec env -u PI_MODEL_API_KEY node" not in demo_script @@ -120,6 +137,7 @@ def test_pi_example_print_all_is_a_concise_walkthrough() -> None: project_dir / "examples/pi-attested-admission/models.json" ), "PI_MODEL_API_KEY": "secret-not-printed", + "PI_WORKSPACE_PATH": "/tmp/example-workspace", }, text=True, ) @@ -128,11 +146,32 @@ def test_pi_example_print_all_is_a_concise_walkthrough() -> None: assert "Configuration visible to this shell" in result.stdout assert "Model credential: set (value hidden)" in result.stdout assert "1. prepare" in result.stdout - assert "6. cleanup" in result.stdout + assert "7. cleanup" in result.stdout assert "secret-not-printed" not in result.stdout assert "working directory:" not in result.stdout +def test_pi_example_launch_preserves_the_prepared_sandbox() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + + result = subprocess.run( + ["bash", str(script), "--print", "launch"], + check=True, + capture_output=True, + env=os.environ, + text=True, + ) + + normalized_output = " ".join(result.stdout.replace("\\\n", " ").split()) + assert "sandbox exec --tty" in normalized_output + assert "--workdir /sandbox/workspace" in normalized_output + assert "sandbox delete" not in result.stdout + assert "sandbox create" not in result.stdout + assert "provider delete" not in result.stdout + assert "provider create" not in result.stdout + + def test_pi_example_uses_terminal_colors_without_leaking_them_to_redirects() -> None: project_dir = Path(__file__).parents[1] script = project_dir / "examples/pi-attested-admission/demo.sh" @@ -222,7 +261,7 @@ def test_pi_example_uses_standard_checked_in_configuration() -> None: settings = json.loads((example_dir / "settings.json").read_text()) assert settings == { "defaultProvider": "attested-provider", - "defaultModel": "azure/anthropic/claude-opus-5", + "defaultModel": "nvidia/qwen/qwen3.8-flash-next", "defaultThinkingLevel": "high", } @@ -241,6 +280,11 @@ def test_pi_example_uses_standard_checked_in_configuration() -> None: assert endpoint["port"] == 443 middleware = policy["network_middlewares"]["pi_egress_gate"] assert middleware["endpoints"]["include"] == ["inference-api.nvidia.com"] + assert set(policy["network_policies"]) == {"model_provider"} + + sandbox_dockerfile = (example_dir / "sandbox/Dockerfile").read_text() + assert "openshell-community/sandboxes/pi:latest" in sandbox_dockerfile + assert "fd-find ripgrep" in sandbox_dockerfile gateway_template = (example_dir / "gateway-middleware.toml.example").read_text() gateway_fragment = tomllib.loads( @@ -266,11 +310,12 @@ def test_pi_example_reports_all_missing_configuration_before_work( "EGRESS_GATE_HOST_IP", "PI_MODELS_PATH", "PI_MODEL_API_KEY", + "PI_WORKSPACE_PATH", } } result = subprocess.run( - ["bash", str(script), "prepare"], + ["bash", str(script), "reset"], capture_output=True, cwd=tmp_path, env=environment, @@ -283,6 +328,7 @@ def test_pi_example_reports_all_missing_configuration_before_work( assert "EGRESS_GATE_HOST_IP" in result.stderr assert "PI_MODELS_PATH" in result.stderr assert "PI_MODEL_API_KEY" in result.stderr + assert "PI_WORKSPACE_PATH" in result.stderr assert "source .env" in result.stderr assert "git pull" not in result.stderr From 5fe35c6cc02ebcaf143a5e2c166b97b45acef906 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 1 Sep 2026 11:37:40 -0400 Subject: [PATCH 28/70] fix(egress-gate): default Pi demo to empty workspace --- .../pi-attested-admission/.env.example | 3 +- .../examples/pi-attested-admission/README.md | 11 +++--- .../examples/pi-attested-admission/demo.sh | 27 +++++++------- .../pi-attested-admission/sandbox/Dockerfile | 2 ++ .../tests/test_pi_example_commands.py | 36 ++++++++++++++++++- 5 files changed, 59 insertions(+), 20 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example index c9909dc3..d9a72595 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.env.example +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -1,4 +1,5 @@ EGRESS_GATE_HOST_IP=YOUR_HOST_IPV4 PI_MODELS_PATH=./models.json PI_MODEL_API_KEY=your-provider-key -PI_WORKSPACE_PATH=/absolute/path/to/your/project +# Optional: omit this to start Pi in an empty workspace. +# PI_WORKSPACE_PATH=/absolute/path/to/your/project diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index b49ff75a..902a17c2 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -59,11 +59,12 @@ If the model endpoint does not require authentication, set `PI_MODEL_API_KEY=unused`. Source `.env` in the terminals that run `gateway` and `reset`; the other actions do not consume the credential. -`PI_WORKSPACE_PATH` is the absolute path of the project you want Pi to work on. -The reset step uploads its contents to `/sandbox/workspace` using the project's normal -`.gitignore` rules. Pi starts in that directory, so project instructions, -extensions, skills, prompts, and session grouping follow its ordinary -current-directory behavior. +`PI_WORKSPACE_PATH` is optional. Set it to the absolute path of a project you +want Pi to work on. The reset step uploads its contents to `/sandbox/workspace` +using the project's normal `.gitignore` rules. If you omit it, Pi starts in an +empty `/sandbox/workspace`; no local files are copied. In either case, Pi starts +there, so project instructions, extensions, skills, prompts, and session +grouping follow its ordinary current-directory behavior. `PI_MODELS_PATH` points to a standard Pi `models.json`. Relative paths are resolved from this example directory. The checked-in [models.json](models.json) diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 3f21ee63..fb1d129e 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -29,7 +29,7 @@ if [[ $models_path_value == /* || $models_path_value == YOUR_MODELS_PATH ]]; the else models_path=$script_dir/${models_path_value#./} fi -workspace_path=${PI_WORKSPACE_PATH:-YOUR_WORKSPACE_PATH} +workspace_path=${PI_WORKSPACE_PATH:-} pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} openshell_cli=$openshell_repo/scripts/bin/openshell @@ -231,12 +231,11 @@ require_setup_configuration() { if [[ -z ${PI_MODEL_API_KEY:-} || ${PI_MODEL_API_KEY:-} == your-provider-key ]]; then missing+=(PI_MODEL_API_KEY) fi - if [[ -z ${PI_WORKSPACE_PATH:-} || ${PI_WORKSPACE_PATH:-} == /absolute/path/to/your/project ]]; then - missing+=(PI_WORKSPACE_PATH) - fi if ((${#missing[@]} == 0)); then require_file "$models_path" "Pi model configuration" - require_directory "$workspace_path" "Pi workspace" + if [[ -n $workspace_path ]]; then + require_directory "$workspace_path" "Pi workspace" + fi return fi @@ -430,9 +429,13 @@ create_demo_sandbox() { --upload "$pi_settings:/sandbox/.pi/agent/settings.json" \ --no-git-ignore \ --detach - describe_printed_commands "Upload the selected workspace with its .gitignore rules:" - run_in "$workspace_path" "$openshell_cli" --gateway "$gateway_name" sandbox upload \ - pi-egress-demo . /sandbox/workspace + if [[ -n $workspace_path ]]; then + describe_printed_commands "Upload the selected workspace with its .gitignore rules:" + run_in "$workspace_path" "$openshell_cli" --gateway "$gateway_name" sandbox upload \ + pi-egress-demo . /sandbox/workspace + elif $print_only; then + describe_printed_commands "No workspace selected; Pi starts in an empty /sandbox/workspace." + fi } reset_demo() { @@ -529,7 +532,7 @@ print_plan() { local credential_status="not set" local displayed_host="$host_ip" local displayed_models_path="$models_path" - local displayed_workspace="not set" + local displayed_workspace="empty /sandbox/workspace" if [[ $displayed_host == YOUR_HOST_IPV4 ]]; then displayed_host="not set" configuration_status="incomplete — edit and source .env" @@ -543,10 +546,8 @@ print_plan() { else configuration_status="incomplete — edit and source .env" fi - if [[ $workspace_path != YOUR_WORKSPACE_PATH && $workspace_path != /absolute/path/to/your/project ]]; then + if [[ -n $workspace_path ]]; then displayed_workspace="$workspace_path" - else - configuration_status="incomplete — edit and source .env" fi if [[ $configuration_status != ready ]]; then status_color="$yellow" @@ -573,7 +574,7 @@ ${bold}${blue}Workflow${reset} ${green}1. prepare${reset} Clone or update the forks and build the configured Pi fork. ${green}2. serve${reset} Start Egress Gate in Terminal 1 and leave it running. ${green}3. gateway${reset} Start the OpenShell gateway in Terminal 2 and leave it running. - ${green}4. reset${reset} Recreate the sandbox once and upload the selected workspace. + ${green}4. reset${reset} Recreate the sandbox; upload a workspace only when one is selected. ${green}5. launch${reset} Start Pi in Terminal 3. Later launches preserve its sessions. ${green}6. test${reset} At the Pi prompt, submit: Reply with exactly: DENY_THIS diff --git a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile index b168725a..d80e70b5 100644 --- a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile +++ b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile @@ -7,5 +7,7 @@ USER root RUN apt-get update \ && apt-get install -y --no-install-recommends fd-find ripgrep \ && ln -s /usr/bin/fdfind /usr/local/bin/fd \ + && mkdir -p /sandbox/workspace \ + && chown sandbox:sandbox /sandbox/workspace \ && rm -rf /var/lib/apt/lists/* USER sandbox diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 8c5c89d5..301ab979 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -151,6 +151,40 @@ def test_pi_example_print_all_is_a_concise_walkthrough() -> None: assert "working directory:" not in result.stdout +def test_pi_example_uses_an_empty_workspace_when_no_path_is_configured() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + environment = { + name: value for name, value in os.environ.items() if name != "PI_WORKSPACE_PATH" + } | { + "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_MODELS_PATH": str( + project_dir / "examples/pi-attested-admission/models.json" + ), + "PI_MODEL_API_KEY": "secret-not-printed", + } + + reset = subprocess.run( + ["bash", str(script), "--print", "reset"], + check=True, + capture_output=True, + env=environment, + text=True, + ) + walkthrough = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=environment, + text=True, + ) + + assert "sandbox upload" not in reset.stdout + assert "empty /sandbox/workspace" in reset.stdout + assert "Status: ready" in walkthrough.stdout + assert "Pi workspace: empty /sandbox/workspace" in walkthrough.stdout + + def test_pi_example_launch_preserves_the_prepared_sandbox() -> None: project_dir = Path(__file__).parents[1] script = project_dir / "examples/pi-attested-admission/demo.sh" @@ -328,7 +362,7 @@ def test_pi_example_reports_all_missing_configuration_before_work( assert "EGRESS_GATE_HOST_IP" in result.stderr assert "PI_MODELS_PATH" in result.stderr assert "PI_MODEL_API_KEY" in result.stderr - assert "PI_WORKSPACE_PATH" in result.stderr + assert "PI_WORKSPACE_PATH" not in result.stderr assert "source .env" in result.stderr assert "git pull" not in result.stderr From b2104d6e1c631a005072249f66156f42cd97c34f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 10:25:13 -0400 Subject: [PATCH 29/70] refactor(egress-gate): externalize Pi runtime integration --- .../examples/pi-attested-admission/README.md | 65 +++-- .../examples/pi-attested-admission/demo.sh | 23 +- .../openshell-context-admission.ts | 235 ++++++++++++++++++ .../runtime-extension/openshell-pi.ts | 15 ++ .../runtime-extension/package.json | 4 + .../runtime-extension/tsconfig.json | 13 + .../js/openshell-context-admission.test.mjs | 119 +++++++++ .../tests/test_pi_example_commands.py | 21 +- 8 files changed, 469 insertions(+), 26 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/runtime-extension/package.json create mode 100644 projects/egress-gate/examples/pi-attested-admission/runtime-extension/tsconfig.json create mode 100644 projects/egress-gate/tests/js/openshell-context-admission.test.mjs diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 902a17c2..9df829fc 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -156,21 +156,24 @@ Then launch Pi: ./demo.sh launch ``` -This executes the fork's normal `pi` entrypoint. The explicit -`PI_OPENSHELL_CONTEXT_ADMISSION=1` setting makes its built-in OpenShell -admission boundary mandatory for the session. `launch` only enters the existing -sandbox; it does not replace the sandbox or Pi's state. Exit and run `launch` -again to use Pi's normal `/resume` flow and persistent JSONL sessions. Run -`reset` only when you intentionally want a fresh sandbox or need to apply a new -runtime, policy, model configuration, credential, or workspace snapshot. +This runs Pi's standard CLI with a trusted runtime extension. Unlike ordinary +user and project extensions, a runtime extension is installed by the launcher, +supplies mandatory runtime boundaries, and is not affected by +`--no-extensions`. Pi still owns argument parsing, the TUI, settings, ordinary +extensions, tools, model selection, compaction, and session storage. `launch` +only enters the existing sandbox; it does not replace the sandbox or Pi's +state. Exit and run `launch` again to use Pi's normal `/resume` flow and +persistent JSONL sessions. Run `reset` only when you intentionally want a fresh +sandbox or need to apply a new runtime, policy, model configuration, credential, +or workspace snapshot. The sandbox image adds the `fd` and `rg` executables used by Pi's standard -`find` and `grep` tools. Pi itself still comes from the prepared fork package, -and starts without a wrapper or restrictive CLI flags. Its standard user and -project resource discovery, extension loading, tools, model picker, thinking -controls, compaction, and session manager remain active. OpenShell's filesystem -and network policy still apply to every process in the sandbox; arbitrary -package downloads are intentionally outside this endpoint-focused example. +`find` and `grep` tools. Pi itself still comes from the prepared fork package +and starts without restrictive CLI flags. Its standard user and project +resource discovery, extension loading, tools, model picker, thinking controls, +compaction, and session manager remain active. OpenShell's filesystem and +network policy still apply to every process in the sandbox; arbitrary package +downloads are intentionally outside this endpoint-focused example. The example registers an endpoint-specific provider profile using the host-side `PI_MODEL_API_KEY`. Its `delivery: proxy` setting keeps the credential and any @@ -210,14 +213,19 @@ message or tool result reaches that history. `launch` preserves the history; ## How it works -1. The forked `pi` entrypoint sees `PI_OPENSHELL_CONTEXT_ADMISSION=1` and installs - its built-in mandatory `ContextAdmission` boundary. Pi otherwise starts - normally, including its standard project and user extension discovery. +1. Pi exposes a provider-neutral `runCli()` entrypoint and a `RuntimeExtension` + interface for mandatory `ContextAdmission` hooks. The TypeScript + [openshell-pi.ts](runtime-extension/openshell-pi.ts) launcher calls that + entrypoint with the OpenShell adapter from + [openshell-context-admission.ts](runtime-extension/openshell-context-admission.ts). + `prepare` type-checks both files against the packaged Pi API and compiles + them to JavaScript for the sandbox. Pi otherwise starts normally, including + standard project and user extension discovery. 2. Pi calls that boundary for each rendered user message and finalized tool result before it queues, appends, or persists the value. -3. The adapter sends the exact context addition to OpenShell's sandbox-local - bridge. Egress Gate applies `policy.yaml` and returns allow, deny, or a - complete replacement. +3. The external adapter sends the exact context addition to OpenShell's + sandbox-local bridge. Egress Gate applies `policy.yaml` and returns allow, + deny, or a complete replacement. 4. OpenShell keeps the signed attestation and gives Pi only an opaque handle. The adapter keeps handles in its private closure, outside Pi messages. 5. Immediately before every provider request, Pi passes the exact outbound @@ -230,6 +238,25 @@ message or tool result reaches that history. `launch` preserves the history; verifies the latest context addition and scans the complete provider request before OpenShell injects the proxy-delivered model credential. +This division is intentional. The Pi fork contributes only reusable harness +primitives: mandatory admission of user messages and finalized tool results, +admission of the exact provider context, an outbound-header transformation, and +a standard-CLI entrypoint that accepts those hooks. OpenShell contributes the +sandbox-local bridge, signed receipts, receipt-to-request binding, middleware +enforcement, and post-policy credential delivery. The TypeScript files under +`runtime-extension/` are the reusable integration layer that translates between +those generic Pi hooks and the OpenShell protocol; no OpenShell-specific code is +built into Pi. + +The current OpenShell bridge is supervisor-owned but reachable by every process +inside the sandbox over loopback; it does not yet authenticate the calling +process. Receipt binding still prevents an unadmitted provider request from +passing the egress middleware. However, OpenShell cannot prove that the +designated harness invoked admission before changing its own local memory or +session files. A stronger runtime needs one additional OpenShell primitive: a +process-scoped admission capability, or a supervisor-owned adapter channel that +only the designated harness can invoke. + ## Current scope The attestation adapter supports normal text turns, text tool results, queued diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index fb1d129e..25104ab7 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -39,6 +39,8 @@ runtime_provider_profile=$script_dir/provider-profile.yaml runtime_gateway_fragment=$runtime_dir/gateway-middleware.toml gateway_fragment_template=$script_dir/gateway-middleware.toml.example pi_settings=$script_dir/settings.json +runtime_extension_source=$script_dir/runtime-extension +runtime_extension_build=$runtime_dir/integration z3_library_path_override=${Z3_LIBRARY_PATH_OVERRIDE:-} bold="" @@ -341,6 +343,11 @@ prepare() { run_in "$pi_repo" npm pack --workspace @earendil-works/pi-agent-core --pack-destination "$pack_dir" run_in "$pi_repo" npm pack --workspace @earendil-works/pi-coding-agent --pack-destination "$pack_dir" run_in "$pi_repo" npm install --prefix "$runtime_dir" --ignore-scripts "$agent_tarball" "$coding_agent_tarball" + describe_printed_commands "Type-check and compile the trusted runtime extension:" + run_in "$pi_repo" mkdir -p "$runtime_dir/integration-src" "$runtime_extension_build" + run_in "$pi_repo" cp -R "$runtime_extension_source/." "$runtime_dir/integration-src" + run_in "$runtime_dir/integration-src" "$pi_repo/node_modules/.bin/tsgo" -p tsconfig.json + run_in "$runtime_dir/integration-src" cp package.json "$runtime_extension_build/package.json" } serve() { @@ -427,6 +434,9 @@ create_demo_sandbox() { --upload "$runtime_dir/node_modules:/sandbox/pi-runtime" \ --upload "$models_path:/sandbox/.pi/agent/models.json" \ --upload "$pi_settings:/sandbox/.pi/agent/settings.json" \ + --upload "$runtime_extension_build/openshell-context-admission.js:/sandbox/pi-runtime/integration/openshell-context-admission.js" \ + --upload "$runtime_extension_build/openshell-pi.js:/sandbox/pi-runtime/integration/openshell-pi.js" \ + --upload "$runtime_extension_build/package.json:/sandbox/pi-runtime/integration/package.json" \ --no-git-ignore \ --detach if [[ -n $workspace_path ]]; then @@ -451,10 +461,14 @@ reset_demo() { "beforeToolResultAppend" \ "installed Pi agent core" require_file_contains \ - "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/cli.js" \ - "PI_OPENSHELL_CONTEXT_ADMISSION" \ - "installed Pi CLI" + "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/index.js" \ + "runCli" \ + "installed Pi SDK" require_file "$pi_settings" "Pi settings" + require_file "$runtime_extension_build/openshell-context-admission.js" \ + "compiled OpenShell context-admission adapter" + require_file "$runtime_extension_build/openshell-pi.js" "compiled OpenShell Pi launcher" + require_file "$runtime_extension_build/package.json" "runtime-extension package metadata" require_file "$runtime_policy" "OpenShell sandbox policy" require_file "$runtime_provider_profile" "OpenShell provider profile" fi @@ -486,9 +500,8 @@ launch() { run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ sandbox exec --tty -n pi-egress-demo --workdir /sandbox/workspace -- \ env \ - PI_OPENSHELL_CONTEXT_ADMISSION=1 \ OPENSHELL_AGENT_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation \ - /sandbox/pi-runtime/node_modules/.bin/pi + node /sandbox/pi-runtime/integration/openshell-pi.js } cleanup() { diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts new file mode 100644 index 00000000..59ce0d37 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts @@ -0,0 +1,235 @@ +import { createHash } from "node:crypto"; +import type { + Context, + ImageContent, + ProviderHeaders, + TextContent, + ToolResultMessage, + UserMessage, +} from "@earendil-works/pi-ai"; +import type { + ContextAdmission, + ContextAdmissionResult, + InputSource, +} from "@earendil-works/pi-coding-agent"; + +const HANDLE_HEADER = "x-openshell-agent-admission-handle"; +const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; +const MAX_BRIDGE_RESPONSE_BYTES = MAX_ADMISSION_BYTES * 4 + 64 * 1024; +const MAX_HANDLE_ENTRIES = 1024; + +type ContentBlock = TextContent | ImageContent; +type UserEnvelope = { schema_version: "openshell.pi-input.v1"; text: string }; +type ToolResultEnvelope = { + schema_version: "openshell.pi-tool-result.v1"; + tool_call_id: string; + tool_name: string; + content: ContentBlock[]; + is_error: boolean; +}; +type AdmissionEnvelope = UserEnvelope | ToolResultEnvelope; +type BridgeResult = + | { decision: "deny"; reason_code?: string } + | { decision: "allow"; handle: string; replacement_body?: number[] }; + +export function createOpenShellContextAdmission( + bridgeUrl: string, + getSessionId: () => string, + fetchRequest: typeof fetch = fetch, +): ContextAdmission { + const handles = new Map(); + + async function requestAdmission( + hook: "rendered_prompt_admission" | "tool_result_admission", + envelope: AdmissionEnvelope, + ): Promise { + const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); + if (requestBody.byteLength > MAX_ADMISSION_BYTES) { + throw new Error("OpenShell admission request is too large"); + } + const response = await fetchRequest(bridgeUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + harness_version: "sdk-v1", + hook, + schema_version: envelope.schema_version, + session_id: getSessionId(), + submission_id: crypto.randomUUID(), + request_body: Array.from(requestBody), + }), + }); + if (!response.ok) throw new Error("OpenShell admission is unavailable"); + const encoded = new Uint8Array(await response.arrayBuffer()); + if (encoded.byteLength > MAX_BRIDGE_RESPONSE_BYTES) { + throw new Error("OpenShell admission response is too large"); + } + return parseBridgeResult(JSON.parse(new TextDecoder().decode(encoded))); + } + + async function admitUserMessage( + message: UserMessage, + _context?: { source: InputSource }, + ): Promise> { + const envelope = userEnvelope(message); + if (!envelope) { + return { action: "deny", reason: "Image inputs are not supported by OpenShell admission" }; + } + const result = await requestAdmission("rendered_prompt_admission", envelope); + if (result.decision === "deny") return denied(result.reason_code); + const admittedEnvelope = result.replacement_body + ? parseUserEnvelope(new Uint8Array(result.replacement_body)) + : envelope; + const admittedMessage: UserMessage = { + ...message, + content: replaceUserText(message.content, admittedEnvelope.text), + }; + rememberHandle(handles, messageKey(admittedMessage), result.handle); + return admittedEnvelope.text === envelope.text + ? { action: "allow" } + : { action: "allow", message: admittedMessage }; + } + + async function admitToolResult( + message: ToolResultMessage, + ): Promise> { + const envelope = toolResultEnvelope(message); + const result = await requestAdmission("tool_result_admission", envelope); + if (result.decision === "deny") return denied(result.reason_code); + const admittedEnvelope = result.replacement_body + ? parseToolResultEnvelope(new Uint8Array(result.replacement_body)) + : envelope; + const admittedMessage: ToolResultMessage = { ...message, content: admittedEnvelope.content }; + rememberHandle(handles, messageKey(admittedMessage), result.handle); + return result.replacement_body ? { action: "allow", message: admittedMessage } : { action: "allow" }; + } + + return { + admitUserMessage, + admitToolResult, + async admitProviderContext(context) { + for (let index = context.messages.length - 1; index >= 0; index -= 1) { + const message = context.messages[index]; + if (message.role !== "user" && message.role !== "toolResult") continue; + const result = message.role === "user" ? await admitUserMessage(message) : await admitToolResult(message); + if (result.action === "deny") return result; + if (!result.message) return { action: "allow" }; + const messages = [...context.messages]; + messages[index] = result.message; + return { action: "allow", context: { ...context, messages } }; + } + return { action: "deny", reason: "Provider context has no user message or tool result to admit" }; + }, + async transformProviderHeaders(headers: ProviderHeaders, context: Context) { + if (Object.keys(headers).some((name) => name.toLowerCase() === HANDLE_HEADER)) { + throw new Error("OpenShell admission handle header is reserved"); + } + for (let index = context.messages.length - 1; index >= 0; index -= 1) { + const message = context.messages[index]; + if (message.role !== "user" && message.role !== "toolResult") continue; + const handle = handles.get(messageKey(message)); + if (handle) return { ...headers, [HANDLE_HEADER]: handle }; + } + throw new Error("OpenShell admission handle is missing for the outbound context"); + }, + }; +} + +function userEnvelope(message: UserMessage): UserEnvelope | undefined { + if (typeof message.content === "string") { + return { schema_version: "openshell.pi-input.v1", text: message.content }; + } + if (message.content.some((block) => block.type === "image")) return undefined; + return { + schema_version: "openshell.pi-input.v1", + text: message.content.map((block) => (block as TextContent).text).join("\n"), + }; +} + +function toolResultEnvelope(message: ToolResultMessage): ToolResultEnvelope { + return { + schema_version: "openshell.pi-tool-result.v1", + tool_call_id: message.toolCallId, + tool_name: message.toolName, + content: message.content, + is_error: message.isError, + }; +} + +function messageKey(message: UserMessage | ToolResultMessage): string { + return createHash("sha256") + .update(JSON.stringify(message.role === "user" ? userEnvelope(message) : toolResultEnvelope(message))) + .digest("hex"); +} + +function rememberHandle(handles: Map, key: string, handle: string): void { + handles.delete(key); + handles.set(key, handle); + if (handles.size > MAX_HANDLE_ENTRIES) { + const oldest = handles.keys().next().value; + if (oldest !== undefined) handles.delete(oldest); + } +} + +function replaceUserText(content: UserMessage["content"], text: string): UserMessage["content"] { + return typeof content === "string" ? text : [{ type: "text", text }]; +} + +function denied(reasonCode?: string): { action: "deny"; reason: string } { + return { + action: "deny", + reason: reasonCode + ? `OpenShell denied this context addition (${reasonCode})` + : "OpenShell denied this context addition", + }; +} + +function parseBridgeResult(value: unknown): BridgeResult { + if (!isRecord(value) || (value.decision !== "allow" && value.decision !== "deny")) { + throw new Error("OpenShell admission returned an invalid response"); + } + if (value.decision === "deny") { + return { decision: "deny", reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined }; + } + if (typeof value.handle !== "string" || !value.handle || value.handle.length > 1024) { + throw new Error("OpenShell admission returned an invalid handle"); + } + if ( + value.replacement_body !== undefined && + (!isByteArray(value.replacement_body) || value.replacement_body.length > MAX_ADMISSION_BYTES) + ) { + throw new Error("OpenShell admission returned an invalid replacement"); + } + return { decision: "allow", handle: value.handle, replacement_body: value.replacement_body }; +} + +function parseUserEnvelope(body: Uint8Array): UserEnvelope { + const value: unknown = JSON.parse(new TextDecoder().decode(body)); + if (!isRecord(value) || value.schema_version !== "openshell.pi-input.v1" || typeof value.text !== "string") { + throw new Error("OpenShell admission returned an invalid user replacement"); + } + return { schema_version: "openshell.pi-input.v1", text: value.text }; +} + +function parseToolResultEnvelope(body: Uint8Array): ToolResultEnvelope { + const value: unknown = JSON.parse(new TextDecoder().decode(body)); + if ( + !isRecord(value) || + value.schema_version !== "openshell.pi-tool-result.v1" || + typeof value.tool_call_id !== "string" || + typeof value.tool_name !== "string" || + !Array.isArray(value.content) || + typeof value.is_error !== "boolean" + ) { + throw new Error("OpenShell admission returned an invalid tool-result replacement"); + } + return value as ToolResultEnvelope; +} + +function isByteArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts new file mode 100644 index 00000000..ed771cbe --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts @@ -0,0 +1,15 @@ +import { runCli, type RuntimeExtension } from "@earendil-works/pi-coding-agent"; + +import { createOpenShellContextAdmission } from "./openshell-context-admission.js"; + +const bridgeUrl = process.env.OPENSHELL_AGENT_CONVERSATION_URL; +if (!bridgeUrl) { + throw new Error("OPENSHELL_AGENT_CONVERSATION_URL is required"); +} + +const runtimeExtension: RuntimeExtension = { + createContextAdmission: (sessionManager) => + createOpenShellContextAdmission(bridgeUrl, () => sessionManager.getSessionId()), +}; + +await runCli(process.argv.slice(2), { runtimeExtension }); diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/package.json b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/package.json new file mode 100644 index 00000000..e986b24b --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/package.json @@ -0,0 +1,4 @@ +{ + "private": true, + "type": "module" +} diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/tsconfig.json b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/tsconfig.json new file mode 100644 index 00000000..cccd9db4 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noEmitOnError": true, + "outDir": "../integration", + "rootDir": ".", + "skipLibCheck": true, + "strict": true, + "target": "ES2022" + }, + "include": ["*.ts"] +} diff --git a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs new file mode 100644 index 00000000..c7ea4801 --- /dev/null +++ b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs @@ -0,0 +1,119 @@ +import assert from "node:assert/strict"; +import { describe, it } from "node:test"; + +import { createOpenShellContextAdmission } from "../../examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts"; + +const HANDLE_HEADER = "x-openshell-agent-admission-handle"; + +function user(text, timestamp) { + return { role: "user", content: [{ type: "text", text }], timestamp }; +} + +function toolResult(text, isError = false) { + return { + role: "toolResult", + toolCallId: "call-1", + toolName: "bash", + content: [{ type: "text", text }], + isError, + timestamp: 2, + }; +} + +async function admittedContext(admission, context) { + const result = await admission.admitProviderContext(context); + assert.equal(result.action, "allow"); + return result.context ?? context; +} + +describe("OpenShell context admission adapter", () => { + it("selects the handle for the exact provider context", async () => { + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + async (_url, init) => { + const request = JSON.parse(String(init?.body)); + const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.text}` })); + }, + ); + const current = user("current", 1); + const queued = user("queued", 2); + + assert.equal((await admission.admitUserMessage(current, { source: "interactive" })).action, "allow"); + assert.equal((await admission.admitUserMessage(queued, { source: "interactive" })).action, "allow"); + + const currentHeaders = await admission.transformProviderHeaders( + {}, + await admittedContext(admission, { messages: [current], tools: [] }), + ); + const queuedHeaders = await admission.transformProviderHeaders( + {}, + await admittedContext(admission, { messages: [current, queued], tools: [] }), + ); + + assert.equal(currentHeaders[HANDLE_HEADER], "handle:current"); + assert.equal(queuedHeaders[HANDLE_HEADER], "handle:queued"); + }); + + it("uses an admitted replacement for the outbound handle", async () => { + const replacement = new TextEncoder().encode( + JSON.stringify({ schema_version: "openshell.pi-input.v1", text: "[REDACTED]" }), + ); + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + async () => + new Response( + JSON.stringify({ decision: "allow", handle: "replacement-handle", replacement_body: [...replacement] }), + ), + ); + const admitted = await admission.admitUserMessage(user("secret", 1), { source: "interactive" }); + + assert.equal(admitted.action, "allow"); + assert.ok(admitted.message); + const context = await admittedContext(admission, { messages: [admitted.message], tools: [] }); + const headers = await admission.transformProviderHeaders({}, context); + + assert.deepEqual(admitted.message.content, [{ type: "text", text: "[REDACTED]" }]); + assert.equal(headers[HANDLE_HEADER], "replacement-handle"); + }); + + it("attests failed tool results", async () => { + const hooks = []; + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + async (_url, init) => { + const request = JSON.parse(String(init?.body)); + hooks.push(request.hook); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${request.hook}` })); + }, + ); + const prompt = user("run command", 1); + const failed = toolResult("Command exited with code 2", true); + + await admission.admitUserMessage(prompt, { source: "interactive" }); + await admission.admitToolResult(failed); + const headers = await admission.transformProviderHeaders( + {}, + await admittedContext(admission, { messages: [prompt, failed], tools: [] }), + ); + + assert.equal(headers[HANDLE_HEADER], "handle:tool_result_admission"); + assert.deepEqual(hooks, ["rendered_prompt_admission", "tool_result_admission", "tool_result_admission"]); + }); + + it("fails closed when provider-only context is denied", async () => { + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + async () => new Response(JSON.stringify({ decision: "deny", reason_code: "policy_denied" })), + ); + + assert.deepEqual(await admission.admitProviderContext({ messages: [user("summary", 1)], tools: [] }), { + action: "deny", + reason: "OpenShell denied this context addition (policy_denied)", + }); + }); +}); diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 301ab979..f7b0a773 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -12,6 +12,19 @@ import yaml +def test_pi_openshell_context_admission_adapter() -> None: + project_dir = Path(__file__).parents[1] + subprocess.run( + [ + "node", + "--test", + str(project_dir / "tests/js/openshell-context-admission.test.mjs"), + ], + check=True, + cwd=project_dir, + ) + + def test_pi_example_can_print_each_action_without_running_it( tmp_path: Path, ) -> None: @@ -43,6 +56,7 @@ def test_pi_example_can_print_each_action_without_running_it( for action in ("prepare", "serve", "gateway", "reset", "launch", "cleanup") ] output = "\n".join(result.stdout for result in results) + normalized_output = " ".join(output.replace("\\\n", " ").split()) assert "npm run build:offline" in output assert "earendil-works-pi-agent-core-VERSION.tgz" in output @@ -92,11 +106,14 @@ def test_pi_example_can_print_each_action_without_running_it( assert "PI_OFFLINE=1" not in output assert "PI_CODING_AGENT_DIR=" not in output assert "--no-extensions" not in output - assert "/sandbox/pi-runtime/node_modules/.bin/pi" in output - assert "PI_OPENSHELL_CONTEXT_ADMISSION=1" in output + assert "node /sandbox/pi-runtime/integration/openshell-pi.js" in normalized_output + assert "PI_OPENSHELL_CONTEXT_ADMISSION" not in output assert "OPENSHELL_AGENT_CONVERSATION_URL=" in output assert "managed-pi" not in output assert "--extension " not in output + assert "integration/openshell-context-admission.js" in output + assert "integration/openshell-pi.js" in output + assert "Type-check and compile the trusted runtime extension" in output assert "sandbox delete" in output assert all(result.stderr == "" for result in results) assert not pi_repo.exists() From c3d01334bf2f3fa6133e8fcb4e9e604f8497e64e Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 17:14:06 +0000 Subject: [PATCH 30/70] feat(egress-gate): finalize agent admission contracts --- .../.openshell-middleware-manifest.json | 7 +- projects/egress-gate/README.md | 17 +- .../examples/pi-attested-admission/README.md | 5 +- .../examples/pi-attested-admission/demo.sh | 2 +- .../openshell-context-admission.ts | 22 ++- .../src/egress_gate/admission/__init__.py | 10 +- .../src/egress_gate/admission/adapters.py | 52 ++++-- .../src/egress_gate/admission/models.py | 10 +- .../src/egress_gate/admission/processor.py | 12 +- .../src/egress_gate/admission/receipts.py | 169 +----------------- projects/egress-gate/src/egress_gate/cli.py | 8 +- .../src/egress_gate/service/server.py | 4 +- .../src/egress_gate/service/servicer.py | 57 ++---- .../tests/admission/fixtures/README.md | 20 +++ .../fixtures/pi-openai-completions.json | 94 +++++++++- .../fixtures/pi-openai-responses.json | 24 +-- .../tests/admission/test_admission.py | 115 ++++++++---- .../js/openshell-context-admission.test.mjs | 9 +- .../tests/service/test_grpc_integration.py | 30 ++-- .../tests/service/test_servicer.py | 6 +- projects/egress-gate/tests/test_cli.py | 10 +- 21 files changed, 339 insertions(+), 344 deletions(-) create mode 100644 projects/egress-gate/tests/admission/fixtures/README.md diff --git a/projects/egress-gate/.openshell-middleware-manifest.json b/projects/egress-gate/.openshell-middleware-manifest.json index 59d9d114..0d0ec995 100644 --- a/projects/egress-gate/.openshell-middleware-manifest.json +++ b/projects/egress-gate/.openshell-middleware-manifest.json @@ -1,7 +1,8 @@ { - "openshell_version": "v0.0.97", - "proto_source": "https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.97/proto/supervisor_middleware.proto", - "proto_sha256": "e9d5a992ff5b50a33e9625176aaf6df8496d6774aa2ef3afe5cae7bc83c01105", + "openshell_version": "johnnygreco/OpenShell@8332c459c89124499e76da0a4095af9661aec10f", + "proto_source": "https://raw.githubusercontent.com/johnnygreco/OpenShell/8332c459c89124499e76da0a4095af9661aec10f/proto/supervisor_middleware.proto", + "proto_sha256": "2bda09fcbabc37663fbddfb8c49b6ae2689b25f3315912b0e8416a6bd2ac8e50", + "contract_note": "EvaluateAgentConversation is fork-only until the agent-conversation contract is upstreamed.", "languages": [ "python" ], diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index a440e1b2..f7820a2b 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -24,7 +24,7 @@ commands work from any directory and do not depend on repository-only files: egress-gate gates list egress-gate gates schema egress-gate validate --policy /absolute/path/to/your-policy.yaml -egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-attestation +egress-gate serve --listen 127.0.0.1:50051 --no-require-agent-attestation ``` ## Source-checkout quickstart @@ -39,7 +39,7 @@ uv run egress-gate gates list uv run egress-gate gates schema uv run egress-gate validate \ --policy examples/regex-redaction/egress-gate-config.yaml -uv run egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-attestation +uv run egress-gate serve --listen 127.0.0.1:50051 --no-require-agent-attestation uv run egress-gate evaluate \ --policy examples/regex-redaction/egress-gate-config.yaml \ --cases examples/regex-redaction/cases.yaml @@ -51,8 +51,9 @@ listen port to trusted networks. The CLI requires managed Pi context attestations by default, coupling admission to provider egress verification. The general Gate quickstarts opt out -explicitly. Keep the default, or pass `--require-pi-attestation`, for managed -Pi; use `--no-require-pi-attestation` only for an intentionally unmanaged deployment. +explicitly. Keep the default, or pass `--require-agent-attestation`, for a +managed harness; use `--no-require-agent-attestation` only for an intentionally +unmanaged deployment. See the [managed Pi example](examples/pi-attested-admission/README.md) for the matching Pi and OpenShell fork branches, startup contract, and current limits. @@ -94,7 +95,7 @@ need initialization, helper bases, or typed resources use the full class-based ```bash uv run egress-gate --registry my_gates:registry gates list -uv run egress-gate --registry my_gates:registry serve --no-require-pi-attestation +uv run egress-gate --registry my_gates:registry serve --no-require-agent-attestation ``` OpenShell owns interception, routing, and credential attachment. Egress Gate @@ -110,13 +111,13 @@ from egress_gate.service import EgressGateServer server = EgressGateServer( create_builtin_registry(), timeout_middleware_processing=10, - require_pi_attestation=False, + require_agent_attestation=False, ) server.serve_sync("127.0.0.1:50051") ``` -Make the `require_pi_attestation` choice explicit in programmatic deployments; set -it to `True` for managed Pi. In this unmanaged example, +Make the `require_agent_attestation` choice explicit in programmatic deployments; +set it to `True` for a managed harness. In this unmanaged example, `timeout_middleware_processing` gives each evaluation 10 seconds. Omitting it uses the one-second service default. The value is expressed in seconds, must be at least 10 milliseconds, and must resolve to whole diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 9df829fc..a72e6462 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -242,8 +242,9 @@ This division is intentional. The Pi fork contributes only reusable harness primitives: mandatory admission of user messages and finalized tool results, admission of the exact provider context, an outbound-header transformation, and a standard-CLI entrypoint that accepts those hooks. OpenShell contributes the -sandbox-local bridge, signed receipts, receipt-to-request binding, middleware -enforcement, and post-policy credential delivery. The TypeScript files under +sandbox-local bridge, signed attestations, attestation-to-request binding, +middleware enforcement, and post-policy credential delivery. The TypeScript +files under `runtime-extension/` are the reusable integration layer that translates between those generic Pi hooks and the OpenShell protocol; no OpenShell-specific code is built into Pi. diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 25104ab7..e1bc5fbd 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -353,7 +353,7 @@ prepare() { serve() { describe_printed_commands "Run Egress Gate and keep it open:" run_in "$egress_gate_dir" uv run egress-gate --debug serve \ - --listen 0.0.0.0:50051 --timeout 4s --require-pi-attestation + --listen 0.0.0.0:50051 --timeout 4s --require-agent-attestation } gateway() { diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts index 59ce0d37..a38177d9 100644 --- a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts @@ -19,7 +19,7 @@ const MAX_BRIDGE_RESPONSE_BYTES = MAX_ADMISSION_BYTES * 4 + 64 * 1024; const MAX_HANDLE_ENTRIES = 1024; type ContentBlock = TextContent | ImageContent; -type UserEnvelope = { schema_version: "openshell.pi-input.v1"; text: string }; +type UserEnvelope = { schema_version: "openshell.pi-message.v1"; origin: "user"; text: string }; type ToolResultEnvelope = { schema_version: "openshell.pi-tool-result.v1"; tool_call_id: string; @@ -40,7 +40,7 @@ export function createOpenShellContextAdmission( const handles = new Map(); async function requestAdmission( - hook: "rendered_prompt_admission" | "tool_result_admission", + hook: "user_message" | "tool_result", envelope: AdmissionEnvelope, ): Promise { const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); @@ -75,7 +75,7 @@ export function createOpenShellContextAdmission( if (!envelope) { return { action: "deny", reason: "Image inputs are not supported by OpenShell admission" }; } - const result = await requestAdmission("rendered_prompt_admission", envelope); + const result = await requestAdmission("user_message", envelope); if (result.decision === "deny") return denied(result.reason_code); const admittedEnvelope = result.replacement_body ? parseUserEnvelope(new Uint8Array(result.replacement_body)) @@ -94,7 +94,7 @@ export function createOpenShellContextAdmission( message: ToolResultMessage, ): Promise> { const envelope = toolResultEnvelope(message); - const result = await requestAdmission("tool_result_admission", envelope); + const result = await requestAdmission("tool_result", envelope); if (result.decision === "deny") return denied(result.reason_code); const admittedEnvelope = result.replacement_body ? parseToolResultEnvelope(new Uint8Array(result.replacement_body)) @@ -137,11 +137,12 @@ export function createOpenShellContextAdmission( function userEnvelope(message: UserMessage): UserEnvelope | undefined { if (typeof message.content === "string") { - return { schema_version: "openshell.pi-input.v1", text: message.content }; + return { schema_version: "openshell.pi-message.v1", origin: "user", text: message.content }; } if (message.content.some((block) => block.type === "image")) return undefined; return { - schema_version: "openshell.pi-input.v1", + schema_version: "openshell.pi-message.v1", + origin: "user", text: message.content.map((block) => (block as TextContent).text).join("\n"), }; } @@ -205,10 +206,15 @@ function parseBridgeResult(value: unknown): BridgeResult { function parseUserEnvelope(body: Uint8Array): UserEnvelope { const value: unknown = JSON.parse(new TextDecoder().decode(body)); - if (!isRecord(value) || value.schema_version !== "openshell.pi-input.v1" || typeof value.text !== "string") { + if ( + !isRecord(value) || + value.schema_version !== "openshell.pi-message.v1" || + value.origin !== "user" || + typeof value.text !== "string" + ) { throw new Error("OpenShell admission returned an invalid user replacement"); } - return { schema_version: "openshell.pi-input.v1", text: value.text }; + return { schema_version: "openshell.pi-message.v1", origin: "user", text: value.text }; } function parseToolResultEnvelope(body: Uint8Array): ToolResultEnvelope { diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py index 5949b8ee..6ac77c18 100644 --- a/projects/egress-gate/src/egress_gate/admission/__init__.py +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -10,11 +10,11 @@ OpenAIChatCompletionsV1Adapter, OpenAIResponsesV1Adapter, PiImageContentV1, - PiInputV1, + PiMessageV1, + PiMessageV1Adapter, PiTextContentV1, PiToolResultV1, PiToolResultV1Adapter, - PiV1Adapter, PreparedHarnessRequest, ProviderAdapterRegistry, ProviderRequestAdapter, @@ -49,7 +49,6 @@ from egress_gate.admission.receipts import ( AgentAttestationClaimsV1, ReceiptAuthority, - ReceiptClaimsV1, ReceiptVerificationError, ) @@ -77,18 +76,17 @@ "ModelRequestV1", "OpenAIChatCompletionsV1Adapter", "OpenAIResponsesV1Adapter", - "PiInputV1", + "PiMessageV1", "PiImageContentV1", "PiTextContentV1", "PiToolResultV1", "PiToolResultV1Adapter", - "PiV1Adapter", + "PiMessageV1Adapter", "PreparedHarnessRequest", "ProviderAdapterRegistry", "ProviderRequestAdapter", "RECEIPT_HEADER", "ReceiptAuthority", - "ReceiptClaimsV1", "ReceiptVerificationError", "canonical_json_bytes", "create_pi_adapter_registry", diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index cb9bfcaa..39e5cf0b 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -51,10 +51,11 @@ class ProviderShapeError(ValueError): """A content-safe signal that a provider request is unsupported.""" -class PiInputV1(StrictDomainModel): +class PiMessageV1(StrictDomainModel): """Rendered text submitted by the managed Pi harness.""" - schema_version: Literal["openshell.pi-input.v1"] + schema_version: Literal["openshell.pi-message.v1"] + origin: Literal["user"] text: ScalarString @@ -88,7 +89,7 @@ def _content_is_a_tuple(cls, value: object) -> object: return tuple(value) if isinstance(value, list) else value -AttestedCandidate: TypeAlias = PiInputV1 | CanonicalMessageV1 +AttestedCandidate: TypeAlias = PiMessageV1 | CanonicalMessageV1 class PreparedHarnessRequest: @@ -97,7 +98,7 @@ class PreparedHarnessRequest: def __init__( self, *, - native: PiInputV1 | PiToolResultV1, + native: PiMessageV1 | PiToolResultV1, projected_body: bytes, original_body: bytes, ) -> None: @@ -125,8 +126,8 @@ def validate_result( ) -> tuple[bytes | None, AttestedCandidate]: ... -class PiV1Adapter: - """Strict rendered-prompt adapter.""" +class PiMessageV1Adapter: + """Strict user-message adapter.""" def prepare( self, @@ -147,7 +148,7 @@ def validate_result( projected_body: bytes, context: HarnessAdmissionContext, timeout: Timeout, - ) -> tuple[bytes | None, PiInputV1]: + ) -> tuple[bytes | None, PiMessageV1]: updated = _parse_pi_body(projected_body, timeout) encoded = canonical_json_bytes(updated) replacement = ( @@ -233,6 +234,11 @@ def resolve(self, context: HarnessAdmissionContext) -> HarnessAdapter: "harness admission shape is unsupported" ) from None + @property + def bindings(self) -> tuple[tuple[str, str, str], ...]: + """Return registered harness, hook, and schema bindings.""" + return tuple(self._adapters) + class _ProviderTextBlock(StrictDomainModel): type: Literal["text"] @@ -256,6 +262,7 @@ class _ProviderMessage(StrictDomainModel): name: ScalarString | None = None tool_call_id: ScalarString | None = None tool_calls: tuple[_ProviderToolCall, ...] = () + reasoning_content: ScalarString | None = None @field_validator("content", "tool_calls", mode="before") @classmethod @@ -272,6 +279,11 @@ def _optional_fields_have_one_representation(self) -> _ProviderMessage: raise ValueError("provider tool-call ID cannot be null") if "tool_calls" in self.model_fields_set and not self.tool_calls: raise ValueError("provider tool calls cannot be empty") + if ( + "reasoning_content" in self.model_fields_set + and self.reasoning_content is None + ): + raise ValueError("provider reasoning content cannot be null") return self @@ -567,8 +579,9 @@ def latest_attested_candidate( canonical = self.canonicalize(request, timeout) for message in reversed(canonical.messages): if message.role is CanonicalRole.USER and message.content is not None: - return PiInputV1( - schema_version="openshell.pi-input.v1", + return PiMessageV1( + schema_version="openshell.pi-message.v1", + origin="user", text=message.content, ) if message.role is CanonicalRole.TOOL and message.content is not None: @@ -649,8 +662,9 @@ def latest_attested_candidate( provider = self._parse(request, timeout) for item in reversed(provider.input): if isinstance(item, _ResponsesInputMessage) and item.role == "user": - return PiInputV1( - schema_version="openshell.pi-input.v1", + return PiMessageV1( + schema_version="openshell.pi-message.v1", + origin="user", text=_responses_text(item.content), ) if isinstance(item, _ResponsesFunctionCallOutput): @@ -705,9 +719,9 @@ def create_pi_adapter_registry() -> HarnessAdapterRegistry: registry = HarnessAdapterRegistry() registry.register( "pi", - AdmissionHook.RENDERED_PROMPT, - "openshell.pi-input.v1", - PiV1Adapter(), + AdmissionHook.USER_MESSAGE, + "openshell.pi-message.v1", + PiMessageV1Adapter(), ) registry.register( "pi", @@ -726,13 +740,13 @@ def create_provider_adapter_registry() -> ProviderAdapterRegistry: return registry -def _parse_pi_body(body: bytes, timeout: Timeout) -> PiInputV1: +def _parse_pi_body(body: bytes, timeout: Timeout) -> PiMessageV1: value = _load_json(body, AdmissionShapeError, timeout) try: parsed = _PI_ADAPTER.validate_python(value, strict=True) except ValidationError: raise AdmissionShapeError("Pi request body is unsupported") from None - if not isinstance(parsed, PiInputV1): + if not isinstance(parsed, PiMessageV1): raise AdmissionShapeError("Pi request body is unsupported") if canonical_json_bytes(parsed) != body: raise AdmissionShapeError("Pi request body is not canonical JSON") @@ -849,7 +863,7 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 ) -_PI_ADAPTER = TypeAdapter(PiInputV1) +_PI_ADAPTER = TypeAdapter(PiMessageV1) _PI_TOOL_RESULT_ADAPTER = TypeAdapter(PiToolResultV1) _PROVIDER_ADAPTER = TypeAdapter(_ProviderRequest) _RESPONSES_PROVIDER_ADAPTER = TypeAdapter(_ResponsesRequest) @@ -863,12 +877,12 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 "HarnessAdapterRegistry", "OpenAIChatCompletionsV1Adapter", "OpenAIResponsesV1Adapter", - "PiInputV1", + "PiMessageV1", "PiImageContentV1", "PiTextContentV1", "PiToolResultV1", "PiToolResultV1Adapter", - "PiV1Adapter", + "PiMessageV1Adapter", "PreparedHarnessRequest", "ProviderAdapterRegistry", "ProviderRequestAdapter", diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py index 16e635f9..4fa99ddd 100644 --- a/projects/egress-gate/src/egress_gate/admission/models.py +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -23,8 +23,8 @@ class AdmissionHook(StrEnum): """Supported Pi admission boundaries.""" - RENDERED_PROMPT = "rendered_prompt_admission" - TOOL_RESULT = "tool_result_admission" + USER_MESSAGE = "user_message" + TOOL_RESULT = "tool_result" class AdmissionDecision(StrEnum): @@ -55,10 +55,10 @@ class HarnessAdmissionContext(StrictDomainModel): request_id: BoundedMetadataString sandbox_id: BoundedMetadataString middleware_name: BoundedMetadataString - harness: Literal["pi"] - harness_version: Literal["extension-v1", "sdk-v1"] + harness: ScalarString + harness_version: Literal["sdk-v1"] hook: AdmissionHook - schema_version: Literal["openshell.pi-input.v1", "openshell.pi-tool-result.v1"] + schema_version: ScalarString provider_target: HttpTarget provider_adapter_schema: Literal["openai.request.v1"] diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py index bcbc7c0b..c648af05 100644 --- a/projects/egress-gate/src/egress_gate/admission/processor.py +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -13,7 +13,7 @@ AdmissionMutationError, AdmissionShapeError, HarnessAdapterRegistry, - PiInputV1, + PiMessageV1, ProviderAdapterRegistry, ProviderShapeError, ) @@ -71,7 +71,7 @@ def __init__( def readiness(self) -> dict[str, str]: """Return content-safe compatibility metadata for a managed launcher.""" return { - "admission_schema": "openshell.pi-input.v1", + "admission_schema": "openshell.pi-message.v1", "canonicalization": "canonical-json.v1", "provider_adapter": "openai.request.v1", "attestation_version": "agent-attestation.v1", @@ -197,16 +197,16 @@ def process( if request.context.enforcement_point is not EnforcementPoint.NETWORK_EGRESS: return self._deny("network_context_invalid") if any(header.name.lower() == RECEIPT_HEADER for header in request.headers): - return self._deny("reserved_receipt_header") + return self._deny("reserved_header_present") if not agent_attestation: return self._deny("attestation_missing") try: adapter = self._provider_adapters.resolve_request(request, timeout) candidate = adapter.latest_attested_candidate(request, timeout) timeout.raise_if_expired() - if isinstance(candidate, PiInputV1): - hook = AdmissionHook.RENDERED_PROMPT - schema_version = "openshell.pi-input.v1" + if isinstance(candidate, PiMessageV1): + hook = AdmissionHook.USER_MESSAGE + schema_version = "openshell.pi-message.v1" elif ( isinstance(candidate, CanonicalMessageV1) and candidate.role is CanonicalRole.TOOL diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py index 5283b443..b213d97e 100644 --- a/projects/egress-gate/src/egress_gate/admission/receipts.py +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -1,14 +1,13 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Short-lived Ed25519 admission receipts.""" +"""Short-lived Ed25519 agent attestations.""" from __future__ import annotations import base64 import hashlib import secrets -import threading from datetime import UTC, datetime from typing import Literal @@ -19,10 +18,9 @@ ) from pydantic import Field, ValidationError -from egress_gate.admission.adapters import AttestedCandidate, PiInputV1 +from egress_gate.admission.adapters import AttestedCandidate from egress_gate.admission.canonical import canonical_json_bytes from egress_gate.admission.models import ( - AdmissionHook, AdmissionProvenance, HarnessAdmissionContext, ) @@ -30,39 +28,15 @@ from egress_gate.string_validators import BoundedMetadataString, ScalarString -class ReceiptClaimsV1(StrictDomainModel): - """All security context signed into one rendered-prompt receipt.""" - - receipt_version: Literal["egress-receipt.v1"] = "egress-receipt.v1" - canonicalization_version: Literal["canonical-json.v1"] = "canonical-json.v1" - harness: Literal["pi"] - harness_version: Literal["extension-v1"] - harness_schema: Literal["openshell.pi-input.v1"] - hook: Literal["rendered_prompt_admission"] - middleware_binding: BoundedMetadataString - policy_fingerprint: ScalarString - sandbox_id: BoundedMetadataString - session_id: BoundedMetadataString - submission_id: BoundedMetadataString - receipt_id: str = Field(pattern=r"^[0-9a-f]{32}$") - provider_adapter_schema: Literal["openai.request.v1"] - host: ScalarString - port: int = Field(ge=0, le=2**32 - 1) - rendered_prompt_hash: str = Field(pattern=r"^[0-9a-f]{64}$") - issued_at: int = Field(ge=0) - expires_at: int = Field(ge=0) - key_id: str = Field(pattern=r"^[0-9a-f]{16}$") - - class AgentAttestationClaimsV1(StrictDomainModel): """Supervisor-only proof that the latest context addition was admitted.""" attestation_version: Literal["agent-attestation.v1"] = "agent-attestation.v1" canonicalization_version: Literal["canonical-json.v1"] = "canonical-json.v1" - harness: Literal["pi"] + harness: ScalarString harness_version: Literal["sdk-v1"] - harness_schema: Literal["openshell.pi-input.v1", "openshell.pi-tool-result.v1"] - hook: Literal["rendered_prompt_admission", "tool_result_admission"] + harness_schema: ScalarString + hook: Literal["user_message", "tool_result"] middleware_binding: BoundedMetadataString policy_fingerprint: ScalarString sandbox_id: BoundedMetadataString @@ -93,13 +67,10 @@ def __init__( self, private_key: Ed25519PrivateKey | None = None, *, - lifetime_seconds: int = 30, allowed_clock_skew_seconds: int = 5, ) -> None: - if not 1 <= lifetime_seconds <= 300: - raise ValueError("receipt lifetime must be between 1 and 300 seconds") if not 0 <= allowed_clock_skew_seconds <= 30: - raise ValueError("receipt clock skew must be between 0 and 30 seconds") + raise ValueError("attestation clock skew must be between 0 and 30 seconds") self._private_key = private_key or Ed25519PrivateKey.generate() self._public_key = self._private_key.public_key() public_bytes = self._public_key.public_bytes( @@ -107,10 +78,7 @@ def __init__( format=serialization.PublicFormat.Raw, ) self._key_id = hashlib.sha256(public_bytes).hexdigest()[:16] - self._lifetime_seconds = lifetime_seconds self._allowed_clock_skew_seconds = allowed_clock_skew_seconds - self._consumed_receipts: dict[str, int] = {} - self._consumed_receipts_lock = threading.Lock() self._attestation_lifetime_seconds = 300 @property @@ -118,48 +86,6 @@ def key_id(self) -> str: """Return the non-secret identifier of the active ephemeral key.""" return self._key_id - def issue( - self, - rendered_prompt: PiInputV1, - context: HarnessAdmissionContext, - provenance: AdmissionProvenance, - *, - policy_fingerprint: str, - now: int | None = None, - ) -> bytes: - """Issue one opaque receipt after final admission validation.""" - if context.hook is not AdmissionHook.RENDERED_PROMPT: - raise ValueError("receipts may be issued only for rendered prompts") - if ( - context.harness_version != "extension-v1" - or context.schema_version != "openshell.pi-input.v1" - ): - raise ValueError("receipt context is unsupported") - issued_at = _now_seconds() if now is None else now - target = context.provider_target - claims = ReceiptClaimsV1( - harness=context.harness, - harness_version=context.harness_version, - harness_schema=context.schema_version, - hook=context.hook.value, - middleware_binding=context.middleware_name, - policy_fingerprint=policy_fingerprint, - sandbox_id=context.sandbox_id, - session_id=provenance.session_id, - submission_id=provenance.submission_id, - receipt_id=secrets.token_hex(16), - provider_adapter_schema=context.provider_adapter_schema, - host=target.host, - port=target.port, - rendered_prompt_hash=_prompt_hash(rendered_prompt), - issued_at=issued_at, - expires_at=issued_at + self._lifetime_seconds, - key_id=self._key_id, - ) - payload = canonical_json_bytes(claims) - signature = self._private_key.sign(payload) - return b"eg1." + _encode(payload) + b"." + _encode(signature) - def issue_attestation( self, candidate: AttestedCandidate, @@ -197,76 +123,6 @@ def issue_attestation( signature = self._private_key.sign(payload) return b"ag1." + _encode(payload) + b"." + _encode(signature) - def verify( - self, - receipt: bytes, - rendered_prompt: PiInputV1, - context: HarnessAdmissionContext, - *, - policy_fingerprint: str, - now: int | None = None, - ) -> ReceiptClaimsV1: - """Verify signature, lifetime, trusted context, target, and prompt hash.""" - if context.hook is not AdmissionHook.RENDERED_PROMPT: - raise ReceiptVerificationError("receipt_context_mismatch") - payload, signature = _decode_receipt(receipt) - try: - self._public_key.verify(signature, payload) - except InvalidSignature: - raise ReceiptVerificationError("receipt_signature_invalid") from None - try: - claims = ReceiptClaimsV1.model_validate_json(payload, strict=True) - except ValidationError: - raise ReceiptVerificationError("receipt_malformed") from None - if canonical_json_bytes(claims) != payload: - raise ReceiptVerificationError("receipt_malformed") - current = _now_seconds() if now is None else now - if claims.key_id != self._key_id: - raise ReceiptVerificationError("receipt_key_mismatch") - if claims.issued_at > current + self._allowed_clock_skew_seconds: - raise ReceiptVerificationError("receipt_not_yet_valid") - if claims.expires_at <= current or claims.expires_at <= claims.issued_at: - raise ReceiptVerificationError("receipt_expired") - target = context.provider_target - expected = ( - context.harness, - context.harness_version, - context.schema_version, - AdmissionHook.RENDERED_PROMPT.value, - context.middleware_name, - policy_fingerprint, - context.sandbox_id, - context.provider_adapter_schema, - target.host, - target.port, - _prompt_hash(rendered_prompt), - ) - actual = ( - claims.harness, - claims.harness_version, - claims.harness_schema, - claims.hook, - claims.middleware_binding, - claims.policy_fingerprint, - claims.sandbox_id, - claims.provider_adapter_schema, - claims.host, - claims.port, - claims.rendered_prompt_hash, - ) - if actual != expected: - raise ReceiptVerificationError("receipt_context_mismatch") - with self._consumed_receipts_lock: - self._consumed_receipts = { - receipt_id: expires_at - for receipt_id, expires_at in self._consumed_receipts.items() - if expires_at > current - } - if claims.receipt_id in self._consumed_receipts: - raise ReceiptVerificationError("receipt_replayed") - self._consumed_receipts[claims.receipt_id] = claims.expires_at - return claims - def verify_attestation( self, attestation: bytes, @@ -329,10 +185,6 @@ def verify_attestation( return claims -def _prompt_hash(rendered_prompt: PiInputV1) -> str: - return hashlib.sha256(canonical_json_bytes(rendered_prompt)).hexdigest() - - def _candidate_hash(candidate: AttestedCandidate) -> str: return hashlib.sha256(canonical_json_bytes(candidate)).hexdigest() @@ -346,11 +198,7 @@ def _decode(value: bytes) -> bytes: try: return base64.b64decode(value + padding, altchars=b"-_", validate=True) except ValueError: - raise ReceiptVerificationError("receipt_malformed") from None - - -def _decode_receipt(receipt: bytes) -> tuple[bytes, bytes]: - return _decode_token(receipt, prefix=b"eg1", malformed_reason="receipt_malformed") + raise ValueError("token is malformed") from None def _decode_token( @@ -363,7 +211,7 @@ def _decode_token( raise ReceiptVerificationError(malformed_reason) try: return _decode(parts[1]), _decode(parts[2]) - except ReceiptVerificationError: + except ValueError: raise ReceiptVerificationError(malformed_reason) from None @@ -374,6 +222,5 @@ def _now_seconds() -> int: __all__ = [ "AgentAttestationClaimsV1", "ReceiptAuthority", - "ReceiptClaimsV1", "ReceiptVerificationError", ] diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 50d06952..d486c682 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -167,12 +167,12 @@ def serve( ), ), ] = f"{DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING:g}s", - require_pi_attestation: Annotated[ + require_agent_attestation: Annotated[ bool, typer.Option( - "--require-pi-attestation/--no-require-pi-attestation", + "--require-agent-attestation/--no-require-agent-attestation", help=( - "Require a supervisor-held Pi context attestation on HTTP " + "Require a supervisor-held agent context attestation on HTTP " "egress. Enabled by default; disable only for an " "explicitly unmanaged deployment." ), @@ -220,7 +220,7 @@ def serve( EgressGateServer( options.registry, timeout_middleware_processing=timeout_middleware_processing, - require_pi_attestation=require_pi_attestation, + require_agent_attestation=require_agent_attestation, ).serve_sync(listen) except EgressGateError as error: _render_egress_error("Egress Gate could not start", error) diff --git a/projects/egress-gate/src/egress_gate/service/server.py b/projects/egress-gate/src/egress_gate/service/server.py index 924b16bd..a1c2e6fb 100644 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ b/projects/egress-gate/src/egress_gate/service/server.py @@ -34,12 +34,12 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - require_pi_attestation: bool = False, + require_agent_attestation: bool = False, ) -> None: self._middleware = EgressGateMiddleware( registry, timeout_middleware_processing=timeout_middleware_processing, - require_pi_attestation=require_pi_attestation, + require_agent_attestation=require_agent_attestation, ) def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index 6470ba87..053ed188 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -92,26 +92,10 @@ ) -def _require_pi_harness(value: str) -> Literal["pi"]: - if value == "pi": - return value - raise ValueError("invalid admission harness") - - -def _require_pi_schema( - value: str, hook: AdmissionHook -) -> Literal["openshell.pi-input.v1", "openshell.pi-tool-result.v1"]: - if hook is AdmissionHook.RENDERED_PROMPT and value == "openshell.pi-input.v1": - return value - if hook is AdmissionHook.TOOL_RESULT and value == "openshell.pi-tool-result.v1": - return value - raise ValueError("invalid admission schema") - - -def _require_pi_harness_version(value: str) -> Literal["sdk-v1"]: +def _require_harness_version(value: str) -> Literal["sdk-v1"]: if value == PI_HARNESS_VERSION: return value - raise ValueError("invalid Pi harness version") + raise ValueError("invalid admission harness version") class EgressGateMiddleware(pb2_grpc.SupervisorMiddlewareServicer): @@ -122,7 +106,7 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - require_pi_attestation: bool = False, + require_agent_attestation: bool = False, ) -> None: registry.configuration_json_schema() self._registry = registry @@ -131,7 +115,8 @@ def __init__( ) self._policy = _ActivePolicy(registry) self._receipt_authority = ReceiptAuthority() - self._require_pi_attestation = require_pi_attestation + self._admission_adapters = create_pi_adapter_registry() + self._require_agent_attestation = require_agent_attestation self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING) self._processing_executor = ThreadPoolExecutor( max_workers=MAX_CONCURRENT_PROCESSING, @@ -173,16 +158,14 @@ async def Describe( operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION, phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, max_payload_bytes=MAX_ADMISSION_BODY_BYTES, - harness="pi", - hook=hook.value, - schema_version=( - "openshell.pi-input.v1" - if hook is AdmissionHook.RENDERED_PROMPT - else "openshell.pi-tool-result.v1" - ), + harness=harness, + hook=hook, + schema_version=schema_version, ) - for hook in AdmissionHook - if self._require_pi_attestation + for harness, hook, schema_version in ( + self._admission_adapters.bindings + ) + if self._require_agent_attestation ), ], ) @@ -230,7 +213,7 @@ def _evaluate_agent_admission( timeout: Timeout, ) -> pb2.AgentConversationResult: try: - if not self._require_pi_attestation: + if not self._require_agent_attestation: raise ValueError("agent admission is disabled") if request.phase != pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: raise ValueError("invalid admission phase") @@ -253,7 +236,7 @@ def _evaluate_agent_admission( self._policy.processor_for( _mapping_from_proto(request.config), timeout=timeout ), - create_pi_adapter_registry(), + self._admission_adapters, self._receipt_authority, ) result = processor.process( @@ -265,14 +248,12 @@ def _evaluate_agent_admission( request_id=request.context.request_id, sandbox_id=request.context.sandbox_id, middleware_name=request.middleware_name, - harness=_require_pi_harness(request.target.harness), - harness_version=_require_pi_harness_version( + harness=request.target.harness, + harness_version=_require_harness_version( request.target.harness_version ), hook=hook, - schema_version=_require_pi_schema( - request.target.schema_version, hook - ), + schema_version=request.target.schema_version, provider_target=target, provider_adapter_schema="openai.request.v1", ), @@ -409,7 +390,7 @@ def _prepare_and_process( values, timeout=timeout, ) - if self._require_pi_attestation: + if self._require_agent_attestation: return AttestedEgressProcessor( processor, create_provider_adapter_registry(), @@ -431,7 +412,7 @@ def _prepare_and_process( gate_name="reserved-receipt-header", gate_type="reserved-receipt-header", ), - reason_code="reserved_receipt_header", + reason_code="reserved_header_present", policy_fingerprint=processor.policy_fingerprint, ) return processor.process(domain_request, timeout=timeout) diff --git a/projects/egress-gate/tests/admission/fixtures/README.md b/projects/egress-gate/tests/admission/fixtures/README.md new file mode 100644 index 00000000..f490dba4 --- /dev/null +++ b/projects/egress-gate/tests/admission/fixtures/README.md @@ -0,0 +1,20 @@ +# Pi provider fixture provenance + +These payloads were captured on 2026-09-02 from Pi commit +`61500e60394060f2f56a76c61a0067c33988c9f8` through the native stream +adapters' `onPayload` fake-fetch boundary. The capture used the three models +and compatibility settings checked into the attested-admission example. No +provider request was sent. + +The strict adapter decisions are deliberate: + +- Chat Completions accepts assistant `tool_calls`, tool replies, and the + `reasoning_content` string emitted when Pi replays Qwen reasoning. That + reasoning field is preserved for validation but is not projected as message + text. +- Responses accepts replayed `reasoning`, assistant `message`, + `function_call`, and `function_call_output` items, including the optional + reasoning fields present in the captured payload. +- Unknown fields, explicit nulls for optional compatibility fields, image + inputs, and mixed top-level Chat Completions/Responses shapes remain + unsupported and fail closed. diff --git a/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json b/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json index 6e1fc010..d912da65 100644 --- a/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json +++ b/projects/egress-gate/tests/admission/fixtures/pi-openai-completions.json @@ -1,5 +1,5 @@ { - "opus": { + "user_request": { "model": "azure/anthropic/claude-opus-5", "messages": [ {"role": "system", "content": "fixture system prompt"}, @@ -7,6 +7,41 @@ ], "stream": true, "stream_options": {"include_usage": true}, + "store": false, + "max_tokens": 128000, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}}, + "strict": false + } + } + ] + }, + "opus": { + "model": "azure/anthropic/claude-opus-5", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "use the tool"}, + { + "role": "assistant", + "content": "I will read it.", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"} + } + ] + }, + {"role": "tool", "content": "safe tool output", "tool_call_id": "call-1"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "store": false, "max_tokens": 128000, "tools": [ { @@ -14,7 +49,8 @@ "function": { "name": "read", "description": "Read a file", - "parameters": {"type": "object", "properties": {}} + "parameters": {"type": "object", "properties": {}}, + "strict": false } } ] @@ -23,10 +59,24 @@ "model": "nvidia/qwen/qwen3.8-flash-next", "messages": [ {"role": "system", "content": "fixture system prompt"}, - {"role": "user", "content": "safe"} + {"role": "user", "content": "use the tool"}, + { + "role": "assistant", + "content": null, + "reasoning_content": "prior reasoning", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"} + } + ] + }, + {"role": "tool", "content": "safe tool output", "tool_call_id": "call-1"} ], "stream": true, "stream_options": {"include_usage": true}, + "store": false, "max_tokens": 32768, "tools": [ { @@ -34,11 +84,47 @@ "function": { "name": "read", "description": "Read a file", - "parameters": {"type": "object", "properties": {}} + "parameters": {"type": "object", "properties": {}}, + "strict": false } } ], "enable_thinking": true, "reasoning_effort": "high" + }, + "compaction_summary": { + "model": "azure/anthropic/claude-opus-5", + "messages": [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": "use the tool"}, + { + "role": "assistant", + "content": "I will read it.", + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"} + } + ] + }, + {"role": "tool", "content": "safe tool output", "tool_call_id": "call-1"}, + {"role": "user", "content": "Summary:\ncompacted context"} + ], + "stream": true, + "stream_options": {"include_usage": true}, + "store": false, + "max_tokens": 128000, + "tools": [ + { + "type": "function", + "function": { + "name": "read", + "description": "Read a file", + "parameters": {"type": "object", "properties": {}}, + "strict": false + } + } + ] } } diff --git a/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json b/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json index e5f99168..315061b9 100644 --- a/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json +++ b/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json @@ -1,17 +1,13 @@ { "user_request": { - "model": "fixture-model", + "model": "azure/openai/gpt-5.6-sol", "input": [ {"role": "developer", "content": "fixture system prompt"}, - { - "role": "user", - "content": [{"type": "input_text", "text": "safe"}] - } + {"role": "user", "content": [{"type": "input_text", "text": "safe"}]} ], "stream": true, - "prompt_cache_key": "session-1", "store": false, - "max_output_tokens": 128, + "max_output_tokens": 128000, "tools": [ { "type": "function", @@ -24,7 +20,7 @@ "include": ["reasoning.encrypted_content"] }, "tool_result_request": { - "model": "fixture-model", + "model": "azure/openai/gpt-5.6-sol", "input": [ {"role": "developer", "content": "fixture system prompt"}, { @@ -35,6 +31,7 @@ "type": "reasoning", "id": "rs-1", "summary": [{"type": "summary_text", "text": "summary"}], + "content": [{"type": "reasoning_text", "text": "reasoning"}], "encrypted_content": "encrypted", "status": "completed" }, @@ -42,14 +39,10 @@ "type": "message", "role": "assistant", "content": [ - { - "type": "output_text", - "text": "I will read it.", - "annotations": [] - } + {"type": "output_text", "text": "I will read it.", "annotations": []} ], "status": "completed", - "id": "msg-1" + "id": "msg_pi_1" }, { "type": "function_call", @@ -64,9 +57,8 @@ } ], "stream": true, - "prompt_cache_key": "session-1", "store": false, - "max_output_tokens": 128, + "max_output_tokens": 128000, "tools": [ { "type": "function", diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index bdda274e..53e912cf 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -7,7 +7,6 @@ import json from pathlib import Path -from typing import Literal import pytest @@ -21,11 +20,10 @@ HarnessAdmissionContext, HarnessAdmissionProcessor, HarnessAdmissionRequest, - PiInputV1, + PiMessageV1, PiTextContentV1, PiToolResultV1, ReceiptAuthority, - ReceiptVerificationError, canonical_json_bytes, create_pi_adapter_registry, create_provider_adapter_registry, @@ -105,7 +103,7 @@ def _processors( request_processor = registry.prepare_processor( config, timeout=Timeout.from_seconds(1) ) - authority = ReceiptAuthority(lifetime_seconds=30) + authority = ReceiptAuthority() return ( HarnessAdmissionProcessor( request_processor, create_pi_adapter_registry(), authority @@ -135,12 +133,11 @@ def _target(*, host: str = "provider.test") -> HttpTarget: def _context( hook: AdmissionHook, *, - harness_version: Literal["extension-v1", "sdk-v1"] = "sdk-v1", target: HttpTarget | None = None, ) -> HarnessAdmissionContext: schema = ( - "openshell.pi-input.v1" - if hook is AdmissionHook.RENDERED_PROMPT + "openshell.pi-message.v1" + if hook is AdmissionHook.USER_MESSAGE else "openshell.pi-tool-result.v1" ) return HarnessAdmissionContext( @@ -148,7 +145,7 @@ def _context( sandbox_id="sandbox-1", middleware_name="pi-egress", harness="pi", - harness_version=harness_version, + harness_version="sdk-v1", hook=hook, schema_version=schema, provider_target=target or _target(), @@ -158,14 +155,14 @@ def _context( def _admit( processor: HarnessAdmissionProcessor, - value: PiInputV1 | PiToolResultV1, + value: PiMessageV1 | PiToolResultV1, *, target: HttpTarget | None = None, timeout: Timeout | None = None, ): hook = ( - AdmissionHook.RENDERED_PROMPT - if isinstance(value, PiInputV1) + AdmissionHook.USER_MESSAGE + if isinstance(value, PiMessageV1) else AdmissionHook.TOOL_RESULT ) return processor.process( @@ -180,8 +177,10 @@ def _admit( ) -def _user(text: str) -> PiInputV1: - return PiInputV1(schema_version="openshell.pi-input.v1", text=text) +def _user(text: str) -> PiMessageV1: + return PiMessageV1( + schema_version="openshell.pi-message.v1", origin="user", text=text + ) def _tool_result( @@ -211,7 +210,7 @@ def _provider_request( headers: tuple[HttpHeader, ...] = (), target: HttpTarget | None = None, ) -> HttpRequest: - provider_body = json.loads(json.dumps(_PI_CHAT_FIXTURES["opus"])) + provider_body = json.loads(json.dumps(_PI_CHAT_FIXTURES["user_request"])) provider_body["messages"][1]["content"] = prompt if tool_result is not None: provider_body["messages"].extend( @@ -299,10 +298,19 @@ def test_user_attestation_authorizes_retries_without_entering_request_headers() assert first.request_mutations.header_mutations == () -@pytest.mark.parametrize("fixture_name", ["opus", "qwen"]) -def test_configured_pi_chat_serializations_are_attested(fixture_name: str) -> None: +@pytest.mark.parametrize( + ("fixture_name", "candidate"), + [ + ("opus", _tool_result("safe tool output")), + ("qwen", _tool_result("safe tool output")), + ("compaction_summary", _user("Summary:\ncompacted context")), + ], +) +def test_configured_pi_chat_serializations_are_attested( + fixture_name: str, candidate: PiMessageV1 | PiToolResultV1 +) -> None: admission, egress, _ = _processors() - admitted = _admit(admission, _user("safe")) + admitted = _admit(admission, candidate) assert admitted.attestation is not None request = _provider_request("safe").model_copy( update={ @@ -347,6 +355,7 @@ def test_changed_responses_context_fails_closed() -> None: "mutation", [ lambda body: body.update({"messages": []}), + lambda body: body.update({"unknown_replay_field": "value"}), lambda body: body["input"].append( {"role": "user", "content": [{"type": "input_image"}]} ), @@ -453,7 +462,7 @@ def test_user_redaction_attests_only_the_replacement() -> None: assert admitted.decision is AdmissionDecision.REPLACE assert admitted.attestation is not None assert admitted.replacement_body is not None - replacement = PiInputV1.model_validate_json( + replacement = PiMessageV1.model_validate_json( admitted.replacement_body, strict=True ).text @@ -525,6 +534,26 @@ def test_denial_returns_no_attestation_or_replacement() -> None: assert denied.replacement_body is None +@pytest.mark.parametrize( + "context_update", + [{"harness": "unknown"}, {"schema_version": "openshell.unknown.v1"}], +) +def test_unknown_harness_binding_fails_closed(context_update: dict[str, str]) -> None: + admission, _, _ = _processors() + result = admission.process( + HarnessAdmissionRequest( + request_body=canonical_json_bytes(_user("safe")), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ), + ), + _context(AdmissionHook.USER_MESSAGE).model_copy(update=context_update), + timeout=Timeout.from_seconds(1), + ) + + assert result.reason_code == "admission_contract_invalid" + + def test_oversized_redaction_attempt_fails_before_attestation_issuance() -> None: admission, _, _ = _processors(replacement_template="x" * 1024) @@ -547,19 +576,23 @@ def test_malformed_duplicate_and_expired_admission_fail_closed() -> None: def admit_body(body: bytes, timeout: Timeout | None = None): return admission.process( HarnessAdmissionRequest(request_body=body, provenance=provenance), - _context(AdmissionHook.RENDERED_PROMPT), + _context(AdmissionHook.USER_MESSAGE), timeout=timeout or Timeout.from_seconds(1), ) malformed = admit_body(b"{") + missing_origin = admit_body( + b'{"schema_version":"openshell.pi-message.v1","text":"safe"}' + ) duplicate = admit_body( - b'{"schema_version":"openshell.pi-input.v1",' - b'"schema_version":"openshell.pi-input.v1","text":"safe"}' + b'{"origin":"user","schema_version":"openshell.pi-message.v1",' + b'"schema_version":"openshell.pi-message.v1","text":"safe"}' ) over_depth = admit_body(b"[" * 129 + b"0" + b"]" * 129) expired = admit_body(b"{}", Timeout(deadline=0.0)) assert malformed.reason_code == "admission_contract_invalid" + assert missing_origin.reason_code == "admission_contract_invalid" assert duplicate.reason_code == "admission_contract_invalid" assert over_depth.reason_code == "admission_unavailable" assert expired.reason_code == "admission_unavailable" @@ -622,6 +655,28 @@ def test_mixed_or_null_chat_compatibility_fields_fail_closed(mutation) -> None: assert result.reason_code == "provider_shape_unsupported" +@pytest.mark.parametrize( + "mutation", + [ + lambda body: body["messages"][2].update({"reasoning_content": None}), + lambda body: body["messages"][2].update({"unknown_replay_field": "value"}), + ], +) +def test_qwen_replay_fields_fail_closed_unless_explicitly_supported(mutation) -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _tool_result("safe tool output")) + assert admitted.attestation is not None + body = json.loads(json.dumps(_PI_CHAT_FIXTURES["qwen"])) + mutation(body) + request = _provider_request("unused").model_copy( + update={"body": json.dumps(body, separators=(",", ":")).encode()} + ) + + result = _egress(egress, request, admitted.attestation) + + assert result.reason_code == "provider_shape_unsupported" + + def test_workload_receipt_header_is_reserved_in_managed_flow() -> None: admission, egress, _ = _processors() admitted = _admit(admission, _user("safe")) @@ -633,20 +688,4 @@ def test_workload_receipt_header_is_reserved_in_managed_flow() -> None: result = _egress(egress, request, admitted.attestation) - assert result.reason_code == "reserved_receipt_header" - - -def test_legacy_workload_receipts_remain_one_use() -> None: - _, _, authority = _processors() - context = _context(AdmissionHook.RENDERED_PROMPT, harness_version="extension-v1") - provenance = AdmissionProvenance( - session_id="session-1", submission_id="submission-1" - ) - prompt = _user("safe") - receipt = authority.issue( - prompt, context, provenance, policy_fingerprint="policy", now=100 - ) - - authority.verify(receipt, prompt, context, policy_fingerprint="policy", now=100) - with pytest.raises(ReceiptVerificationError, match="receipt_replayed"): - authority.verify(receipt, prompt, context, policy_fingerprint="policy", now=100) + assert result.reason_code == "reserved_header_present" diff --git a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs index c7ea4801..a486db2d 100644 --- a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs +++ b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs @@ -34,6 +34,9 @@ describe("OpenShell context admission adapter", () => { async (_url, init) => { const request = JSON.parse(String(init?.body)); const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + assert.equal(request.hook, "user_message"); + assert.equal(envelope.schema_version, "openshell.pi-message.v1"); + assert.equal(envelope.origin, "user"); return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.text}` })); }, ); @@ -58,7 +61,7 @@ describe("OpenShell context admission adapter", () => { it("uses an admitted replacement for the outbound handle", async () => { const replacement = new TextEncoder().encode( - JSON.stringify({ schema_version: "openshell.pi-input.v1", text: "[REDACTED]" }), + JSON.stringify({ schema_version: "openshell.pi-message.v1", origin: "user", text: "[REDACTED]" }), ); const admission = createOpenShellContextAdmission( "http://bridge.test/admit", @@ -100,8 +103,8 @@ describe("OpenShell context admission adapter", () => { await admittedContext(admission, { messages: [prompt, failed], tools: [] }), ); - assert.equal(headers[HANDLE_HEADER], "handle:tool_result_admission"); - assert.deepEqual(hooks, ["rendered_prompt_admission", "tool_result_admission", "tool_result_admission"]); + assert.equal(headers[HANDLE_HEADER], "handle:tool_result"); + assert.deepEqual(hooks, ["user_message", "tool_result", "tool_result"]); }); it("fails closed when provider-only context is denied", async () => { diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index 654f1888..feea68da 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -15,7 +15,7 @@ from google.protobuf.message import Message from egress_gate.admission import ( - PiInputV1, + PiMessageV1, canonical_json_bytes, ) from egress_gate.bindings import supervisor_middleware_pb2 as pb2 @@ -159,9 +159,11 @@ async def test_generated_stub_round_trip_covers_manifest_and_gate_actions() -> N @pytest.mark.asyncio -async def test_generated_stub_issues_a_rendered_prompt_attestation() -> None: +async def test_generated_stub_issues_a_user_message_attestation() -> None: body = canonical_json_bytes( - PiInputV1(schema_version="openshell.pi-input.v1", text="safe") + PiMessageV1( + schema_version="openshell.pi-message.v1", origin="user", text="safe" + ) ) request = pb2.AgentConversationEvaluation( phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, @@ -170,8 +172,8 @@ async def test_generated_stub_issues_a_rendered_prompt_attestation() -> None: target=pb2.AgentConversationTarget( harness="pi", harness_version="sdk-v1", - hook="rendered_prompt_admission", - schema_version="openshell.pi-input.v1", + hook="user_message", + schema_version="openshell.pi-message.v1", scheme="https", host="provider.invalid", port=443, @@ -183,7 +185,7 @@ async def test_generated_stub_issues_a_rendered_prompt_attestation() -> None: request_body=body, ) middleware = EgressGateMiddleware( - create_builtin_registry(), require_pi_attestation=True + create_builtin_registry(), require_agent_attestation=True ) async with _running_stub(middleware) as (stub, _): response = await stub.EvaluateAgentConversation(request) @@ -191,7 +193,7 @@ async def test_generated_stub_issues_a_rendered_prompt_attestation() -> None: assert response.decision == pb2.DECISION_ALLOW assert response.attestation.startswith(b"ag1.") assert response.has_replacement_body is False - assert response.metadata["admission_schema"] == "openshell.pi-input.v1" + assert response.metadata["admission_schema"] == "openshell.pi-message.v1" @pytest.mark.asyncio @@ -210,7 +212,9 @@ async def test_agent_admission_is_unavailable_when_managed_mode_is_off() -> None @pytest.mark.asyncio async def test_trusted_agent_attestation_is_verified_for_http_egress() -> None: pi_body = canonical_json_bytes( - PiInputV1(schema_version="openshell.pi-input.v1", text="safe") + PiMessageV1( + schema_version="openshell.pi-message.v1", origin="user", text="safe" + ) ) admission = pb2.AgentConversationEvaluation( phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, @@ -219,8 +223,8 @@ async def test_trusted_agent_attestation_is_verified_for_http_egress() -> None: target=pb2.AgentConversationTarget( harness="pi", harness_version="sdk-v1", - hook="rendered_prompt_admission", - schema_version="openshell.pi-input.v1", + hook="user_message", + schema_version="openshell.pi-message.v1", scheme="https", host="provider.invalid", port=443, @@ -249,7 +253,7 @@ async def test_trusted_agent_attestation_is_verified_for_http_egress() -> None: separators=(",", ":"), ).encode() middleware = EgressGateMiddleware( - create_builtin_registry(), require_pi_attestation=True + create_builtin_registry(), require_agent_attestation=True ) async with _running_stub(middleware) as (stub, _): admitted = await stub.EvaluateAgentConversation(admission) @@ -279,7 +283,7 @@ async def test_trusted_agent_attestation_is_verified_for_http_egress() -> None: @pytest.mark.asyncio -async def test_unmanaged_http_rejects_the_reserved_receipt_header() -> None: +async def test_unmanaged_http_rejects_the_reserved_header() -> None: middleware = EgressGateMiddleware(create_builtin_registry()) request = _evaluation(b"safe", action_kind="detect") request.headers.append( @@ -293,7 +297,7 @@ async def test_unmanaged_http_rejects_the_reserved_receipt_header() -> None: response = await stub.EvaluateHttpRequest(request) assert response.decision == pb2.DECISION_DENY - assert response.reason_code == "reserved_receipt_header" + assert response.reason_code == "reserved_header_present" @pytest.mark.asyncio diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 54b9ec85..113512e9 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -133,7 +133,7 @@ def test_manifest_leaves_the_gateway_rpc_timeout_to_the_operator() -> None: def test_managed_manifest_advertises_exact_user_and_tool_result_bindings() -> None: middleware = EgressGateMiddleware( - create_builtin_registry(), require_pi_attestation=True + create_builtin_registry(), require_agent_attestation=True ) try: manifest = asyncio.run(middleware.Describe(object(), Mock())) @@ -149,8 +149,8 @@ def test_managed_manifest_advertises_exact_user_and_tool_result_bindings() -> No (binding.harness, binding.hook, binding.schema_version) for binding in agent_bindings ] == [ - ("pi", "rendered_prompt_admission", "openshell.pi-input.v1"), - ("pi", "tool_result_admission", "openshell.pi-tool-result.v1"), + ("pi", "user_message", "openshell.pi-message.v1"), + ("pi", "tool_result", "openshell.pi-tool-result.v1"), ] diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 4ac136e0..983981cd 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -74,9 +74,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, - require_pi_attestation: bool = False, + require_agent_attestation: bool = False, ) -> None: - del registry, require_pi_attestation + del registry, require_agent_attestation self.timeout_middleware_processing = timeout_middleware_processing def serve_sync(self, listen: str) -> None: @@ -103,6 +103,8 @@ def serve_sync(self, listen: str) -> None: assert "s for seconds or ms for milliseconds" in serve_help assert "Minimum 10ms" in serve_help assert "RPC timeout" in serve_help + assert "--require-agent-attestation" in serve_help + assert "--require-" + "pi-attestation" not in serve_help evaluate_help = CliRunner().invoke(app, ["evaluate", "--help"]) assert evaluate_help.exit_code == 0, evaluate_help.output @@ -547,9 +549,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, - require_pi_attestation: bool = False, + require_agent_attestation: bool = False, ) -> None: - del registry, timeout_middleware_processing, require_pi_attestation + del registry, timeout_middleware_processing, require_agent_attestation def serve_sync(self, listen: str) -> None: calls.append(listen) From 4a3f7808ac5f912f68c69353a0223751296cca5d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 17:20:26 +0000 Subject: [PATCH 31/70] fix(egress-gate): canonicalize user admission envelope --- .../runtime-extension/openshell-context-admission.ts | 4 ++-- .../tests/js/openshell-context-admission.test.mjs | 9 +++++++-- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts index a38177d9..1570e011 100644 --- a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts @@ -137,12 +137,12 @@ export function createOpenShellContextAdmission( function userEnvelope(message: UserMessage): UserEnvelope | undefined { if (typeof message.content === "string") { - return { schema_version: "openshell.pi-message.v1", origin: "user", text: message.content }; + return { origin: "user", schema_version: "openshell.pi-message.v1", text: message.content }; } if (message.content.some((block) => block.type === "image")) return undefined; return { - schema_version: "openshell.pi-message.v1", origin: "user", + schema_version: "openshell.pi-message.v1", text: message.content.map((block) => (block as TextContent).text).join("\n"), }; } diff --git a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs index a486db2d..580238ca 100644 --- a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs +++ b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs @@ -33,15 +33,20 @@ describe("OpenShell context admission adapter", () => { () => "session-123", async (_url, init) => { const request = JSON.parse(String(init?.body)); - const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + const requestBody = new TextDecoder().decode(new Uint8Array(request.request_body)); + const envelope = JSON.parse(requestBody); assert.equal(request.hook, "user_message"); assert.equal(envelope.schema_version, "openshell.pi-message.v1"); assert.equal(envelope.origin, "user"); + assert.equal( + requestBody, + `{"origin":"user","schema_version":"openshell.pi-message.v1","text":${JSON.stringify(envelope.text)}}`, + ); return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.text}` })); }, ); const current = user("current", 1); - const queued = user("queued", 2); + const queued = { role: "user", content: "queued", timestamp: 2 }; assert.equal((await admission.admitUserMessage(current, { source: "interactive" })).action, "allow"); assert.equal((await admission.admitUserMessage(queued, { source: "interactive" })).action, "allow"); From e0acc9783d90d755a540325d09c8ee0e832e522b Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 17:38:14 +0000 Subject: [PATCH 32/70] feat(egress-gate): admit every Pi history origin --- .../openshell-context-admission.ts | 289 +++++++++++++----- .../src/egress_gate/admission/__init__.py | 10 + .../src/egress_gate/admission/adapters.py | 196 +++++++++++- .../src/egress_gate/admission/models.py | 7 +- .../src/egress_gate/admission/receipts.py | 10 +- .../tests/admission/test_admission.py | 169 +++++++++- .../js/openshell-context-admission.test.mjs | 143 ++++++++- .../tests/service/test_servicer.py | 7 +- 8 files changed, 717 insertions(+), 114 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts index 1570e011..ed58c175 100644 --- a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts @@ -1,5 +1,7 @@ import { createHash } from "node:crypto"; +import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { + AssistantMessage, Context, ImageContent, ProviderHeaders, @@ -10,7 +12,7 @@ import type { import type { ContextAdmission, ContextAdmissionResult, - InputSource, + MessageOrigin, } from "@earendil-works/pi-coding-agent"; const HANDLE_HEADER = "x-openshell-agent-admission-handle"; @@ -19,7 +21,11 @@ const MAX_BRIDGE_RESPONSE_BYTES = MAX_ADMISSION_BYTES * 4 + 64 * 1024; const MAX_HANDLE_ENTRIES = 1024; type ContentBlock = TextContent | ImageContent; -type UserEnvelope = { schema_version: "openshell.pi-message.v1"; origin: "user"; text: string }; +type MessageEnvelope = { + schema_version: "openshell.pi-message.v1"; + origin: "user" | "compaction_summary" | "branch_summary" | "extension_message"; + text: string; +}; type ToolResultEnvelope = { schema_version: "openshell.pi-tool-result.v1"; tool_call_id: string; @@ -27,11 +33,34 @@ type ToolResultEnvelope = { content: ContentBlock[]; is_error: boolean; }; -type AdmissionEnvelope = UserEnvelope | ToolResultEnvelope; +type AssistantEnvelope = { + schema_version: "openshell.pi-assistant-message.v1"; + text: string; + tool_calls: { id: string; name: string; arguments: Record }[]; +}; +type BashEnvelope = { + schema_version: "openshell.pi-bash-execution.v1"; + command: string; + output: string; + exit_code: number | null; +}; +type AdmissionEnvelope = MessageEnvelope | ToolResultEnvelope | AssistantEnvelope | BashEnvelope; +type AdmissionHook = + | "user_message" + | "tool_result" + | "assistant_message" + | "compaction_summary" + | "branch_summary" + | "extension_message" + | "bash_execution"; type BridgeResult = | { decision: "deny"; reason_code?: string } | { decision: "allow"; handle: string; replacement_body?: number[] }; +type SummaryMessage = AgentMessage & { summary: string }; +type CustomMessage = AgentMessage & { content: string | ContentBlock[] }; +type BashMessage = AgentMessage & { command: string; output: string; exitCode: number | undefined }; + export function createOpenShellContextAdmission( bridgeUrl: string, getSessionId: () => string, @@ -39,11 +68,8 @@ export function createOpenShellContextAdmission( ): ContextAdmission { const handles = new Map(); - async function requestAdmission( - hook: "user_message" | "tool_result", - envelope: AdmissionEnvelope, - ): Promise { - const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); + async function requestAdmission(hook: AdmissionHook, envelope: AdmissionEnvelope): Promise { + const requestBody = new TextEncoder().encode(canonicalJson(envelope)); if (requestBody.byteLength > MAX_ADMISSION_BYTES) { throw new Error("OpenShell admission request is too large"); } @@ -67,51 +93,34 @@ export function createOpenShellContextAdmission( return parseBridgeResult(JSON.parse(new TextDecoder().decode(encoded))); } - async function admitUserMessage( - message: UserMessage, - _context?: { source: InputSource }, - ): Promise> { - const envelope = userEnvelope(message); - if (!envelope) { + async function admitMessage( + message: T, + meta: { origin: MessageOrigin }, + ): Promise> { + const prepared = envelopeForMessage(message, meta.origin); + if (!prepared) { return { action: "deny", reason: "Image inputs are not supported by OpenShell admission" }; } - const result = await requestAdmission("user_message", envelope); - if (result.decision === "deny") return denied(result.reason_code); - const admittedEnvelope = result.replacement_body - ? parseUserEnvelope(new Uint8Array(result.replacement_body)) - : envelope; - const admittedMessage: UserMessage = { - ...message, - content: replaceUserText(message.content, admittedEnvelope.text), - }; - rememberHandle(handles, messageKey(admittedMessage), result.handle); - return admittedEnvelope.text === envelope.text - ? { action: "allow" } - : { action: "allow", message: admittedMessage }; - } - - async function admitToolResult( - message: ToolResultMessage, - ): Promise> { - const envelope = toolResultEnvelope(message); - const result = await requestAdmission("tool_result", envelope); + const result = await requestAdmission(prepared.hook, prepared.envelope); if (result.decision === "deny") return denied(result.reason_code); const admittedEnvelope = result.replacement_body - ? parseToolResultEnvelope(new Uint8Array(result.replacement_body)) - : envelope; - const admittedMessage: ToolResultMessage = { ...message, content: admittedEnvelope.content }; - rememberHandle(handles, messageKey(admittedMessage), result.handle); + ? parseReplacement(prepared.hook, new Uint8Array(result.replacement_body)) + : prepared.envelope; + const admittedMessage = applyReplacement(message, meta.origin, admittedEnvelope); + if (meta.origin === "user" || meta.origin === "tool_result") { + rememberHandle(handles, messageKey(admittedMessage), result.handle); + } return result.replacement_body ? { action: "allow", message: admittedMessage } : { action: "allow" }; } return { - admitUserMessage, - admitToolResult, + admitMessage, async admitProviderContext(context) { for (let index = context.messages.length - 1; index >= 0; index -= 1) { const message = context.messages[index]; if (message.role !== "user" && message.role !== "toolResult") continue; - const result = message.role === "user" ? await admitUserMessage(message) : await admitToolResult(message); + const origin = message.role === "user" ? "user" : "tool_result"; + const result = await admitMessage(message, { origin }); if (result.action === "deny") return result; if (!result.message) return { action: "allow" }; const messages = [...context.messages]; @@ -135,15 +144,66 @@ export function createOpenShellContextAdmission( }; } -function userEnvelope(message: UserMessage): UserEnvelope | undefined { - if (typeof message.content === "string") { - return { origin: "user", schema_version: "openshell.pi-message.v1", text: message.content }; +function envelopeForMessage( + message: AgentMessage, + origin: MessageOrigin, +): { hook: AdmissionHook; envelope: AdmissionEnvelope } | undefined { + switch (origin) { + case "user": { + if (message.role !== "user") throw new Error("Pi admission origin does not match the message"); + const envelope = textEnvelope("user", message.content); + return envelope && { hook: "user_message", envelope }; + } + case "tool_result": + if (message.role !== "toolResult") throw new Error("Pi admission origin does not match the message"); + return { hook: "tool_result", envelope: toolResultEnvelope(message) }; + case "assistant": + if (message.role !== "assistant") throw new Error("Pi admission origin does not match the message"); + return { hook: "assistant_message", envelope: assistantEnvelope(message) }; + case "compaction_summary": + if (message.role !== "compactionSummary") throw new Error("Pi admission origin does not match the message"); + return { + hook: "compaction_summary", + envelope: messageEnvelope("compaction_summary", (message as SummaryMessage).summary), + }; + case "branch_summary": + if (message.role !== "branchSummary") throw new Error("Pi admission origin does not match the message"); + return { + hook: "branch_summary", + envelope: messageEnvelope("branch_summary", (message as SummaryMessage).summary), + }; + case "extension_message": { + if (message.role !== "custom") throw new Error("Pi admission origin does not match the message"); + const envelope = textEnvelope("extension_message", (message as CustomMessage).content); + return envelope && { hook: "extension_message", envelope }; + } + case "bash_execution": { + if (message.role !== "bashExecution") throw new Error("Pi admission origin does not match the message"); + const bash = message as BashMessage; + return { + hook: "bash_execution", + envelope: { + command: bash.command, + exit_code: bash.exitCode ?? null, + output: bash.output, + schema_version: "openshell.pi-bash-execution.v1", + }, + }; + } } - if (message.content.some((block) => block.type === "image")) return undefined; +} + +function messageEnvelope(origin: MessageEnvelope["origin"], text: string): MessageEnvelope { + return { origin, schema_version: "openshell.pi-message.v1", text }; +} + +function textEnvelope(origin: MessageEnvelope["origin"], content: string | ContentBlock[]): MessageEnvelope | undefined { + if (typeof content === "string") return messageEnvelope(origin, content); + if (content.some((block) => block.type === "image")) return undefined; return { - origin: "user", + origin, schema_version: "openshell.pi-message.v1", - text: message.content.map((block) => (block as TextContent).text).join("\n"), + text: content.map((block) => (block as TextContent).text).join("\n"), }; } @@ -157,10 +217,72 @@ function toolResultEnvelope(message: ToolResultMessage): ToolResultEnvelope { }; } -function messageKey(message: UserMessage | ToolResultMessage): string { - return createHash("sha256") - .update(JSON.stringify(message.role === "user" ? userEnvelope(message) : toolResultEnvelope(message))) - .digest("hex"); +function assistantEnvelope(message: AssistantMessage): AssistantEnvelope { + return { + schema_version: "openshell.pi-assistant-message.v1", + text: message.content + .filter((block): block is TextContent => block.type === "text") + .map((block) => block.text) + .join("\n"), + tool_calls: message.content + .filter((block) => block.type === "toolCall") + .map(({ id, name, arguments: args }) => ({ arguments: args, id, name })), + }; +} + +function applyReplacement(message: T, origin: MessageOrigin, envelope: AdmissionEnvelope): T { + switch (origin) { + case "user": + return { ...message, content: replaceTextContent((message as UserMessage).content, (envelope as MessageEnvelope).text) }; + case "tool_result": + return { ...message, content: (envelope as ToolResultEnvelope).content }; + case "assistant": + return replaceAssistantText(message as AssistantMessage, (envelope as AssistantEnvelope).text) as T; + case "compaction_summary": + case "branch_summary": + return { ...message, summary: (envelope as MessageEnvelope).text }; + case "extension_message": + return { ...message, content: replaceTextContent((message as CustomMessage).content, (envelope as MessageEnvelope).text) }; + case "bash_execution": + return { ...message, output: (envelope as BashEnvelope).output }; + } + throw new Error("Pi admission origin is unsupported"); +} + +function replaceAssistantText(message: AssistantMessage, text: string): AssistantMessage { + const content: AssistantMessage["content"] = []; + let replaced = false; + for (const block of message.content) { + if (block.type !== "text") { + content.push(block); + } else if (!replaced) { + if (text) content.push({ type: "text", text }); + replaced = true; + } + } + if (!replaced && text) content.push({ type: "text", text }); + return { ...message, content }; +} + +function replaceTextContent(content: string | ContentBlock[], text: string): string | TextContent[] { + return typeof content === "string" ? text : [{ type: "text", text }]; +} + +function messageKey(message: AgentMessage): string { + const origin = message.role === "user" ? "user" : "tool_result"; + const prepared = envelopeForMessage(message, origin); + if (!prepared) throw new Error("Image inputs are not supported by OpenShell admission"); + return createHash("sha256").update(canonicalJson(prepared.envelope)).digest("hex"); +} + +function canonicalJson(value: unknown): string { + return JSON.stringify(sortJson(value)); +} + +function sortJson(value: unknown): unknown { + if (Array.isArray(value)) return value.map(sortJson); + if (!isRecord(value)) return value; + return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); } function rememberHandle(handles: Map, key: string, handle: string): void { @@ -172,10 +294,6 @@ function rememberHandle(handles: Map, key: string, handle: strin } } -function replaceUserText(content: UserMessage["content"], text: string): UserMessage["content"] { - return typeof content === "string" ? text : [{ type: "text", text }]; -} - function denied(reasonCode?: string): { action: "deny"; reason: string } { return { action: "deny", @@ -204,32 +322,45 @@ function parseBridgeResult(value: unknown): BridgeResult { return { decision: "allow", handle: value.handle, replacement_body: value.replacement_body }; } -function parseUserEnvelope(body: Uint8Array): UserEnvelope { +function parseReplacement(hook: AdmissionHook, body: Uint8Array): AdmissionEnvelope { const value: unknown = JSON.parse(new TextDecoder().decode(body)); - if ( - !isRecord(value) || - value.schema_version !== "openshell.pi-message.v1" || - value.origin !== "user" || - typeof value.text !== "string" - ) { - throw new Error("OpenShell admission returned an invalid user replacement"); - } - return { schema_version: "openshell.pi-message.v1", origin: "user", text: value.text }; -} - -function parseToolResultEnvelope(body: Uint8Array): ToolResultEnvelope { - const value: unknown = JSON.parse(new TextDecoder().decode(body)); - if ( - !isRecord(value) || - value.schema_version !== "openshell.pi-tool-result.v1" || - typeof value.tool_call_id !== "string" || - typeof value.tool_name !== "string" || - !Array.isArray(value.content) || - typeof value.is_error !== "boolean" - ) { - throw new Error("OpenShell admission returned an invalid tool-result replacement"); + if (!isRecord(value)) throw new Error("OpenShell admission returned an invalid replacement"); + switch (hook) { + case "user_message": + case "compaction_summary": + case "branch_summary": + case "extension_message": + if ( + value.schema_version !== "openshell.pi-message.v1" || + typeof value.origin !== "string" || + typeof value.text !== "string" + ) throw new Error("OpenShell admission returned an invalid message replacement"); + return value as MessageEnvelope; + case "tool_result": + if ( + value.schema_version !== "openshell.pi-tool-result.v1" || + typeof value.tool_call_id !== "string" || + typeof value.tool_name !== "string" || + !Array.isArray(value.content) || + typeof value.is_error !== "boolean" + ) throw new Error("OpenShell admission returned an invalid tool-result replacement"); + return value as ToolResultEnvelope; + case "assistant_message": + if ( + value.schema_version !== "openshell.pi-assistant-message.v1" || + typeof value.text !== "string" || + !Array.isArray(value.tool_calls) + ) throw new Error("OpenShell admission returned an invalid assistant replacement"); + return value as AssistantEnvelope; + case "bash_execution": + if ( + value.schema_version !== "openshell.pi-bash-execution.v1" || + typeof value.command !== "string" || + typeof value.output !== "string" || + (value.exit_code !== null && typeof value.exit_code !== "number") + ) throw new Error("OpenShell admission returned an invalid bash replacement"); + return value as BashEnvelope; } - return value as ToolResultEnvelope; } function isByteArray(value: unknown): value is number[] { diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py index 6ac77c18..c97101e1 100644 --- a/projects/egress-gate/src/egress_gate/admission/__init__.py +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -9,6 +9,11 @@ HarnessAdapterRegistry, OpenAIChatCompletionsV1Adapter, OpenAIResponsesV1Adapter, + PiAssistantMessageV1, + PiAssistantMessageV1Adapter, + PiAssistantToolCallV1, + PiBashExecutionV1, + PiBashExecutionV1Adapter, PiImageContentV1, PiMessageV1, PiMessageV1Adapter, @@ -76,6 +81,11 @@ "ModelRequestV1", "OpenAIChatCompletionsV1Adapter", "OpenAIResponsesV1Adapter", + "PiAssistantMessageV1", + "PiAssistantMessageV1Adapter", + "PiAssistantToolCallV1", + "PiBashExecutionV1", + "PiBashExecutionV1Adapter", "PiMessageV1", "PiImageContentV1", "PiTextContentV1", diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index 39e5cf0b..43412a9c 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -51,11 +51,16 @@ class ProviderShapeError(ValueError): """A content-safe signal that a provider request is unsupported.""" +PiMessageOrigin: TypeAlias = Literal[ + "user", "compaction_summary", "branch_summary", "extension_message" +] + + class PiMessageV1(StrictDomainModel): - """Rendered text submitted by the managed Pi harness.""" + """Text-bearing message submitted by the managed Pi harness.""" schema_version: Literal["openshell.pi-message.v1"] - origin: Literal["user"] + origin: PiMessageOrigin text: ScalarString @@ -89,7 +94,40 @@ def _content_is_a_tuple(cls, value: object) -> object: return tuple(value) if isinstance(value, list) else value -AttestedCandidate: TypeAlias = PiMessageV1 | CanonicalMessageV1 +class PiAssistantToolCallV1(StrictDomainModel): + """One immutable Pi assistant tool call.""" + + id: ScalarString + name: ScalarString + arguments: dict[str, object] + + +class PiAssistantMessageV1(StrictDomainModel): + """Replaceable assistant text and immutable tool calls.""" + + schema_version: Literal["openshell.pi-assistant-message.v1"] + text: ScalarString + tool_calls: tuple[PiAssistantToolCallV1, ...] + + @field_validator("tool_calls", mode="before") + @classmethod + def _tool_calls_are_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +class PiBashExecutionV1(StrictDomainModel): + """Replaceable bash output and immutable execution metadata.""" + + schema_version: Literal["openshell.pi-bash-execution.v1"] + command: ScalarString + output: ScalarString + exit_code: int | None + + +HarnessNative: TypeAlias = ( + PiMessageV1 | PiToolResultV1 | PiAssistantMessageV1 | PiBashExecutionV1 +) +AttestedCandidate: TypeAlias = HarnessNative | CanonicalMessageV1 class PreparedHarnessRequest: @@ -98,7 +136,7 @@ class PreparedHarnessRequest: def __init__( self, *, - native: PiMessageV1 | PiToolResultV1, + native: HarnessNative, projected_body: bytes, original_body: bytes, ) -> None: @@ -127,7 +165,10 @@ def validate_result( class PiMessageV1Adapter: - """Strict user-message adapter.""" + """Strict adapter for one text-bearing Pi origin.""" + + def __init__(self, accepted_origin: PiMessageOrigin) -> None: + self._accepted_origin = accepted_origin def prepare( self, @@ -135,7 +176,9 @@ def prepare( context: HarnessAdmissionContext, timeout: Timeout, ) -> PreparedHarnessRequest: - native = _parse_pi_body(request.request_body, timeout) + native = _parse_pi_body( + request.request_body, timeout, accepted_origin=self._accepted_origin + ) return PreparedHarnessRequest( native=native, projected_body=canonical_json_bytes(native), @@ -149,7 +192,9 @@ def validate_result( context: HarnessAdmissionContext, timeout: Timeout, ) -> tuple[bytes | None, PiMessageV1]: - updated = _parse_pi_body(projected_body, timeout) + updated = _parse_pi_body( + projected_body, timeout, accepted_origin=self._accepted_origin + ) encoded = canonical_json_bytes(updated) replacement = ( None @@ -159,6 +204,78 @@ def validate_result( return replacement, updated +class PiAssistantMessageV1Adapter: + """Strict adapter for Pi assistant text and tool calls.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: + native = _parse_pi_assistant_message(request.request_body, timeout) + return PreparedHarnessRequest( + native=native, + projected_body=canonical_json_bytes(native), + original_body=request.request_body, + ) + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, PiAssistantMessageV1]: + updated = _parse_pi_assistant_message(projected_body, timeout) + if not isinstance(prepared.native, PiAssistantMessageV1): + raise AdmissionMutationError("assistant admission state is invalid") + if updated.tool_calls != prepared.native.tool_calls: + raise AdmissionMutationError("admission changed assistant tool calls") + encoded = canonical_json_bytes(updated) + replacement = ( + None if encoded == canonical_json_bytes(prepared.native) else encoded + ) + return replacement, updated + + +class PiBashExecutionV1Adapter: + """Strict adapter for Pi bash output.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: + native = _parse_pi_bash_execution(request.request_body, timeout) + return PreparedHarnessRequest( + native=native, + projected_body=canonical_json_bytes(native), + original_body=request.request_body, + ) + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, PiBashExecutionV1]: + updated = _parse_pi_bash_execution(projected_body, timeout) + if not isinstance(prepared.native, PiBashExecutionV1): + raise AdmissionMutationError("bash admission state is invalid") + immutable_before = (prepared.native.command, prepared.native.exit_code) + immutable_after = (updated.command, updated.exit_code) + if immutable_after != immutable_before: + raise AdmissionMutationError("admission changed bash execution metadata") + encoded = canonical_json_bytes(updated) + replacement = ( + None if encoded == canonical_json_bytes(prepared.native) else encoded + ) + return replacement, updated + + class PiToolResultV1Adapter: """Strict adapter for Pi tool-result content blocks.""" @@ -717,18 +834,36 @@ def resolve_request( def create_pi_adapter_registry() -> HarnessAdapterRegistry: """Return the built-in Pi v1 admission registry.""" registry = HarnessAdapterRegistry() - registry.register( - "pi", - AdmissionHook.USER_MESSAGE, - "openshell.pi-message.v1", - PiMessageV1Adapter(), - ) + for hook, origin in ( + (AdmissionHook.USER_MESSAGE, "user"), + (AdmissionHook.COMPACTION_SUMMARY, "compaction_summary"), + (AdmissionHook.BRANCH_SUMMARY, "branch_summary"), + (AdmissionHook.EXTENSION_MESSAGE, "extension_message"), + ): + registry.register( + "pi", + hook, + "openshell.pi-message.v1", + PiMessageV1Adapter(origin), + ) registry.register( "pi", AdmissionHook.TOOL_RESULT, "openshell.pi-tool-result.v1", PiToolResultV1Adapter(), ) + registry.register( + "pi", + AdmissionHook.ASSISTANT_MESSAGE, + "openshell.pi-assistant-message.v1", + PiAssistantMessageV1Adapter(), + ) + registry.register( + "pi", + AdmissionHook.BASH_EXECUTION, + "openshell.pi-bash-execution.v1", + PiBashExecutionV1Adapter(), + ) return registry @@ -740,7 +875,9 @@ def create_provider_adapter_registry() -> ProviderAdapterRegistry: return registry -def _parse_pi_body(body: bytes, timeout: Timeout) -> PiMessageV1: +def _parse_pi_body( + body: bytes, timeout: Timeout, *, accepted_origin: PiMessageOrigin = "user" +) -> PiMessageV1: value = _load_json(body, AdmissionShapeError, timeout) try: parsed = _PI_ADAPTER.validate_python(value, strict=True) @@ -748,11 +885,35 @@ def _parse_pi_body(body: bytes, timeout: Timeout) -> PiMessageV1: raise AdmissionShapeError("Pi request body is unsupported") from None if not isinstance(parsed, PiMessageV1): raise AdmissionShapeError("Pi request body is unsupported") + if parsed.origin != accepted_origin: + raise AdmissionShapeError("Pi message origin is unsupported") if canonical_json_bytes(parsed) != body: raise AdmissionShapeError("Pi request body is not canonical JSON") return parsed +def _parse_pi_assistant_message(body: bytes, timeout: Timeout) -> PiAssistantMessageV1: + value = _load_json(body, AdmissionShapeError, timeout) + try: + parsed = _PI_ASSISTANT_MESSAGE_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi assistant-message body is unsupported") from None + if canonical_json_bytes(parsed) != body: + raise AdmissionShapeError("Pi assistant-message body is not canonical JSON") + return parsed + + +def _parse_pi_bash_execution(body: bytes, timeout: Timeout) -> PiBashExecutionV1: + value = _load_json(body, AdmissionShapeError, timeout) + try: + parsed = _PI_BASH_EXECUTION_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi bash-execution body is unsupported") from None + if canonical_json_bytes(parsed) != body: + raise AdmissionShapeError("Pi bash-execution body is not canonical JSON") + return parsed + + def _parse_pi_tool_result(body: bytes, timeout: Timeout) -> PiToolResultV1: value = _load_json(body, AdmissionShapeError, timeout) try: @@ -865,6 +1026,8 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 _PI_ADAPTER = TypeAdapter(PiMessageV1) _PI_TOOL_RESULT_ADAPTER = TypeAdapter(PiToolResultV1) +_PI_ASSISTANT_MESSAGE_ADAPTER = TypeAdapter(PiAssistantMessageV1) +_PI_BASH_EXECUTION_ADAPTER = TypeAdapter(PiBashExecutionV1) _PROVIDER_ADAPTER = TypeAdapter(_ProviderRequest) _RESPONSES_PROVIDER_ADAPTER = TypeAdapter(_ResponsesRequest) @@ -879,6 +1042,11 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 "OpenAIResponsesV1Adapter", "PiMessageV1", "PiImageContentV1", + "PiAssistantMessageV1", + "PiAssistantMessageV1Adapter", + "PiAssistantToolCallV1", + "PiBashExecutionV1", + "PiBashExecutionV1Adapter", "PiTextContentV1", "PiToolResultV1", "PiToolResultV1Adapter", diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py index 4fa99ddd..bd5f50d7 100644 --- a/projects/egress-gate/src/egress_gate/admission/models.py +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -21,10 +21,15 @@ class AdmissionHook(StrEnum): - """Supported Pi admission boundaries.""" + """Supported harness admission boundaries.""" USER_MESSAGE = "user_message" TOOL_RESULT = "tool_result" + ASSISTANT_MESSAGE = "assistant_message" + COMPACTION_SUMMARY = "compaction_summary" + BRANCH_SUMMARY = "branch_summary" + EXTENSION_MESSAGE = "extension_message" + BASH_EXECUTION = "bash_execution" class AdmissionDecision(StrEnum): diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py index b213d97e..783d4b92 100644 --- a/projects/egress-gate/src/egress_gate/admission/receipts.py +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -36,7 +36,15 @@ class AgentAttestationClaimsV1(StrictDomainModel): harness: ScalarString harness_version: Literal["sdk-v1"] harness_schema: ScalarString - hook: Literal["user_message", "tool_result"] + hook: Literal[ + "user_message", + "tool_result", + "assistant_message", + "compaction_summary", + "branch_summary", + "extension_message", + "bash_execution", + ] middleware_binding: BoundedMetadataString policy_fingerprint: ScalarString sandbox_id: BoundedMetadataString diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index 53e912cf..b7b492db 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -7,6 +7,7 @@ import json from pathlib import Path +from typing import Literal import pytest @@ -20,6 +21,9 @@ HarnessAdmissionContext, HarnessAdmissionProcessor, HarnessAdmissionRequest, + PiAssistantMessageV1, + PiAssistantToolCallV1, + PiBashExecutionV1, PiMessageV1, PiTextContentV1, PiToolResultV1, @@ -135,11 +139,15 @@ def _context( *, target: HttpTarget | None = None, ) -> HarnessAdmissionContext: - schema = ( - "openshell.pi-message.v1" - if hook is AdmissionHook.USER_MESSAGE - else "openshell.pi-tool-result.v1" - ) + schema = { + AdmissionHook.USER_MESSAGE: "openshell.pi-message.v1", + AdmissionHook.COMPACTION_SUMMARY: "openshell.pi-message.v1", + AdmissionHook.BRANCH_SUMMARY: "openshell.pi-message.v1", + AdmissionHook.EXTENSION_MESSAGE: "openshell.pi-message.v1", + AdmissionHook.TOOL_RESULT: "openshell.pi-tool-result.v1", + AdmissionHook.ASSISTANT_MESSAGE: "openshell.pi-assistant-message.v1", + AdmissionHook.BASH_EXECUTION: "openshell.pi-bash-execution.v1", + }[hook] return HarnessAdmissionContext( request_id="admission-1", sandbox_id="sandbox-1", @@ -155,16 +163,24 @@ def _context( def _admit( processor: HarnessAdmissionProcessor, - value: PiMessageV1 | PiToolResultV1, + value: PiMessageV1 | PiToolResultV1 | PiAssistantMessageV1 | PiBashExecutionV1, *, target: HttpTarget | None = None, timeout: Timeout | None = None, ): - hook = ( - AdmissionHook.USER_MESSAGE - if isinstance(value, PiMessageV1) - else AdmissionHook.TOOL_RESULT - ) + if isinstance(value, PiMessageV1): + hook = { + "user": AdmissionHook.USER_MESSAGE, + "compaction_summary": AdmissionHook.COMPACTION_SUMMARY, + "branch_summary": AdmissionHook.BRANCH_SUMMARY, + "extension_message": AdmissionHook.EXTENSION_MESSAGE, + }[value.origin] + elif isinstance(value, PiToolResultV1): + hook = AdmissionHook.TOOL_RESULT + elif isinstance(value, PiAssistantMessageV1): + hook = AdmissionHook.ASSISTANT_MESSAGE + else: + hook = AdmissionHook.BASH_EXECUTION return processor.process( HarnessAdmissionRequest( request_body=canonical_json_bytes(value), @@ -177,9 +193,42 @@ def _admit( ) -def _user(text: str) -> PiMessageV1: +def _message( + text: str, + *, + origin: Literal[ + "user", "compaction_summary", "branch_summary", "extension_message" + ] = "user", +) -> PiMessageV1: return PiMessageV1( - schema_version="openshell.pi-message.v1", origin="user", text=text + schema_version="openshell.pi-message.v1", origin=origin, text=text + ) + + +def _user(text: str) -> PiMessageV1: + return _message(text) + + +def _assistant( + text: str, *, arguments: dict[str, object] | None = None +) -> PiAssistantMessageV1: + return PiAssistantMessageV1( + schema_version="openshell.pi-assistant-message.v1", + text=text, + tool_calls=( + PiAssistantToolCallV1( + id="call-1", name="read", arguments=arguments or {"path": "safe"} + ), + ), + ) + + +def _bash(output: str, *, command: str = "printf safe") -> PiBashExecutionV1: + return PiBashExecutionV1( + schema_version="openshell.pi-bash-execution.v1", + command=command, + output=output, + exit_code=0, ) @@ -524,6 +573,100 @@ def test_tool_result_denial_redaction_and_images_fail_closed() -> None: assert image.reason_code == "admission_contract_invalid" +@pytest.mark.parametrize( + "origin", + ["user", "compaction_summary", "branch_summary", "extension_message"], +) +def test_text_message_origins_allow_replace_and_deny(origin) -> None: + admission, _, _ = _processors() + + allowed = _admit(admission, _message("safe", origin=origin)) + redacted = _admit(admission, _message(REDACT_TEXT, origin=origin)) + denied = _admit(admission, _message(DENY_TEXT, origin=origin)) + + assert allowed.decision is AdmissionDecision.ALLOW + assert redacted.decision is AdmissionDecision.REPLACE + assert redacted.replacement_body is not None + replacement = PiMessageV1.model_validate_json( + redacted.replacement_body, strict=True + ) + assert replacement.origin == origin + assert replacement.text == "[REDACTED]" + assert denied.decision is AdmissionDecision.DENY + + +def test_text_message_binding_rejects_a_different_origin() -> None: + admission, _, _ = _processors() + value = _message("safe", origin="branch_summary") + + result = admission.process( + HarnessAdmissionRequest( + request_body=canonical_json_bytes(value), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ), + ), + _context(AdmissionHook.COMPACTION_SUMMARY), + timeout=Timeout.from_seconds(1), + ) + + assert result.reason_code == "admission_contract_invalid" + + +def test_assistant_message_allows_text_replacement_and_denial() -> None: + admission, _, _ = _processors() + + allowed = _admit(admission, _assistant("safe")) + redacted = _admit(admission, _assistant(REDACT_TEXT)) + denied = _admit(admission, _assistant(DENY_TEXT)) + + assert allowed.decision is AdmissionDecision.ALLOW + assert redacted.decision is AdmissionDecision.REPLACE + assert redacted.replacement_body is not None + replacement = PiAssistantMessageV1.model_validate_json( + redacted.replacement_body, strict=True + ) + assert replacement.text == "[REDACTED]" + assert replacement.tool_calls == _assistant("safe").tool_calls + assert denied.decision is AdmissionDecision.DENY + + +def test_assistant_message_rejects_tool_call_mutation() -> None: + admission, _, _ = _processors() + + result = _admit(admission, _assistant("safe", arguments={"path": REDACT_TEXT})) + + assert result.decision is AdmissionDecision.DENY + assert result.reason_code == "admission_contract_invalid" + + +def test_bash_execution_allows_output_replacement_and_denial() -> None: + admission, _, _ = _processors() + + allowed = _admit(admission, _bash("safe")) + redacted = _admit(admission, _bash(REDACT_TEXT)) + denied = _admit(admission, _bash(DENY_TEXT)) + + assert allowed.decision is AdmissionDecision.ALLOW + assert redacted.decision is AdmissionDecision.REPLACE + assert redacted.replacement_body is not None + replacement = PiBashExecutionV1.model_validate_json( + redacted.replacement_body, strict=True + ) + assert replacement.output == "[REDACTED]" + assert (replacement.command, replacement.exit_code) == ("printf safe", 0) + assert denied.decision is AdmissionDecision.DENY + + +def test_bash_execution_rejects_command_mutation() -> None: + admission, _, _ = _processors() + + result = _admit(admission, _bash("safe", command=f"printf {REDACT_TEXT}")) + + assert result.decision is AdmissionDecision.DENY + assert result.reason_code == "admission_contract_invalid" + + def test_denial_returns_no_attestation_or_replacement() -> None: admission, _, _ = _processors() diff --git a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs index 580238ca..2a807b18 100644 --- a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs +++ b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs @@ -20,6 +20,17 @@ function toolResult(text, isError = false) { }; } +function assistant(text) { + return { + role: "assistant", + content: [ + { type: "thinking", thinking: "keep reasoning" }, + { type: "text", text }, + { type: "toolCall", id: "call-1", name: "read", arguments: { path: "safe" } }, + ], + }; +} + async function admittedContext(admission, context) { const result = await admission.admitProviderContext(context); assert.equal(result.action, "allow"); @@ -48,8 +59,8 @@ describe("OpenShell context admission adapter", () => { const current = user("current", 1); const queued = { role: "user", content: "queued", timestamp: 2 }; - assert.equal((await admission.admitUserMessage(current, { source: "interactive" })).action, "allow"); - assert.equal((await admission.admitUserMessage(queued, { source: "interactive" })).action, "allow"); + assert.equal((await admission.admitMessage(current, { origin: "user", source: "interactive" })).action, "allow"); + assert.equal((await admission.admitMessage(queued, { origin: "user", source: "interactive" })).action, "allow"); const currentHeaders = await admission.transformProviderHeaders( {}, @@ -76,7 +87,7 @@ describe("OpenShell context admission adapter", () => { JSON.stringify({ decision: "allow", handle: "replacement-handle", replacement_body: [...replacement] }), ), ); - const admitted = await admission.admitUserMessage(user("secret", 1), { source: "interactive" }); + const admitted = await admission.admitMessage(user("secret", 1), { origin: "user", source: "interactive" }); assert.equal(admitted.action, "allow"); assert.ok(admitted.message); @@ -101,8 +112,8 @@ describe("OpenShell context admission adapter", () => { const prompt = user("run command", 1); const failed = toolResult("Command exited with code 2", true); - await admission.admitUserMessage(prompt, { source: "interactive" }); - await admission.admitToolResult(failed); + await admission.admitMessage(prompt, { origin: "user", source: "interactive" }); + await admission.admitMessage(failed, { origin: "tool_result" }); const headers = await admission.transformProviderHeaders( {}, await admittedContext(admission, { messages: [prompt, failed], tools: [] }), @@ -112,6 +123,128 @@ describe("OpenShell context admission adapter", () => { assert.deepEqual(hooks, ["user_message", "tool_result", "tool_result"]); }); + it("maps every Pi message origin to its exact hook and envelope", async () => { + const cases = [ + { + origin: "user", + message: user("user text", 1), + hook: "user_message", + schema: "openshell.pi-message.v1", + expected: { origin: "user", text: "user text" }, + }, + { + origin: "tool_result", + message: toolResult("tool text"), + hook: "tool_result", + schema: "openshell.pi-tool-result.v1", + expected: { tool_call_id: "call-1", tool_name: "bash", is_error: false }, + }, + { + origin: "assistant", + message: assistant("assistant text"), + hook: "assistant_message", + schema: "openshell.pi-assistant-message.v1", + expected: { text: "assistant text", tool_calls: [{ id: "call-1", name: "read", arguments: { path: "safe" } }] }, + }, + { + origin: "compaction_summary", + message: { role: "compactionSummary", summary: "compact text" }, + hook: "compaction_summary", + schema: "openshell.pi-message.v1", + expected: { origin: "compaction_summary", text: "compact text" }, + }, + { + origin: "branch_summary", + message: { role: "branchSummary", summary: "branch text" }, + hook: "branch_summary", + schema: "openshell.pi-message.v1", + expected: { origin: "branch_summary", text: "branch text" }, + }, + { + origin: "extension_message", + message: { role: "custom", content: "extension text" }, + hook: "extension_message", + schema: "openshell.pi-message.v1", + expected: { origin: "extension_message", text: "extension text" }, + }, + { + origin: "bash_execution", + message: { role: "bashExecution", command: "printf safe", output: "bash text", exitCode: 0 }, + hook: "bash_execution", + schema: "openshell.pi-bash-execution.v1", + expected: { command: "printf safe", output: "bash text", exit_code: 0 }, + }, + ]; + const observed = []; + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + async (_url, init) => { + const request = JSON.parse(String(init?.body)); + const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + observed.push({ request, envelope }); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${request.hook}` })); + }, + ); + + for (const item of cases) { + assert.equal((await admission.admitMessage(item.message, { origin: item.origin })).action, "allow"); + } + assert.equal(observed.length, cases.length); + for (const [index, item] of cases.entries()) { + assert.equal(observed[index].request.hook, item.hook); + assert.equal(observed[index].request.schema_version, item.schema); + assert.equal(observed[index].envelope.schema_version, item.schema); + for (const [key, value] of Object.entries(item.expected)) { + assert.deepEqual(observed[index].envelope[key], value); + } + } + assert.deepEqual(Object.keys(observed[2].envelope), ["schema_version", "text", "tool_calls"]); + assert.deepEqual(Object.keys(observed[2].envelope.tool_calls[0]), ["arguments", "id", "name"]); + assert.deepEqual(Object.keys(observed[6].envelope), ["command", "exit_code", "output", "schema_version"]); + }); + + it("applies replacements only to the origin's replaceable text", async () => { + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + async (_url, init) => { + const request = JSON.parse(String(init?.body)); + const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + if ("text" in envelope) envelope.text = "[REDACTED]"; + if ("output" in envelope) envelope.output = "[REDACTED]"; + return new Response( + JSON.stringify({ + decision: "allow", + handle: `handle:${request.hook}`, + replacement_body: [...new TextEncoder().encode(JSON.stringify(envelope))], + }), + ); + }, + ); + + const summary = await admission.admitMessage( + { role: "compactionSummary", summary: "secret" }, + { origin: "compaction_summary" }, + ); + const bash = await admission.admitMessage( + { role: "bashExecution", command: "printf safe", output: "secret", exitCode: 7 }, + { origin: "bash_execution" }, + ); + const reply = await admission.admitMessage(assistant("secret"), { origin: "assistant" }); + + assert.equal(summary.message.summary, "[REDACTED]"); + assert.deepEqual( + { command: bash.message.command, output: bash.message.output, exitCode: bash.message.exitCode }, + { command: "printf safe", output: "[REDACTED]", exitCode: 7 }, + ); + assert.deepEqual(reply.message.content, [ + { type: "thinking", thinking: "keep reasoning" }, + { type: "text", text: "[REDACTED]" }, + { type: "toolCall", id: "call-1", name: "read", arguments: { path: "safe" } }, + ]); + }); + it("fails closed when provider-only context is denied", async () => { const admission = createOpenShellContextAdmission( "http://bridge.test/admit", diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 113512e9..91311137 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -131,7 +131,7 @@ def test_manifest_leaves_the_gateway_rpc_timeout_to_the_operator() -> None: assert manifest.bindings[0].timeout == "" -def test_managed_manifest_advertises_exact_user_and_tool_result_bindings() -> None: +def test_managed_manifest_advertises_every_pi_admission_binding() -> None: middleware = EgressGateMiddleware( create_builtin_registry(), require_agent_attestation=True ) @@ -150,7 +150,12 @@ def test_managed_manifest_advertises_exact_user_and_tool_result_bindings() -> No for binding in agent_bindings ] == [ ("pi", "user_message", "openshell.pi-message.v1"), + ("pi", "compaction_summary", "openshell.pi-message.v1"), + ("pi", "branch_summary", "openshell.pi-message.v1"), + ("pi", "extension_message", "openshell.pi-message.v1"), ("pi", "tool_result", "openshell.pi-tool-result.v1"), + ("pi", "assistant_message", "openshell.pi-assistant-message.v1"), + ("pi", "bash_execution", "openshell.pi-bash-execution.v1"), ] From ebf8d4d2273b9b846f8ae0d1b757a093defc35fc Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 17:45:15 +0000 Subject: [PATCH 33/70] fix(egress-gate): accept JavaScript tool arguments --- .../src/egress_gate/admission/adapters.py | 2 -- .../tests/admission/test_admission.py | 22 +++++++++++++++++++ 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index 43412a9c..2e106fd3 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -898,8 +898,6 @@ def _parse_pi_assistant_message(body: bytes, timeout: Timeout) -> PiAssistantMes parsed = _PI_ASSISTANT_MESSAGE_ADAPTER.validate_python(value, strict=True) except ValidationError: raise AdmissionShapeError("Pi assistant-message body is unsupported") from None - if canonical_json_bytes(parsed) != body: - raise AdmissionShapeError("Pi assistant-message body is not canonical JSON") return parsed diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index b7b492db..3d43d836 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -631,6 +631,28 @@ def test_assistant_message_allows_text_replacement_and_denial() -> None: assert denied.decision is AdmissionDecision.DENY +def test_assistant_message_accepts_javascript_number_serialization() -> None: + admission, _, _ = _processors() + body = ( + b'{"schema_version":"openshell.pi-assistant-message.v1","text":"safe",' + b'"tool_calls":[{"arguments":{"threshold":1e-7},"id":"call-1",' + b'"name":"read"}]}' + ) + + result = admission.process( + HarnessAdmissionRequest( + request_body=body, + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ), + ), + _context(AdmissionHook.ASSISTANT_MESSAGE), + timeout=Timeout.from_seconds(1), + ) + + assert result.decision is AdmissionDecision.ALLOW + + def test_assistant_message_rejects_tool_call_mutation() -> None: admission, _, _ = _processors() From 3fb88be2786f558a8e4c2a19c2926ec8004e19f3 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 18:11:57 +0000 Subject: [PATCH 34/70] feat(egress-gate): bind complete provider context --- .../examples/pi-attested-admission/README.md | 30 +- .../openshell-context-admission.ts | 148 +++++++-- .../src/egress_gate/admission/__init__.py | 20 +- .../src/egress_gate/admission/adapters.py | 231 +++++++++++--- .../src/egress_gate/admission/models.py | 9 +- .../src/egress_gate/admission/processor.py | 51 ++-- .../src/egress_gate/admission/receipts.py | 60 ++-- .../admission/fixtures/context-entries.json | 52 ++++ .../tests/admission/test_admission.py | 287 +++++++++--------- .../js/openshell-context-admission.test.mjs | 91 ++++-- .../tests/service/test_grpc_integration.py | 15 +- .../tests/service/test_servicer.py | 1 + 12 files changed, 676 insertions(+), 319 deletions(-) create mode 100644 projects/egress-gate/tests/admission/fixtures/context-entries.json diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index a72e6462..f36c11d7 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -225,18 +225,19 @@ message or tool result reaches that history. `launch` preserves the history; result before it queues, appends, or persists the value. 3. The external adapter sends the exact context addition to OpenShell's sandbox-local bridge. Egress Gate applies `policy.yaml` and returns allow, - deny, or a complete replacement. -4. OpenShell keeps the signed attestation and gives Pi only an opaque handle. - The adapter keeps handles in its private closure, outside Pi messages. -5. Immediately before every provider request, Pi passes the exact outbound + deny, or a complete replacement. This append-time checkpoint returns no + attestation or handle. +4. Immediately before every provider request, Pi passes the exact outbound context through admission. This includes normal turns, retries, compaction, branch summaries, and contexts restored from a prior session. The adapter - applies any replacement and obtains a fresh handle for the newest user - message or tool result in that exact context. -6. OpenShell strips the handle, resolves the supervisor-held attestation, and - supplies it only to the configured Egress Gate middleware stage. Egress Gate - verifies the latest context addition and scans the complete provider request - before OpenShell injects the proxy-delivered model credential. + applies per-entry replacements and obtains one fresh handle for the complete + ordered user/tool context. +5. OpenShell keeps the signed whole-context attestation and gives Pi only the + opaque handle, which the adapter keeps outside Pi messages. At egress, + OpenShell strips the handle and supplies the attestation only to the + configured Egress Gate stage. Egress Gate verifies the same ordered entries + before and after request policy runs, before OpenShell injects the + proxy-delivered model credential. This division is intentional. The Pi fork contributes only reusable harness primitives: mandatory admission of user messages and finalized tool results, @@ -251,10 +252,11 @@ built into Pi. The current OpenShell bridge is supervisor-owned but reachable by every process inside the sandbox over loopback; it does not yet authenticate the calling -process. Receipt binding still prevents an unadmitted provider request from -passing the egress middleware. However, OpenShell cannot prove that the -designated harness invoked admission before changing its own local memory or -session files. A stronger runtime needs one additional OpenShell primitive: a +process. Whole-context attestation binding still prevents an unadmitted +provider request from passing the egress middleware. However, OpenShell cannot +prove that the designated harness invoked admission before changing its own +local memory or session files. A stronger runtime needs one additional +OpenShell primitive: a process-scoped admission capability, or a supervisor-owned adapter channel that only the designated harness can invoke. diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts index ed58c175..23568b2e 100644 --- a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts @@ -44,8 +44,16 @@ type BashEnvelope = { output: string; exit_code: number | null; }; +type ContextEntry = + | { role: "user"; text: string } + | { role: "tool"; tool_call_id: string; text: string }; +type ProviderContextEnvelope = { + schema_version: "openshell.pi-provider-context.v1"; + entries: ContextEntry[]; +}; type AdmissionEnvelope = MessageEnvelope | ToolResultEnvelope | AssistantEnvelope | BashEnvelope; -type AdmissionHook = +type BridgeEnvelope = AdmissionEnvelope | ProviderContextEnvelope; +type AppendAdmissionHook = | "user_message" | "tool_result" | "assistant_message" @@ -53,9 +61,10 @@ type AdmissionHook = | "branch_summary" | "extension_message" | "bash_execution"; +type AdmissionHook = AppendAdmissionHook | "provider_context"; type BridgeResult = | { decision: "deny"; reason_code?: string } - | { decision: "allow"; handle: string; replacement_body?: number[] }; + | { decision: "allow"; handle?: string; replacement_body?: number[] }; type SummaryMessage = AgentMessage & { summary: string }; type CustomMessage = AgentMessage & { content: string | ContentBlock[] }; @@ -68,7 +77,7 @@ export function createOpenShellContextAdmission( ): ContextAdmission { const handles = new Map(); - async function requestAdmission(hook: AdmissionHook, envelope: AdmissionEnvelope): Promise { + async function requestAdmission(hook: AdmissionHook, envelope: BridgeEnvelope): Promise { const requestBody = new TextEncoder().encode(canonicalJson(envelope)); if (requestBody.byteLength > MAX_ADMISSION_BYTES) { throw new Error("OpenShell admission request is too large"); @@ -107,38 +116,34 @@ export function createOpenShellContextAdmission( ? parseReplacement(prepared.hook, new Uint8Array(result.replacement_body)) : prepared.envelope; const admittedMessage = applyReplacement(message, meta.origin, admittedEnvelope); - if (meta.origin === "user" || meta.origin === "tool_result") { - rememberHandle(handles, messageKey(admittedMessage), result.handle); - } return result.replacement_body ? { action: "allow", message: admittedMessage } : { action: "allow" }; } return { admitMessage, async admitProviderContext(context) { - for (let index = context.messages.length - 1; index >= 0; index -= 1) { - const message = context.messages[index]; - if (message.role !== "user" && message.role !== "toolResult") continue; - const origin = message.role === "user" ? "user" : "tool_result"; - const result = await admitMessage(message, { origin }); - if (result.action === "deny") return result; - if (!result.message) return { action: "allow" }; - const messages = [...context.messages]; - messages[index] = result.message; - return { action: "allow", context: { ...context, messages } }; + const envelope = providerContextEnvelope(context); + if (!envelope) { + return { action: "deny", reason: "Image inputs are not supported by OpenShell admission" }; } - return { action: "deny", reason: "Provider context has no user message or tool result to admit" }; + const result = await requestAdmission("provider_context", envelope); + if (result.decision === "deny") return denied(result.reason_code); + if (!result.handle) throw new Error("OpenShell admission returned no provider-context handle"); + const admittedEnvelope = result.replacement_body + ? parseProviderContextReplacement(new Uint8Array(result.replacement_body), envelope) + : envelope; + const admittedContext = applyProviderContextReplacement(context, admittedEnvelope.entries); + rememberHandle(handles, contextKey(admittedEnvelope), result.handle); + return result.replacement_body ? { action: "allow", context: admittedContext } : { action: "allow" }; }, async transformProviderHeaders(headers: ProviderHeaders, context: Context) { if (Object.keys(headers).some((name) => name.toLowerCase() === HANDLE_HEADER)) { throw new Error("OpenShell admission handle header is reserved"); } - for (let index = context.messages.length - 1; index >= 0; index -= 1) { - const message = context.messages[index]; - if (message.role !== "user" && message.role !== "toolResult") continue; - const handle = handles.get(messageKey(message)); - if (handle) return { ...headers, [HANDLE_HEADER]: handle }; - } + const envelope = providerContextEnvelope(context); + if (!envelope) throw new Error("Image inputs are not supported by OpenShell admission"); + const handle = handles.get(contextKey(envelope)); + if (handle) return { ...headers, [HANDLE_HEADER]: handle }; throw new Error("OpenShell admission handle is missing for the outbound context"); }, }; @@ -147,7 +152,7 @@ export function createOpenShellContextAdmission( function envelopeForMessage( message: AgentMessage, origin: MessageOrigin, -): { hook: AdmissionHook; envelope: AdmissionEnvelope } | undefined { +): { hook: AppendAdmissionHook; envelope: AdmissionEnvelope } | undefined { switch (origin) { case "user": { if (message.role !== "user") throw new Error("Pi admission origin does not match the message"); @@ -268,11 +273,57 @@ function replaceTextContent(content: string | ContentBlock[], text: string): str return typeof content === "string" ? text : [{ type: "text", text }]; } -function messageKey(message: AgentMessage): string { - const origin = message.role === "user" ? "user" : "tool_result"; - const prepared = envelopeForMessage(message, origin); - if (!prepared) throw new Error("Image inputs are not supported by OpenShell admission"); - return createHash("sha256").update(canonicalJson(prepared.envelope)).digest("hex"); +function providerContextEnvelope(context: Context): ProviderContextEnvelope | undefined { + const entries: ContextEntry[] = []; + for (const message of context.messages) { + if (message.role === "user") { + const text = textContent(message.content); + if (text === undefined) return undefined; + entries.push({ role: "user", text }); + } else if (message.role === "toolResult") { + const text = textBlocks(message.content); + if (text === undefined) return undefined; + entries.push({ + role: "tool", + tool_call_id: message.toolCallId.split("|", 1)[0], + text: text || "(no tool output)", + }); + } + } + if (entries.length === 0) throw new Error("Provider context has no user message or tool result to admit"); + return { schema_version: "openshell.pi-provider-context.v1", entries }; +} + +function textContent(content: string | ContentBlock[]): string | undefined { + if (typeof content === "string") return content; + return textBlocks(content); +} + +function textBlocks(content: ContentBlock[]): string | undefined { + if (content.some((block) => block.type === "image")) return undefined; + return content.map((block) => (block as TextContent).text).join("\n"); +} + +function applyProviderContextReplacement(context: Context, entries: ContextEntry[]): Context { + let entryIndex = 0; + const messages = context.messages.map((message) => { + if (message.role !== "user" && message.role !== "toolResult") return message; + const entry = entries[entryIndex++]; + if (message.role === "user") { + if (entry.role !== "user") throw new Error("OpenShell admission changed provider-context structure"); + return { ...message, content: replaceTextContent(message.content, entry.text) }; + } + if (entry.role !== "tool" || entry.tool_call_id !== message.toolCallId.split("|", 1)[0]) { + throw new Error("OpenShell admission changed provider-context structure"); + } + return { ...message, content: [{ type: "text" as const, text: entry.text }] }; + }); + if (entryIndex !== entries.length) throw new Error("OpenShell admission changed provider-context structure"); + return { ...context, messages }; +} + +function contextKey(envelope: ProviderContextEnvelope): string { + return createHash("sha256").update(canonicalJson(envelope.entries)).digest("hex"); } function canonicalJson(value: unknown): string { @@ -310,7 +361,10 @@ function parseBridgeResult(value: unknown): BridgeResult { if (value.decision === "deny") { return { decision: "deny", reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined }; } - if (typeof value.handle !== "string" || !value.handle || value.handle.length > 1024) { + if ( + value.handle !== undefined && + (typeof value.handle !== "string" || !value.handle || value.handle.length > 1024) + ) { throw new Error("OpenShell admission returned an invalid handle"); } if ( @@ -322,7 +376,39 @@ function parseBridgeResult(value: unknown): BridgeResult { return { decision: "allow", handle: value.handle, replacement_body: value.replacement_body }; } -function parseReplacement(hook: AdmissionHook, body: Uint8Array): AdmissionEnvelope { +function parseProviderContextReplacement( + body: Uint8Array, + original: ProviderContextEnvelope, +): ProviderContextEnvelope { + const value: unknown = JSON.parse(new TextDecoder().decode(body)); + if ( + !isRecord(value) || + value.schema_version !== "openshell.pi-provider-context.v1" || + !Array.isArray(value.entries) || + value.entries.length !== original.entries.length + ) { + throw new Error("OpenShell admission returned an invalid provider-context replacement"); + } + const entries = value.entries.map((entry, index): ContextEntry => { + const expected = original.entries[index]; + if (!isRecord(entry) || entry.role !== expected.role || typeof entry.text !== "string") { + throw new Error("OpenShell admission changed provider-context structure"); + } + if (entry.role === "user" && entry.tool_call_id === undefined) return { role: "user", text: entry.text }; + if ( + entry.role === "tool" && + typeof entry.tool_call_id === "string" && + expected.role === "tool" && + entry.tool_call_id === expected.tool_call_id + ) { + return { role: "tool", tool_call_id: entry.tool_call_id, text: entry.text }; + } + throw new Error("OpenShell admission changed provider-context structure"); + }); + return { schema_version: "openshell.pi-provider-context.v1", entries }; +} + +function parseReplacement(hook: AppendAdmissionHook, body: Uint8Array): AdmissionEnvelope { const value: unknown = JSON.parse(new TextDecoder().decode(body)); if (!isRecord(value)) throw new Error("OpenShell admission returned an invalid replacement"); switch (hook) { diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py index c97101e1..c453ed7c 100644 --- a/projects/egress-gate/src/egress_gate/admission/__init__.py +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -4,7 +4,8 @@ """First-class harness admission and attested-egress APIs.""" from egress_gate.admission.adapters import ( - AttestedCandidate, + AttestedEntries, + ContextEntryV1, HarnessAdapter, HarnessAdapterRegistry, OpenAIChatCompletionsV1Adapter, @@ -17,12 +18,17 @@ PiImageContentV1, PiMessageV1, PiMessageV1Adapter, + PiProviderContextV1, + PiProviderContextV1Adapter, PiTextContentV1, PiToolResultV1, PiToolResultV1Adapter, PreparedHarnessRequest, ProviderAdapterRegistry, ProviderRequestAdapter, + ToolContextEntryV1, + UserContextEntryV1, + context_entries_subject, create_pi_adapter_registry, create_provider_adapter_registry, ) @@ -52,7 +58,7 @@ HarnessAdmissionProcessor, ) from egress_gate.admission.receipts import ( - AgentAttestationClaimsV1, + AgentAttestationClaimsV2, ReceiptAuthority, ReceiptVerificationError, ) @@ -61,8 +67,8 @@ "AdmissionDecision", "AdmissionHook", "AdmissionProvenance", - "AgentAttestationClaimsV1", - "AttestedCandidate", + "AgentAttestationClaimsV2", + "AttestedEntries", "AttestedEgressProcessor", "CanonicalFunctionCallV1", "CanonicalGenerationV1", @@ -70,6 +76,7 @@ "CanonicalRole", "CanonicalToolChoiceV1", "CanonicalToolV1", + "ContextEntryV1", "HarnessAdapter", "HarnessAdapterRegistry", "HarnessAdmissionContext", @@ -92,13 +99,18 @@ "PiToolResultV1", "PiToolResultV1Adapter", "PiMessageV1Adapter", + "PiProviderContextV1", + "PiProviderContextV1Adapter", "PreparedHarnessRequest", "ProviderAdapterRegistry", "ProviderRequestAdapter", "RECEIPT_HEADER", "ReceiptAuthority", "ReceiptVerificationError", + "ToolContextEntryV1", + "UserContextEntryV1", "canonical_json_bytes", + "context_entries_subject", "create_pi_adapter_registry", "create_provider_adapter_registry", ] diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index 2e106fd3..ab27ed40 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -5,6 +5,7 @@ from __future__ import annotations +import hashlib import json from typing import Literal, Protocol, TypeAlias @@ -124,10 +125,44 @@ class PiBashExecutionV1(StrictDomainModel): exit_code: int | None +class UserContextEntryV1(StrictDomainModel): + """One ordered user entry sent to a provider.""" + + role: Literal["user"] + text: ScalarString + + +class ToolContextEntryV1(StrictDomainModel): + """One ordered tool entry sent to a provider.""" + + role: Literal["tool"] + tool_call_id: ScalarString + text: ScalarString + + +ContextEntryV1: TypeAlias = UserContextEntryV1 | ToolContextEntryV1 + + +class PiProviderContextV1(StrictDomainModel): + """Every provider-visible user and tool entry in order.""" + + schema_version: Literal["openshell.pi-provider-context.v1"] + entries: tuple[ContextEntryV1, ...] = Field(min_length=1) + + @field_validator("entries", mode="before") + @classmethod + def _entries_are_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + HarnessNative: TypeAlias = ( - PiMessageV1 | PiToolResultV1 | PiAssistantMessageV1 | PiBashExecutionV1 + PiMessageV1 + | PiToolResultV1 + | PiAssistantMessageV1 + | PiBashExecutionV1 + | PiProviderContextV1 ) -AttestedCandidate: TypeAlias = HarnessNative | CanonicalMessageV1 +AttestedEntries: TypeAlias = tuple[ContextEntryV1, ...] class PreparedHarnessRequest: @@ -161,10 +196,25 @@ def validate_result( projected_body: bytes, context: HarnessAdmissionContext, timeout: Timeout, - ) -> tuple[bytes | None, AttestedCandidate]: ... + ) -> tuple[bytes | None, HarnessNative]: ... + + def attestation_subject( + self, + prepared: PreparedHarnessRequest, + final: HarnessNative, + ) -> tuple[str, int] | None: ... -class PiMessageV1Adapter: +class _AppendHarnessAdapter: + def attestation_subject( + self, + prepared: PreparedHarnessRequest, + final: HarnessNative, + ) -> None: + return None + + +class PiMessageV1Adapter(_AppendHarnessAdapter): """Strict adapter for one text-bearing Pi origin.""" def __init__(self, accepted_origin: PiMessageOrigin) -> None: @@ -204,7 +254,7 @@ def validate_result( return replacement, updated -class PiAssistantMessageV1Adapter: +class PiAssistantMessageV1Adapter(_AppendHarnessAdapter): """Strict adapter for Pi assistant text and tool calls.""" def prepare( @@ -239,7 +289,7 @@ def validate_result( return replacement, updated -class PiBashExecutionV1Adapter: +class PiBashExecutionV1Adapter(_AppendHarnessAdapter): """Strict adapter for Pi bash output.""" def prepare( @@ -276,7 +326,7 @@ def validate_result( return replacement, updated -class PiToolResultV1Adapter: +class PiToolResultV1Adapter(_AppendHarnessAdapter): """Strict adapter for Pi tool-result content blocks.""" def prepare( @@ -286,7 +336,7 @@ def prepare( timeout: Timeout, ) -> PreparedHarnessRequest: native = _parse_pi_tool_result(request.request_body, timeout) - _tool_result_attested_candidate(native) + _tool_result_entry(native) return PreparedHarnessRequest( native=native, projected_body=canonical_json_bytes(native), @@ -299,7 +349,7 @@ def validate_result( projected_body: bytes, context: HarnessAdmissionContext, timeout: Timeout, - ) -> tuple[bytes | None, AttestedCandidate]: + ) -> tuple[bytes | None, PiToolResultV1]: updated = _parse_pi_tool_result(projected_body, timeout) if not isinstance(prepared.native, PiToolResultV1): raise AdmissionMutationError("tool-result admission state is invalid") @@ -321,7 +371,59 @@ def validate_result( replacement = ( None if encoded == canonical_json_bytes(prepared.native) else encoded ) - return replacement, _tool_result_attested_candidate(updated) + return replacement, updated + + +class PiProviderContextV1Adapter: + """Strict adapter for the complete ordered provider context.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: + native = _parse_pi_provider_context(request.request_body, timeout) + return PreparedHarnessRequest( + native=native, + projected_body=canonical_json_bytes(native), + original_body=request.request_body, + ) + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, PiProviderContextV1]: + updated = _parse_pi_provider_context(projected_body, timeout) + if not isinstance(prepared.native, PiProviderContextV1): + raise AdmissionMutationError("provider-context admission state is invalid") + before = tuple( + (entry.role, getattr(entry, "tool_call_id", None)) + for entry in prepared.native.entries + ) + after = tuple( + (entry.role, getattr(entry, "tool_call_id", None)) + for entry in updated.entries + ) + if after != before: + raise AdmissionMutationError("admission changed provider-context structure") + encoded = canonical_json_bytes(updated) + replacement = ( + None if encoded == canonical_json_bytes(prepared.native) else encoded + ) + return replacement, updated + + def attestation_subject( + self, + prepared: PreparedHarnessRequest, + final: HarnessNative, + ) -> tuple[str, int]: + if not isinstance(final, PiProviderContextV1): + raise AdmissionMutationError("provider-context admission state is invalid") + return context_entries_subject(final.entries) class HarnessAdapterRegistry: @@ -631,9 +733,9 @@ def canonicalize( self, request: HttpRequest, timeout: Timeout ) -> ModelRequestV1: ... - def latest_attested_candidate( + def attested_entries( self, request: HttpRequest, timeout: Timeout - ) -> AttestedCandidate: ... + ) -> AttestedEntries: ... class OpenAIChatCompletionsV1Adapter: @@ -689,23 +791,28 @@ def canonicalize(self, request: HttpRequest, timeout: Timeout) -> ModelRequestV1 ), ) - def latest_attested_candidate( + def attested_entries( self, request: HttpRequest, timeout: Timeout - ) -> AttestedCandidate: - """Extract the latest user or tool context addition.""" + ) -> AttestedEntries: + """Extract every user and tool entry in provider order.""" canonical = self.canonicalize(request, timeout) - for message in reversed(canonical.messages): + entries: list[ContextEntryV1] = [] + for message in canonical.messages: if message.role is CanonicalRole.USER and message.content is not None: - return PiMessageV1( - schema_version="openshell.pi-message.v1", - origin="user", - text=message.content, - ) + entries.append(UserContextEntryV1(role="user", text=message.content)) if message.role is CanonicalRole.TOOL and message.content is not None: if message.tool_call_id is None: raise ProviderShapeError("provider tool result has no call ID") - return message - raise ProviderShapeError("provider request has no attested context addition") + entries.append( + ToolContextEntryV1( + role="tool", + tool_call_id=message.tool_call_id, + text=message.content, + ) + ) + if not entries: + raise ProviderShapeError("provider request has no attested context entries") + return tuple(entries) class OpenAIResponsesV1Adapter: @@ -772,21 +879,31 @@ def canonicalize(self, request: HttpRequest, timeout: Timeout) -> ModelRequestV1 ), ) - def latest_attested_candidate( + def attested_entries( self, request: HttpRequest, timeout: Timeout - ) -> AttestedCandidate: - """Extract the latest user or function-call output context addition.""" + ) -> AttestedEntries: + """Extract every user and function-call output entry in provider order.""" provider = self._parse(request, timeout) - for item in reversed(provider.input): + entries: list[ContextEntryV1] = [] + for item in provider.input: if isinstance(item, _ResponsesInputMessage) and item.role == "user": - return PiMessageV1( - schema_version="openshell.pi-message.v1", - origin="user", - text=_responses_text(item.content), + entries.append( + UserContextEntryV1(role="user", text=_responses_text(item.content)) ) if isinstance(item, _ResponsesFunctionCallOutput): - return _responses_tool_result(item) - raise ProviderShapeError("provider request has no attested context addition") + message = _responses_tool_result(item) + if message.tool_call_id is None or message.content is None: + raise ProviderShapeError("provider tool result is incomplete") + entries.append( + ToolContextEntryV1( + role="tool", + tool_call_id=message.tool_call_id, + text=message.content, + ) + ) + if not entries: + raise ProviderShapeError("provider request has no attested context entries") + return tuple(entries) def _parse(self, request: HttpRequest, timeout: Timeout) -> _ResponsesRequest: _validate_json_request(request) @@ -864,6 +981,12 @@ def create_pi_adapter_registry() -> HarnessAdapterRegistry: "openshell.pi-bash-execution.v1", PiBashExecutionV1Adapter(), ) + registry.register( + "pi", + AdmissionHook.PROVIDER_CONTEXT, + "openshell.pi-provider-context.v1", + PiProviderContextV1Adapter(), + ) return registry @@ -923,21 +1046,42 @@ def _parse_pi_tool_result(body: bytes, timeout: Timeout) -> PiToolResultV1: return parsed -def _tool_result_attested_candidate( - result: PiToolResultV1, -) -> CanonicalMessageV1: +def _parse_pi_provider_context(body: bytes, timeout: Timeout) -> PiProviderContextV1: + value = _load_json(body, AdmissionShapeError, timeout) + try: + parsed = _PI_PROVIDER_CONTEXT_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi provider-context body is unsupported") from None + if canonical_json_bytes(parsed) != body: + raise AdmissionShapeError("Pi provider-context body is not canonical JSON") + return parsed + + +def _tool_result_entry(result: PiToolResultV1) -> ToolContextEntryV1: if any(block.type == "image" for block in result.content): raise AdmissionShapeError("Pi tool-result images are unsupported") text = "\n".join( block.text for block in result.content if isinstance(block, PiTextContentV1) ) - return CanonicalMessageV1( - role=CanonicalRole.TOOL, - content=text or "(no tool output)", + return ToolContextEntryV1( + role="tool", + text=text or "(no tool output)", tool_call_id=_provider_tool_call_id(result.tool_call_id), ) +def context_entries_subject(entries: AttestedEntries) -> tuple[str, int]: + """Return the v2 hash and count for one ordered entry list.""" + body = json.dumps( + [entry.model_dump(mode="json") for entry in entries], + allow_nan=False, + ensure_ascii=False, + separators=(",", ":"), + sort_keys=True, + ).encode("utf-8") + return hashlib.sha256(body).hexdigest(), len(entries) + + def _validate_json_request(request: HttpRequest) -> None: if request.target.method.upper() != "POST": raise ProviderShapeError("provider request method is unsupported") @@ -1026,6 +1170,7 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 _PI_TOOL_RESULT_ADAPTER = TypeAdapter(PiToolResultV1) _PI_ASSISTANT_MESSAGE_ADAPTER = TypeAdapter(PiAssistantMessageV1) _PI_BASH_EXECUTION_ADAPTER = TypeAdapter(PiBashExecutionV1) +_PI_PROVIDER_CONTEXT_ADAPTER = TypeAdapter(PiProviderContextV1) _PROVIDER_ADAPTER = TypeAdapter(_ProviderRequest) _RESPONSES_PROVIDER_ADAPTER = TypeAdapter(_ResponsesRequest) @@ -1033,7 +1178,8 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 __all__ = [ "AdmissionMutationError", "AdmissionShapeError", - "AttestedCandidate", + "AttestedEntries", + "ContextEntryV1", "HarnessAdapter", "HarnessAdapterRegistry", "OpenAIChatCompletionsV1Adapter", @@ -1049,10 +1195,15 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 "PiToolResultV1", "PiToolResultV1Adapter", "PiMessageV1Adapter", + "PiProviderContextV1", + "PiProviderContextV1Adapter", "PreparedHarnessRequest", "ProviderAdapterRegistry", "ProviderRequestAdapter", "ProviderShapeError", + "ToolContextEntryV1", + "UserContextEntryV1", + "context_entries_subject", "create_pi_adapter_registry", "create_provider_adapter_registry", ] diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py index bd5f50d7..062d19f7 100644 --- a/projects/egress-gate/src/egress_gate/admission/models.py +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -30,6 +30,7 @@ class AdmissionHook(StrEnum): BRANCH_SUMMARY = "branch_summary" EXTENSION_MESSAGE = "extension_message" BASH_EXECUTION = "bash_execution" + PROVIDER_CONTEXT = "provider_context" class AdmissionDecision(StrEnum): @@ -110,8 +111,12 @@ def _decision_contract_is_consistent(self) -> HarnessAdmissionResult: and self.replacement_body is not None ): raise ValueError("allow decisions cannot carry a replacement body") - if self.attestation is None: - raise ValueError("admission requires an attestation") + if (self.hook is AdmissionHook.PROVIDER_CONTEXT) != ( + self.attestation is not None + ): + raise ValueError( + "only provider-context admission carries an attestation" + ) return self diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py index c648af05..4d1c9f9d 100644 --- a/projects/egress-gate/src/egress_gate/admission/processor.py +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -13,14 +13,9 @@ AdmissionMutationError, AdmissionShapeError, HarnessAdapterRegistry, - PiMessageV1, ProviderAdapterRegistry, ProviderShapeError, -) -from egress_gate.admission.canonical import ( - CanonicalMessageV1, - CanonicalRole, - canonical_json_bytes, + context_entries_subject, ) from egress_gate.admission.models import ( MAX_ADMISSION_BODY_BYTES, @@ -74,7 +69,7 @@ def readiness(self) -> dict[str, str]: "admission_schema": "openshell.pi-message.v1", "canonicalization": "canonical-json.v1", "provider_adapter": "openai.request.v1", - "attestation_version": "agent-attestation.v1", + "attestation_version": "agent-attestation.v2", "key_id": self._receipt_authority.key_id, "policy_fingerprint": self._policy_fingerprint, } @@ -121,18 +116,21 @@ def process( final_request = apply_request_mutations( projected, gate_result.request_mutations ) - replacement, rendered_prompt = adapter.validate_result( + replacement, final = adapter.validate_result( prepared, final_request.body, context, timeout ) if replacement is not None and len(replacement) > MAX_ADMISSION_BODY_BYTES: raise AdmissionMutationError("admission replacement body is too large") timeout.raise_if_expired() - attestation = self._receipt_authority.issue_attestation( - rendered_prompt, - context, - request.provenance, - policy_fingerprint=self._policy_fingerprint, - ) + subject = adapter.attestation_subject(prepared, final) + attestation = None + if subject is not None: + attestation = self._receipt_authority.issue_attestation( + *subject, + context, + request.provenance, + policy_fingerprint=self._policy_fingerprint, + ) timeout.raise_if_expired() return HarnessAdmissionResult( hook=context.hook, @@ -202,33 +200,24 @@ def process( return self._deny("attestation_missing") try: adapter = self._provider_adapters.resolve_request(request, timeout) - candidate = adapter.latest_attested_candidate(request, timeout) + entries = adapter.attested_entries(request, timeout) + subject_hash, entry_count = context_entries_subject(entries) timeout.raise_if_expired() - if isinstance(candidate, PiMessageV1): - hook = AdmissionHook.USER_MESSAGE - schema_version = "openshell.pi-message.v1" - elif ( - isinstance(candidate, CanonicalMessageV1) - and candidate.role is CanonicalRole.TOOL - ): - hook = AdmissionHook.TOOL_RESULT - schema_version = "openshell.pi-tool-result.v1" - else: - raise ProviderShapeError("provider context addition is unsupported") context = HarnessAdmissionContext( request_id=request.context.request_id, sandbox_id=request.context.sandbox_id, middleware_name=self._middleware_name, harness="pi", harness_version=self._harness_version, - hook=hook, - schema_version=schema_version, + hook=AdmissionHook.PROVIDER_CONTEXT, + schema_version="openshell.pi-provider-context.v1", provider_target=request.target, provider_adapter_schema="openai.request.v1", ) self._receipt_authority.verify_attestation( agent_attestation, - candidate, + subject_hash, + entry_count, context, policy_fingerprint=self._policy_fingerprint, ) @@ -240,8 +229,8 @@ def process( final_request = apply_request_mutations( request, gate_result.request_mutations ) - final_candidate = adapter.latest_attested_candidate(final_request, timeout) - if canonical_json_bytes(final_candidate) != canonical_json_bytes(candidate): + final_entries = adapter.attested_entries(final_request, timeout) + if final_entries != entries: return self._deny("semantic_mutation_denied") timeout.raise_if_expired() return gate_result diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py index 783d4b92..8b10f593 100644 --- a/projects/egress-gate/src/egress_gate/admission/receipts.py +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -18,9 +18,9 @@ ) from pydantic import Field, ValidationError -from egress_gate.admission.adapters import AttestedCandidate from egress_gate.admission.canonical import canonical_json_bytes from egress_gate.admission.models import ( + AdmissionHook, AdmissionProvenance, HarnessAdmissionContext, ) @@ -28,23 +28,15 @@ from egress_gate.string_validators import BoundedMetadataString, ScalarString -class AgentAttestationClaimsV1(StrictDomainModel): - """Supervisor-only proof that the latest context addition was admitted.""" +class AgentAttestationClaimsV2(StrictDomainModel): + """Supervisor-only proof that one complete provider context was admitted.""" - attestation_version: Literal["agent-attestation.v1"] = "agent-attestation.v1" + attestation_version: Literal["agent-attestation.v2"] = "agent-attestation.v2" canonicalization_version: Literal["canonical-json.v1"] = "canonical-json.v1" harness: ScalarString harness_version: Literal["sdk-v1"] harness_schema: ScalarString - hook: Literal[ - "user_message", - "tool_result", - "assistant_message", - "compaction_summary", - "branch_summary", - "extension_message", - "bash_execution", - ] + hook: Literal["provider_context"] middleware_binding: BoundedMetadataString policy_fingerprint: ScalarString sandbox_id: BoundedMetadataString @@ -54,7 +46,9 @@ class AgentAttestationClaimsV1(StrictDomainModel): provider_adapter_schema: Literal["openai.request.v1"] host: ScalarString port: int = Field(ge=0, le=2**32 - 1) - candidate_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + subject_kind: Literal["context"] = "context" + subject_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + entry_count: int = Field(ge=1) issued_at: int = Field(ge=0) expires_at: int = Field(ge=0) key_id: str = Field(pattern=r"^[0-9a-f]{16}$") @@ -96,7 +90,8 @@ def key_id(self) -> str: def issue_attestation( self, - candidate: AttestedCandidate, + subject_hash: str, + entry_count: int, context: HarnessAdmissionContext, provenance: AdmissionProvenance, *, @@ -104,11 +99,14 @@ def issue_attestation( now: int | None = None, ) -> bytes: """Issue a retry-safe proof retained by the OpenShell supervisor.""" - if context.harness_version != "sdk-v1": + if ( + context.harness_version != "sdk-v1" + or context.hook is not AdmissionHook.PROVIDER_CONTEXT + ): raise ValueError("agent attestation context is unsupported") issued_at = _now_seconds() if now is None else now target = context.provider_target - claims = AgentAttestationClaimsV1( + claims = AgentAttestationClaimsV2( harness=context.harness, harness_version=context.harness_version, harness_schema=context.schema_version, @@ -122,34 +120,36 @@ def issue_attestation( provider_adapter_schema=context.provider_adapter_schema, host=target.host, port=target.port, - candidate_hash=_candidate_hash(candidate), + subject_hash=subject_hash, + entry_count=entry_count, issued_at=issued_at, expires_at=issued_at + self._attestation_lifetime_seconds, key_id=self._key_id, ) payload = canonical_json_bytes(claims) signature = self._private_key.sign(payload) - return b"ag1." + _encode(payload) + b"." + _encode(signature) + return b"ag2." + _encode(payload) + b"." + _encode(signature) def verify_attestation( self, attestation: bytes, - candidate: AttestedCandidate, + subject_hash: str, + entry_count: int, context: HarnessAdmissionContext, *, policy_fingerprint: str, now: int | None = None, - ) -> AgentAttestationClaimsV1: - """Verify a supervisor-supplied context-addition attestation.""" + ) -> AgentAttestationClaimsV2: + """Verify a supervisor-supplied provider-context attestation.""" payload, signature = _decode_token( - attestation, prefix=b"ag1", malformed_reason="attestation_malformed" + attestation, prefix=b"ag2", malformed_reason="attestation_malformed" ) try: self._public_key.verify(signature, payload) except InvalidSignature: raise ReceiptVerificationError("attestation_signature_invalid") from None try: - claims = AgentAttestationClaimsV1.model_validate_json(payload, strict=True) + claims = AgentAttestationClaimsV2.model_validate_json(payload, strict=True) except ValidationError: raise ReceiptVerificationError("attestation_malformed") from None if canonical_json_bytes(claims) != payload: @@ -173,7 +173,6 @@ def verify_attestation( context.provider_adapter_schema, target.host, target.port, - _candidate_hash(candidate), ) actual = ( claims.harness, @@ -186,17 +185,16 @@ def verify_attestation( claims.provider_adapter_schema, claims.host, claims.port, - claims.candidate_hash, ) if actual != expected: raise ReceiptVerificationError("attestation_context_mismatch") + if claims.entry_count != entry_count: + raise ReceiptVerificationError("entry_count_mismatch") + if claims.subject_hash != subject_hash: + raise ReceiptVerificationError("context_hash_mismatch") return claims -def _candidate_hash(candidate: AttestedCandidate) -> str: - return hashlib.sha256(canonical_json_bytes(candidate)).hexdigest() - - def _encode(value: bytes) -> bytes: return base64.urlsafe_b64encode(value).rstrip(b"=") @@ -228,7 +226,7 @@ def _now_seconds() -> int: __all__ = [ - "AgentAttestationClaimsV1", + "AgentAttestationClaimsV2", "ReceiptAuthority", "ReceiptVerificationError", ] diff --git a/projects/egress-gate/tests/admission/fixtures/context-entries.json b/projects/egress-gate/tests/admission/fixtures/context-entries.json new file mode 100644 index 00000000..8e5f5240 --- /dev/null +++ b/projects/egress-gate/tests/admission/fixtures/context-entries.json @@ -0,0 +1,52 @@ +{ + "cases": [ + { + "name": "converted-history-origins", + "context": { + "messages": [ + { + "role": "user", + "content": [ + { + "type": "text", + "text": "The conversation history before this point was compacted into the following summary:\n\n\ncompact text\n" + } + ] + }, + { + "role": "assistant", + "content": [{"type": "text", "text": "assistant text"}] + }, + { + "role": "user", + "content": "Ran `printf safe`\n```\nbash text\n```" + }, + { + "role": "user", + "content": [{"type": "text", "text": "extension text"}] + }, + { + "role": "toolResult", + "toolCallId": "call-1|provider-id", + "toolName": "read", + "content": [{"type": "text", "text": "tool text"}], + "isError": false + } + ], + "tools": [] + }, + "entries": [ + { + "role": "user", + "text": "The conversation history before this point was compacted into the following summary:\n\n\ncompact text\n" + }, + { + "role": "user", + "text": "Ran `printf safe`\n```\nbash text\n```" + }, + {"role": "user", "text": "extension text"}, + {"role": "tool", "tool_call_id": "call-1", "text": "tool text"} + ] + } + ] +} diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index 3d43d836..1e9a63c0 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -25,6 +25,7 @@ PiAssistantToolCallV1, PiBashExecutionV1, PiMessageV1, + PiProviderContextV1, PiTextContentV1, PiToolResultV1, ReceiptAuthority, @@ -44,6 +45,9 @@ _PI_CHAT_FIXTURES = json.loads( (Path(__file__).parent / "fixtures/pi-openai-completions.json").read_text() ) +_CONTEXT_ENTRY_VECTORS = json.loads( + (Path(__file__).parent / "fixtures/context-entries.json").read_text() +) # These payloads were captured at Pi's fake-fetch boundary from its native # openai-responses and openai-completions stream functions. They intentionally # preserve the serializer output rather than restating it through test builders. @@ -147,6 +151,7 @@ def _context( AdmissionHook.TOOL_RESULT: "openshell.pi-tool-result.v1", AdmissionHook.ASSISTANT_MESSAGE: "openshell.pi-assistant-message.v1", AdmissionHook.BASH_EXECUTION: "openshell.pi-bash-execution.v1", + AdmissionHook.PROVIDER_CONTEXT: "openshell.pi-provider-context.v1", }[hook] return HarnessAdmissionContext( request_id="admission-1", @@ -163,7 +168,13 @@ def _context( def _admit( processor: HarnessAdmissionProcessor, - value: PiMessageV1 | PiToolResultV1 | PiAssistantMessageV1 | PiBashExecutionV1, + value: ( + PiMessageV1 + | PiToolResultV1 + | PiAssistantMessageV1 + | PiBashExecutionV1 + | PiProviderContextV1 + ), *, target: HttpTarget | None = None, timeout: Timeout | None = None, @@ -179,6 +190,8 @@ def _admit( hook = AdmissionHook.TOOL_RESULT elif isinstance(value, PiAssistantMessageV1): hook = AdmissionHook.ASSISTANT_MESSAGE + elif isinstance(value, PiProviderContextV1): + hook = AdmissionHook.PROVIDER_CONTEXT else: hook = AdmissionHook.BASH_EXECUTION return processor.process( @@ -332,35 +345,25 @@ def _egress( ) -def test_user_attestation_authorizes_retries_without_entering_request_headers() -> None: - admission, egress, _ = _processors() - admitted = _admit(admission, _user("safe rendered prompt")) - - assert admitted.decision is AdmissionDecision.ALLOW - assert admitted.attestation is not None - request = _provider_request("safe rendered prompt") - first = _egress(egress, request, admitted.attestation) - retry = _egress(egress, request, admitted.attestation) - - assert first.decision.value == "allow" - assert retry.decision.value == "allow" - assert first.request_mutations.header_mutations == () +def _admit_provider_request( + admission: HarnessAdmissionProcessor, + request: HttpRequest, +): + registry = create_provider_adapter_registry() + adapter = registry.resolve_request(request, Timeout.from_seconds(1)) + return _admit( + admission, + PiProviderContextV1( + schema_version="openshell.pi-provider-context.v1", + entries=adapter.attested_entries(request, Timeout.from_seconds(1)), + ), + target=request.target, + ) -@pytest.mark.parametrize( - ("fixture_name", "candidate"), - [ - ("opus", _tool_result("safe tool output")), - ("qwen", _tool_result("safe tool output")), - ("compaction_summary", _user("Summary:\ncompacted context")), - ], -) -def test_configured_pi_chat_serializations_are_attested( - fixture_name: str, candidate: PiMessageV1 | PiToolResultV1 -) -> None: +@pytest.mark.parametrize("fixture_name", ["opus", "qwen", "compaction_summary"]) +def test_complete_pi_chat_context_is_attested(fixture_name: str) -> None: admission, egress, _ = _processors() - admitted = _admit(admission, candidate) - assert admitted.attestation is not None request = _provider_request("safe").model_copy( update={ "body": json.dumps( @@ -371,54 +374,104 @@ def test_configured_pi_chat_serializations_are_attested( ).encode() } ) + admitted = _admit_provider_request(admission, request) result = _egress(egress, request, admitted.attestation) + assert admitted.attestation is not None + assert admitted.attestation.startswith(b"ag2.") assert result.decision.value == "allow" -def test_user_attestation_authorizes_responses_requests_and_retries() -> None: +def test_provider_adapters_match_shared_context_entry_vectors() -> None: + expected = PiProviderContextV1.model_validate( + { + "schema_version": "openshell.pi-provider-context.v1", + "entries": _CONTEXT_ENTRY_VECTORS["cases"][0]["entries"], + }, + strict=True, + ).entries + messages = [{"role": "system", "content": "system"}] + responses_input = [{"role": "developer", "content": "system"}] + for entry in expected: + if entry.role == "user": + messages.append({"role": "user", "content": entry.text}) + responses_input.append({"role": "user", "content": entry.text}) + else: + messages.append( + { + "role": "tool", + "content": entry.text, + "tool_call_id": entry.tool_call_id, + } + ) + responses_input.append( + { + "type": "function_call_output", + "call_id": entry.tool_call_id, + "output": entry.text, + } + ) + chat_body = json.loads(json.dumps(_PI_CHAT_FIXTURES["user_request"])) + chat_body["messages"] = messages + chat = _provider_request("unused").model_copy( + update={"body": json.dumps(chat_body, separators=(",", ":")).encode()} + ) + responses_body = json.loads(json.dumps(_PI_RESPONSES_FIXTURES["user_request"])) + responses_body["input"] = responses_input + responses = _responses_request("unused").model_copy( + update={"body": json.dumps(responses_body, separators=(",", ":")).encode()} + ) + registry = create_provider_adapter_registry() + + assert ( + registry.resolve_request(chat, Timeout.from_seconds(1)).attested_entries( + chat, Timeout.from_seconds(1) + ) + == expected + ) + assert ( + registry.resolve_request(responses, Timeout.from_seconds(1)).attested_entries( + responses, Timeout.from_seconds(1) + ) + == expected + ) + + +def test_complete_responses_context_authorizes_retries() -> None: admission, egress, _ = _processors() - admitted = _admit(admission, _user("safe")) - assert admitted.attestation is not None - request = _responses_request("safe") + request = _responses_request("use the tool", tool_result="safe tool output") + admitted = _admit_provider_request(admission, request) first = _egress(egress, request, admitted.attestation) retry = _egress(egress, request, admitted.attestation) + assert admitted.attestation is not None assert first.decision.value == "allow" assert retry.decision.value == "allow" -def test_changed_responses_context_fails_closed() -> None: - admission, egress, _ = _processors() - admitted = _admit(admission, _user("safe")) - assert admitted.attestation is not None - - result = _egress(egress, _responses_request("changed prompt"), admitted.attestation) - - assert result.reason_code == "attestation_context_mismatch" - - @pytest.mark.parametrize( - "mutation", + ("mutation", "reason_code"), [ - lambda body: body.update({"messages": []}), - lambda body: body.update({"unknown_replay_field": "value"}), - lambda body: body["input"].append( - {"role": "user", "content": [{"type": "input_image"}]} + ( + lambda body: body["messages"][1].update({"content": "changed prompt"}), + "context_hash_mismatch", + ), + ( + lambda body: body["messages"].append({"role": "user", "content": "extra"}), + "entry_count_mismatch", ), - lambda body: body.pop("max_output_tokens"), + (lambda body: body["messages"].pop(), "entry_count_mismatch"), ], ) -def test_mixed_or_malformed_responses_shapes_fail_closed(mutation) -> None: +def test_chat_context_tampering_is_denied(mutation, reason_code: str) -> None: admission, egress, _ = _processors() - admitted = _admit(admission, _user("safe")) - assert admitted.attestation is not None - request = _responses_request("safe") + request = _provider_request("use the tool", tool_result="safe tool output") + admitted = _admit_provider_request(admission, request) body = json.loads(request.body) mutation(body) - malformed = request.model_copy( + changed = request.model_copy( update={ "body": json.dumps( body, @@ -429,55 +482,56 @@ def test_mixed_or_malformed_responses_shapes_fail_closed(mutation) -> None: } ) - result = _egress(egress, malformed, admitted.attestation) + result = _egress(egress, changed, admitted.attestation) - assert result.reason_code == "provider_shape_unsupported" + assert result.reason_code == reason_code -def test_tool_result_attestation_authorizes_responses_call_id_projection() -> None: +def test_responses_earlier_entry_tampering_is_denied() -> None: admission, egress, _ = _processors() - admitted = _admit( - admission, - _tool_result("safe tool output", tool_call_id="call-1|fc-1"), - ) - assert admitted.attestation is not None + request = _responses_request("use the tool", tool_result="safe tool output") + admitted = _admit_provider_request(admission, request) result = _egress( egress, - _responses_request("use the tool", tool_result="safe tool output"), + _responses_request("changed prompt", tool_result="safe tool output"), admitted.attestation, ) - assert result.decision.value == "allow" + assert result.reason_code == "context_hash_mismatch" -def test_changed_or_unattested_user_context_fails_closed() -> None: +def test_provider_context_redaction_binds_only_the_replacement() -> None: admission, egress, _ = _processors() - admitted = _admit(admission, _user("safe rendered prompt")) + original = _provider_request(f"hide {REDACT_TEXT} please") + admitted = _admit_provider_request(admission, original) + + assert admitted.decision is AdmissionDecision.REPLACE assert admitted.attestation is not None + assert admitted.replacement_body is not None + replacement = PiProviderContextV1.model_validate_json( + admitted.replacement_body, strict=True + ) + replaced = _provider_request(replacement.entries[0].text) - changed = _egress(egress, _provider_request("changed prompt"), admitted.attestation) - missing = _egress(egress, _provider_request("safe rendered prompt"), None) + assert _egress(egress, replaced, admitted.attestation).decision.value == "allow" + assert ( + _egress(egress, original, admitted.attestation).reason_code + == "context_hash_mismatch" + ) - assert changed.reason_code == "attestation_context_mismatch" - assert missing.reason_code == "attestation_missing" + +def test_restored_context_with_denied_text_is_blocked_at_send_time() -> None: + admission, _, _ = _processors() + + denied = _admit_provider_request(admission, _provider_request(DENY_TEXT)) + + assert denied.decision is AdmissionDecision.DENY + assert denied.reason_code == "egress_gate_regex_denied" def test_attestation_uses_stable_destination_across_tls_proxy_normalization() -> None: admission, egress, _ = _processors() - admitted = _admit( - admission, - _user("safe"), - target=HttpTarget( - scheme="https", - host="provider.test", - port=443, - method="POST", - path="", - query="", - ), - ) - assert admitted.attestation is not None normalized = HttpTarget( scheme="http", host="provider.test", @@ -486,12 +540,10 @@ def test_attestation_uses_stable_destination_across_tls_proxy_normalization() -> path="/v1/chat/completions", query="", ) + request = _provider_request("safe", target=normalized) + admitted = _admit_provider_request(admission, request) - allowed = _egress( - egress, - _provider_request("safe", target=normalized), - admitted.attestation, - ) + allowed = _egress(egress, request, admitted.attestation) wrong_host = _egress( egress, _provider_request( @@ -504,53 +556,13 @@ def test_attestation_uses_stable_destination_across_tls_proxy_normalization() -> assert wrong_host.reason_code == "attestation_context_mismatch" -def test_user_redaction_attests_only_the_replacement() -> None: - admission, egress, _ = _processors() - admitted = _admit(admission, _user(f"hide {REDACT_TEXT} please")) - - assert admitted.decision is AdmissionDecision.REPLACE - assert admitted.attestation is not None - assert admitted.replacement_body is not None - replacement = PiMessageV1.model_validate_json( - admitted.replacement_body, strict=True - ).text - - assert replacement == "hide [REDACTED] please" - assert ( - _egress( - egress, _provider_request(replacement), admitted.attestation - ).decision.value - == "allow" - ) - assert ( - _egress( - egress, - _provider_request(f"hide {REDACT_TEXT} please"), - admitted.attestation, - ).reason_code - == "attestation_context_mismatch" - ) - +def test_append_time_allow_returns_no_attestation() -> None: + admission, _, _ = _processors() -def test_tool_result_is_admitted_before_persistence_and_attested_at_egress() -> None: - admission, egress, _ = _processors() - admitted = _admit(admission, _tool_result("safe tool output")) + admitted = _admit(admission, _user("safe")) assert admitted.decision is AdmissionDecision.ALLOW - assert admitted.attestation is not None - matching = _egress( - egress, - _provider_request("inspect", tool_result="safe tool output"), - admitted.attestation, - ) - changed = _egress( - egress, - _provider_request("inspect", tool_result="changed tool output"), - admitted.attestation, - ) - - assert matching.decision.value == "allow" - assert changed.reason_code == "attestation_context_mismatch" + assert admitted.attestation is None def test_tool_result_denial_redaction_and_images_fail_closed() -> None: @@ -765,9 +777,8 @@ def admit_body(body: bytes, timeout: Timeout | None = None): def test_provider_shape_validation_and_optional_reasoning_field_are_preserved() -> None: admission, egress, _ = _processors() - admitted = _admit(admission, _user("safe")) - assert admitted.attestation is not None request = _provider_request("safe") + admitted = _admit_provider_request(admission, request) malformed = request.model_copy(update={"body": b"{"}) provider_body = json.loads(request.body) provider_body["reasoning_effort"] = "medium" @@ -799,9 +810,8 @@ def test_provider_shape_validation_and_optional_reasoning_field_are_preserved() ) def test_mixed_or_null_chat_compatibility_fields_fail_closed(mutation) -> None: admission, egress, _ = _processors() - admitted = _admit(admission, _user("safe")) - assert admitted.attestation is not None request = _provider_request("safe") + admitted = _admit_provider_request(admission, request) body = json.loads(request.body) mutation(body) malformed = request.model_copy( @@ -829,9 +839,11 @@ def test_mixed_or_null_chat_compatibility_fields_fail_closed(mutation) -> None: ) def test_qwen_replay_fields_fail_closed_unless_explicitly_supported(mutation) -> None: admission, egress, _ = _processors() - admitted = _admit(admission, _tool_result("safe tool output")) - assert admitted.attestation is not None body = json.loads(json.dumps(_PI_CHAT_FIXTURES["qwen"])) + original = _provider_request("unused").model_copy( + update={"body": json.dumps(body, separators=(",", ":")).encode()} + ) + admitted = _admit_provider_request(admission, original) mutation(body) request = _provider_request("unused").model_copy( update={"body": json.dumps(body, separators=(",", ":")).encode()} @@ -844,12 +856,11 @@ def test_qwen_replay_fields_fail_closed_unless_explicitly_supported(mutation) -> def test_workload_receipt_header_is_reserved_in_managed_flow() -> None: admission, egress, _ = _processors() - admitted = _admit(admission, _user("safe")) - assert admitted.attestation is not None request = _provider_request( "safe", headers=(HttpHeader(name=RECEIPT_HEADER, value="eg1.untrusted"),), ) + admitted = _admit_provider_request(admission, request) result = _egress(egress, request, admitted.attestation) diff --git a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs index 2a807b18..91c3310a 100644 --- a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs +++ b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs @@ -1,9 +1,13 @@ import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; import { describe, it } from "node:test"; import { createOpenShellContextAdmission } from "../../examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts"; const HANDLE_HEADER = "x-openshell-agent-admission-handle"; +const ENTRY_VECTORS = JSON.parse( + readFileSync(new URL("../admission/fixtures/context-entries.json", import.meta.url), "utf8"), +); function user(text, timestamp) { return { role: "user", content: [{ type: "text", text }], timestamp }; @@ -39,6 +43,7 @@ async function admittedContext(admission, context) { describe("OpenShell context admission adapter", () => { it("selects the handle for the exact provider context", async () => { + let providerCalls = 0; const admission = createOpenShellContextAdmission( "http://bridge.test/admit", () => "session-123", @@ -46,14 +51,12 @@ describe("OpenShell context admission adapter", () => { const request = JSON.parse(String(init?.body)); const requestBody = new TextDecoder().decode(new Uint8Array(request.request_body)); const envelope = JSON.parse(requestBody); - assert.equal(request.hook, "user_message"); - assert.equal(envelope.schema_version, "openshell.pi-message.v1"); - assert.equal(envelope.origin, "user"); - assert.equal( - requestBody, - `{"origin":"user","schema_version":"openshell.pi-message.v1","text":${JSON.stringify(envelope.text)}}`, - ); - return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.text}` })); + if (request.hook !== "provider_context") { + return new Response(JSON.stringify({ decision: "allow" })); + } + providerCalls += 1; + assert.equal(envelope.schema_version, "openshell.pi-provider-context.v1"); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.entries.length}` })); }, ); const current = user("current", 1); @@ -71,13 +74,20 @@ describe("OpenShell context admission adapter", () => { await admittedContext(admission, { messages: [current, queued], tools: [] }), ); - assert.equal(currentHeaders[HANDLE_HEADER], "handle:current"); - assert.equal(queuedHeaders[HANDLE_HEADER], "handle:queued"); + assert.equal(currentHeaders[HANDLE_HEADER], "handle:1"); + assert.equal(queuedHeaders[HANDLE_HEADER], "handle:2"); + assert.equal(providerCalls, 2); }); it("uses an admitted replacement for the outbound handle", async () => { const replacement = new TextEncoder().encode( - JSON.stringify({ schema_version: "openshell.pi-message.v1", origin: "user", text: "[REDACTED]" }), + JSON.stringify({ + schema_version: "openshell.pi-provider-context.v1", + entries: [ + { role: "user", text: "[REDACTED]" }, + { role: "tool", tool_call_id: "call-1", text: "[TOOL REDACTED]" }, + ], + }), ); const admission = createOpenShellContextAdmission( "http://bridge.test/admit", @@ -87,14 +97,14 @@ describe("OpenShell context admission adapter", () => { JSON.stringify({ decision: "allow", handle: "replacement-handle", replacement_body: [...replacement] }), ), ); - const admitted = await admission.admitMessage(user("secret", 1), { origin: "user", source: "interactive" }); - - assert.equal(admitted.action, "allow"); - assert.ok(admitted.message); - const context = await admittedContext(admission, { messages: [admitted.message], tools: [] }); + const context = await admittedContext(admission, { + messages: [user("secret", 1), toolResult("tool secret")], + tools: [], + }); const headers = await admission.transformProviderHeaders({}, context); - assert.deepEqual(admitted.message.content, [{ type: "text", text: "[REDACTED]" }]); + assert.deepEqual(context.messages[0].content, [{ type: "text", text: "[REDACTED]" }]); + assert.deepEqual(context.messages[1].content, [{ type: "text", text: "[TOOL REDACTED]" }]); assert.equal(headers[HANDLE_HEADER], "replacement-handle"); }); @@ -106,7 +116,10 @@ describe("OpenShell context admission adapter", () => { async (_url, init) => { const request = JSON.parse(String(init?.body)); hooks.push(request.hook); - return new Response(JSON.stringify({ decision: "allow", handle: `handle:${request.hook}` })); + return new Response(JSON.stringify({ + decision: "allow", + ...(request.hook === "provider_context" ? { handle: "context-handle" } : {}), + })); }, ); const prompt = user("run command", 1); @@ -119,8 +132,8 @@ describe("OpenShell context admission adapter", () => { await admittedContext(admission, { messages: [prompt, failed], tools: [] }), ); - assert.equal(headers[HANDLE_HEADER], "handle:tool_result"); - assert.deepEqual(hooks, ["user_message", "tool_result", "tool_result"]); + assert.equal(headers[HANDLE_HEADER], "context-handle"); + assert.deepEqual(hooks, ["user_message", "tool_result", "provider_context"]); }); it("maps every Pi message origin to its exact hook and envelope", async () => { @@ -183,7 +196,7 @@ describe("OpenShell context admission adapter", () => { const request = JSON.parse(String(init?.body)); const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); observed.push({ request, envelope }); - return new Response(JSON.stringify({ decision: "allow", handle: `handle:${request.hook}` })); + return new Response(JSON.stringify({ decision: "allow" })); }, ); @@ -216,7 +229,6 @@ describe("OpenShell context admission adapter", () => { return new Response( JSON.stringify({ decision: "allow", - handle: `handle:${request.hook}`, replacement_body: [...new TextEncoder().encode(JSON.stringify(envelope))], }), ); @@ -245,6 +257,41 @@ describe("OpenShell context admission adapter", () => { ]); }); + it("matches the shared context-entry vectors", async () => { + for (const vector of ENTRY_VECTORS.cases) { + let observed; + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + async (_url, init) => { + const request = JSON.parse(String(init?.body)); + observed = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + return new Response(JSON.stringify({ decision: "allow", handle: "context-handle" })); + }, + ); + + await admission.admitProviderContext(vector.context); + + assert.equal(observed.schema_version, "openshell.pi-provider-context.v1"); + assert.deepEqual(observed.entries, vector.entries, vector.name); + } + }); + + it("denies provider contexts containing images before calling the bridge", async () => { + const admission = createOpenShellContextAdmission( + "http://bridge.test/admit", + () => "session-123", + async () => { throw new Error("bridge should not be called"); }, + ); + + const result = await admission.admitProviderContext({ + messages: [{ role: "user", content: [{ type: "image", data: "AA==", mimeType: "image/png" }] }], + tools: [], + }); + + assert.equal(result.action, "deny"); + }); + it("fails closed when provider-only context is denied", async () => { const admission = createOpenShellContextAdmission( "http://bridge.test/admit", diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index feea68da..b43abc09 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -16,6 +16,8 @@ from egress_gate.admission import ( PiMessageV1, + PiProviderContextV1, + UserContextEntryV1, canonical_json_bytes, ) from egress_gate.bindings import supervisor_middleware_pb2 as pb2 @@ -159,7 +161,7 @@ async def test_generated_stub_round_trip_covers_manifest_and_gate_actions() -> N @pytest.mark.asyncio -async def test_generated_stub_issues_a_user_message_attestation() -> None: +async def test_generated_stub_returns_no_attestation_for_append_time_allow() -> None: body = canonical_json_bytes( PiMessageV1( schema_version="openshell.pi-message.v1", origin="user", text="safe" @@ -191,7 +193,7 @@ async def test_generated_stub_issues_a_user_message_attestation() -> None: response = await stub.EvaluateAgentConversation(request) assert response.decision == pb2.DECISION_ALLOW - assert response.attestation.startswith(b"ag1.") + assert response.attestation == b"" assert response.has_replacement_body is False assert response.metadata["admission_schema"] == "openshell.pi-message.v1" @@ -212,8 +214,9 @@ async def test_agent_admission_is_unavailable_when_managed_mode_is_off() -> None @pytest.mark.asyncio async def test_trusted_agent_attestation_is_verified_for_http_egress() -> None: pi_body = canonical_json_bytes( - PiMessageV1( - schema_version="openshell.pi-message.v1", origin="user", text="safe" + PiProviderContextV1( + schema_version="openshell.pi-provider-context.v1", + entries=(UserContextEntryV1(role="user", text="safe"),), ) ) admission = pb2.AgentConversationEvaluation( @@ -223,8 +226,8 @@ async def test_trusted_agent_attestation_is_verified_for_http_egress() -> None: target=pb2.AgentConversationTarget( harness="pi", harness_version="sdk-v1", - hook="user_message", - schema_version="openshell.pi-message.v1", + hook="provider_context", + schema_version="openshell.pi-provider-context.v1", scheme="https", host="provider.invalid", port=443, diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 91311137..483743fc 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -156,6 +156,7 @@ def test_managed_manifest_advertises_every_pi_admission_binding() -> None: ("pi", "tool_result", "openshell.pi-tool-result.v1"), ("pi", "assistant_message", "openshell.pi-assistant-message.v1"), ("pi", "bash_execution", "openshell.pi-bash-execution.v1"), + ("pi", "provider_context", "openshell.pi-provider-context.v1"), ] From 5a3a1986fb765e108a2c1fcfe439ddba8bb594fa Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 18:22:11 +0000 Subject: [PATCH 35/70] fix(egress-gate): join provider text blocks --- .../src/egress_gate/admission/adapters.py | 4 +--- .../admission/fixtures/context-entries.json | 7 ++++-- .../tests/admission/test_admission.py | 24 ++++++++++++++++--- 3 files changed, 27 insertions(+), 8 deletions(-) diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index ab27ed40..e0faa73c 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -1141,9 +1141,7 @@ def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1: if isinstance(item.content, tuple): - if len(item.content) != 1: - raise ProviderShapeError("multipart text requires exactly one block") - content = item.content[0].text + content = "\n".join(block.text for block in item.content) else: content = item.content return CanonicalMessageV1( diff --git a/projects/egress-gate/tests/admission/fixtures/context-entries.json b/projects/egress-gate/tests/admission/fixtures/context-entries.json index 8e5f5240..75c4e279 100644 --- a/projects/egress-gate/tests/admission/fixtures/context-entries.json +++ b/projects/egress-gate/tests/admission/fixtures/context-entries.json @@ -23,7 +23,10 @@ }, { "role": "user", - "content": [{"type": "text", "text": "extension text"}] + "content": [ + {"type": "text", "text": "extension text"}, + {"type": "text", "text": "continued"} + ] }, { "role": "toolResult", @@ -44,7 +47,7 @@ "role": "user", "text": "Ran `printf safe`\n```\nbash text\n```" }, - {"role": "user", "text": "extension text"}, + {"role": "user", "text": "extension text\ncontinued"}, {"role": "tool", "tool_call_id": "call-1", "text": "tool text"} ] } diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index 1e9a63c0..f53df31b 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -391,12 +391,30 @@ def test_provider_adapters_match_shared_context_entry_vectors() -> None: }, strict=True, ).entries + source_messages = [ + message + for message in _CONTEXT_ENTRY_VECTORS["cases"][0]["context"]["messages"] + if message["role"] in {"user", "toolResult"} + ] messages = [{"role": "system", "content": "system"}] responses_input = [{"role": "developer", "content": "system"}] - for entry in expected: + for entry, source in zip(expected, source_messages, strict=True): if entry.role == "user": - messages.append({"role": "user", "content": entry.text}) - responses_input.append({"role": "user", "content": entry.text}) + messages.append({"role": "user", "content": source["content"]}) + content = source["content"] + responses_input.append( + { + "role": "user", + "content": ( + content + if isinstance(content, str) + else [ + {"type": "input_text", "text": block["text"]} + for block in content + ] + ), + } + ) else: messages.append( { From 494ae46b634ee9cc6e33935d39efcf7d2c381694 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 18:25:14 +0000 Subject: [PATCH 36/70] docs(egress-gate): note transport history limits --- .../egress-gate/examples/pi-attested-admission/README.md | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index f36c11d7..a04a4f12 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -268,6 +268,11 @@ compaction, branch summaries, and restored sessions using the OpenAI Chat Completions and Responses wire formats. Image inputs are outside this example's current scope and fail closed. +Provider-context admission runs before Pi's transport-specific history +rewrites. Switching transports with existing tool history or sending orphaned +tool calls may therefore fail closed. Start a fresh session when switching +transports, and complete each tool-call/result sequence before sending. + ## Cleanup Exit Pi, but leave the OpenShell gateway running while cleanup deletes the From ae3e92017f5140e711a688f73b3737e1cdd36219 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 18:42:43 +0000 Subject: [PATCH 37/70] feat(egress-gate): add self-verifying admission demo --- .../docs/architecture/admission.md | 76 ++++++++ .../egress-gate/docs/architecture/index.md | 4 +- .../examples/pi-attested-admission/README.md | 48 ++++- .../examples/pi-attested-admission/demo.sh | 178 +++++++++++++++++- .../openshell-context-admission.ts | 30 +-- .../src/egress_gate/admission/processor.py | 12 -- projects/egress-gate/src/egress_gate/cli.py | 16 +- .../egress-gate/src/egress_gate/logging.py | 41 +++- .../src/egress_gate/service/servicer.py | 15 +- .../js/openshell-context-admission.test.mjs | 16 +- .../tests/service/test_grpc_integration.py | 2 +- .../tests/service/test_servicer.py | 9 +- projects/egress-gate/tests/test_cli.py | 1 + projects/egress-gate/tests/test_logging.py | 35 ++++ .../tests/test_pi_example_commands.py | 14 +- 15 files changed, 443 insertions(+), 54 deletions(-) create mode 100644 projects/egress-gate/docs/architecture/admission.md diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md new file mode 100644 index 00000000..ea7aa644 --- /dev/null +++ b/projects/egress-gate/docs/architecture/admission.md @@ -0,0 +1,76 @@ +--- +title: Managed harness admission +description: Pi context admission, whole-context attestations, and egress verification. +agent_markdown: true +--- + +# Managed harness admission + +Managed Pi sessions use the same Egress Gate policy at three checkpoints. The +append and provider-context checkpoints keep Pi's live and persisted history +consistent with policy. Egress verification supplies the security boundary: a +provider request without a valid attestation is denied before credentials are +attached. + +| Checkpoint | Pi hook | Result | +| --- | --- | --- | +| History append | `user_message`, `tool_result`, `assistant_message`, `compaction_summary`, `branch_summary`, `extension_message`, or `bash_execution` | Allow, deny, or replace one complete Pi entry before append | +| Provider context | `provider_context` | Allow, deny, or replace the complete ordered user/tool context and issue an attestation | +| Network egress | OpenShell pre-credentials middleware | Verify the attestation against the provider request, then run request policy | + +Append-time allows do not carry attestations. Immediately before a provider +request, Pi submits the complete context so retries, continuations, compaction, +queued input, and restored sessions do not depend on the newest entry alone. +OpenShell retains the signed attestation and returns only an opaque handle to +the runtime adapter. + +## Attestation and verification + +An `agent-attestation.v2` claim set binds the canonical context hash and entry +count to the harness and schema versions, middleware binding, policy +fingerprint, sandbox, session and submission identifiers, provider adapter, +provider host and port, signing-key identifier, and issue and expiry times. The +attestation is signed with the Egress Gate instance's ephemeral Ed25519 key and +expires after 300 seconds. + +At egress, Egress Gate: + +1. requires the network enforcement point, rejects the reserved handle header, + and requires an attestation; +2. parses the provider request with the selected OpenAI request adapter and + derives its complete ordered user/tool context; +3. verifies the signature, key, lifetime, trusted context fields, entry count, + and context hash; +4. runs the configured request gate pipeline; and +5. parses the resulting request again and denies if policy mutation changed the + attested semantic context. + +OpenShell attaches proxy-delivered credentials only after this middleware +allows the request. + +## Failures and limits + +Admission payloads and replacements are limited to 4 MiB. The middleware +manifest advertises the registered harness, hook, schema, and limit. Image +inputs are not supported by the Pi adapter and fail closed. Provider-context +admission currently runs before Pi's transport-specific history rewrites, so +switching transports with tool history or sending an orphaned tool call can +also fail closed. + +Stable admission failures include `admission_contract_invalid` and +`admission_unavailable`. Egress verification failures include +`network_context_invalid`, `reserved_header_present`, `attestation_missing`, +`attestation_malformed`, +`attestation_signature_invalid`, `attestation_key_mismatch`, +`attestation_not_yet_valid`, `attestation_expired`, +`attestation_context_mismatch`, `entry_count_mismatch`, +`context_hash_mismatch`, `provider_shape_unsupported`, +`semantic_mutation_denied`, and `egress_verification_failed`. A configured gate +may instead return its own deny reason. + +An Egress Gate started with `--require-agent-attestation` is dedicated to +managed harness traffic: unattested matching provider requests fail closed. +The current loopback bridge is supervisor-owned but, until caller capabilities +are added, any process in the sandbox can invoke it. That does not let an +unattested request pass egress, but it means OpenShell cannot yet prove which +process requested admission. diff --git a/projects/egress-gate/docs/architecture/index.md b/projects/egress-gate/docs/architecture/index.md index 81ebd3ac..8d7dd044 100644 --- a/projects/egress-gate/docs/architecture/index.md +++ b/projects/egress-gate/docs/architecture/index.md @@ -71,4 +71,6 @@ the shared deadline checks. A failed candidate leaves the existing policy unchanged. Gate instances are reused across worker threads, so per-request state must remain local to `evaluate`. -See [Request lifecycle](request-lifecycle.md) and [Service boundary](service-boundary.md). +See [Request lifecycle](request-lifecycle.md), +[Service boundary](service-boundary.md), and +[Managed harness admission](admission.md). diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index a04a4f12..7ec847cf 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -4,7 +4,9 @@ This example runs the normal forked Pi CLI inside OpenShell and sends admitted conversation context to the configured NVIDIA inference endpoint. One endpoint-scoped provider and credential serve all configured models. -The example demonstrates the same policy at both context boundaries: +The example demonstrates the same policy at three checkpoints: before Pi +appends history, immediately before Pi sends its complete provider context, and +again at provider egress before OpenShell attaches credentials. - `DENY_THIS` is rejected before Pi adds a user message or tool result to its live context. @@ -131,6 +133,11 @@ Keep Egress Gate running in one terminal: ./demo.sh serve ``` +This starts a managed-harness-only Egress Gate instance and writes content-safe +evaluation records to `/tmp/pi-egress-runtime/egress-gate.jsonl`. Set +`EGRESS_GATE_LOG` to use another path. Request bodies, headers, and message text +are not written to this log. + Start the matching OpenShell gateway in a second terminal: ```shell title="Terminal 2: OpenShell gateway" @@ -150,7 +157,21 @@ and workspace. ./demo.sh reset ``` -Then launch Pi: +Run the complete non-interactive verification: + +```shell title="Terminal 3: verify the example" +./demo.sh verify +``` + +`verify` runs the real packaged Pi and OpenShell sandbox. It checks a denied +prompt without a session write, a persisted redaction, a raw provider request +without an admission handle, the stock Pi binary without the runtime adapter, +and best-effort tool-result cases. Each case uses a fresh session and prints one +`PASS` or `SKIP` line. Tool cases can skip because choosing to call a tool is +model-dependent; the other cases are required. There is no mock fallback. Run +`./demo.sh --print verify` to inspect every underlying sandbox command. + +To explore interactively afterward, launch Pi: ```shell title="Terminal 3: Pi" ./demo.sh launch @@ -192,8 +213,9 @@ Reply with exactly: DENY_THIS Reply with exactly: REDACT_THIS ``` -The first submission is denied without starting a model request. The second -makes a request containing `[REDACTED]`. +The first submission is denied without starting a model request. The submitted +text is not appended, but Pi does not restore it to the editor after denial. +The second makes a request containing `[REDACTED]`. To exercise tool-result admission without putting the marker in the user message, ask Pi: @@ -221,8 +243,9 @@ message or tool result reaches that history. `launch` preserves the history; `prepare` type-checks both files against the packaged Pi API and compiles them to JavaScript for the sandbox. Pi otherwise starts normally, including standard project and user extension discovery. -2. Pi calls that boundary for each rendered user message and finalized tool - result before it queues, appends, or persists the value. +2. Pi calls that boundary before each supported message reaches live or + persisted history. Assistant output is admitted when the complete assistant + message is finalized, after streamed output has already been displayed. 3. The external adapter sends the exact context addition to OpenShell's sandbox-local bridge. Egress Gate applies `policy.yaml` and returns allow, deny, or a complete replacement. This append-time checkpoint returns no @@ -239,6 +262,13 @@ message or tool result reaches that history. `launch` preserves the history; before and after request policy runs, before OpenShell injects the proxy-delivered model credential. +The two Pi checkpoints serve different purposes. Append-time admission keeps +the UI, live context, and session file consistent with policy. Provider-context +admission covers every entry actually selected for the request, including +history introduced by retries, compaction, continuations, or session restore. +The egress checkpoint is the enforcement boundary: without a matching fresh +attestation, OpenShell does not attach the credential or forward the request. + This division is intentional. The Pi fork contributes only reusable harness primitives: mandatory admission of user messages and finalized tool results, admission of the exact provider context, an outbound-header transformation, and @@ -273,6 +303,12 @@ rewrites. Switching transports with existing tool history or sending orphaned tool calls may therefore fail closed. Start a fresh session when switching transports, and complete each tool-call/result sequence before sending. +Admission handles and their attestations expire after 300 seconds. Pi refreshes +them immediately before ordinary requests, but a provider retry that begins +more than five minutes later is denied. An Egress Gate started with +`--require-agent-attestation` serves managed harnesses only; an ordinary client +using the same middleware registration is denied because it has no attestation. + ## Cleanup Exit Pi, but leave the OpenShell gateway running while cleanup deletes the diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index e1bc5fbd..37e4f55d 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -41,6 +41,7 @@ gateway_fragment_template=$script_dir/gateway-middleware.toml.example pi_settings=$script_dir/settings.json runtime_extension_source=$script_dir/runtime-extension runtime_extension_build=$runtime_dir/integration +egress_gate_log=${EGRESS_GATE_LOG:-$runtime_dir/egress-gate.jsonl} z3_library_path_override=${Z3_LIBRARY_PATH_OVERRIDE:-} bold="" @@ -353,7 +354,8 @@ prepare() { serve() { describe_printed_commands "Run Egress Gate and keep it open:" run_in "$egress_gate_dir" uv run egress-gate --debug serve \ - --listen 0.0.0.0:50051 --timeout 4s --require-agent-attestation + --listen 0.0.0.0:50051 --timeout 4s --require-agent-attestation \ + --json-log "$egress_gate_log" } gateway() { @@ -504,6 +506,172 @@ launch() { node /sandbox/pi-runtime/integration/openshell-pi.js } +run_sandbox_command() { + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec --no-tty -n pi-egress-demo --workdir /sandbox/workspace -- "$@" +} + +capture_sandbox_command() { + local output=$1 + local error=$2 + shift 2 + if $print_only; then + run_sandbox_command "$@" + return + fi + (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec --no-tty -n pi-egress-demo --workdir /sandbox/workspace -- "$@") \ + >"$output" 2>"$error" +} + +copy_session() { + local session_file=$1 + local output=$2 + local error=$3 + capture_sandbox_command "$output" "$error" /bin/cat "$session_file" +} + +log_line_count() { + if [[ -f $egress_gate_log ]]; then + wc -l <"$egress_gate_log" + else + printf '0\n' + fi +} + +assert_logged_reason() { + local first_line=$1 + local reason=$2 + local label=$3 + if ! awk -v first="$first_line" -v reason="\"reason_code\":\"$reason\"" \ + 'NR >= first && index($0, reason) { found = 1 } END { exit !found }' \ + "$egress_gate_log"; then + printf '%s did not produce reason code %s in %s.\n' "$label" "$reason" "$egress_gate_log" >&2 + exit 1 + fi + printf 'PASS %-18s reason_code=%s\n' "$label" "$reason" +} + +verify() { + local verify_id="$(date +%s)-$$" + local verify_dir="/sandbox/pi-admission-verify/$verify_id" + local bridge_url="http://127.0.0.1:8193/v1/agent/conversation" + local temporary_dir + local output + local error + local status + local session + local first_log_line + local raw_request_script='fetch("https://inference-api.nvidia.com/v1/chat/completions", {method:"POST", headers:{"content-type":"application/json", authorization:"Bearer openshell-proxy"}, body:JSON.stringify({model:"nvidia/qwen/qwen3.8-flash-next", messages:[{role:"user",content:"hello"}]})}).then(async response => { console.error(`provider status=${response.status}`); process.exit(response.ok ? 0 : 1); }).catch(error => { console.error(String(error)); process.exit(1); });' + + if $print_only; then + describe_printed_commands "Create fresh real Pi sessions and run every verification case:" + temporary_dir=/tmp/pi-admission-verify + else + require_file "$openshell_cli" "OpenShell CLI wrapper" + require_demo_sandbox + if [[ ! -f $egress_gate_log ]]; then + printf 'Missing Egress Gate JSON log: %s\n' "$egress_gate_log" >&2 + printf 'Start Egress Gate with ./demo.sh serve before running verify.\n' >&2 + exit 1 + fi + temporary_dir=$(mktemp -d) + trap 'rm -rf -- "$temporary_dir"' RETURN + fi + + output=$temporary_dir/deny.out + error=$temporary_dir/deny.err + session=$verify_dir/deny.jsonl + status=0 + capture_sandbox_command "$output" "$error" env \ + OPENSHELL_AGENT_CONVERSATION_URL="$bridge_url" \ + node /sandbox/pi-runtime/integration/openshell-pi.js \ + --session "$session" -p "Reply with exactly: DENY_THIS" || status=$? + if ! $print_only; then + if ((status == 0)) || ! grep -Fq "OpenShell denied this context addition" "$error"; then + printf 'Denied-prompt verification failed. See %s and %s.\n' "$output" "$error" >&2 + exit 1 + fi + if capture_sandbox_command "$output.session" "$error.session" test -e "$session"; then + printf 'Denied prompt unexpectedly created a session: %s\n' "$session" >&2 + exit 1 + fi + printf 'PASS %-18s denied; session not written\n' "denied prompt" + fi + + output=$temporary_dir/redact.out + error=$temporary_dir/redact.err + session=$verify_dir/redact.jsonl + capture_sandbox_command "$output" "$error" env \ + OPENSHELL_AGENT_CONVERSATION_URL="$bridge_url" \ + node /sandbox/pi-runtime/integration/openshell-pi.js \ + --session "$session" -p "Reply with exactly: REDACT_THIS" + copy_session "$session" "$temporary_dir/redact.jsonl" "$temporary_dir/redact-session.err" + if ! $print_only; then + if ! grep -Fq "[REDACTED]" "$temporary_dir/redact.jsonl" || \ + grep -Fq "REDACT_THIS" "$temporary_dir/redact.jsonl"; then + printf 'Redacted-prompt verification failed for %s.\n' "$session" >&2 + exit 1 + fi + printf 'PASS %-18s session contains only [REDACTED]\n' "redacted prompt" + fi + + first_log_line=$(( $(log_line_count) + 1 )) + status=0 + capture_sandbox_command "$temporary_dir/raw.out" "$temporary_dir/raw.err" \ + /usr/local/bin/node -e "$raw_request_script" || status=$? + if ! $print_only; then + if ((status == 0)); then + printf 'Raw provider request unexpectedly succeeded.\n' >&2 + exit 1 + fi + assert_logged_reason "$first_log_line" attestation_missing "raw provider" + fi + + first_log_line=$(( $(log_line_count) + 1 )) + status=0 + capture_sandbox_command "$temporary_dir/stock.out" "$temporary_dir/stock.err" \ + /usr/local/bin/pi --session "$verify_dir/stock.jsonl" -p hello || status=$? + if ! $print_only; then + if ((status == 0)); then + printf 'Stock Pi unexpectedly reached the provider.\n' >&2 + exit 1 + fi + assert_logged_reason "$first_log_line" attestation_missing "stock Pi" + fi + + for marker in DENY REDACT; do + local marker_lower=${marker,,} + local expected="[REDACTED]" + if [[ $marker == DENY ]]; then + expected="[Tool result blocked by context admission]" + fi + session=$verify_dir/tool-$marker_lower.jsonl + capture_sandbox_command "$temporary_dir/tool-$marker_lower.out" "$temporary_dir/tool-$marker_lower.err" env \ + OPENSHELL_AGENT_CONVERSATION_URL="$bridge_url" \ + node /sandbox/pi-runtime/integration/openshell-pi.js --session "$session" -p \ + "Use bash to print the concatenation of ${marker}_ and THIS, then tell me the output." + copy_session "$session" "$temporary_dir/tool-$marker_lower.jsonl" \ + "$temporary_dir/tool-$marker_lower-session.err" + if ! $print_only; then + if grep -Fq '"role":"toolResult"' "$temporary_dir/tool-$marker_lower.jsonl"; then + if ! grep -Fq "$expected" "$temporary_dir/tool-$marker_lower.jsonl"; then + printf 'Tool %s result was present without the expected admitted text.\n' "$marker_lower" >&2 + exit 1 + fi + printf 'PASS %-18s tool result contains %s\n' "tool $marker_lower" "$expected" + else + printf 'SKIP %-18s model did not call bash\n' "tool $marker_lower" + fi + fi + done + + if $print_only; then + printf ' Assertions inspect each fresh session and %s; request content is never logged.\n' \ + "$egress_gate_log" + fi +} + cleanup() { if ! $print_only; then require_file "$openshell_cli" "OpenShell CLI wrapper" @@ -528,6 +696,7 @@ usage() { gateway Start the forked OpenShell gateway reset Recreate the demo sandbox and configure its model credential launch Start Pi in the existing sandbox without deleting its sessions + verify Run real deny, redact, negative-control, and best-effort tool cases cleanup Delete the example sandbox and credential provider all Show the concise workflow walkthrough (requires --print) EOF @@ -536,6 +705,7 @@ EOF cat <<'EOF' ./demo.sh --print prepare ./demo.sh --print all + ./demo.sh --print verify EOF } @@ -588,8 +758,8 @@ ${bold}${blue}Workflow${reset} ${green}2. serve${reset} Start Egress Gate in Terminal 1 and leave it running. ${green}3. gateway${reset} Start the OpenShell gateway in Terminal 2 and leave it running. ${green}4. reset${reset} Recreate the sandbox; upload a workspace only when one is selected. - ${green}5. launch${reset} Start Pi in Terminal 3. Later launches preserve its sessions. - ${green}6. test${reset} At the Pi prompt, submit: + ${green}5. verify${reset} Run the real non-interactive cases. + ${green}6. launch${reset} Optionally explore interactively with: Reply with exactly: DENY_THIS Reply with exactly: REDACT_THIS ${green}7. cleanup${reset} Delete the sandbox and credential provider. @@ -600,6 +770,7 @@ ${bold}${blue}Inspect exact commands${reset} ./demo.sh --print gateway ./demo.sh --print reset ./demo.sh --print launch + ./demo.sh --print verify ./demo.sh --print cleanup Run an action without --print when you are ready. @@ -612,6 +783,7 @@ case "$action" in gateway) gateway ;; reset) reset_demo ;; launch) launch ;; + verify) verify ;; cleanup) cleanup ;; all) if ! $print_only; then diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts index 23568b2e..1c2d047c 100644 --- a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts @@ -1,3 +1,4 @@ +import { Buffer } from "node:buffer"; import { createHash } from "node:crypto"; import type { AgentMessage } from "@earendil-works/pi-agent-core"; import type { @@ -64,7 +65,7 @@ type AppendAdmissionHook = type AdmissionHook = AppendAdmissionHook | "provider_context"; type BridgeResult = | { decision: "deny"; reason_code?: string } - | { decision: "allow"; handle?: string; replacement_body?: number[] }; + | { decision: "allow"; handle?: string; replacement_body?: Uint8Array }; type SummaryMessage = AgentMessage & { summary: string }; type CustomMessage = AgentMessage & { content: string | ContentBlock[] }; @@ -91,7 +92,7 @@ export function createOpenShellContextAdmission( schema_version: envelope.schema_version, session_id: getSessionId(), submission_id: crypto.randomUUID(), - request_body: Array.from(requestBody), + request_body_b64: Buffer.from(requestBody).toString("base64"), }), }); if (!response.ok) throw new Error("OpenShell admission is unavailable"); @@ -113,7 +114,7 @@ export function createOpenShellContextAdmission( const result = await requestAdmission(prepared.hook, prepared.envelope); if (result.decision === "deny") return denied(result.reason_code); const admittedEnvelope = result.replacement_body - ? parseReplacement(prepared.hook, new Uint8Array(result.replacement_body)) + ? parseReplacement(prepared.hook, result.replacement_body) : prepared.envelope; const admittedMessage = applyReplacement(message, meta.origin, admittedEnvelope); return result.replacement_body ? { action: "allow", message: admittedMessage } : { action: "allow" }; @@ -130,7 +131,7 @@ export function createOpenShellContextAdmission( if (result.decision === "deny") return denied(result.reason_code); if (!result.handle) throw new Error("OpenShell admission returned no provider-context handle"); const admittedEnvelope = result.replacement_body - ? parseProviderContextReplacement(new Uint8Array(result.replacement_body), envelope) + ? parseProviderContextReplacement(result.replacement_body, envelope) : envelope; const admittedContext = applyProviderContextReplacement(context, admittedEnvelope.entries); rememberHandle(handles, contextKey(admittedEnvelope), result.handle); @@ -367,13 +368,18 @@ function parseBridgeResult(value: unknown): BridgeResult { ) { throw new Error("OpenShell admission returned an invalid handle"); } - if ( - value.replacement_body !== undefined && - (!isByteArray(value.replacement_body) || value.replacement_body.length > MAX_ADMISSION_BYTES) - ) { - throw new Error("OpenShell admission returned an invalid replacement"); + let replacementBody: Uint8Array | undefined; + if (value.replacement_body_b64 !== undefined) { + if (typeof value.replacement_body_b64 !== "string") { + throw new Error("OpenShell admission returned an invalid replacement"); + } + const decoded = Buffer.from(value.replacement_body_b64, "base64"); + if (decoded.toString("base64") !== value.replacement_body_b64 || decoded.byteLength > MAX_ADMISSION_BYTES) { + throw new Error("OpenShell admission returned an invalid replacement"); + } + replacementBody = decoded; } - return { decision: "allow", handle: value.handle, replacement_body: value.replacement_body }; + return { decision: "allow", handle: value.handle, replacement_body: replacementBody }; } function parseProviderContextReplacement( @@ -449,10 +455,6 @@ function parseReplacement(hook: AppendAdmissionHook, body: Uint8Array): Admissio } } -function isByteArray(value: unknown): value is number[] { - return Array.isArray(value) && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255); -} - function isRecord(value: unknown): value is Record { return value !== null && typeof value === "object"; } diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py index 4d1c9f9d..9a978f63 100644 --- a/projects/egress-gate/src/egress_gate/admission/processor.py +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -62,18 +62,6 @@ def __init__( self._receipt_authority = receipt_authority self._policy_fingerprint = fingerprint - @property - def readiness(self) -> dict[str, str]: - """Return content-safe compatibility metadata for a managed launcher.""" - return { - "admission_schema": "openshell.pi-message.v1", - "canonicalization": "canonical-json.v1", - "provider_adapter": "openai.request.v1", - "attestation_version": "agent-attestation.v2", - "key_id": self._receipt_authority.key_id, - "policy_fingerprint": self._policy_fingerprint, - } - def process( self, request: HarnessAdmissionRequest, diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index d486c682..5e2c4491 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -64,7 +64,12 @@ validate_gateway_timeout, validate_middleware_name, ) -from egress_gate.logging import LoggingConfig, configure_logging, get_logger +from egress_gate.logging import ( + LoggingConfig, + configure_json_log, + configure_logging, + get_logger, +) from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext from egress_gate.result import EgressResult, GateDecisionSource from egress_gate.string_validators import BoundedMetadataString @@ -167,6 +172,13 @@ def serve( ), ), ] = f"{DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING:g}s", + json_log: Annotated[ + Path | None, + typer.Option( + "--json-log", + help="Write content-safe evaluation records as newline-delimited JSON.", + ), + ] = None, require_agent_attestation: Annotated[ bool, typer.Option( @@ -216,6 +228,8 @@ def serve( remembered.middleware_name, remembered.config_path, ) + if json_log is not None: + configure_json_log(json_log) try: EgressGateServer( options.registry, diff --git a/projects/egress-gate/src/egress_gate/logging.py b/projects/egress-gate/src/egress_gate/logging.py index ed135326..38bb65ec 100644 --- a/projects/egress-gate/src/egress_gate/logging.py +++ b/projects/egress-gate/src/egress_gate/logging.py @@ -6,10 +6,12 @@ from __future__ import annotations import copy +import json import logging import os from dataclasses import dataclass from enum import StrEnum +from pathlib import Path from typing import TextIO @@ -65,13 +67,26 @@ def configure_logging( package_logger.propagate = False +def configure_json_log(path: Path) -> None: + """Write content-safe Egress Gate records as newline-delimited JSON.""" + package_logger = get_logger("egress_gate") + for handler in package_logger.handlers[:]: + if isinstance(handler, _EgressGateJsonHandler): + package_logger.removeHandler(handler) + handler.close() + + handler = _EgressGateJsonHandler(path, encoding="utf-8") + handler.setFormatter(_EgressGateJsonFormatter()) + package_logger.addHandler(handler) + + def reset_logging() -> None: """Remove logging configuration installed by :func:`configure_logging`.""" package_logger = get_logger("egress_gate") managed_handlers = [ handler for handler in package_logger.handlers - if isinstance(handler, _EgressGateStreamHandler) + if isinstance(handler, _EgressGateStreamHandler | _EgressGateJsonHandler) ] if not managed_handlers: return @@ -87,6 +102,29 @@ class _EgressGateStreamHandler(logging.StreamHandler[TextIO]): """Stream handler owned by Egress Gate's logging configuration.""" +class _EgressGateJsonHandler(logging.FileHandler): + """Optional content-safe JSON sink owned by Egress Gate.""" + + +class _EgressGateJsonFormatter(logging.Formatter): + """Serialize only the bounded evaluation fields used by verification.""" + + def format(self, record: logging.LogRecord) -> str: + return json.dumps( + { + "event": getattr(record, "event", record.getMessage()), + "request_id": getattr(record, "request_id", None), + "duration_ms": getattr(record, "duration_ms", None), + "action": getattr(record, "action", None), + "reason_code": getattr(record, "reason_code", None), + "finding_count": getattr(record, "finding_count", None), + "decision_source_kind": getattr(record, "decision_source_kind", None), + "error_code": getattr(record, "error_code", None), + }, + separators=(",", ":"), + ) + + class _EgressGateFormatter(logging.Formatter): """Readable console formatter with optional level-aware color.""" @@ -129,6 +167,7 @@ def format(self, record: logging.LogRecord) -> str: "ColorMode", "DEFAULT_LOGGING_CONFIG", "LoggingConfig", + "configure_json_log", "configure_logging", "get_logger", "reset_logging", diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index 053ed188..8e6aa8c8 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -273,12 +273,6 @@ def _evaluate_agent_admission( response.findings.extend( _finding_to_proto(item) for item in result.findings ) - response.metadata.update( - { - **processor.readiness, - "policy_fingerprint": result.policy_fingerprint, - } - ) return response except Exception: return pb2.AgentConversationResult( @@ -310,6 +304,7 @@ async def _evaluate_rpc( request_id = _request_id_for_logging(request.context.request_id) failure: EgressGateError | None = None action = "error" + reason_code: str | None = None finding_count = 0 source_kind = "none" try: @@ -319,11 +314,13 @@ async def _evaluate_rpc( timeout, ) action = "allow" if response.decision == pb2.DECISION_ALLOW else "deny" + reason_code = response.reason_code or None finding_count = sum(finding.count for finding in response.findings) return response except TimeoutExpiredError: response = _limit_deny() action = "deny" + reason_code = response.reason_code or None source_kind = DecisionSourceKind.RUNTIME_LIMIT.value return response except EgressGateError as error: @@ -335,6 +332,7 @@ async def _evaluate_rpc( request_id=request_id, started=started, action=action, + reason_code=reason_code, finding_count=finding_count, source_kind=source_kind, failure=failure, @@ -508,9 +506,11 @@ async def abort(self, code: grpc.StatusCode, details: str) -> Never: ... class _EvaluationLogExtra(TypedDict): + event: str request_id: str duration_ms: float action: str + reason_code: str | None finding_count: int decision_source_kind: str error_code: str | None @@ -521,14 +521,17 @@ def _evaluation_log_extra( request_id: str, started: float, action: str, + reason_code: str | None, finding_count: int, source_kind: str, failure: EgressGateError | None, ) -> _EvaluationLogExtra: return { + "event": "egress_gate_evaluation", "request_id": request_id, "duration_ms": round((time.monotonic() - started) * 1000, 3), "action": action, + "reason_code": reason_code, "finding_count": finding_count, "decision_source_kind": source_kind, "error_code": failure.code.value if failure is not None else None, diff --git a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs index 91c3310a..a21aeceb 100644 --- a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs +++ b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs @@ -49,7 +49,7 @@ describe("OpenShell context admission adapter", () => { () => "session-123", async (_url, init) => { const request = JSON.parse(String(init?.body)); - const requestBody = new TextDecoder().decode(new Uint8Array(request.request_body)); + const requestBody = Buffer.from(request.request_body_b64, "base64").toString(); const envelope = JSON.parse(requestBody); if (request.hook !== "provider_context") { return new Response(JSON.stringify({ decision: "allow" })); @@ -94,7 +94,11 @@ describe("OpenShell context admission adapter", () => { () => "session-123", async () => new Response( - JSON.stringify({ decision: "allow", handle: "replacement-handle", replacement_body: [...replacement] }), + JSON.stringify({ + decision: "allow", + handle: "replacement-handle", + replacement_body_b64: Buffer.from(replacement).toString("base64"), + }), ), ); const context = await admittedContext(admission, { @@ -194,7 +198,7 @@ describe("OpenShell context admission adapter", () => { () => "session-123", async (_url, init) => { const request = JSON.parse(String(init?.body)); - const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + const envelope = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); observed.push({ request, envelope }); return new Response(JSON.stringify({ decision: "allow" })); }, @@ -223,13 +227,13 @@ describe("OpenShell context admission adapter", () => { () => "session-123", async (_url, init) => { const request = JSON.parse(String(init?.body)); - const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + const envelope = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); if ("text" in envelope) envelope.text = "[REDACTED]"; if ("output" in envelope) envelope.output = "[REDACTED]"; return new Response( JSON.stringify({ decision: "allow", - replacement_body: [...new TextEncoder().encode(JSON.stringify(envelope))], + replacement_body_b64: Buffer.from(JSON.stringify(envelope)).toString("base64"), }), ); }, @@ -265,7 +269,7 @@ describe("OpenShell context admission adapter", () => { () => "session-123", async (_url, init) => { const request = JSON.parse(String(init?.body)); - observed = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + observed = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); return new Response(JSON.stringify({ decision: "allow", handle: "context-handle" })); }, ); diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index b43abc09..907b871d 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -195,7 +195,7 @@ async def test_generated_stub_returns_no_attestation_for_append_time_allow() -> assert response.decision == pb2.DECISION_ALLOW assert response.attestation == b"" assert response.has_replacement_body is False - assert response.metadata["admission_schema"] == "openshell.pi-message.v1" + assert not response.metadata @pytest.mark.asyncio diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 483743fc..f9636a9f 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -573,12 +573,16 @@ def record_serialization( @pytest.mark.asyncio @pytest.mark.parametrize( - ("action_kind", "expected_source"), - (("detect", "pipeline_default"), ("deny", "gate")), + ("action_kind", "expected_source", "expected_reason"), + ( + ("detect", "pipeline_default", None), + ("deny", "gate", "egress_gate_regex_denied"), + ), ) async def test_evaluation_log_records_decision_source( action_kind: str, expected_source: str, + expected_reason: str | None, caplog: pytest.LogCaptureFixture, ) -> None: middleware = EgressGateMiddleware(create_builtin_registry()) @@ -596,6 +600,7 @@ async def test_evaluation_log_records_decision_source( if item.message.startswith("egress_gate_evaluation") ) assert getattr(record, "decision_source_kind", None) == expected_source + assert getattr(record, "reason_code", None) == expected_reason @pytest.mark.asyncio diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 983981cd..1a354aab 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -104,6 +104,7 @@ def serve_sync(self, listen: str) -> None: assert "Minimum 10ms" in serve_help assert "RPC timeout" in serve_help assert "--require-agent-attestation" in serve_help + assert "--json-log" in serve_help assert "--require-" + "pi-attestation" not in serve_help evaluate_help = CliRunner().invoke(app, ["evaluate", "--help"]) diff --git a/projects/egress-gate/tests/test_logging.py b/projects/egress-gate/tests/test_logging.py index 4070c5b2..1b55ed41 100644 --- a/projects/egress-gate/tests/test_logging.py +++ b/projects/egress-gate/tests/test_logging.py @@ -6,6 +6,7 @@ from __future__ import annotations import ast +import json import logging import re from collections.abc import Iterator @@ -18,6 +19,7 @@ DEFAULT_LOGGING_CONFIG, ColorMode, LoggingConfig, + configure_json_log, configure_logging, get_logger, reset_logging, @@ -124,6 +126,39 @@ def test_configure_logging_accepts_native_log_levels() -> None: ) +def test_configure_json_log_writes_only_content_safe_fields(tmp_path: Path) -> None: + path = tmp_path / "evaluations.jsonl" + configure_logging() + configure_json_log(path) + + logging.getLogger("egress_gate.service").info( + "egress_gate_evaluation", + extra={ + "event": "egress_gate_evaluation", + "request_id": "request-1", + "duration_ms": 1.25, + "action": "deny", + "reason_code": "attestation_missing", + "finding_count": 0, + "decision_source_kind": "gate", + "error_code": None, + "request_body": "must not be logged", + }, + ) + + record = json.loads(path.read_text()) + assert record == { + "event": "egress_gate_evaluation", + "request_id": "request-1", + "duration_ms": 1.25, + "action": "deny", + "reason_code": "attestation_missing", + "finding_count": 0, + "decision_source_kind": "gate", + "error_code": None, + } + + def test_configure_logging_replaces_its_previous_handler() -> None: first_stream = StringIO() second_stream = StringIO() diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index f7b0a773..70271d1a 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -53,7 +53,15 @@ def test_pi_example_can_print_each_action_without_running_it( env=environment, text=True, ) - for action in ("prepare", "serve", "gateway", "reset", "launch", "cleanup") + for action in ( + "prepare", + "serve", + "gateway", + "reset", + "launch", + "verify", + "cleanup", + ) ] output = "\n".join(result.stdout for result in results) normalized_output = " ".join(output.replace("\\\n", " ").split()) @@ -78,6 +86,7 @@ def test_pi_example_can_print_each_action_without_running_it( assert "render-runtime-config.mjs" not in output assert str(models_path) in output assert "egress-gate --debug serve" in output + assert "--json-log" in output assert "CARGO_BUILD_JOBS=4" in output assert "OPENSHELL_GATEWAY_NAME=pi-egress-demo-gateway" in output assert "--gateway pi-egress-demo-gateway" in output @@ -103,6 +112,9 @@ def test_pi_example_can_print_each_action_without_running_it( assert "/sandbox/workspace" in output assert "sandbox exec" in output assert "sandbox exec --tty" in output + assert "sandbox exec --no-tty" in output + assert "DENY_THIS" in output + assert "/usr/local/bin/pi" in output assert "PI_OFFLINE=1" not in output assert "PI_CODING_AGENT_DIR=" not in output assert "--no-extensions" not in output From defd17797e8aae25f49474f6de583ec10681ef09 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 18:51:48 +0000 Subject: [PATCH 38/70] fix(egress-gate): verify admitted tool results --- .../examples/pi-attested-admission/README.md | 21 +++++++++-------- .../examples/pi-attested-admission/demo.sh | 23 ++++++++++++++----- 2 files changed, 29 insertions(+), 15 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 7ec847cf..70fdb36d 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -229,9 +229,11 @@ before it enters live context. Repeat with `REDACT_` and `THIS` to see the tool result admitted as `[REDACTED]`. Pi uses its standard session manager and JSONL session location, and exposes -the active path to tools as `PI_SESSION_FILE`. Admission runs before a user -message or tool result reaches that history. `launch` preserves the history; -`reset` and `cleanup` delete it with the sandbox. +the active path to tools as `PI_SESSION_FILE`. Every supported history origin +passes the same generic append boundary before it reaches that history: user +messages, tool results, finalized assistant output, summaries, extension +messages, and bash executions. `launch` preserves the history; `reset` and +`cleanup` delete it with the sandbox. ## How it works @@ -270,12 +272,13 @@ The egress checkpoint is the enforcement boundary: without a matching fresh attestation, OpenShell does not attach the credential or forward the request. This division is intentional. The Pi fork contributes only reusable harness -primitives: mandatory admission of user messages and finalized tool results, -admission of the exact provider context, an outbound-header transformation, and -a standard-CLI entrypoint that accepts those hooks. OpenShell contributes the -sandbox-local bridge, signed attestations, attestation-to-request binding, -middleware enforcement, and post-policy credential delivery. The TypeScript -files under +primitives: generic append admission for every supported history origin, +including finalized assistant output, summaries, extension messages, and bash +executions; admission of the exact provider context; an outbound-header +transformation; and a standard-CLI entrypoint that accepts those hooks. +OpenShell contributes the sandbox-local bridge, signed attestations, +attestation-to-request binding, middleware enforcement, and post-policy +credential delivery. The TypeScript files under `runtime-extension/` are the reusable integration layer that translates between those generic Pi hooks and the OpenShell protocol; no OpenShell-specific code is built into Pi. diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 37e4f55d..6da45337 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -654,14 +654,25 @@ verify() { copy_session "$session" "$temporary_dir/tool-$marker_lower.jsonl" \ "$temporary_dir/tool-$marker_lower-session.err" if ! $print_only; then - if grep -Fq '"role":"toolResult"' "$temporary_dir/tool-$marker_lower.jsonl"; then - if ! grep -Fq "$expected" "$temporary_dir/tool-$marker_lower.jsonl"; then - printf 'Tool %s result was present without the expected admitted text.\n' "$marker_lower" >&2 - exit 1 - fi + status=0 + awk -v expected="$expected" -v forbidden="${marker}_THIS" ' + index($0, "\"role\":\"toolResult\"") { + found = 1 + if (index($0, expected)) admitted = 1 + if (index($0, forbidden)) leaked = 1 + } + END { + if (!found) exit 2 + if (!admitted || leaked) exit 1 + } + ' "$temporary_dir/tool-$marker_lower.jsonl" || status=$? + if ((status == 0)); then printf 'PASS %-18s tool result contains %s\n' "tool $marker_lower" "$expected" - else + elif ((status == 2)); then printf 'SKIP %-18s model did not call bash\n' "tool $marker_lower" + else + printf 'Tool %s result was not safely admitted.\n' "$marker_lower" >&2 + exit 1 fi fi done From 3736c1f4c644ed67e75186df33b95dd412e2f226 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 19:07:50 +0000 Subject: [PATCH 39/70] feat(egress-gate): authenticate bridge calls --- .../examples/pi-attested-admission/README.md | 37 +++++++++-------- .../examples/pi-attested-admission/demo.sh | 16 ++++++++ .../openshell-context-admission.ts | 6 ++- .../runtime-extension/openshell-pi.ts | 41 +++++++++++++++++-- .../js/openshell-context-admission.test.mjs | 10 +++++ .../tests/test_pi_example_commands.py | 10 +++++ 6 files changed, 99 insertions(+), 21 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 70fdb36d..01d4afe7 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -164,12 +164,13 @@ Run the complete non-interactive verification: ``` `verify` runs the real packaged Pi and OpenShell sandbox. It checks a denied -prompt without a session write, a persisted redaction, a raw provider request -without an admission handle, the stock Pi binary without the runtime adapter, -and best-effort tool-result cases. Each case uses a fresh session and prints one -`PASS` or `SKIP` line. Tool cases can skip because choosing to call a tool is -model-dependent; the other cases are required. There is no mock fallback. Run -`./demo.sh --print verify` to inspect every underlying sandbox command. +prompt without a session write, a persisted redaction, an unauthenticated call +to the admission bridge, a raw provider request without an admission handle, +the stock Pi binary without the runtime adapter, and best-effort tool-result +cases. Each case uses a fresh session and prints one `PASS` or `SKIP` line. Tool +cases can skip because choosing to call a tool is model-dependent; the other +cases are required. There is no mock fallback. Run `./demo.sh --print verify` +to inspect every underlying sandbox command. To explore interactively afterward, launch Pi: @@ -248,7 +249,10 @@ messages, and bash executions. `launch` preserves the history; `reset` and 2. Pi calls that boundary before each supported message reaches live or persisted history. Assistant output is admitted when the complete assistant message is finalized, after streamed output has already been displayed. -3. The external adapter sends the exact context addition to OpenShell's +3. OpenShell gives the launched runtime an inherited descriptor containing its + per-exec bridge token. The launcher reads and closes the descriptor and + deletes its environment name before Pi or its extensions start. The external + adapter sends the token with the exact context addition to OpenShell's sandbox-local bridge. Egress Gate applies `policy.yaml` and returns allow, deny, or a complete replacement. This append-time checkpoint returns no attestation or handle. @@ -283,15 +287,12 @@ credential delivery. The TypeScript files under those generic Pi hooks and the OpenShell protocol; no OpenShell-specific code is built into Pi. -The current OpenShell bridge is supervisor-owned but reachable by every process -inside the sandbox over loopback; it does not yet authenticate the calling -process. Whole-context attestation binding still prevents an unadmitted -provider request from passing the egress middleware. However, OpenShell cannot -prove that the designated harness invoked admission before changing its own -local memory or session files. A stronger runtime needs one additional -OpenShell primitive: a -process-scoped admission capability, or a supervisor-owned adapter channel that -only the designated harness can invoke. +The supervisor mints a separate admission token for each `sandbox exec`, accepts +it only while that process is running, and rejects bridge calls without a valid +token. Because the launcher consumes the descriptor before starting Pi, tool +subprocesses receive neither the descriptor nor its environment name. The +OpenShell middleware field `require_caller_token: false` disables this check for +debugging; this example keeps the secure default. ## Current scope @@ -312,6 +313,10 @@ more than five minutes later is denied. An Egress Gate started with `--require-agent-attestation` serves managed harnesses only; an ordinary client using the same middleware registration is denied because it has no attestation. +The per-exec token remains in the Pi process's memory. A same-user process that +can read that memory could copy it; the sandbox's process isolation and ptrace +restrictions reduce this residual risk but do not make the token hardware-bound. + ## Cleanup Exit Pi, but leave the OpenShell gateway running while cleanup deletes the diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 6da45337..64399571 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -616,6 +616,22 @@ verify() { printf 'PASS %-18s session contains only [REDACTED]\n' "redacted prompt" fi + capture_sandbox_command "$temporary_dir/bridge.out" "$temporary_dir/bridge.err" \ + /usr/bin/curl --silent --show-error \ + --header "content-type: application/json" \ + --data '{}' \ + --write-out $'\n%{http_code}\n' \ + "$bridge_url" + if ! $print_only; then + if ! grep -Fq '"error":"caller_not_authorized"' "$temporary_dir/bridge.out" || \ + ! tail -n 1 "$temporary_dir/bridge.out" | grep -Fxq 401; then + printf 'Unauthenticated bridge request was not rejected. See %s and %s.\n' \ + "$temporary_dir/bridge.out" "$temporary_dir/bridge.err" >&2 + exit 1 + fi + printf 'PASS %-18s caller_not_authorized\n' "raw bridge" + fi + first_log_line=$(( $(log_line_count) + 1 )) status=0 capture_sandbox_command "$temporary_dir/raw.out" "$temporary_dir/raw.err" \ diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts index 1c2d047c..8279c9f0 100644 --- a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts @@ -74,6 +74,7 @@ type BashMessage = AgentMessage & { command: string; output: string; exitCode: n export function createOpenShellContextAdmission( bridgeUrl: string, getSessionId: () => string, + admissionToken: string, fetchRequest: typeof fetch = fetch, ): ContextAdmission { const handles = new Map(); @@ -85,7 +86,10 @@ export function createOpenShellContextAdmission( } const response = await fetchRequest(bridgeUrl, { method: "POST", - headers: { "content-type": "application/json" }, + headers: { + authorization: `Bearer ${admissionToken}`, + "content-type": "application/json", + }, body: JSON.stringify({ harness_version: "sdk-v1", hook, diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts index ed771cbe..5cd15793 100644 --- a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts +++ b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts @@ -1,3 +1,4 @@ +import { closeSync, readFileSync } from "node:fs"; import { runCli, type RuntimeExtension } from "@earendil-works/pi-coding-agent"; import { createOpenShellContextAdmission } from "./openshell-context-admission.js"; @@ -7,9 +8,41 @@ if (!bridgeUrl) { throw new Error("OPENSHELL_AGENT_CONVERSATION_URL is required"); } -const runtimeExtension: RuntimeExtension = { - createContextAdmission: (sessionManager) => - createOpenShellContextAdmission(bridgeUrl, () => sessionManager.getSessionId()), -}; +const runtimeExtension = createRuntimeExtension(bridgeUrl, readAdmissionToken()); await runCli(process.argv.slice(2), { runtimeExtension }); + +function createRuntimeExtension(bridgeUrl: string, admissionToken: string): RuntimeExtension { + return { + createContextAdmission: (sessionManager) => + createOpenShellContextAdmission(bridgeUrl, () => sessionManager.getSessionId(), admissionToken), + }; +} + +function readAdmissionToken(): string { + const tokenFdValue = process.env.OPENSHELL_AGENT_ADMISSION_TOKEN_FD; + delete process.env.OPENSHELL_AGENT_ADMISSION_TOKEN_FD; + if (!tokenFdValue || !/^\d+$/.test(tokenFdValue)) { + throw new Error("OPENSHELL_AGENT_ADMISSION_TOKEN_FD must name a readable file descriptor"); + } + + const tokenFd = Number(tokenFdValue); + let admissionToken: string; + try { + admissionToken = readFileSync(tokenFd, "utf8"); + } catch (cause) { + try { + closeSync(tokenFd); + } catch {} + throw new Error("Could not read the OpenShell agent admission token", { cause }); + } + try { + closeSync(tokenFd); + } catch (cause) { + throw new Error("Could not close the OpenShell agent admission token descriptor", { cause }); + } + if (!/^[A-Za-z0-9_-]{43}$/.test(admissionToken)) { + throw new Error("OpenShell supplied an invalid agent admission token"); + } + return admissionToken; +} diff --git a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs index a21aeceb..31107c8b 100644 --- a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs +++ b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs @@ -5,6 +5,7 @@ import { describe, it } from "node:test"; import { createOpenShellContextAdmission } from "../../examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts"; const HANDLE_HEADER = "x-openshell-agent-admission-handle"; +const ADMISSION_TOKEN = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; const ENTRY_VECTORS = JSON.parse( readFileSync(new URL("../admission/fixtures/context-entries.json", import.meta.url), "utf8"), ); @@ -47,7 +48,9 @@ describe("OpenShell context admission adapter", () => { const admission = createOpenShellContextAdmission( "http://bridge.test/admit", () => "session-123", + ADMISSION_TOKEN, async (_url, init) => { + assert.equal(new Headers(init?.headers).get("authorization"), `Bearer ${ADMISSION_TOKEN}`); const request = JSON.parse(String(init?.body)); const requestBody = Buffer.from(request.request_body_b64, "base64").toString(); const envelope = JSON.parse(requestBody); @@ -92,6 +95,7 @@ describe("OpenShell context admission adapter", () => { const admission = createOpenShellContextAdmission( "http://bridge.test/admit", () => "session-123", + ADMISSION_TOKEN, async () => new Response( JSON.stringify({ @@ -117,6 +121,7 @@ describe("OpenShell context admission adapter", () => { const admission = createOpenShellContextAdmission( "http://bridge.test/admit", () => "session-123", + ADMISSION_TOKEN, async (_url, init) => { const request = JSON.parse(String(init?.body)); hooks.push(request.hook); @@ -196,6 +201,7 @@ describe("OpenShell context admission adapter", () => { const admission = createOpenShellContextAdmission( "http://bridge.test/admit", () => "session-123", + ADMISSION_TOKEN, async (_url, init) => { const request = JSON.parse(String(init?.body)); const envelope = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); @@ -225,6 +231,7 @@ describe("OpenShell context admission adapter", () => { const admission = createOpenShellContextAdmission( "http://bridge.test/admit", () => "session-123", + ADMISSION_TOKEN, async (_url, init) => { const request = JSON.parse(String(init?.body)); const envelope = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); @@ -267,6 +274,7 @@ describe("OpenShell context admission adapter", () => { const admission = createOpenShellContextAdmission( "http://bridge.test/admit", () => "session-123", + ADMISSION_TOKEN, async (_url, init) => { const request = JSON.parse(String(init?.body)); observed = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); @@ -285,6 +293,7 @@ describe("OpenShell context admission adapter", () => { const admission = createOpenShellContextAdmission( "http://bridge.test/admit", () => "session-123", + ADMISSION_TOKEN, async () => { throw new Error("bridge should not be called"); }, ); @@ -300,6 +309,7 @@ describe("OpenShell context admission adapter", () => { const admission = createOpenShellContextAdmission( "http://bridge.test/admit", () => "session-123", + ADMISSION_TOKEN, async () => new Response(JSON.stringify({ decision: "deny", reason_code: "policy_denied" })), ); diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 70271d1a..7a72e598 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -121,6 +121,7 @@ def test_pi_example_can_print_each_action_without_running_it( assert "node /sandbox/pi-runtime/integration/openshell-pi.js" in normalized_output assert "PI_OPENSHELL_CONTEXT_ADMISSION" not in output assert "OPENSHELL_AGENT_CONVERSATION_URL=" in output + assert "/usr/bin/curl --silent --show-error" in normalized_output assert "managed-pi" not in output assert "--extension " not in output assert "integration/openshell-context-admission.js" in output @@ -145,10 +146,19 @@ def test_pi_example_can_print_each_action_without_running_it( ) demo_script = (project_dir / "examples/pi-attested-admission/demo.sh").read_text() + launcher = ( + project_dir / "examples/pi-attested-admission/runtime-extension/openshell-pi.ts" + ).read_text() assert '"beforeToolResultAppend"' in demo_script + assert "caller_not_authorized" in demo_script assert "exec env -u PI_MODEL_API_KEY node" not in demo_script assert '3<<<"$PI_MODEL_API_KEY"' not in demo_script assert "render-runtime-config.mjs" not in demo_script + assert "OPENSHELL_AGENT_ADMISSION_TOKEN_FD" in launcher + assert "delete process.env.OPENSHELL_AGENT_ADMISSION_TOKEN_FD" in launcher + assert 'readFileSync(tokenFd, "utf8")' in launcher + assert "closeSync(tokenFd)" in launcher + assert "/^[A-Za-z0-9_-]{43}$/" in launcher def test_pi_example_print_all_is_a_concise_walkthrough() -> None: From 5d8d6a319e1c821b782e49882dc5d150a6533119 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 19:40:40 +0000 Subject: [PATCH 40/70] docs(egress-gate): describe bridge caller capability --- projects/egress-gate/docs/architecture/admission.md | 10 ++++++---- .../examples/pi-attested-admission/README.md | 7 ++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md index ea7aa644..de12d165 100644 --- a/projects/egress-gate/docs/architecture/admission.md +++ b/projects/egress-gate/docs/architecture/admission.md @@ -70,7 +70,9 @@ may instead return its own deny reason. An Egress Gate started with `--require-agent-attestation` is dedicated to managed harness traffic: unattested matching provider requests fail closed. -The current loopback bridge is supervisor-owned but, until caller capabilities -are added, any process in the sandbox can invoke it. That does not let an -unattested request pass egress, but it means OpenShell cannot yet prove which -process requested admission. +The supervisor-owned loopback bridge requires a per-exec capability delivered +to the launched harness on an inherited file descriptor. The launcher reads and +closes that descriptor and deletes its environment name before Pi starts, so +tool subprocesses do not receive the capability. The token remains in Pi's +memory; a same-user process able to read that memory could copy it, though the +sandbox's process isolation and ptrace restrictions reduce this residual risk. diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 01d4afe7..42831b8d 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -290,9 +290,10 @@ built into Pi. The supervisor mints a separate admission token for each `sandbox exec`, accepts it only while that process is running, and rejects bridge calls without a valid token. Because the launcher consumes the descriptor before starting Pi, tool -subprocesses receive neither the descriptor nor its environment name. The -OpenShell middleware field `require_caller_token: false` disables this check for -debugging; this example keeps the secure default. +subprocesses receive neither the descriptor nor its environment name. Setting +`OPENSHELL_AGENT_ADMISSION_REQUIRE_CALLER_TOKEN=false` when starting the +OpenShell supervisor disables this check for debugging; this example keeps the +secure default. ## Current scope From 638b98a8c7ed49f87d646bdfb94b2efbd03de34d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 20:12:32 +0000 Subject: [PATCH 41/70] docs(egress-gate): record integration QA --- .../analysis/qa-reports/2026-09-02.html | 80 +++++++++++++++++++ .../examples/pi-attested-admission/README.md | 13 +-- .../src/egress_gate/service/servicer.py | 2 +- 3 files changed, 89 insertions(+), 6 deletions(-) create mode 100644 projects/egress-gate/analysis/qa-reports/2026-09-02.html diff --git a/projects/egress-gate/analysis/qa-reports/2026-09-02.html b/projects/egress-gate/analysis/qa-reports/2026-09-02.html new file mode 100644 index 00000000..a85bccc1 --- /dev/null +++ b/projects/egress-gate/analysis/qa-reports/2026-09-02.html @@ -0,0 +1,80 @@ + + + + + + + Pi attested-admission integration QA — 2026-09-02 + + + +
+

Pi attested-admission integration QA

+

Date: 2026-09-02 UTC

+

+ This report records the final proof-of-concept integration state across + Pi, OpenShell, and OpenShell Research. It contains only sanitized command + summaries: no credentials, request bodies, model output, environment + values, or local workspace paths. +

+ +

Reviewed revisions

+ + + + + + + +
RepositoryIntegration headUpstream base
Piabb462d677fa1039a68518deb5b8d998ca0b0a28e266507b606b9552fa277252644054afd4384b11
OpenShell25fb8a635c6df749cab50b73270dc30ebbef5c8ea6b757d35f983fe4415427484ad06533d32e9e4b
OpenShell Research4d99082b199b07136a9c93558d95c73d07b929d8 (pre-report)743839dae47621c13a3bc339bad2a2c8d0167591
+ +

Validation

+ + + + + + + + +
RepositoryResult
PiFocused suites passed: agent 24/24 and coding-agent 51/51. npm run check passed its preliminary gates and reported only unchanged upstream packages/ai catalog TypeScript drift.
OpenShellPassed: pre-commit, test, and the Phase 4 Docker end-to-end lane. mise run ci reported only the known existing Go converter coverage gap for ProviderProfileCredential.delivery.
OpenShell ResearchPassed: make check and make check-py311, 377/377 tests in each environment after the upstream merge, plus formatting, lint, typing, dependency audit, 11/11 documentation-renderer tests, the clean strict documentation build, and an HTTP 200 artifact preview.
Independent reviewPi and OpenShell clean: no blocker, high, or medium findings at either integration head. Final Research merge-head review was still pending when this report was prepared; earlier Research phase reviews were clean after their findings were resolved.
+ +

External end-to-end blocker

+

+ The real ./demo.sh verify run was not substituted with a mock. + This checkout does not contain the ignored prepared fork/runtime, local + .env, JSON log, or provider credential required to run the + live sandbox and model request. The verifier reports those prerequisites; + a configured environment must run the clean reset and verification before + the proof of concept is promoted beyond draft status. +

+ +

Verified design boundary

+

+ One policy fingerprint connects append-time admission, provider-context + admission, and provider egress. The attestation binds one hash of the + complete ordered user/tool context. System/developer and assistant content + is scanned by the request policy at egress but is not included in that + hash. Denied additions do not enter Pi's live or persisted history. +

+ +

Known limits

+
    +
  • Image inputs are unsupported by this example and fail closed.
  • +
  • Provider-context admission precedes transport-specific history rewrites; switching transports with existing tool history or sending orphaned tool calls may fail closed.
  • +
  • Admission payloads are limited to 4 MiB and attestations expire after 300 seconds.
  • +
  • The per-exec bridge token remains in Pi process memory; a same-user process able to read that memory could copy it.
  • +
  • This is a proof of concept. Phase 2b provenance-ledger work, additional message hashing, and production deployment automation remain out of scope.
  • +
+
+ + diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 42831b8d..74179b72 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -4,9 +4,10 @@ This example runs the normal forked Pi CLI inside OpenShell and sends admitted conversation context to the configured NVIDIA inference endpoint. One endpoint-scoped provider and credential serve all configured models. -The example demonstrates the same policy at three checkpoints: before Pi -appends history, immediately before Pi sends its complete provider context, and -again at provider egress before OpenShell attaches credentials. +The example demonstrates the same policy, identified by the same fingerprint, +at three checkpoints: before Pi appends history, immediately before Pi sends +its complete provider context, and again at provider egress before OpenShell +attaches credentials. - `DENY_THIS` is rejected before Pi adds a user message or tool result to its live context. @@ -259,8 +260,10 @@ messages, and bash executions. `launch` preserves the history; `reset` and 4. Immediately before every provider request, Pi passes the exact outbound context through admission. This includes normal turns, retries, compaction, branch summaries, and contexts restored from a prior session. The adapter - applies per-entry replacements and obtains one fresh handle for the complete - ordered user/tool context. + applies per-entry replacements and obtains one fresh handle bound to one hash + of the complete ordered user/tool context. System/developer and assistant + content is scanned by request policy at egress but is not included in that + attested context hash. 5. OpenShell keeps the signed whole-context attestation and gives Pi only the opaque handle, which the adapter keeps outside Pi messages. At egress, OpenShell strips the handle and supplies the attestation only to the diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index 8e6aa8c8..5bf22573 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -200,7 +200,7 @@ async def EvaluateAgentConversation( pb2.AgentConversationResult, ], ) -> pb2.AgentConversationResult: - """Evaluate one supervisor-stamped Pi admission request.""" + """Evaluate one supervisor-stamped agent admission request.""" timeout = Timeout.from_seconds(self._timeout_middleware_processing_seconds) return await self._run_in_worker( lambda: self._evaluate_agent_admission(request, timeout), From 178ec740fa5aa36042ccc50bb1844fadb3e56ab4 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 20:26:34 +0000 Subject: [PATCH 42/70] docs(egress-gate): clarify final POC limits --- .../egress-gate/analysis/qa-reports/2026-09-02.html | 10 +++++----- .../examples/pi-attested-admission/README.md | 10 +++++++--- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/projects/egress-gate/analysis/qa-reports/2026-09-02.html b/projects/egress-gate/analysis/qa-reports/2026-09-02.html index a85bccc1..3cde7863 100644 --- a/projects/egress-gate/analysis/qa-reports/2026-09-02.html +++ b/projects/egress-gate/analysis/qa-reports/2026-09-02.html @@ -31,8 +31,8 @@

Reviewed revisions

- - + +
RepositoryIntegration headUpstream base
Piabb462d677fa1039a68518deb5b8d998ca0b0a28e266507b606b9552fa277252644054afd4384b11
OpenShell25fb8a635c6df749cab50b73270dc30ebbef5c8ea6b757d35f983fe4415427484ad06533d32e9e4b
Pi177b42723d072b7954a5b1690ccf62c97f075b37e266507b606b9552fa277252644054afd4384b11
OpenShellabf1a6253009c78196d8fe4fb560590453216f04a6b757d35f983fe4415427484ad06533d32e9e4b
OpenShell Research4d99082b199b07136a9c93558d95c73d07b929d8 (pre-report)743839dae47621c13a3bc339bad2a2c8d0167591
@@ -41,10 +41,10 @@

Validation

- - + + - +
RepositoryResult
PiFocused suites passed: agent 24/24 and coding-agent 51/51. npm run check passed its preliminary gates and reported only unchanged upstream packages/ai catalog TypeScript drift.
OpenShellPassed: pre-commit, test, and the Phase 4 Docker end-to-end lane. mise run ci reported only the known existing Go converter coverage gap for ProviderProfileCredential.delivery.
PiFocused suite passed: 38/38 after the assistant atomic-deny fix. npm run check passed its preliminary gates and reported only unchanged upstream packages/ai catalog TypeScript drift.
OpenShellPassed: pre-commit and the full mise run go:ci suite after the provider-profile delivery round-trip fix. The Phase 4 Docker end-to-end lane passed on the parent integration head.
OpenShell ResearchPassed: make check and make check-py311, 377/377 tests in each environment after the upstream merge, plus formatting, lint, typing, dependency audit, 11/11 documentation-renderer tests, the clean strict documentation build, and an HTTP 200 artifact preview.
Independent reviewPi and OpenShell clean: no blocker, high, or medium findings at either integration head. Final Research merge-head review was still pending when this report was prepared; earlier Research phase reviews were clean after their findings were resolved.
Independent reviewParent Pi and OpenShell heads clean: no blocker, high, or medium findings. Review of the final narrow fixes and the final Research head was still pending when this report was prepared.
diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 74179b72..2d5ac2f9 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -91,7 +91,9 @@ To use another catalog for the same endpoint, copy `models.json`, edit it using Pi's documented JSON format, and set `PI_MODELS_PATH` to that file. OpenShell pins network and credential access independently of Pi. To change endpoints, update the matching host and port explicitly in `models.json`, `policy.yaml`, -and `provider-profile.yaml`. +and `provider-profile.yaml`. The automated `verify` cases target the checked-in +NVIDIA endpoint and model catalog; the script does not parse arbitrary catalogs +to adapt those checks. `EGRESS_GATE_HOST_IP` is the address OpenShell uses to reach Egress Gate on this machine. It must be a reachable, non-loopback IPv4 address; do not use @@ -248,8 +250,10 @@ messages, and bash executions. `launch` preserves the history; `reset` and them to JavaScript for the sandbox. Pi otherwise starts normally, including standard project and user extension discovery. 2. Pi calls that boundary before each supported message reaches live or - persisted history. Assistant output is admitted when the complete assistant - message is finalized, after streamed output has already been displayed. + persisted history. Assistant text and tool calls are admitted when the + assistant message is finalized, after streamed output has already been + displayed. Assistant thinking is outside this append-time envelope; request + policy scans it at egress, but the context attestation does not hash it. 3. OpenShell gives the launched runtime an inherited descriptor containing its per-exec bridge token. The launcher reads and closes the descriptor and deletes its environment name before Pi or its extensions start. The external From cab9033570944a157a9fa74f4f3a7143ec86b3c2 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 20:29:45 +0000 Subject: [PATCH 43/70] docs(egress-gate): clarify assistant admission scope --- .../egress-gate/docs/architecture/admission.md | 16 +++++++++++----- .../examples/pi-attested-admission/README.md | 2 ++ 2 files changed, 13 insertions(+), 5 deletions(-) diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md index de12d165..0bd429eb 100644 --- a/projects/egress-gate/docs/architecture/admission.md +++ b/projects/egress-gate/docs/architecture/admission.md @@ -7,17 +7,23 @@ agent_markdown: true # Managed harness admission Managed Pi sessions use the same Egress Gate policy at three checkpoints. The -append and provider-context checkpoints keep Pi's live and persisted history -consistent with policy. Egress verification supplies the security boundary: a -provider request without a valid attestation is denied before credentials are -attached. +append and provider-context checkpoints apply policy to supported message +content before it enters history or is sent. Egress verification supplies the +security boundary: a provider request without a valid attestation is denied +before credentials are attached. | Checkpoint | Pi hook | Result | | --- | --- | --- | -| History append | `user_message`, `tool_result`, `assistant_message`, `compaction_summary`, `branch_summary`, `extension_message`, or `bash_execution` | Allow, deny, or replace one complete Pi entry before append | +| History append | `user_message`, `tool_result`, `assistant_message`, `compaction_summary`, `branch_summary`, `extension_message`, or `bash_execution` | Allow, deny, or replace supported content before append | | Provider context | `provider_context` | Allow, deny, or replace the complete ordered user/tool context and issue an attestation | | Network egress | OpenShell pre-credentials middleware | Verify the attestation against the provider request, then run request policy | +Assistant append admission covers finalized text and tool calls. Assistant +thinking is not append-admitted or included in the attested user/tool context +hash; request policy scans it at egress. Tool calls are inspectable and +denyable but immutable: a redaction targeting their ID, name, or arguments +fails closed rather than changing them. + Append-time allows do not carry attestations. Immediately before a provider request, Pi submits the complete context so retries, continuations, compaction, queued input, and restored sessions do not depend on the newest entry alone. diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 2d5ac2f9..8ac27219 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -254,6 +254,8 @@ messages, and bash executions. `launch` preserves the history; `reset` and assistant message is finalized, after streamed output has already been displayed. Assistant thinking is outside this append-time envelope; request policy scans it at egress, but the context attestation does not hash it. + Assistant tool calls are inspectable and denyable but immutable; a redaction + targeting one fails closed. 3. OpenShell gives the launched runtime an inherited descriptor containing its per-exec bridge token. The launcher reads and closes the descriptor and deletes its environment name before Pi or its extensions start. The external From b03e4231cc53a3772843fe6e6a1518e73cdec016 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 21:10:24 +0000 Subject: [PATCH 44/70] docs(egress-gate): record proxy delivery integration Signed-off-by: Johnny Greco --- projects/egress-gate/analysis/qa-reports/2026-09-02.html | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/egress-gate/analysis/qa-reports/2026-09-02.html b/projects/egress-gate/analysis/qa-reports/2026-09-02.html index 3cde7863..770c8e10 100644 --- a/projects/egress-gate/analysis/qa-reports/2026-09-02.html +++ b/projects/egress-gate/analysis/qa-reports/2026-09-02.html @@ -32,7 +32,7 @@

Reviewed revisions

RepositoryIntegration headUpstream base Pi177b42723d072b7954a5b1690ccf62c97f075b37e266507b606b9552fa277252644054afd4384b11 - OpenShellabf1a6253009c78196d8fe4fb560590453216f04a6b757d35f983fe4415427484ad06533d32e9e4b + OpenShell57cbc0dc9305e657bb6177d2d6dd63baa2f5ccfca6b757d35f983fe4415427484ad06533d32e9e4b OpenShell Research4d99082b199b07136a9c93558d95c73d07b929d8 (pre-report)743839dae47621c13a3bc339bad2a2c8d0167591 @@ -42,9 +42,9 @@

Validation

RepositoryResult PiFocused suite passed: 38/38 after the assistant atomic-deny fix. npm run check passed its preliminary gates and reported only unchanged upstream packages/ai catalog TypeScript drift. - OpenShellPassed: pre-commit and the full mise run go:ci suite after the provider-profile delivery round-trip fix. The Phase 4 Docker end-to-end lane passed on the parent integration head. + OpenShellPassed: pre-commit, the full repository test and CI lanes, and mise run go:ci. The merged proxy-delivery end-to-end case passed; the broader Docker lane later failed a separate policy-reload case because its sandbox emitted no JSON result. OpenShell ResearchPassed: make check and make check-py311, 377/377 tests in each environment after the upstream merge, plus formatting, lint, typing, dependency audit, 11/11 documentation-renderer tests, the clean strict documentation build, and an HTTP 200 artifact preview. - Independent reviewParent Pi and OpenShell heads clean: no blocker, high, or medium findings. Review of the final narrow fixes and the final Research head was still pending when this report was prepared. + Independent reviewPreviously reviewed Pi and OpenShell work clean: no blocker, high, or medium findings. The later proxy-delivery branch integration was validated but not independently re-reviewed when this report was updated. From da83dcf2463b9c95126f2fc3c218be6e328e5bcc Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 18:37:53 -0400 Subject: [PATCH 45/70] fix(egress-gate): verify Pi provider responses --- .../examples/pi-attested-admission/README.md | 3 ++- .../examples/pi-attested-admission/demo.sh | 12 +++++++++--- .../egress-gate/tests/test_pi_example_commands.py | 2 +- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 8ac27219..28417715 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -167,7 +167,8 @@ Run the complete non-interactive verification: ``` `verify` runs the real packaged Pi and OpenShell sandbox. It checks a denied -prompt without a session write, a persisted redaction, an unauthenticated call +prompt without a session write, a persisted redaction with a successful model +response, an unauthenticated call to the admission bridge, a raw provider request without an admission handle, the stock Pi binary without the runtime adapter, and best-effort tool-result cases. Each case uses a fresh session and prints one `PASS` or `SKIP` line. Tool diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 64399571..bcfaf300 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -613,7 +613,13 @@ verify() { printf 'Redacted-prompt verification failed for %s.\n' "$session" >&2 exit 1 fi - printf 'PASS %-18s session contains only [REDACTED]\n' "redacted prompt" + if ! grep -Fq '"role":"assistant"' "$temporary_dir/redact.jsonl" || \ + grep -Fq '"stopReason":"error"' "$temporary_dir/redact.jsonl"; then + printf 'Provider-response verification failed for %s.\n' "$session" >&2 + exit 1 + fi + printf 'PASS %-18s provider answered; session contains only [REDACTED]\n' \ + "redacted prompt" fi capture_sandbox_command "$temporary_dir/bridge.out" "$temporary_dir/bridge.err" \ @@ -635,7 +641,7 @@ verify() { first_log_line=$(( $(log_line_count) + 1 )) status=0 capture_sandbox_command "$temporary_dir/raw.out" "$temporary_dir/raw.err" \ - /usr/local/bin/node -e "$raw_request_script" || status=$? + /usr/bin/node -e "$raw_request_script" || status=$? if ! $print_only; then if ((status == 0)); then printf 'Raw provider request unexpectedly succeeded.\n' >&2 @@ -647,7 +653,7 @@ verify() { first_log_line=$(( $(log_line_count) + 1 )) status=0 capture_sandbox_command "$temporary_dir/stock.out" "$temporary_dir/stock.err" \ - /usr/local/bin/pi --session "$verify_dir/stock.jsonl" -p hello || status=$? + /usr/bin/pi --session "$verify_dir/stock.jsonl" -p hello || status=$? if ! $print_only; then if ((status == 0)); then printf 'Stock Pi unexpectedly reached the provider.\n' >&2 diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 7a72e598..37cc638b 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -114,7 +114,7 @@ def test_pi_example_can_print_each_action_without_running_it( assert "sandbox exec --tty" in output assert "sandbox exec --no-tty" in output assert "DENY_THIS" in output - assert "/usr/local/bin/pi" in output + assert "/usr/bin/pi" in output assert "PI_OFFLINE=1" not in output assert "PI_CODING_AGENT_DIR=" not in output assert "--no-extensions" not in output From 7eaf6c1a2c8378444accd967977e7d725a96c19c Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 21:43:58 -0400 Subject: [PATCH 46/70] feat(egress-gate): load Pi demo environment automatically --- .../examples/pi-attested-admission/README.md | 14 ++--- .../examples/pi-attested-admission/demo.sh | 25 +++++---- .../tests/test_pi_example_commands.py | 53 +++++++++++++++++-- 3 files changed, 67 insertions(+), 25 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 28417715..2a1ed28d 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -44,23 +44,19 @@ all remaining commands there: cd projects/egress-gate/examples/pi-attested-admission ``` -Create the local configuration file, replace every example value, and load it -into the current shell: +Create the local configuration file and replace every example value: ```shell cp .env.example .env # Edit .env before continuing. -set -a -source .env -set +a ``` -`set -a` makes assignments loaded by `source .env` available to commands run -from this shell; `set +a` restores the shell's default behavior afterward. +Every `demo.sh` invocation loads this file automatically. The values remain +local to the script and its child commands; they are not added to your current +shell. Set `PI_EGRESS_ENV_FILE` to use a configuration file elsewhere. If the model endpoint does not require authentication, set -`PI_MODEL_API_KEY=unused`. Source `.env` in the terminals that run `gateway` and -`reset`; the other actions do not consume the credential. +`PI_MODEL_API_KEY=unused`. `PI_WORKSPACE_PATH` is optional. Set it to the absolute path of a project you want Pi to work on. The reset step uploads its contents to `/sandbox/workspace` diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index bcfaf300..b6626ecf 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -14,6 +14,13 @@ fi action=${1:-help} script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) egress_gate_dir=$(cd -- "$script_dir/../.." && pwd) +env_file=${PI_EGRESS_ENV_FILE-$script_dir/.env} +if [[ -n $env_file && -f $env_file ]]; then + set -a + # shellcheck disable=SC1090 + source "$env_file" + set +a +fi forks_dir=${PI_EGRESS_FORKS_DIR:-$egress_gate_dir/.workspaces/pi-attested-admission} pi_repo=${PI_REPO:-$forks_dir/pi} @@ -218,8 +225,7 @@ require_host_configuration() { if [[ -n ${EGRESS_GATE_HOST_IP:-} && ${EGRESS_GATE_HOST_IP:-} != YOUR_HOST_IPV4 ]]; then return fi - printf 'Set EGRESS_GATE_HOST_IP in %s and source it before starting the gateway.\n' \ - "$script_dir/.env" >&2 + printf 'Set EGRESS_GATE_HOST_IP in %s before starting the gateway.\n' "$env_file" >&2 exit 1 } @@ -246,15 +252,12 @@ require_setup_configuration() { printf 'Set these environment variables:\n' >&2 printf ' %s\n' "${missing[@]}" >&2 printf '\n' >&2 - printf 'Configure and load %s:\n' "$script_dir/.env" >&2 + printf 'Configure %s:\n' "$env_file" >&2 printf ' cd %s\n' "$script_dir" >&2 - if [[ ! -f $script_dir/.env ]]; then + if [[ ! -f $env_file ]]; then printf ' cp .env.example .env\n' >&2 fi printf ' # Edit .env and replace every example value.\n' >&2 - printf ' set -a\n' >&2 - printf ' source .env\n' >&2 - printf ' set +a\n' >&2 exit 1 } @@ -751,16 +754,16 @@ print_plan() { local displayed_workspace="empty /sandbox/workspace" if [[ $displayed_host == YOUR_HOST_IPV4 ]]; then displayed_host="not set" - configuration_status="incomplete — edit and source .env" + configuration_status="incomplete — edit .env" fi if [[ $displayed_models_path == YOUR_MODELS_PATH ]]; then displayed_models_path="not set" - configuration_status="incomplete — edit and source .env" + configuration_status="incomplete — edit .env" fi if [[ -n ${PI_MODEL_API_KEY:-} && ${PI_MODEL_API_KEY:-} != your-provider-key ]]; then credential_status="set (value hidden)" else - configuration_status="incomplete — edit and source .env" + configuration_status="incomplete — edit .env" fi if [[ -n $workspace_path ]]; then displayed_workspace="$workspace_path" @@ -775,7 +778,7 @@ This is a preview; no commands are running. The numbered items show the order of operations and which terminal to use. Print one action separately to inspect its exact commands. -${bold}${blue}Configuration visible to this shell${reset} +${bold}${blue}Configuration loaded by demo.sh${reset} Status: ${status_color}${configuration_status}${reset} Egress Gate host: $displayed_host Pi models file: $displayed_models_path diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 37cc638b..d00a4e50 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -42,6 +42,7 @@ def test_pi_example_can_print_each_action_without_running_it( "PI_MODELS_PATH": str(models_path), "PI_EGRESS_PACK_DIR": str(pack_dir), "PI_EGRESS_RUNTIME_DIR": str(runtime_dir), + "PI_EGRESS_ENV_FILE": "", "PI_WORKSPACE_PATH": str(tmp_path / "workspace"), } @@ -176,13 +177,14 @@ def test_pi_example_print_all_is_a_concise_walkthrough() -> None: project_dir / "examples/pi-attested-admission/models.json" ), "PI_MODEL_API_KEY": "secret-not-printed", + "PI_EGRESS_ENV_FILE": "", "PI_WORKSPACE_PATH": "/tmp/example-workspace", }, text=True, ) assert "Pi attested-admission walkthrough" in result.stdout - assert "Configuration visible to this shell" in result.stdout + assert "Configuration loaded by demo.sh" in result.stdout assert "Model credential: set (value hidden)" in result.stdout assert "1. prepare" in result.stdout assert "7. cleanup" in result.stdout @@ -201,6 +203,7 @@ def test_pi_example_uses_an_empty_workspace_when_no_path_is_configured() -> None project_dir / "examples/pi-attested-admission/models.json" ), "PI_MODEL_API_KEY": "secret-not-printed", + "PI_EGRESS_ENV_FILE": "", } reset = subprocess.run( @@ -232,7 +235,7 @@ def test_pi_example_launch_preserves_the_prepared_sandbox() -> None: ["bash", str(script), "--print", "launch"], check=True, capture_output=True, - env=os.environ, + env=os.environ | {"PI_EGRESS_ENV_FILE": ""}, text=True, ) @@ -250,7 +253,7 @@ def test_pi_example_uses_terminal_colors_without_leaking_them_to_redirects() -> script = project_dir / "examples/pi-attested-admission/demo.sh" environment = { name: value for name, value in os.environ.items() if name != "NO_COLOR" - } | {"FORCE_COLOR": "1"} + } | {"FORCE_COLOR": "1", "PI_EGRESS_ENV_FILE": ""} colored = subprocess.run( ["bash", str(script), "--print", "all"], @@ -282,7 +285,8 @@ def test_pi_example_defaults_to_an_ignored_external_workspace() -> None: name: value for name, value in os.environ.items() if name not in {"PI_REPO", "OPENSHELL_REPO", "PI_EGRESS_FORKS_DIR"} - }, + } + | {"PI_EGRESS_ENV_FILE": ""}, text=True, ) @@ -370,6 +374,43 @@ def test_pi_example_uses_standard_checked_in_configuration() -> None: assert registration["max_payload_bytes"] == 4 * 1024 * 1024 +def test_pi_example_loads_its_env_file_automatically(tmp_path: Path) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + models_path = project_dir / "examples/pi-attested-admission/models.json" + env_file = tmp_path / ".env" + env_file.write_text( + "EGRESS_GATE_HOST_IP=192.0.2.10\n" + f"PI_MODELS_PATH={models_path}\n" + "PI_MODEL_API_KEY=loaded-from-env-file\n" + ) + environment = { + name: value + for name, value in os.environ.items() + if name + not in { + "EGRESS_GATE_HOST_IP", + "PI_MODELS_PATH", + "PI_MODEL_API_KEY", + "PI_WORKSPACE_PATH", + } + } | {"PI_EGRESS_ENV_FILE": str(env_file)} + + result = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=environment, + text=True, + ) + + assert "Status: ready" in result.stdout + assert "Egress Gate host: 192.0.2.10" in result.stdout + assert f"Pi models file: {models_path}" in result.stdout + assert "Model credential: set (value hidden)" in result.stdout + assert "loaded-from-env-file" not in result.stdout + + def test_pi_example_reports_all_missing_configuration_before_work( tmp_path: Path, ) -> None: @@ -386,6 +427,7 @@ def test_pi_example_reports_all_missing_configuration_before_work( "PI_WORKSPACE_PATH", } } + environment["PI_EGRESS_ENV_FILE"] = str(tmp_path / "missing.env") result = subprocess.run( ["bash", str(script), "reset"], @@ -402,7 +444,7 @@ def test_pi_example_reports_all_missing_configuration_before_work( assert "PI_MODELS_PATH" in result.stderr assert "PI_MODEL_API_KEY" in result.stderr assert "PI_WORKSPACE_PATH" not in result.stderr - assert "source .env" in result.stderr + assert "cp .env.example .env" in result.stderr assert "git pull" not in result.stderr @@ -423,6 +465,7 @@ def test_pi_example_reports_a_missing_compute_backend_before_mise( | { "PATH": f"{tmp_path}:{os.environ['PATH']}", "OPENSHELL_DRIVERS": "", + "PI_EGRESS_ENV_FILE": "", "EGRESS_GATE_HOST_IP": "192.0.2.10", "PI_MODELS_PATH": str( project_dir / "examples/pi-attested-admission/models.json" From c9a6155c0f359ad1764a52763a607ded9be91cd8 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 22:48:10 -0400 Subject: [PATCH 47/70] docs(egress-gate): record live Pi session findings --- .../analysis/qa-reports/2026-09-02.html | 99 +++++++++++++++++-- 1 file changed, 90 insertions(+), 9 deletions(-) diff --git a/projects/egress-gate/analysis/qa-reports/2026-09-02.html b/projects/egress-gate/analysis/qa-reports/2026-09-02.html index 770c8e10..6492be42 100644 --- a/projects/egress-gate/analysis/qa-reports/2026-09-02.html +++ b/projects/egress-gate/analysis/qa-reports/2026-09-02.html @@ -20,6 +20,7 @@

Pi attested-admission integration QA

Date: 2026-09-02 UTC

+

Live-session update: 2026-09-03 UTC, through 02:40:14

This report records the final proof-of-concept integration state across Pi, OpenShell, and OpenShell Research. It contains only sanitized command @@ -32,8 +33,8 @@

Reviewed revisions

RepositoryIntegration headUpstream base Pi177b42723d072b7954a5b1690ccf62c97f075b37e266507b606b9552fa277252644054afd4384b11 - OpenShell57cbc0dc9305e657bb6177d2d6dd63baa2f5ccfca6b757d35f983fe4415427484ad06533d32e9e4b - OpenShell Research4d99082b199b07136a9c93558d95c73d07b929d8 (pre-report)743839dae47621c13a3bc339bad2a2c8d0167591 + OpenShell4d7194dc8166bfafc7236e0212d2e88aee4f7231a6b757d35f983fe4415427484ad06533d32e9e4b + OpenShell Researchff8319a69255ce0f858691095c6ea9f24d9b603f743839dae47621c13a3bc339bad2a2c8d0167591 @@ -48,16 +49,96 @@

Validation

-

External end-to-end blocker

+

Live end-to-end result

- The real ./demo.sh verify run was not substituted with a mock. - This checkout does not contain the ignored prepared fork/runtime, local - .env, JSON log, or provider credential required to run the - live sandbox and model request. The verifier reports those prerequisites; - a configured environment must run the clean reset and verification before - the proof of concept is promoted beyond draft status. + The configured machine completed the real ./demo.sh verify + workflow and an extended interactive Pi session. The required verifier + cases passed: denied input was not written, redacted input was persisted + only as [REDACTED], unauthorized bridge and unattested + provider calls were rejected, stock Pi could not bypass attestation, and + an admitted tool-result replacement persisted correctly. The + model-dependent tool-denial case skipped because the model did not choose + to call Bash; this is the verifier's documented non-failing outcome.

+

Exploratory session review

+

+ The reviewed session is the persistent Pi JSONL session created at + 2026-09-03T01:45:53Z, ID + 01a064f1-a65a-7fe8-ab58-e20a67f868d7. The review used the + session structure and sanitized Egress Gate decision log; it did not copy + credentials or provider request bodies into this report. +

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
AreaObserved behaviorFinding
Standard Pi workflowOne 158-entry JSONL session preserved 14 user messages, 76 assistant messages, 63 tool results, model and thinking-level changes, and one compaction event. Pi created and edited six chapter files and resumed work across repeated turns.Working. Persistent sessions, the normal tools, workspace writes, long-running interaction, and the standard TUI path were active.
Credential isolationWhen Pi inspected every PI_* environment variable, it saw model, provider, reasoning, and session metadata but no model API key or OpenShell resolver value.Working. Proxy-delivered provider authentication kept the credential out of the agent environment.
Reasoning controlsThe session began at high, changed to xhigh, and persisted 72 assistant thinking blocks. Provider usage separately reported reasoning tokens.Working as a model feature. The model produced reasoning and Pi retained it as ordinary session state.
CompactionPi compacted after a 69,090-token turn. The next provider request used 26,727 input tokens, then read the on-disk progress ledger and returned an accurate current story summary.Working. The compacted summary describes only the discarded prefix and looks stale in isolation, but firstKeptEntryId retains the Chapter 4 completion and the retained tail contains Chapters 5 and 6.
Provider failureImmediately after switching to xhigh, one continue submission produced four retries containing Connection error., empty content, and zero token usage.Failure, not denial. The session does not contain an HTTP status or lower-level cause, so it cannot distinguish the provider, proxy, or network source. The later request succeeded without a configuration change; correlation does not prove that xhigh caused it.
Reasoning-only completionThe next continue request returned 810 output tokens, of which 807 were reported as reasoning. It stopped successfully with one thinking block, no visible text, and no tool call. The user had to submit write it before work continued.Usability failure. This was a provider completion accepted by Pi, not an admission denial. It should first be reproduced against the same endpoint with stock Pi before changing the integration.
Denied-message auditAll 14 user entries visible in the reviewed JSONL were persisted, but an input denied before append is intentionally absent. The content-safe Egress Gate log has request IDs but no session ID, submission ID, or timestamp that can correlate a denial to this session.Observability gap. Pi can prove what was appended; it cannot prove from its own history whether another submitted message was denied. The model's claim that no denial occurred was therefore stronger than its evidence.
Thinking admissionAssistant thinking is persisted, can be sent on later provider turns, and consumes context. The current append envelope and attested context omit it; only request-time policy scanning covers it when it appears on the wire.Security-model gap. The current proof of concept does not guarantee that all persisted or provider-visible reasoning was admitted and bound into the context attestation.
Sandbox utilitiesThe model attempted to use bc and file, which are absent from the image. Both Bash tool results were recorded as successful because a later command in each shell invocation exited successfully.Minor environment/diagnostic issue. It did not stop the workflow, but demonstrates that a successful tool result does not imply every command in a compound shell command succeeded.
+ +

Decision-log evidence and limits

+

+ Since the latest Egress Gate server-start record, the content-safe log + contains 80 allows and two denials with + reason_code=attestation_missing. It contains no middleware + errors and no regex-denial reason. This supports the conclusion that the + visible interactive failures were not middleware denials. It cannot prove + which session or submission produced a record because the current log + deliberately omits the correlation fields needed for that join. +

+ +

Recommended follow-up

+
    +
  1. Add content-free denial audit metadata that can be correlated by sandbox, session, and submission without retaining the denied text or adding it to model context.
  2. +
  3. Bring assistant thinking inside the same append-admission and whole-context attestation contract as other persisted provider-visible content.
  4. +
  5. Reproduce the reasoning-only completion and the four connection retries with stock Pi against the same endpoint. Preserve standard Pi behavior unless the integration is shown to be responsible.
  6. +
  7. Expose enough transport error detail to distinguish an upstream/provider failure from a middleware denial without logging request content or credentials.
  8. +
+

Verified design boundary

One policy fingerprint connects append-time admission, provider-context From 5b09ac8d334465ba0df5ee2aeb014aab83efaf78 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 2 Sep 2026 23:03:09 -0400 Subject: [PATCH 48/70] fix(egress-gate): explain unavailable cleanup gateway --- .../examples/pi-attested-admission/README.md | 4 +++ .../examples/pi-attested-admission/demo.sh | 15 ++++++++ .../tests/test_pi_example_commands.py | 36 +++++++++++++++++++ 3 files changed, 55 insertions(+) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 2a1ed28d..99f06794 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -333,5 +333,9 @@ sandbox and provider: ./demo.sh cleanup ``` +If the gateway is unavailable, `cleanup` stops before changing anything and +prints the exact commands needed to restart the local services. Cleanup goes +through OpenShell so sandbox and provider state are removed consistently. + Then stop the OpenShell gateway and Egress Gate with `Ctrl-C`. For another session in the same prepared sandbox, use `./demo.sh launch` instead of cleanup. diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index b6626ecf..54defffc 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -711,6 +711,21 @@ verify() { cleanup() { if ! $print_only; then require_file "$openshell_cli" "OpenShell CLI wrapper" + if ! (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + status >/dev/null 2>&1); then + cat >&2 < None: assert "provider create" not in result.stdout +def test_pi_example_cleanup_explains_when_gateway_is_unavailable( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + openshell_repo = tmp_path / "OpenShell" + openshell_cli = openshell_repo / "scripts/bin/openshell" + openshell_cli.parent.mkdir(parents=True) + openshell_cli.write_text( + "#!/bin/sh\necho 'transport error: Connection refused' >&2\nexit 1\n" + ) + openshell_cli.chmod(0o755) + + result = subprocess.run( + ["bash", str(script), "cleanup"], + capture_output=True, + env=os.environ + | { + "OPENSHELL_REPO": str(openshell_repo), + "PI_EGRESS_ENV_FILE": "", + }, + text=True, + ) + + assert result.returncode == 1 + assert result.stdout == "" + assert "OpenShell gateway 'pi-egress-demo-gateway' is not reachable" in ( + result.stderr + ) + assert "Terminal 1: ./demo.sh serve" in result.stderr + assert "Terminal 2: ./demo.sh gateway" in result.stderr + assert "Then run: ./demo.sh cleanup" in result.stderr + assert "Do not run ./demo.sh reset" in result.stderr + assert "transport error" not in result.stderr + + def test_pi_example_uses_terminal_colors_without_leaking_them_to_redirects() -> None: project_dir = Path(__file__).parents[1] script = project_dir / "examples/pi-attested-admission/demo.sh" From 1d32283d3e30f27c72e1542899cad292df53d470 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 03:14:40 +0000 Subject: [PATCH 49/70] chore(egress-gate): sync OpenShell middleware protocol Signed-off-by: Johnny Greco --- .../.openshell-middleware-manifest.json | 6 +- .../proto/supervisor_middleware.proto | 386 ++++++++++++++++-- .../bindings/supervisor_middleware_pb2.py | 172 +++++--- .../bindings/supervisor_middleware_pb2.pyi | 266 ++++++++++-- .../supervisor_middleware_pb2_grpc.py | 102 ++++- 5 files changed, 782 insertions(+), 150 deletions(-) diff --git a/projects/egress-gate/.openshell-middleware-manifest.json b/projects/egress-gate/.openshell-middleware-manifest.json index 0d0ec995..fa26f4f4 100644 --- a/projects/egress-gate/.openshell-middleware-manifest.json +++ b/projects/egress-gate/.openshell-middleware-manifest.json @@ -1,7 +1,7 @@ { - "openshell_version": "johnnygreco/OpenShell@8332c459c89124499e76da0a4095af9661aec10f", - "proto_source": "https://raw.githubusercontent.com/johnnygreco/OpenShell/8332c459c89124499e76da0a4095af9661aec10f/proto/supervisor_middleware.proto", - "proto_sha256": "2bda09fcbabc37663fbddfb8c49b6ae2689b25f3315912b0e8416a6bd2ac8e50", + "openshell_version": "johnnygreco/OpenShell@08aa6a26381ef8ad10b67394201394a33db54835", + "proto_source": "https://raw.githubusercontent.com/johnnygreco/OpenShell/08aa6a26381ef8ad10b67394201394a33db54835/proto/supervisor_middleware.proto", + "proto_sha256": "eb73e8fa9c9a2bb7a733da5110944712010153543c50a41fe18a7ddfeba5ccdd", "contract_note": "EvaluateAgentConversation is fork-only until the agent-conversation contract is upstreamed.", "languages": [ "python" diff --git a/projects/egress-gate/proto/supervisor_middleware.proto b/projects/egress-gate/proto/supervisor_middleware.proto index a51a57ba..79ff3dbc 100644 --- a/projects/egress-gate/proto/supervisor_middleware.proto +++ b/projects/egress-gate/proto/supervisor_middleware.proto @@ -8,9 +8,10 @@ package openshell.middleware.v1; import "google/protobuf/empty.proto"; import "google/protobuf/struct.proto"; -// SupervisorMiddleware lets an operator-run service inspect and transform -// sandbox HTTP requests and client WebSocket text messages before OpenShell -// injects credentials, or evaluate a supported agent-harness request. +// SupervisorMiddleware discovers and configures one operator-run middleware. +// It evaluates HTTP requests, HTTP responses, WebSocket messages, and supported +// agent-harness requests at their declared phases. +// Phase-specific services share the same registration. service SupervisorMiddleware { // Describe returns the service manifest and declared bindings. rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); @@ -37,9 +38,17 @@ service SupervisorMiddleware { returns (stream WebSocketSessionEventResult); } -// MiddlewareManifest describes one middleware service and the bindings it -// exposes. The service is the operator-run gRPC server implementing -// SupervisorMiddleware. +// HttpResponsePreReturn evaluates one response for one middleware stage before +// OpenShell returns it to the sandbox. +service HttpResponsePreReturn { + // Evaluate starts with preflight and may continue with selected body units + // and trailers. A body unit marked end_of_stream ends body inspection, not + // the event stream. Trailers and one best-effort session_end may follow. + rpc Evaluate(stream HttpResponseEvent) + returns (stream HttpResponseEventResult); +} + +// MiddlewareManifest describes one middleware service and its bindings. message MiddlewareManifest { // Human-readable middleware service name used only for diagnostics. This is // not required to match an operator-owned registration name. @@ -61,14 +70,11 @@ message MiddlewareManifest { message MiddlewareBinding { // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported evaluation phase. PR 1 supports PRE_CREDENTIALS. PRE_RETURN is - // reserved for the return-path follow-up and is rejected by current - // manifest validation. + // Supported phase. SupervisorMiddlewarePhase phase = 2; - // Maximum logical payload or replacement this binding can process. For - // HTTP_REQUEST and AGENT_CONVERSATION this is the request body; for - // WEBSOCKET_MESSAGE this is one complete message. Required for every - // payload-bearing operation. + // Maximum request body, agent request body, WebSocket message, or response + // body unit/replacement. + // Required for payload-bearing operations. uint64 max_payload_bytes = 3; // Optional binding-specific RPC timeout. Empty uses the operator-configured // service timeout, or the 500ms platform default when that is also omitted. @@ -127,7 +133,7 @@ message HttpRequestEvaluation { bytes agent_attestation = 8; } -// HttpHeader is one request header line. +// HttpHeader is one HTTP header line. message HttpHeader { // Lowercased header name. string name = 1; @@ -135,12 +141,328 @@ message HttpHeader { string value = 2; } +// One ordered response event. A stream starts with preflight, may continue with +// body units ending in end_of_stream, may then include trailers, and may end +// with one best-effort session_end. +message HttpResponseEvent { + oneof event { + // Initial response head and request context. + HttpResponsePreflight preflight = 1; + // Next normalized body unit. + HttpResponseBodyUnit body = 2; + // Normalized trailers after the final body result. + HttpResponseTrailers trailers = 4; + // Optional terminal notification. + MiddlewareSessionEnd session_end = 3; + } +} + +// Each preflight, body, and trailers event requires one ordered result. +// session_end has no result. +message HttpResponseEventResult { + oneof result { + // Result for preflight. + HttpResponsePreflightResult preflight_result = 1; + // Result for the next body unit. + HttpResponseBodyResult body_result = 2; + // Result for response trailers. + HttpResponseTrailersResult trailers_result = 3; + } +} + +// HttpResponsePreflight exposes the current final response head to one stage. +message HttpResponsePreflight { + // Request identity. request_id links request and response evaluations. + // Limited to 4 KiB encoded. + RequestContext context = 1; + // Admitted request target with a redacted query. Limited to 32 KiB encoded. + HttpRequestTarget target = 2; + // Final non-informational upstream status. Upgrades are not evaluated. + uint32 status_code = 3; + // Response headers after prior stages, in wire order. Repeated names remain + // separate. Credential, routing, and hop-by-hop headers are omitted. + // Content-Length, Content-Encoding, and Content-Range retain their read-only + // upstream values. OpenShell may recompute or remove Content-Length later. + // Limited to 128 lines and 64 KiB encoded. + repeated HttpHeader headers = 4; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 5; + // Validated service configuration. Limited to 64 KiB encoded. + google.protobuf.Struct config = 6; + // Effective minimum of platform, registration, and binding limits. Applies to + // whole-body input/replacement and each stream input/replacement. Stream + // inputs use at most min(64 KiB, max_payload_bytes). + uint64 max_payload_bytes = 7; + // Modes derived independently for this stage. OpenShell first determines + // response-shape eligibility from the original final response head, then + // applies this stage's effective max_payload_bytes. Different stages may + // receive different lists. HEADERS_ONLY is always present and is the only + // mode for bodyless, partial, encoded, or no-transform responses. For an + // otherwise eligible response, a known body larger than this stage's limit + // omits WHOLE_BODY_BYTES. An eligible unknown-length response may select + // WHOLE_BODY_BYTES and later fail with whole_body_over_capacity according to + // this stage's on_error. STREAM_BYTES is omitted when + // max_payload_bytes is zero. Selecting an unlisted mode fails according to + // on_error. + repeated HttpResponseBodyMode permitted_body_modes = 8; +} + +// Selects skip, inspect, or block. Diagnostic fields apply to every action. +// Invalid diagnostics make the entire result a middleware failure handled +// according to on_error. +message HttpResponsePreflightResult { + oneof action { + // Deliver unchanged without invoking on_error. + HttpResponsePreflightSkip skip = 1; + // Inspect with the selected body mode and mutations. + HttpResponsePreflightInspect inspect = 2; + // Prevent delivery to the sandbox. + HttpResponseBlockDelivery block_delivery = 7; + } + // Service diagnostic, never sent to the sandbox or security logs. Maximum + // 4 KiB. + string reason = 3; + // Optional audit code using the HttpRequestResult.reason_code format and + // 64-byte maximum. Returned to the sandbox only for block_delivery. + string reason_code = 4; + // Up to 32 audit-safe findings, each limited to 4 KiB encoded. + repeated Finding findings = 5; + // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. + map metadata = 6; +} + +// Ends this stage successfully without body inspection. +message HttpResponsePreflightSkip {} + +// Blocks delivery as a successful decision regardless of on_error. OpenShell +// evaluates results in policy order. Once it accepts a valid block, it stops +// later middleware evaluation and ends every still-writable opened stage with +// MIDDLEWARE_DENIAL. A failure handled earlier may already have stopped +// evaluation, so a later block does not override it. An invalid block result +// is a middleware failure handled according to on_error. The upstream request +// has already run; blocking its response does not reject or roll back that +// request. +// +// Before response commitment, including at preflight and during +// WHOLE_BODY_BYTES, OpenShell replaces the upstream response with the canonical +// 403 Forbidden middleware-denial response. Its JSON body has +// error = "middleware_denied" and includes a validated reason_code when the +// result supplies one. OpenShell never returns the free-form reason or writes it +// to security logs. For HEAD, OpenShell sends the canonical response headers +// and Content-Length but no body. It closes the downstream connection after the +// denial response. +// +// After response commitment, including during STREAM_BYTES, OpenShell aborts +// downstream delivery. It does not inject an error body, a terminating chunk, +// or an error trailer. OpenShell does not reuse the upstream connection. +message HttpResponseBlockDelivery {} + +// Selects body inspection and response-header mutations. +message HttpResponsePreflightInspect { + // Required mode from permitted_body_modes. Invalid values fail according to + // on_error. + HttpResponseBodyMode body_mode = 1; + // Ordered mutations applied atomically before the next stage. Only visible + // end-to-end headers may change. Routing, credential, framing, coding, range, + // and hop-by-hop headers are protected; integrity headers may only be removed. + // Limited to 64 operations, 32 KiB of name/value data, and 64 KiB encoded. + repeated HeaderMutation header_mutations = 2; +} + +// Controls which response-body units a stage receives. +enum HttpResponseBodyMode { + // Invalid value handled according to on_error. + HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0; + // Inspect only the response head. + HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; + // Buffer the normalized body as one final unit before committing the head. + // Input and replacement must fit max_payload_bytes. Capacity failures use + // whole_body_over_capacity and follow this stage's on_error. + HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; + // Receive normalized units ending with end_of_stream. Each input is at most + // min(64 KiB, max_payload_bytes), and each replacement must fit + // max_payload_bytes. Each result fully accounts for its input unit; V1 does + // not permit retaining input across units. The full body may exceed the + // limit. STREAM_BYTES has no total response-lifetime deadline. + HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; +} + +// One normalized body unit. Boundaries have no transport or application +// meaning. +message HttpResponseBodyUnit { + // Contiguous and stage-local, starting at 1. + uint64 sequence = 1; + oneof payload { + // Bytes without transfer framing. A body-capable response with no body bytes + // has present empty data in sequence 1. STREAM_BYTES input size is at most + // min(64 KiB, max_payload_bytes). A unit may be shorter to preserve + // flushing. + bytes data = 2; + } + // Marks the final body unit. Every normally completed body inspection receives + // exactly one. For a body-capable response with no body bytes, this is the + // empty sequence-1 unit. OpenShell does not read ahead, so it may send an empty + // final unit after the last nonempty unit. Trailers and session_end may + // follow. A stage ended by skip_remaining, block, or failure receives no + // later final unit. + bool end_of_stream = 3; +} + +// Result for one body unit. Units are processed in lockstep; V1 does not +// support ownership transfer or cross-unit retention. Diagnostic fields apply +// to every action. Invalid diagnostics make the entire result a middleware +// failure handled according to on_error. OpenShell retains the current input +// until it validates the result, so fail-open can continue from the last input +// OpenShell still owns. +message HttpResponseBodyResult { + // Must match the next unit. Zero, gaps, duplicates, and regressions fail. + uint64 sequence = 1; + // Exactly one explicit action is required. + oneof action { + // Forward the input unit unchanged. + HttpResponseBodyPassThrough pass_through = 2; + // Replace the complete input unit. + HttpResponseBodyTransform transform = 3; + // Stop delivery. See HttpResponseBlockDelivery. + HttpResponseBlockDelivery block_delivery = 8; + // Finalize this unit and stop inspecting. + HttpResponseBodySkipRemaining skip_remaining = 9; + } + // Service diagnostic, never sent to the sandbox or security logs. Maximum + // 4 KiB. + string reason = 4; + // Optional audit code using the HttpRequestResult.reason_code format and + // 64-byte maximum. When OpenShell accepts block_delivery before response + // commitment, it includes this code in the canonical denial response. It is + // never returned after commitment. + string reason_code = 5; + // Up to 32 audit-safe findings, each limited to 4 KiB encoded. + repeated Finding findings = 6; + // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. + map metadata = 7; +} + +// Preserves the input unit. +message HttpResponseBodyPassThrough {} + +// Finalizes this unit and ends the stage. This stage receives no later body or +// trailer events. The current and later units continue through other stages. +// For WHOLE_BODY_BYTES, this equals its nested action. +message HttpResponseBodySkipRemaining { + // Exactly one action for the current unit. + oneof current { + // Forward the current unit unchanged. + HttpResponseBodyPassThrough pass_through = 1; + // Replace the current unit. + HttpResponseBodyTransform transform = 2; + } +} + +// Replaces the complete input unit. +message HttpResponseBodyTransform { + // Required replacement, limited to max_payload_bytes. Present empty data + // deletes the input unit. The replacement fully accounts for this input unit; + // middleware must not retain input bytes for a later unit in V1. + oneof replacement { + // Normalized replacement bytes. + bytes data = 1; + } +} + +// The current normalized response trailers in wire order. Repeated names stay +// as separate fields. A stage that completes WHOLE_BODY_BYTES or STREAM_BYTES +// receives exactly one trailers event after its final body result, including +// when this set is empty. SKIP, HEADERS_ONLY, semantically bodyless responses, +// and stages ended by block, failure, or skip_remaining receive no trailers. +message HttpResponseTrailers { + repeated HttpHeader headers = 1; +} + +// Applies ordered trailer mutations atomically. An empty mutation list +// preserves the current trailers. A write may target only a case-insensitive +// name present in the trailers event; V1 cannot create a trailer name. Removal +// of an absent name is a no-op. Credential, routing, framing, coding, range, +// hop-by-hop, and connection-nominated fields are protected. Diagnostic fields +// apply whether mutations are empty or nonempty. Invalid diagnostics or +// mutations make the entire result a middleware failure handled according to +// on_error. +message HttpResponseTrailersResult { + // At most 64 operations, 32 KiB of validated name/value data, and 64 KiB + // encoded are accepted. + repeated HeaderMutation trailer_mutations = 1; + // Service diagnostic, never sent to the sandbox or security logs. Maximum + // 4 KiB. + string reason = 2; + // Optional audit code using the HttpRequestResult.reason_code format and + // 64-byte maximum. Never sent to the sandbox. + string reason_code = 3; + // Up to 32 audit-safe findings, each limited to 4 KiB encoded. + repeated Finding findings = 4; + // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. + map metadata = 5; +} + +// Stable reason OpenShell ended a middleware stage stream. +enum MiddlewareSessionEndReason { + // Invalid reason. + MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED = 0; + // Evaluation completed. + MIDDLEWARE_SESSION_END_REASON_NORMAL = 1; + // The sandbox peer disconnected. + MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT = 2; + // A policy reload replaced the active middleware chain. + MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD = 3; + // A stage denied the operation or blocked the response. + MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; + // A selected stage failed. + MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; + // A proxied or middleware protocol was violated. + MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR = 6; + // Evaluation was canceled for another reason. + MIDDLEWARE_SESSION_END_REASON_CANCELLATION = 7; + // Upstream rejected or failed before a valid response or upgrade. + MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE = 8; + // Network policy denied the operation. + MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL = 9; + // The stage successfully declined inspection during preflight. + MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED = 10; + // Upstream disconnected after a valid response or upgrade. + MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT = 11; +} + +// Best-effort terminal notification. A stage receives at most one and sends no +// result. +message MiddlewareSessionEnd { + // Terminal reason. Producers never send UNSPECIFIED. + MiddlewareSessionEndReason reason = 1; + // Set only for PROTOCOL_ERROR. Missing or unknown details mean a generic + // protocol error. + MiddlewareSessionProtocolError protocol_error = 2; +} + +// Details for a protocol-error session end. +message MiddlewareSessionProtocolError { + oneof domain { + // WebSocket protocol violation. + WebSocketProtocolError web_socket = 1; + // Middleware event/result protocol violation. + MiddlewareExchangeProtocolError middleware_exchange = 2; + } +} + +// WebSocket protocol error details, reserved for future categories. +message WebSocketProtocolError {} + +// Middleware exchange error details, reserved for future categories. +message MiddlewareExchangeProtocolError {} + // Supervisor operation selected for middleware evaluation. enum SupervisorMiddlewareOperation { SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE = 2; - SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION = 3; + SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE = 3; + SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION = 4; } // Ordered phase within a supervisor operation. @@ -151,24 +473,6 @@ enum SupervisorMiddlewarePhase { SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT = 3; } -// Why OpenShell is ending a middleware stream. -enum WebSocketSessionEndReason { - WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED = 0; - WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE = 1; - WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT = 2; - WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD = 3; - WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; - WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; - WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR = 6; - WEB_SOCKET_SESSION_END_REASON_CANCELLATION = 7; - WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED = 8; - WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL = 9; - // The middleware stage voluntarily declined inspection during preflight. - // This is a successful stage-local outcome, not a cancellation or denial of - // the WebSocket upgrade. - WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED = 10; -} - // WebSocketSessionEvent is one ordered event in a stage-local stream. // Message sequence numbers identify logical messages session-wide. A stage // receives a strictly increasing subset of those numbers; gaps are valid when @@ -178,7 +482,7 @@ message WebSocketSessionEvent { WebSocketPreflight preflight = 1; WebSocketSessionStart session_start = 2; WebSocketMessage message = 3; - WebSocketSessionEnd session_end = 4; + MiddlewareSessionEnd session_end = 4; } } @@ -219,12 +523,6 @@ message WebSocketMessage { } } -// WebSocketSessionEnd is OpenShell's best-effort terminal notification for one -// opened stage stream. A stage receives at most one such notification. -message WebSocketSessionEnd { - WebSocketSessionEndReason reason = 1; -} - // WebSocketPreflightAction is the service's one-time scoping decision. enum WebSocketPreflightAction { // Invalid response value handled according to the policy failure mode. @@ -426,7 +724,7 @@ message RemoveHeader { string name = 1; } -// HeaderMutation is one ordered request-header operation. +// HeaderMutation is one ordered HTTP header operation. message HeaderMutation { oneof operation { WriteHeader write = 1; @@ -446,10 +744,10 @@ message HttpRequestResult { // True when body should replace the request body, including with an empty body. bool has_body = 4; // Ordered request-header mutations applied before the next middleware and - // before forwarding. Header writes are restricted to the - // "x-openshell-middleware-" namespace. Removes may target other visible + // before forwarding. Writes and removals may target visible end-to-end // request headers, but credential, routing, framing, and hop-by-hop headers - // are always protected. A violating result is a middleware failure handled + // are always protected. Written values cannot contain OpenShell credential + // placeholder syntax. A violating result is a middleware failure handled // according to the policy failure mode. At most 64 operations, 32 KiB of // validated name/value data, and 64 KiB encoded are accepted. repeated HeaderMutation header_mutations = 5; diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py index a611b35d..79a6d67f 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py @@ -26,13 +26,19 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\x84\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xf1\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\x12\x19\n\x11\x61gent_attestation\x18\x08 \x01(\x0c\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xe5\x02\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0cJ\x04\x08\x05\x10\x06J\x04\x08\n\x10\x0e\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xf1\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x03*\xd4\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x03*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xda\x04\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\x84\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xf1\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\x12\x19\n\x11\x61gent_attestation\x18\x08 \x01(\x0c\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xa9\x02\n\x11HttpResponseEvent\x12\x43\n\tpreflight\x18\x01 \x01(\x0b\x32..openshell.middleware.v1.HttpResponsePreflightH\x00\x12=\n\x04\x62ody\x18\x02 \x01(\x0b\x32-.openshell.middleware.v1.HttpResponseBodyUnitH\x00\x12\x41\n\x08trailers\x18\x04 \x01(\x0b\x32-.openshell.middleware.v1.HttpResponseTrailersH\x00\x12\x44\n\x0bsession_end\x18\x03 \x01(\x0b\x32-.openshell.middleware.v1.MiddlewareSessionEndH\x00\x42\x07\n\x05\x65vent\"\x8d\x02\n\x17HttpResponseEventResult\x12P\n\x10preflight_result\x18\x01 \x01(\x0b\x32\x34.openshell.middleware.v1.HttpResponsePreflightResultH\x00\x12\x46\n\x0b\x62ody_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.HttpResponseBodyResultH\x00\x12N\n\x0ftrailers_result\x18\x03 \x01(\x0b\x32\x33.openshell.middleware.v1.HttpResponseTrailersResultH\x00\x42\x08\n\x06result\"\x82\x03\n\x15HttpResponsePreflight\x12\x38\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x02 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x13\n\x0bstatus_code\x18\x03 \x01(\r\x12\x34\n\x07headers\x18\x04 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x17\n\x0fmiddleware_name\x18\x05 \x01(\t\x12\'\n\x06\x63onfig\x18\x06 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x19\n\x11max_payload_bytes\x18\x07 \x01(\x04\x12K\n\x14permitted_body_modes\x18\x08 \x03(\x0e\x32-.openshell.middleware.v1.HttpResponseBodyMode\"\xe3\x03\n\x1bHttpResponsePreflightResult\x12\x42\n\x04skip\x18\x01 \x01(\x0b\x32\x32.openshell.middleware.v1.HttpResponsePreflightSkipH\x00\x12H\n\x07inspect\x18\x02 \x01(\x0b\x32\x35.openshell.middleware.v1.HttpResponsePreflightInspectH\x00\x12L\n\x0e\x62lock_delivery\x18\x07 \x01(\x0b\x32\x32.openshell.middleware.v1.HttpResponseBlockDeliveryH\x00\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x13\n\x0breason_code\x18\x04 \x01(\t\x12\x32\n\x08\x66indings\x18\x05 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12T\n\x08metadata\x18\x06 \x03(\x0b\x32\x42.openshell.middleware.v1.HttpResponsePreflightResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x08\n\x06\x61\x63tion\"\x1b\n\x19HttpResponsePreflightSkip\"\x1b\n\x19HttpResponseBlockDelivery\"\xa3\x01\n\x1cHttpResponsePreflightInspect\x12@\n\tbody_mode\x18\x01 \x01(\x0e\x32-.openshell.middleware.v1.HttpResponseBodyMode\x12\x41\n\x10header_mutations\x18\x02 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\"Z\n\x14HttpResponseBodyUnit\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04\x64\x61ta\x18\x02 \x01(\x0cH\x00\x12\x15\n\rend_of_stream\x18\x03 \x01(\x08\x42\t\n\x07payload\"\xc6\x04\n\x16HttpResponseBodyResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12L\n\x0cpass_through\x18\x02 \x01(\x0b\x32\x34.openshell.middleware.v1.HttpResponseBodyPassThroughH\x00\x12G\n\ttransform\x18\x03 \x01(\x0b\x32\x32.openshell.middleware.v1.HttpResponseBodyTransformH\x00\x12L\n\x0e\x62lock_delivery\x18\x08 \x01(\x0b\x32\x32.openshell.middleware.v1.HttpResponseBlockDeliveryH\x00\x12P\n\x0eskip_remaining\x18\t \x01(\x0b\x32\x36.openshell.middleware.v1.HttpResponseBodySkipRemainingH\x00\x12\x0e\n\x06reason\x18\x04 \x01(\t\x12\x13\n\x0breason_code\x18\x05 \x01(\t\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x07 \x03(\x0b\x32=.openshell.middleware.v1.HttpResponseBodyResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x08\n\x06\x61\x63tion\"\x1d\n\x1bHttpResponseBodyPassThrough\"\xc1\x01\n\x1dHttpResponseBodySkipRemaining\x12L\n\x0cpass_through\x18\x01 \x01(\x0b\x32\x34.openshell.middleware.v1.HttpResponseBodyPassThroughH\x00\x12G\n\ttransform\x18\x02 \x01(\x0b\x32\x32.openshell.middleware.v1.HttpResponseBodyTransformH\x00\x42\t\n\x07\x63urrent\":\n\x19HttpResponseBodyTransform\x12\x0e\n\x04\x64\x61ta\x18\x01 \x01(\x0cH\x00\x42\r\n\x0breplacement\"L\n\x14HttpResponseTrailers\x12\x34\n\x07headers\x18\x01 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\"\xbf\x02\n\x1aHttpResponseTrailersResult\x12\x42\n\x11trailer_mutations\x18\x01 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.HttpResponseTrailersResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xac\x01\n\x14MiddlewareSessionEnd\x12\x43\n\x06reason\x18\x01 \x01(\x0e\x32\x33.openshell.middleware.v1.MiddlewareSessionEndReason\x12O\n\x0eprotocol_error\x18\x02 \x01(\x0b\x32\x37.openshell.middleware.v1.MiddlewareSessionProtocolError\"\xca\x01\n\x1eMiddlewareSessionProtocolError\x12\x45\n\nweb_socket\x18\x01 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketProtocolErrorH\x00\x12W\n\x13middleware_exchange\x18\x02 \x01(\x0b\x32\x38.openshell.middleware.v1.MiddlewareExchangeProtocolErrorH\x00\x42\x08\n\x06\x64omain\"\x18\n\x16WebSocketProtocolError\"!\n\x1fMiddlewareExchangeProtocolError\"\xaf\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x44\n\x0bsession_end\x18\x04 \x01(\x0b\x32-.openshell.middleware.v1.MiddlewareSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xe5\x02\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0cJ\x04\x08\x05\x10\x06J\x04\x08\n\x10\x0e\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xc1\x01\n\x14HttpResponseBodyMode\x12\'\n#HTTP_RESPONSE_BODY_MODE_UNSPECIFIED\x10\x00\x12(\n$HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY\x10\x01\x12,\n(HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES\x10\x02\x12(\n$HTTP_RESPONSE_BODY_MODE_STREAM_BYTES\x10\x03*\xf9\x04\n\x1aMiddlewareSessionEndReason\x12-\n)MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12(\n$MIDDLEWARE_SESSION_END_REASON_NORMAL\x10\x01\x12\x37\n3MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT\x10\x02\x12/\n+MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*MIDDLEWARE_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x32\n.MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE\x10\x08\x12/\n+MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED\x10\n\x12\x35\n1MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT\x10\x0b*\xa4\x02\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02\x12\x31\n-SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE\x10\x03\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x04*\xd4\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x03*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xda\x04\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x32\x85\x01\n\x15HttpResponsePreReturn\x12l\n\x08\x45valuate\x12*.openshell.middleware.v1.HttpResponseEvent\x1a\x30.openshell.middleware.v1.HttpResponseEventResult(\x01\x30\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'supervisor_middleware_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None + _globals['_HTTPRESPONSEPREFLIGHTRESULT_METADATAENTRY']._loaded_options = None + _globals['_HTTPRESPONSEPREFLIGHTRESULT_METADATAENTRY']._serialized_options = b'8\001' + _globals['_HTTPRESPONSEBODYRESULT_METADATAENTRY']._loaded_options = None + _globals['_HTTPRESPONSEBODYRESULT_METADATAENTRY']._serialized_options = b'8\001' + _globals['_HTTPRESPONSETRAILERSRESULT_METADATAENTRY']._loaded_options = None + _globals['_HTTPRESPONSETRAILERSRESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._loaded_options = None _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_options = b'8\001' _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._loaded_options = None @@ -41,18 +47,20 @@ _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_HTTPREQUESTRESULT_METADATAENTRY']._loaded_options = None _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=4855 - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=5096 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=5099 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=5311 - _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=5314 - _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=5892 - _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=5895 - _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=6083 - _globals['_DECISION']._serialized_start=6085 - _globals['_DECISION']._serialized_end=6160 - _globals['_EXISTINGHEADERACTION']._serialized_start=6163 - _globals['_EXISTINGHEADERACTION']._serialized_end=6331 + _globals['_HTTPRESPONSEBODYMODE']._serialized_start=8241 + _globals['_HTTPRESPONSEBODYMODE']._serialized_end=8434 + _globals['_MIDDLEWARESESSIONENDREASON']._serialized_start=8437 + _globals['_MIDDLEWARESESSIONENDREASON']._serialized_end=9070 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=9073 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=9365 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=9368 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=9580 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=9583 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=9771 + _globals['_DECISION']._serialized_start=9773 + _globals['_DECISION']._serialized_end=9848 + _globals['_EXISTINGHEADERACTION']._serialized_start=9851 + _globals['_EXISTINGHEADERACTION']._serialized_end=10019 _globals['_MIDDLEWAREMANIFEST']._serialized_start=116 _globals['_MIDDLEWAREMANIFEST']._serialized_end=264 _globals['_MIDDLEWAREBINDING']._serialized_start=267 @@ -65,52 +73,94 @@ _globals['_HTTPREQUESTEVALUATION']._serialized_end=1047 _globals['_HTTPHEADER']._serialized_start=1049 _globals['_HTTPHEADER']._serialized_end=1090 - _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1093 - _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1395 - _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1398 - _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1721 - _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1723 - _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1776 - _globals['_WEBSOCKETMESSAGE']._serialized_start=1778 - _globals['_WEBSOCKETMESSAGE']._serialized_end=1859 - _globals['_WEBSOCKETSESSIONEND']._serialized_start=1861 - _globals['_WEBSOCKETSESSIONEND']._serialized_end=1950 - _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1953 - _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2271 - _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2224 - _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2271 - _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2274 - _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2637 - _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2224 - _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2271 - _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2640 - _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2837 - _globals['_REQUESTCONTEXT']._serialized_start=2840 - _globals['_REQUESTCONTEXT']._serialized_end=3000 - _globals['_HTTPREQUESTTARGET']._serialized_start=3002 - _globals['_HTTPREQUESTTARGET']._serialized_end=3110 - _globals['_PROCESS']._serialized_start=3112 - _globals['_PROCESS']._serialized_end=3169 - _globals['_AGENTCONVERSATIONTARGET']._serialized_start=3172 - _globals['_AGENTCONVERSATIONTARGET']._serialized_end=3335 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=3338 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=3695 - _globals['_AGENTCONVERSATIONRESULT']._serialized_start=3698 - _globals['_AGENTCONVERSATIONRESULT']._serialized_end=4085 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2224 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2271 - _globals['_FINDING']._serialized_start=4087 - _globals['_FINDING']._serialized_end=4178 - _globals['_WRITEHEADER']._serialized_start=4180 - _globals['_WRITEHEADER']._serialized_end=4290 - _globals['_REMOVEHEADER']._serialized_start=4292 - _globals['_REMOVEHEADER']._serialized_end=4320 - _globals['_HEADERMUTATION']._serialized_start=4323 - _globals['_HEADERMUTATION']._serialized_end=4464 - _globals['_HTTPREQUESTRESULT']._serialized_start=4467 - _globals['_HTTPREQUESTRESULT']._serialized_end=4852 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2224 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2271 - _globals['_SUPERVISORMIDDLEWARE']._serialized_start=6334 - _globals['_SUPERVISORMIDDLEWARE']._serialized_end=6936 + _globals['_HTTPRESPONSEEVENT']._serialized_start=1093 + _globals['_HTTPRESPONSEEVENT']._serialized_end=1390 + _globals['_HTTPRESPONSEEVENTRESULT']._serialized_start=1393 + _globals['_HTTPRESPONSEEVENTRESULT']._serialized_end=1662 + _globals['_HTTPRESPONSEPREFLIGHT']._serialized_start=1665 + _globals['_HTTPRESPONSEPREFLIGHT']._serialized_end=2051 + _globals['_HTTPRESPONSEPREFLIGHTRESULT']._serialized_start=2054 + _globals['_HTTPRESPONSEPREFLIGHTRESULT']._serialized_end=2537 + _globals['_HTTPRESPONSEPREFLIGHTRESULT_METADATAENTRY']._serialized_start=2480 + _globals['_HTTPRESPONSEPREFLIGHTRESULT_METADATAENTRY']._serialized_end=2527 + _globals['_HTTPRESPONSEPREFLIGHTSKIP']._serialized_start=2539 + _globals['_HTTPRESPONSEPREFLIGHTSKIP']._serialized_end=2566 + _globals['_HTTPRESPONSEBLOCKDELIVERY']._serialized_start=2568 + _globals['_HTTPRESPONSEBLOCKDELIVERY']._serialized_end=2595 + _globals['_HTTPRESPONSEPREFLIGHTINSPECT']._serialized_start=2598 + _globals['_HTTPRESPONSEPREFLIGHTINSPECT']._serialized_end=2761 + _globals['_HTTPRESPONSEBODYUNIT']._serialized_start=2763 + _globals['_HTTPRESPONSEBODYUNIT']._serialized_end=2853 + _globals['_HTTPRESPONSEBODYRESULT']._serialized_start=2856 + _globals['_HTTPRESPONSEBODYRESULT']._serialized_end=3438 + _globals['_HTTPRESPONSEBODYRESULT_METADATAENTRY']._serialized_start=2480 + _globals['_HTTPRESPONSEBODYRESULT_METADATAENTRY']._serialized_end=2527 + _globals['_HTTPRESPONSEBODYPASSTHROUGH']._serialized_start=3440 + _globals['_HTTPRESPONSEBODYPASSTHROUGH']._serialized_end=3469 + _globals['_HTTPRESPONSEBODYSKIPREMAINING']._serialized_start=3472 + _globals['_HTTPRESPONSEBODYSKIPREMAINING']._serialized_end=3665 + _globals['_HTTPRESPONSEBODYTRANSFORM']._serialized_start=3667 + _globals['_HTTPRESPONSEBODYTRANSFORM']._serialized_end=3725 + _globals['_HTTPRESPONSETRAILERS']._serialized_start=3727 + _globals['_HTTPRESPONSETRAILERS']._serialized_end=3803 + _globals['_HTTPRESPONSETRAILERSRESULT']._serialized_start=3806 + _globals['_HTTPRESPONSETRAILERSRESULT']._serialized_end=4125 + _globals['_HTTPRESPONSETRAILERSRESULT_METADATAENTRY']._serialized_start=2480 + _globals['_HTTPRESPONSETRAILERSRESULT_METADATAENTRY']._serialized_end=2527 + _globals['_MIDDLEWARESESSIONEND']._serialized_start=4128 + _globals['_MIDDLEWARESESSIONEND']._serialized_end=4300 + _globals['_MIDDLEWARESESSIONPROTOCOLERROR']._serialized_start=4303 + _globals['_MIDDLEWARESESSIONPROTOCOLERROR']._serialized_end=4505 + _globals['_WEBSOCKETPROTOCOLERROR']._serialized_start=4507 + _globals['_WEBSOCKETPROTOCOLERROR']._serialized_end=4531 + _globals['_MIDDLEWAREEXCHANGEPROTOCOLERROR']._serialized_start=4533 + _globals['_MIDDLEWAREEXCHANGEPROTOCOLERROR']._serialized_end=4566 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=4569 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=4872 + _globals['_WEBSOCKETPREFLIGHT']._serialized_start=4875 + _globals['_WEBSOCKETPREFLIGHT']._serialized_end=5198 + _globals['_WEBSOCKETSESSIONSTART']._serialized_start=5200 + _globals['_WEBSOCKETSESSIONSTART']._serialized_end=5253 + _globals['_WEBSOCKETMESSAGE']._serialized_start=5255 + _globals['_WEBSOCKETMESSAGE']._serialized_end=5336 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=5339 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=5657 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2480 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2527 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=5660 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=6023 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2480 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2527 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=6026 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=6223 + _globals['_REQUESTCONTEXT']._serialized_start=6226 + _globals['_REQUESTCONTEXT']._serialized_end=6386 + _globals['_HTTPREQUESTTARGET']._serialized_start=6388 + _globals['_HTTPREQUESTTARGET']._serialized_end=6496 + _globals['_PROCESS']._serialized_start=6498 + _globals['_PROCESS']._serialized_end=6555 + _globals['_AGENTCONVERSATIONTARGET']._serialized_start=6558 + _globals['_AGENTCONVERSATIONTARGET']._serialized_end=6721 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=6724 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=7081 + _globals['_AGENTCONVERSATIONRESULT']._serialized_start=7084 + _globals['_AGENTCONVERSATIONRESULT']._serialized_end=7471 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2480 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2527 + _globals['_FINDING']._serialized_start=7473 + _globals['_FINDING']._serialized_end=7564 + _globals['_WRITEHEADER']._serialized_start=7566 + _globals['_WRITEHEADER']._serialized_end=7676 + _globals['_REMOVEHEADER']._serialized_start=7678 + _globals['_REMOVEHEADER']._serialized_end=7706 + _globals['_HEADERMUTATION']._serialized_start=7709 + _globals['_HEADERMUTATION']._serialized_end=7850 + _globals['_HTTPREQUESTRESULT']._serialized_start=7853 + _globals['_HTTPREQUESTRESULT']._serialized_end=8238 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2480 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2527 + _globals['_SUPERVISORMIDDLEWARE']._serialized_start=10022 + _globals['_SUPERVISORMIDDLEWARE']._serialized_end=10624 + _globals['_HTTPRESPONSEPRERETURN']._serialized_start=10627 + _globals['_HTTPRESPONSEPRERETURN']._serialized_end=10760 # @@protoc_insertion_point(module_scope) diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi index 9549a7f1..7e4c4d3c 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi @@ -9,11 +9,34 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor +class HttpResponseBodyMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + HTTP_RESPONSE_BODY_MODE_UNSPECIFIED: _ClassVar[HttpResponseBodyMode] + HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY: _ClassVar[HttpResponseBodyMode] + HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES: _ClassVar[HttpResponseBodyMode] + HTTP_RESPONSE_BODY_MODE_STREAM_BYTES: _ClassVar[HttpResponseBodyMode] + +class MiddlewareSessionEndReason(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED: _ClassVar[MiddlewareSessionEndReason] + MIDDLEWARE_SESSION_END_REASON_NORMAL: _ClassVar[MiddlewareSessionEndReason] + MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT: _ClassVar[MiddlewareSessionEndReason] + MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD: _ClassVar[MiddlewareSessionEndReason] + MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL: _ClassVar[MiddlewareSessionEndReason] + MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE: _ClassVar[MiddlewareSessionEndReason] + MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR: _ClassVar[MiddlewareSessionEndReason] + MIDDLEWARE_SESSION_END_REASON_CANCELLATION: _ClassVar[MiddlewareSessionEndReason] + MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE: _ClassVar[MiddlewareSessionEndReason] + MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL: _ClassVar[MiddlewareSessionEndReason] + MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED: _ClassVar[MiddlewareSessionEndReason] + MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT: _ClassVar[MiddlewareSessionEndReason] + class SupervisorMiddlewareOperation(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: _ClassVar[SupervisorMiddlewareOperation] + SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: _ClassVar[SupervisorMiddlewareOperation] class SupervisorMiddlewarePhase(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): @@ -23,20 +46,6 @@ class SupervisorMiddlewarePhase(int, metaclass=_enum_type_wrapper.EnumTypeWrappe SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: _ClassVar[SupervisorMiddlewarePhase] SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: _ClassVar[SupervisorMiddlewarePhase] -class WebSocketSessionEndReason(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: _ClassVar[WebSocketSessionEndReason] - WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: _ClassVar[WebSocketSessionEndReason] - WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: _ClassVar[WebSocketSessionEndReason] - WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: _ClassVar[WebSocketSessionEndReason] - WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: _ClassVar[WebSocketSessionEndReason] - WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: _ClassVar[WebSocketSessionEndReason] - WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: _ClassVar[WebSocketSessionEndReason] - WEB_SOCKET_SESSION_END_REASON_CANCELLATION: _ClassVar[WebSocketSessionEndReason] - WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: _ClassVar[WebSocketSessionEndReason] - WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: _ClassVar[WebSocketSessionEndReason] - WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: _ClassVar[WebSocketSessionEndReason] - class WebSocketPreflightAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED: _ClassVar[WebSocketPreflightAction] @@ -56,25 +65,31 @@ class ExistingHeaderAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): EXISTING_HEADER_ACTION_APPEND: _ClassVar[ExistingHeaderAction] EXISTING_HEADER_ACTION_OVERWRITE: _ClassVar[ExistingHeaderAction] EXISTING_HEADER_ACTION_SKIP: _ClassVar[ExistingHeaderAction] +HTTP_RESPONSE_BODY_MODE_UNSPECIFIED: HttpResponseBodyMode +HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY: HttpResponseBodyMode +HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES: HttpResponseBodyMode +HTTP_RESPONSE_BODY_MODE_STREAM_BYTES: HttpResponseBodyMode +MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED: MiddlewareSessionEndReason +MIDDLEWARE_SESSION_END_REASON_NORMAL: MiddlewareSessionEndReason +MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT: MiddlewareSessionEndReason +MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD: MiddlewareSessionEndReason +MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL: MiddlewareSessionEndReason +MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE: MiddlewareSessionEndReason +MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR: MiddlewareSessionEndReason +MIDDLEWARE_SESSION_END_REASON_CANCELLATION: MiddlewareSessionEndReason +MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE: MiddlewareSessionEndReason +MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL: MiddlewareSessionEndReason +MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED: MiddlewareSessionEndReason +MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT: MiddlewareSessionEndReason SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: SupervisorMiddlewareOperation +SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: SupervisorMiddlewarePhase -WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: WebSocketSessionEndReason -WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: WebSocketSessionEndReason -WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: WebSocketSessionEndReason -WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: WebSocketSessionEndReason -WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: WebSocketSessionEndReason -WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: WebSocketSessionEndReason -WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: WebSocketSessionEndReason -WEB_SOCKET_SESSION_END_REASON_CANCELLATION: WebSocketSessionEndReason -WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: WebSocketSessionEndReason -WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: WebSocketSessionEndReason -WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: WebSocketSessionEndReason WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED: WebSocketPreflightAction WEB_SOCKET_PREFLIGHT_ACTION_INSPECT: WebSocketPreflightAction WEB_SOCKET_PREFLIGHT_ACTION_SKIP: WebSocketPreflightAction @@ -161,6 +176,197 @@ class HttpHeader(_message.Message): value: str def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... +class HttpResponseEvent(_message.Message): + __slots__ = ("preflight", "body", "trailers", "session_end") + PREFLIGHT_FIELD_NUMBER: _ClassVar[int] + BODY_FIELD_NUMBER: _ClassVar[int] + TRAILERS_FIELD_NUMBER: _ClassVar[int] + SESSION_END_FIELD_NUMBER: _ClassVar[int] + preflight: HttpResponsePreflight + body: HttpResponseBodyUnit + trailers: HttpResponseTrailers + session_end: MiddlewareSessionEnd + def __init__(self, preflight: _Optional[_Union[HttpResponsePreflight, _Mapping]] = ..., body: _Optional[_Union[HttpResponseBodyUnit, _Mapping]] = ..., trailers: _Optional[_Union[HttpResponseTrailers, _Mapping]] = ..., session_end: _Optional[_Union[MiddlewareSessionEnd, _Mapping]] = ...) -> None: ... + +class HttpResponseEventResult(_message.Message): + __slots__ = ("preflight_result", "body_result", "trailers_result") + PREFLIGHT_RESULT_FIELD_NUMBER: _ClassVar[int] + BODY_RESULT_FIELD_NUMBER: _ClassVar[int] + TRAILERS_RESULT_FIELD_NUMBER: _ClassVar[int] + preflight_result: HttpResponsePreflightResult + body_result: HttpResponseBodyResult + trailers_result: HttpResponseTrailersResult + def __init__(self, preflight_result: _Optional[_Union[HttpResponsePreflightResult, _Mapping]] = ..., body_result: _Optional[_Union[HttpResponseBodyResult, _Mapping]] = ..., trailers_result: _Optional[_Union[HttpResponseTrailersResult, _Mapping]] = ...) -> None: ... + +class HttpResponsePreflight(_message.Message): + __slots__ = ("context", "target", "status_code", "headers", "middleware_name", "config", "max_payload_bytes", "permitted_body_modes") + CONTEXT_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + STATUS_CODE_FIELD_NUMBER: _ClassVar[int] + HEADERS_FIELD_NUMBER: _ClassVar[int] + MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + CONFIG_FIELD_NUMBER: _ClassVar[int] + MAX_PAYLOAD_BYTES_FIELD_NUMBER: _ClassVar[int] + PERMITTED_BODY_MODES_FIELD_NUMBER: _ClassVar[int] + context: RequestContext + target: HttpRequestTarget + status_code: int + headers: _containers.RepeatedCompositeFieldContainer[HttpHeader] + middleware_name: str + config: _struct_pb2.Struct + max_payload_bytes: int + permitted_body_modes: _containers.RepeatedScalarFieldContainer[HttpResponseBodyMode] + def __init__(self, context: _Optional[_Union[RequestContext, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., status_code: _Optional[int] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., middleware_name: _Optional[str] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., max_payload_bytes: _Optional[int] = ..., permitted_body_modes: _Optional[_Iterable[_Union[HttpResponseBodyMode, str]]] = ...) -> None: ... + +class HttpResponsePreflightResult(_message.Message): + __slots__ = ("skip", "inspect", "block_delivery", "reason", "reason_code", "findings", "metadata") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + SKIP_FIELD_NUMBER: _ClassVar[int] + INSPECT_FIELD_NUMBER: _ClassVar[int] + BLOCK_DELIVERY_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + skip: HttpResponsePreflightSkip + inspect: HttpResponsePreflightInspect + block_delivery: HttpResponseBlockDelivery + reason: str + reason_code: str + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + def __init__(self, skip: _Optional[_Union[HttpResponsePreflightSkip, _Mapping]] = ..., inspect: _Optional[_Union[HttpResponsePreflightInspect, _Mapping]] = ..., block_delivery: _Optional[_Union[HttpResponseBlockDelivery, _Mapping]] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class HttpResponsePreflightSkip(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class HttpResponseBlockDelivery(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class HttpResponsePreflightInspect(_message.Message): + __slots__ = ("body_mode", "header_mutations") + BODY_MODE_FIELD_NUMBER: _ClassVar[int] + HEADER_MUTATIONS_FIELD_NUMBER: _ClassVar[int] + body_mode: HttpResponseBodyMode + header_mutations: _containers.RepeatedCompositeFieldContainer[HeaderMutation] + def __init__(self, body_mode: _Optional[_Union[HttpResponseBodyMode, str]] = ..., header_mutations: _Optional[_Iterable[_Union[HeaderMutation, _Mapping]]] = ...) -> None: ... + +class HttpResponseBodyUnit(_message.Message): + __slots__ = ("sequence", "data", "end_of_stream") + SEQUENCE_FIELD_NUMBER: _ClassVar[int] + DATA_FIELD_NUMBER: _ClassVar[int] + END_OF_STREAM_FIELD_NUMBER: _ClassVar[int] + sequence: int + data: bytes + end_of_stream: bool + def __init__(self, sequence: _Optional[int] = ..., data: _Optional[bytes] = ..., end_of_stream: _Optional[bool] = ...) -> None: ... + +class HttpResponseBodyResult(_message.Message): + __slots__ = ("sequence", "pass_through", "transform", "block_delivery", "skip_remaining", "reason", "reason_code", "findings", "metadata") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + SEQUENCE_FIELD_NUMBER: _ClassVar[int] + PASS_THROUGH_FIELD_NUMBER: _ClassVar[int] + TRANSFORM_FIELD_NUMBER: _ClassVar[int] + BLOCK_DELIVERY_FIELD_NUMBER: _ClassVar[int] + SKIP_REMAINING_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + sequence: int + pass_through: HttpResponseBodyPassThrough + transform: HttpResponseBodyTransform + block_delivery: HttpResponseBlockDelivery + skip_remaining: HttpResponseBodySkipRemaining + reason: str + reason_code: str + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + def __init__(self, sequence: _Optional[int] = ..., pass_through: _Optional[_Union[HttpResponseBodyPassThrough, _Mapping]] = ..., transform: _Optional[_Union[HttpResponseBodyTransform, _Mapping]] = ..., block_delivery: _Optional[_Union[HttpResponseBlockDelivery, _Mapping]] = ..., skip_remaining: _Optional[_Union[HttpResponseBodySkipRemaining, _Mapping]] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class HttpResponseBodyPassThrough(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class HttpResponseBodySkipRemaining(_message.Message): + __slots__ = ("pass_through", "transform") + PASS_THROUGH_FIELD_NUMBER: _ClassVar[int] + TRANSFORM_FIELD_NUMBER: _ClassVar[int] + pass_through: HttpResponseBodyPassThrough + transform: HttpResponseBodyTransform + def __init__(self, pass_through: _Optional[_Union[HttpResponseBodyPassThrough, _Mapping]] = ..., transform: _Optional[_Union[HttpResponseBodyTransform, _Mapping]] = ...) -> None: ... + +class HttpResponseBodyTransform(_message.Message): + __slots__ = ("data",) + DATA_FIELD_NUMBER: _ClassVar[int] + data: bytes + def __init__(self, data: _Optional[bytes] = ...) -> None: ... + +class HttpResponseTrailers(_message.Message): + __slots__ = ("headers",) + HEADERS_FIELD_NUMBER: _ClassVar[int] + headers: _containers.RepeatedCompositeFieldContainer[HttpHeader] + def __init__(self, headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ...) -> None: ... + +class HttpResponseTrailersResult(_message.Message): + __slots__ = ("trailer_mutations", "reason", "reason_code", "findings", "metadata") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + TRAILER_MUTATIONS_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + trailer_mutations: _containers.RepeatedCompositeFieldContainer[HeaderMutation] + reason: str + reason_code: str + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + def __init__(self, trailer_mutations: _Optional[_Iterable[_Union[HeaderMutation, _Mapping]]] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class MiddlewareSessionEnd(_message.Message): + __slots__ = ("reason", "protocol_error") + REASON_FIELD_NUMBER: _ClassVar[int] + PROTOCOL_ERROR_FIELD_NUMBER: _ClassVar[int] + reason: MiddlewareSessionEndReason + protocol_error: MiddlewareSessionProtocolError + def __init__(self, reason: _Optional[_Union[MiddlewareSessionEndReason, str]] = ..., protocol_error: _Optional[_Union[MiddlewareSessionProtocolError, _Mapping]] = ...) -> None: ... + +class MiddlewareSessionProtocolError(_message.Message): + __slots__ = ("web_socket", "middleware_exchange") + WEB_SOCKET_FIELD_NUMBER: _ClassVar[int] + MIDDLEWARE_EXCHANGE_FIELD_NUMBER: _ClassVar[int] + web_socket: WebSocketProtocolError + middleware_exchange: MiddlewareExchangeProtocolError + def __init__(self, web_socket: _Optional[_Union[WebSocketProtocolError, _Mapping]] = ..., middleware_exchange: _Optional[_Union[MiddlewareExchangeProtocolError, _Mapping]] = ...) -> None: ... + +class WebSocketProtocolError(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + +class MiddlewareExchangeProtocolError(_message.Message): + __slots__ = () + def __init__(self) -> None: ... + class WebSocketSessionEvent(_message.Message): __slots__ = ("preflight", "session_start", "message", "session_end") PREFLIGHT_FIELD_NUMBER: _ClassVar[int] @@ -170,8 +376,8 @@ class WebSocketSessionEvent(_message.Message): preflight: WebSocketPreflight session_start: WebSocketSessionStart message: WebSocketMessage - session_end: WebSocketSessionEnd - def __init__(self, preflight: _Optional[_Union[WebSocketPreflight, _Mapping]] = ..., session_start: _Optional[_Union[WebSocketSessionStart, _Mapping]] = ..., message: _Optional[_Union[WebSocketMessage, _Mapping]] = ..., session_end: _Optional[_Union[WebSocketSessionEnd, _Mapping]] = ...) -> None: ... + session_end: MiddlewareSessionEnd + def __init__(self, preflight: _Optional[_Union[WebSocketPreflight, _Mapping]] = ..., session_start: _Optional[_Union[WebSocketSessionStart, _Mapping]] = ..., message: _Optional[_Union[WebSocketMessage, _Mapping]] = ..., session_end: _Optional[_Union[MiddlewareSessionEnd, _Mapping]] = ...) -> None: ... class WebSocketPreflight(_message.Message): __slots__ = ("session_id", "phase", "context", "target", "requested_subprotocols", "middleware_name", "config") @@ -207,12 +413,6 @@ class WebSocketMessage(_message.Message): binary: bytes def __init__(self, sequence: _Optional[int] = ..., text: _Optional[str] = ..., binary: _Optional[bytes] = ...) -> None: ... -class WebSocketSessionEnd(_message.Message): - __slots__ = ("reason",) - REASON_FIELD_NUMBER: _ClassVar[int] - reason: WebSocketSessionEndReason - def __init__(self, reason: _Optional[_Union[WebSocketSessionEndReason, str]] = ...) -> None: ... - class WebSocketPreflightDecision(_message.Message): __slots__ = ("action", "reason", "reason_code", "findings", "metadata") class MetadataEntry(_message.Message): diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py index e3421f1c..a298fbcd 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py @@ -27,9 +27,10 @@ class SupervisorMiddlewareStub: - """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP requests and client WebSocket text messages before OpenShell - injects credentials, or evaluate a supported agent-harness request. + """SupervisorMiddleware discovers and configures one operator-run middleware. + It evaluates HTTP requests, HTTP responses, WebSocket messages, and supported + agent-harness requests at their declared phases. + Phase-specific services share the same registration. """ def __init__(self, channel): @@ -66,9 +67,10 @@ def __init__(self, channel): class SupervisorMiddlewareServicer: - """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP requests and client WebSocket text messages before OpenShell - injects credentials, or evaluate a supported agent-harness request. + """SupervisorMiddleware discovers and configures one operator-run middleware. + It evaluates HTTP requests, HTTP responses, WebSocket messages, and supported + agent-harness requests at their declared phases. + Phase-specific services share the same registration. """ def Describe(self, request, context): @@ -151,9 +153,10 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class SupervisorMiddleware: - """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP requests and client WebSocket text messages before OpenShell - injects credentials, or evaluate a supported agent-harness request. + """SupervisorMiddleware discovers and configures one operator-run middleware. + It evaluates HTTP requests, HTTP responses, WebSocket messages, and supported + agent-harness requests at their declared phases. + Phase-specific services share the same registration. """ @staticmethod @@ -183,6 +186,87 @@ def Describe(request, metadata, _registered_method=True) + +class HttpResponsePreReturnStub: + """HttpResponsePreReturn evaluates one response for one middleware stage before + OpenShell returns it to the sandbox. + """ + + def __init__(self, channel): + """Constructor. + + Args: + channel: A grpc.Channel. + """ + self.Evaluate = channel.stream_stream( + '/openshell.middleware.v1.HttpResponsePreReturn/Evaluate', + request_serializer=supervisor__middleware__pb2.HttpResponseEvent.SerializeToString, + response_deserializer=supervisor__middleware__pb2.HttpResponseEventResult.FromString, + _registered_method=True) + + +class HttpResponsePreReturnServicer: + """HttpResponsePreReturn evaluates one response for one middleware stage before + OpenShell returns it to the sandbox. + """ + + def Evaluate(self, request_iterator, context): + """Evaluate starts with preflight and may continue with selected body units + and trailers. A body unit marked end_of_stream ends body inspection, not + the event stream. Trailers and one best-effort session_end may follow. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + + +def add_HttpResponsePreReturnServicer_to_server(servicer, server): + rpc_method_handlers = { + 'Evaluate': grpc.stream_stream_rpc_method_handler( + servicer.Evaluate, + request_deserializer=supervisor__middleware__pb2.HttpResponseEvent.FromString, + response_serializer=supervisor__middleware__pb2.HttpResponseEventResult.SerializeToString, + ), + } + generic_handler = grpc.method_handlers_generic_handler( + 'openshell.middleware.v1.HttpResponsePreReturn', rpc_method_handlers) + server.add_generic_rpc_handlers((generic_handler,)) + server.add_registered_method_handlers('openshell.middleware.v1.HttpResponsePreReturn', rpc_method_handlers) + + + # This class is part of an EXPERIMENTAL API. +class HttpResponsePreReturn: + """HttpResponsePreReturn evaluates one response for one middleware stage before + OpenShell returns it to the sandbox. + """ + + @staticmethod + def Evaluate(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream( + request_iterator, + target, + '/openshell.middleware.v1.HttpResponsePreReturn/Evaluate', + supervisor__middleware__pb2.HttpResponseEvent.SerializeToString, + supervisor__middleware__pb2.HttpResponseEventResult.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) + @staticmethod def ValidateConfig(request, target, From 68ba769e6d6fd9d0c3be6b73f3ac503972192574 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 17:55:16 +0000 Subject: [PATCH 50/70] feat(egress-gate): use upstream middleware with authenticated admission HTTP --- .../proto/supervisor_middleware.proto | 440 ++---------- projects/egress-gate/pyproject.toml | 2 + .../egress-gate/scripts/generate-bindings.sh | 19 + .../src/egress_gate/admission/adapters.py | 3 +- .../src/egress_gate/admission/models.py | 1 + .../src/egress_gate/admission/processor.py | 50 +- .../src/egress_gate/admission/receipts.py | 4 +- .../bindings/supervisor_middleware_pb2.py | 196 ++---- .../bindings/supervisor_middleware_pb2.pyi | 353 +--------- .../supervisor_middleware_pb2_grpc.py | 193 +----- projects/egress-gate/src/egress_gate/cli.py | 23 +- .../src/egress_gate/service/admission.py | 137 ++++ .../src/egress_gate/service/authentication.py | 51 ++ .../src/egress_gate/service/server.py | 78 ++- .../src/egress_gate/service/servicer.py | 128 +--- .../tests/admission/test_admission.py | 23 +- .../tests/service/test_grpc_integration.py | 134 +--- .../tests/service/test_http_admission.py | 262 +++++++ .../egress-gate/tests/service/test_server.py | 4 +- .../tests/service/test_servicer.py | 40 +- projects/egress-gate/tests/test_cli.py | 10 +- projects/egress-gate/uv.lock | 640 ++++++++++++++++++ 22 files changed, 1478 insertions(+), 1313 deletions(-) create mode 100755 projects/egress-gate/scripts/generate-bindings.sh create mode 100644 projects/egress-gate/src/egress_gate/service/admission.py create mode 100644 projects/egress-gate/src/egress_gate/service/authentication.py create mode 100644 projects/egress-gate/tests/service/test_http_admission.py diff --git a/projects/egress-gate/proto/supervisor_middleware.proto b/projects/egress-gate/proto/supervisor_middleware.proto index 79ff3dbc..27fd804b 100644 --- a/projects/egress-gate/proto/supervisor_middleware.proto +++ b/projects/egress-gate/proto/supervisor_middleware.proto @@ -8,10 +8,9 @@ package openshell.middleware.v1; import "google/protobuf/empty.proto"; import "google/protobuf/struct.proto"; -// SupervisorMiddleware discovers and configures one operator-run middleware. -// It evaluates HTTP requests, HTTP responses, WebSocket messages, and supported -// agent-harness requests at their declared phases. -// Phase-specific services share the same registration. +// SupervisorMiddleware lets an operator-run service inspect and transform +// sandbox HTTP requests and client WebSocket text messages before OpenShell +// injects credentials. service SupervisorMiddleware { // Describe returns the service manifest and declared bindings. rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); @@ -23,10 +22,6 @@ service SupervisorMiddleware { // buffered HTTP request. rpc EvaluateHttpRequest(HttpRequestEvaluation) returns (HttpRequestResult); - // EvaluateAgentConversation returns an allow, deny, or replacement decision for - // one versioned, harness-native request before the harness commits or sends it. - rpc EvaluateAgentConversation(AgentConversationEvaluation) returns (AgentConversationResult); - // EvaluateWebSocketSession opens one ordered, phase-specific stream for a // single middleware stage and WebSocket upgrade attempt. The current // implementation supports client-to-upstream text messages at @@ -38,17 +33,9 @@ service SupervisorMiddleware { returns (stream WebSocketSessionEventResult); } -// HttpResponsePreReturn evaluates one response for one middleware stage before -// OpenShell returns it to the sandbox. -service HttpResponsePreReturn { - // Evaluate starts with preflight and may continue with selected body units - // and trailers. A body unit marked end_of_stream ends body inspection, not - // the event stream. Trailers and one best-effort session_end may follow. - rpc Evaluate(stream HttpResponseEvent) - returns (stream HttpResponseEventResult); -} - -// MiddlewareManifest describes one middleware service and its bindings. +// MiddlewareManifest describes one middleware service and the bindings it +// exposes. The service is the operator-run gRPC server implementing +// SupervisorMiddleware. message MiddlewareManifest { // Human-readable middleware service name used only for diagnostics. This is // not required to match an operator-owned registration name. @@ -70,11 +57,13 @@ message MiddlewareManifest { message MiddlewareBinding { // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported phase. + // Supported evaluation phase. PR 1 supports PRE_CREDENTIALS. PRE_RETURN is + // reserved for the return-path follow-up and is rejected by current + // manifest validation. SupervisorMiddlewarePhase phase = 2; - // Maximum request body, agent request body, WebSocket message, or response - // body unit/replacement. - // Required for payload-bearing operations. + // Maximum logical payload or replacement this binding can process. For + // HTTP_REQUEST this is the request body; for WEBSOCKET_MESSAGE this is one + // complete message. Required for every payload-bearing operation. uint64 max_payload_bytes = 3; // Optional binding-specific RPC timeout. Empty uses the operator-configured // service timeout, or the 500ms platform default when that is also omitted. @@ -82,12 +71,6 @@ message MiddlewareBinding { // Values use an integer with an `ms` or `s` suffix and must be between // 10ms and 30s. string timeout = 4; - // Agent harness supported by an AGENT_CONVERSATION binding. Empty otherwise. - string harness = 5; - // Harness hook supported by an AGENT_CONVERSATION binding. Empty otherwise. - string hook = 6; - // Version of the harness-native request schema. Empty otherwise. - string schema_version = 7; } // ValidateConfigRequest contains one policy configuration to validate. @@ -128,12 +111,9 @@ message HttpRequestEvaluation { bytes body = 6; // Built-in middleware name or operator-owned registration name. string middleware_name = 7; - // Supervisor-resolved agent attestation for this middleware stage. The - // workload cannot set or observe these bytes. Limited to 8 KiB. - bytes agent_attestation = 8; } -// HttpHeader is one HTTP header line. +// HttpHeader is one request header line. message HttpHeader { // Lowercased header name. string name = 1; @@ -141,328 +121,11 @@ message HttpHeader { string value = 2; } -// One ordered response event. A stream starts with preflight, may continue with -// body units ending in end_of_stream, may then include trailers, and may end -// with one best-effort session_end. -message HttpResponseEvent { - oneof event { - // Initial response head and request context. - HttpResponsePreflight preflight = 1; - // Next normalized body unit. - HttpResponseBodyUnit body = 2; - // Normalized trailers after the final body result. - HttpResponseTrailers trailers = 4; - // Optional terminal notification. - MiddlewareSessionEnd session_end = 3; - } -} - -// Each preflight, body, and trailers event requires one ordered result. -// session_end has no result. -message HttpResponseEventResult { - oneof result { - // Result for preflight. - HttpResponsePreflightResult preflight_result = 1; - // Result for the next body unit. - HttpResponseBodyResult body_result = 2; - // Result for response trailers. - HttpResponseTrailersResult trailers_result = 3; - } -} - -// HttpResponsePreflight exposes the current final response head to one stage. -message HttpResponsePreflight { - // Request identity. request_id links request and response evaluations. - // Limited to 4 KiB encoded. - RequestContext context = 1; - // Admitted request target with a redacted query. Limited to 32 KiB encoded. - HttpRequestTarget target = 2; - // Final non-informational upstream status. Upgrades are not evaluated. - uint32 status_code = 3; - // Response headers after prior stages, in wire order. Repeated names remain - // separate. Credential, routing, and hop-by-hop headers are omitted. - // Content-Length, Content-Encoding, and Content-Range retain their read-only - // upstream values. OpenShell may recompute or remove Content-Length later. - // Limited to 128 lines and 64 KiB encoded. - repeated HttpHeader headers = 4; - // Built-in middleware name or operator-owned registration name. - string middleware_name = 5; - // Validated service configuration. Limited to 64 KiB encoded. - google.protobuf.Struct config = 6; - // Effective minimum of platform, registration, and binding limits. Applies to - // whole-body input/replacement and each stream input/replacement. Stream - // inputs use at most min(64 KiB, max_payload_bytes). - uint64 max_payload_bytes = 7; - // Modes derived independently for this stage. OpenShell first determines - // response-shape eligibility from the original final response head, then - // applies this stage's effective max_payload_bytes. Different stages may - // receive different lists. HEADERS_ONLY is always present and is the only - // mode for bodyless, partial, encoded, or no-transform responses. For an - // otherwise eligible response, a known body larger than this stage's limit - // omits WHOLE_BODY_BYTES. An eligible unknown-length response may select - // WHOLE_BODY_BYTES and later fail with whole_body_over_capacity according to - // this stage's on_error. STREAM_BYTES is omitted when - // max_payload_bytes is zero. Selecting an unlisted mode fails according to - // on_error. - repeated HttpResponseBodyMode permitted_body_modes = 8; -} - -// Selects skip, inspect, or block. Diagnostic fields apply to every action. -// Invalid diagnostics make the entire result a middleware failure handled -// according to on_error. -message HttpResponsePreflightResult { - oneof action { - // Deliver unchanged without invoking on_error. - HttpResponsePreflightSkip skip = 1; - // Inspect with the selected body mode and mutations. - HttpResponsePreflightInspect inspect = 2; - // Prevent delivery to the sandbox. - HttpResponseBlockDelivery block_delivery = 7; - } - // Service diagnostic, never sent to the sandbox or security logs. Maximum - // 4 KiB. - string reason = 3; - // Optional audit code using the HttpRequestResult.reason_code format and - // 64-byte maximum. Returned to the sandbox only for block_delivery. - string reason_code = 4; - // Up to 32 audit-safe findings, each limited to 4 KiB encoded. - repeated Finding findings = 5; - // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. - map metadata = 6; -} - -// Ends this stage successfully without body inspection. -message HttpResponsePreflightSkip {} - -// Blocks delivery as a successful decision regardless of on_error. OpenShell -// evaluates results in policy order. Once it accepts a valid block, it stops -// later middleware evaluation and ends every still-writable opened stage with -// MIDDLEWARE_DENIAL. A failure handled earlier may already have stopped -// evaluation, so a later block does not override it. An invalid block result -// is a middleware failure handled according to on_error. The upstream request -// has already run; blocking its response does not reject or roll back that -// request. -// -// Before response commitment, including at preflight and during -// WHOLE_BODY_BYTES, OpenShell replaces the upstream response with the canonical -// 403 Forbidden middleware-denial response. Its JSON body has -// error = "middleware_denied" and includes a validated reason_code when the -// result supplies one. OpenShell never returns the free-form reason or writes it -// to security logs. For HEAD, OpenShell sends the canonical response headers -// and Content-Length but no body. It closes the downstream connection after the -// denial response. -// -// After response commitment, including during STREAM_BYTES, OpenShell aborts -// downstream delivery. It does not inject an error body, a terminating chunk, -// or an error trailer. OpenShell does not reuse the upstream connection. -message HttpResponseBlockDelivery {} - -// Selects body inspection and response-header mutations. -message HttpResponsePreflightInspect { - // Required mode from permitted_body_modes. Invalid values fail according to - // on_error. - HttpResponseBodyMode body_mode = 1; - // Ordered mutations applied atomically before the next stage. Only visible - // end-to-end headers may change. Routing, credential, framing, coding, range, - // and hop-by-hop headers are protected; integrity headers may only be removed. - // Limited to 64 operations, 32 KiB of name/value data, and 64 KiB encoded. - repeated HeaderMutation header_mutations = 2; -} - -// Controls which response-body units a stage receives. -enum HttpResponseBodyMode { - // Invalid value handled according to on_error. - HTTP_RESPONSE_BODY_MODE_UNSPECIFIED = 0; - // Inspect only the response head. - HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY = 1; - // Buffer the normalized body as one final unit before committing the head. - // Input and replacement must fit max_payload_bytes. Capacity failures use - // whole_body_over_capacity and follow this stage's on_error. - HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES = 2; - // Receive normalized units ending with end_of_stream. Each input is at most - // min(64 KiB, max_payload_bytes), and each replacement must fit - // max_payload_bytes. Each result fully accounts for its input unit; V1 does - // not permit retaining input across units. The full body may exceed the - // limit. STREAM_BYTES has no total response-lifetime deadline. - HTTP_RESPONSE_BODY_MODE_STREAM_BYTES = 3; -} - -// One normalized body unit. Boundaries have no transport or application -// meaning. -message HttpResponseBodyUnit { - // Contiguous and stage-local, starting at 1. - uint64 sequence = 1; - oneof payload { - // Bytes without transfer framing. A body-capable response with no body bytes - // has present empty data in sequence 1. STREAM_BYTES input size is at most - // min(64 KiB, max_payload_bytes). A unit may be shorter to preserve - // flushing. - bytes data = 2; - } - // Marks the final body unit. Every normally completed body inspection receives - // exactly one. For a body-capable response with no body bytes, this is the - // empty sequence-1 unit. OpenShell does not read ahead, so it may send an empty - // final unit after the last nonempty unit. Trailers and session_end may - // follow. A stage ended by skip_remaining, block, or failure receives no - // later final unit. - bool end_of_stream = 3; -} - -// Result for one body unit. Units are processed in lockstep; V1 does not -// support ownership transfer or cross-unit retention. Diagnostic fields apply -// to every action. Invalid diagnostics make the entire result a middleware -// failure handled according to on_error. OpenShell retains the current input -// until it validates the result, so fail-open can continue from the last input -// OpenShell still owns. -message HttpResponseBodyResult { - // Must match the next unit. Zero, gaps, duplicates, and regressions fail. - uint64 sequence = 1; - // Exactly one explicit action is required. - oneof action { - // Forward the input unit unchanged. - HttpResponseBodyPassThrough pass_through = 2; - // Replace the complete input unit. - HttpResponseBodyTransform transform = 3; - // Stop delivery. See HttpResponseBlockDelivery. - HttpResponseBlockDelivery block_delivery = 8; - // Finalize this unit and stop inspecting. - HttpResponseBodySkipRemaining skip_remaining = 9; - } - // Service diagnostic, never sent to the sandbox or security logs. Maximum - // 4 KiB. - string reason = 4; - // Optional audit code using the HttpRequestResult.reason_code format and - // 64-byte maximum. When OpenShell accepts block_delivery before response - // commitment, it includes this code in the canonical denial response. It is - // never returned after commitment. - string reason_code = 5; - // Up to 32 audit-safe findings, each limited to 4 KiB encoded. - repeated Finding findings = 6; - // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. - map metadata = 7; -} - -// Preserves the input unit. -message HttpResponseBodyPassThrough {} - -// Finalizes this unit and ends the stage. This stage receives no later body or -// trailer events. The current and later units continue through other stages. -// For WHOLE_BODY_BYTES, this equals its nested action. -message HttpResponseBodySkipRemaining { - // Exactly one action for the current unit. - oneof current { - // Forward the current unit unchanged. - HttpResponseBodyPassThrough pass_through = 1; - // Replace the current unit. - HttpResponseBodyTransform transform = 2; - } -} - -// Replaces the complete input unit. -message HttpResponseBodyTransform { - // Required replacement, limited to max_payload_bytes. Present empty data - // deletes the input unit. The replacement fully accounts for this input unit; - // middleware must not retain input bytes for a later unit in V1. - oneof replacement { - // Normalized replacement bytes. - bytes data = 1; - } -} - -// The current normalized response trailers in wire order. Repeated names stay -// as separate fields. A stage that completes WHOLE_BODY_BYTES or STREAM_BYTES -// receives exactly one trailers event after its final body result, including -// when this set is empty. SKIP, HEADERS_ONLY, semantically bodyless responses, -// and stages ended by block, failure, or skip_remaining receive no trailers. -message HttpResponseTrailers { - repeated HttpHeader headers = 1; -} - -// Applies ordered trailer mutations atomically. An empty mutation list -// preserves the current trailers. A write may target only a case-insensitive -// name present in the trailers event; V1 cannot create a trailer name. Removal -// of an absent name is a no-op. Credential, routing, framing, coding, range, -// hop-by-hop, and connection-nominated fields are protected. Diagnostic fields -// apply whether mutations are empty or nonempty. Invalid diagnostics or -// mutations make the entire result a middleware failure handled according to -// on_error. -message HttpResponseTrailersResult { - // At most 64 operations, 32 KiB of validated name/value data, and 64 KiB - // encoded are accepted. - repeated HeaderMutation trailer_mutations = 1; - // Service diagnostic, never sent to the sandbox or security logs. Maximum - // 4 KiB. - string reason = 2; - // Optional audit code using the HttpRequestResult.reason_code format and - // 64-byte maximum. Never sent to the sandbox. - string reason_code = 3; - // Up to 32 audit-safe findings, each limited to 4 KiB encoded. - repeated Finding findings = 4; - // Non-secret diagnostic metadata, limited to 64 entries and 32 KiB. - map metadata = 5; -} - -// Stable reason OpenShell ended a middleware stage stream. -enum MiddlewareSessionEndReason { - // Invalid reason. - MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED = 0; - // Evaluation completed. - MIDDLEWARE_SESSION_END_REASON_NORMAL = 1; - // The sandbox peer disconnected. - MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT = 2; - // A policy reload replaced the active middleware chain. - MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD = 3; - // A stage denied the operation or blocked the response. - MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; - // A selected stage failed. - MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; - // A proxied or middleware protocol was violated. - MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR = 6; - // Evaluation was canceled for another reason. - MIDDLEWARE_SESSION_END_REASON_CANCELLATION = 7; - // Upstream rejected or failed before a valid response or upgrade. - MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE = 8; - // Network policy denied the operation. - MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL = 9; - // The stage successfully declined inspection during preflight. - MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED = 10; - // Upstream disconnected after a valid response or upgrade. - MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT = 11; -} - -// Best-effort terminal notification. A stage receives at most one and sends no -// result. -message MiddlewareSessionEnd { - // Terminal reason. Producers never send UNSPECIFIED. - MiddlewareSessionEndReason reason = 1; - // Set only for PROTOCOL_ERROR. Missing or unknown details mean a generic - // protocol error. - MiddlewareSessionProtocolError protocol_error = 2; -} - -// Details for a protocol-error session end. -message MiddlewareSessionProtocolError { - oneof domain { - // WebSocket protocol violation. - WebSocketProtocolError web_socket = 1; - // Middleware event/result protocol violation. - MiddlewareExchangeProtocolError middleware_exchange = 2; - } -} - -// WebSocket protocol error details, reserved for future categories. -message WebSocketProtocolError {} - -// Middleware exchange error details, reserved for future categories. -message MiddlewareExchangeProtocolError {} - // Supervisor operation selected for middleware evaluation. enum SupervisorMiddlewareOperation { SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE = 2; - SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE = 3; - SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION = 4; } // Ordered phase within a supervisor operation. @@ -470,7 +133,24 @@ enum SupervisorMiddlewarePhase { SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS = 1; SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN = 2; - SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT = 3; +} + +// Why OpenShell is ending a middleware stream. +enum WebSocketSessionEndReason { + WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED = 0; + WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE = 1; + WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT = 2; + WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD = 3; + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; + WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR = 6; + WEB_SOCKET_SESSION_END_REASON_CANCELLATION = 7; + WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED = 8; + WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL = 9; + // The middleware stage voluntarily declined inspection during preflight. + // This is a successful stage-local outcome, not a cancellation or denial of + // the WebSocket upgrade. + WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED = 10; } // WebSocketSessionEvent is one ordered event in a stage-local stream. @@ -482,7 +162,7 @@ message WebSocketSessionEvent { WebSocketPreflight preflight = 1; WebSocketSessionStart session_start = 2; WebSocketMessage message = 3; - MiddlewareSessionEnd session_end = 4; + WebSocketSessionEnd session_end = 4; } } @@ -523,6 +203,12 @@ message WebSocketMessage { } } +// WebSocketSessionEnd is OpenShell's best-effort terminal notification for one +// opened stage stream. A stage receives at most one such notification. +message WebSocketSessionEnd { + WebSocketSessionEndReason reason = 1; +} + // WebSocketPreflightAction is the service's one-time scoping decision. enum WebSocketPreflightAction { // Invalid response value handled according to the policy failure mode. @@ -630,48 +316,6 @@ message Process { repeated string ancestors = 3; } -// AgentConversationTarget identifies the harness hook and provider destination for -// which an allowed model request may receive a receipt. -message AgentConversationTarget { - string harness = 1; - string harness_version = 2; - string hook = 3; - string schema_version = 4; - string scheme = 5; - string host = 6; - uint32 port = 7; - string path = 8; -} - -// AgentConversationEvaluation is stamped by the supervisor-owned bridge. Workload -// callers supply only the harness request and untrusted request provenance. -message AgentConversationEvaluation { - SupervisorMiddlewarePhase phase = 1; - RequestContext context = 2; - google.protobuf.Struct config = 3; - AgentConversationTarget target = 4; - reserved 5; - string middleware_name = 6; - string session_id = 7; - string turn_id = 8; - bytes request_body = 9; - reserved 10 to 13; -} - -// AgentConversationResult carries the authority decision, an optional complete -// replacement body, and a model-request receipt opaque to OpenShell. -message AgentConversationResult { - Decision decision = 1; - string reason = 2; - reserved 3, 4; - bytes attestation = 5; - repeated Finding findings = 6; - map metadata = 7; - string reason_code = 8; - bytes replacement_body = 9; - bool has_replacement_body = 10; -} - // Decision controls whether OpenShell continues processing the current // evaluation unit. enum Decision { @@ -724,7 +368,7 @@ message RemoveHeader { string name = 1; } -// HeaderMutation is one ordered HTTP header operation. +// HeaderMutation is one ordered request-header operation. message HeaderMutation { oneof operation { WriteHeader write = 1; @@ -744,10 +388,10 @@ message HttpRequestResult { // True when body should replace the request body, including with an empty body. bool has_body = 4; // Ordered request-header mutations applied before the next middleware and - // before forwarding. Writes and removals may target visible end-to-end + // before forwarding. Header writes are restricted to the + // "x-openshell-middleware-" namespace. Removes may target other visible // request headers, but credential, routing, framing, and hop-by-hop headers - // are always protected. Written values cannot contain OpenShell credential - // placeholder syntax. A violating result is a middleware failure handled + // are always protected. A violating result is a middleware failure handled // according to the policy failure mode. At most 64 operations, 32 KiB of // validated name/value data, and 64 KiB encoded are accepted. repeated HeaderMutation header_mutations = 5; diff --git a/projects/egress-gate/pyproject.toml b/projects/egress-gate/pyproject.toml index 3aa84f68..044fe88d 100644 --- a/projects/egress-gate/pyproject.toml +++ b/projects/egress-gate/pyproject.toml @@ -10,10 +10,12 @@ authors = [ { name = "NVIDIA CORPORATION & AFFILIATES" }, ] dependencies = [ + "aiohttp>=3.12,<4", "cryptography>=50,<51", "grpcio>=1.81.1,<2", "protobuf>=7.36,<8", # 7.36.0 fixes protobuf security advisories. "pydantic>=2.11,<3", + "pyjwt[crypto]>=2.10,<3", "pyyaml>=6,<7", "regex>=2026.7.19,<2027", "rich>=14,<16", diff --git a/projects/egress-gate/scripts/generate-bindings.sh b/projects/egress-gate/scripts/generate-bindings.sh new file mode 100755 index 00000000..191c2f3d --- /dev/null +++ b/projects/egress-gate/scripts/generate-bindings.sh @@ -0,0 +1,19 @@ +#!/usr/bin/env bash +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 +set -euo pipefail +cd "$(dirname "${BASH_SOURCE[0]}")/.." + +# OpenShell v0.0.116, unchanged upstream protocol. The generator is isolated +# because it requires protobuf 6; the application uses the patched protobuf 7. +revision=d1155aa70042d3e2ee49dbfa15346b108b7c1d92 +binding_tmp=$(mktemp -d) +trap 'rm -r -- "$binding_tmp"' EXIT +mkdir -p "$binding_tmp/egress_gate/bindings" +curl --fail --silent --show-error \ + "https://raw.githubusercontent.com/NVIDIA/OpenShell/$revision/proto/supervisor_middleware.proto" \ + -o "$binding_tmp/egress_gate/bindings/supervisor_middleware.proto" +cp "$binding_tmp/egress_gate/bindings/supervisor_middleware.proto" proto/supervisor_middleware.proto +uvx --from grpcio-tools==1.81.1 python -m grpc_tools.protoc \ + -I "$binding_tmp" --python_out=src --pyi_out=src --grpc_python_out=src \ + "$binding_tmp/egress_gate/bindings/supervisor_middleware.proto" diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index e0faa73c..920aa3d7 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -53,7 +53,7 @@ class ProviderShapeError(ValueError): PiMessageOrigin: TypeAlias = Literal[ - "user", "compaction_summary", "branch_summary", "extension_message" + "user", "system", "compaction_summary", "branch_summary", "extension_message" ] @@ -953,6 +953,7 @@ def create_pi_adapter_registry() -> HarnessAdapterRegistry: registry = HarnessAdapterRegistry() for hook, origin in ( (AdmissionHook.USER_MESSAGE, "user"), + (AdmissionHook.SYSTEM_CONTEXT, "system"), (AdmissionHook.COMPACTION_SUMMARY, "compaction_summary"), (AdmissionHook.BRANCH_SUMMARY, "branch_summary"), (AdmissionHook.EXTENSION_MESSAGE, "extension_message"), diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py index 062d19f7..3e55f4e7 100644 --- a/projects/egress-gate/src/egress_gate/admission/models.py +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -24,6 +24,7 @@ class AdmissionHook(StrEnum): """Supported harness admission boundaries.""" USER_MESSAGE = "user_message" + SYSTEM_CONTEXT = "system_context" TOOL_RESULT = "tool_result" ASSISTANT_MESSAGE = "assistant_message" COMPACTION_SUMMARY = "compaction_summary" diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py index 9a978f63..29c62759 100644 --- a/projects/egress-gate/src/egress_gate/admission/processor.py +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -5,6 +5,8 @@ from __future__ import annotations +import base64 +import binascii from typing import Literal from pydantic import ValidationError @@ -26,12 +28,15 @@ HarnessAdmissionResult, ) from egress_gate.admission.receipts import ReceiptAuthority, ReceiptVerificationError +from egress_gate.constants import MAX_AGENT_ATTESTATION_BYTES from egress_gate.errors import EgressGateError, GateError, TimeoutExpiredError from egress_gate.request import ( EnforcementPoint, HarnessAdmissionMetadata, HttpRequest, + RemoveHeaderMutation, RequestContext, + RequestMutations, ) from egress_gate.request_processor import RequestProcessor, apply_request_mutations from egress_gate.result import ( @@ -42,7 +47,7 @@ ) from egress_gate.timeout import Timeout -RECEIPT_HEADER = "x-openshell-middleware-egress-receipt" +RECEIPT_HEADER = "x-egress-admission" class HarnessAdmissionProcessor: @@ -176,16 +181,39 @@ def process( self, request: HttpRequest, *, - agent_attestation: bytes, timeout: Timeout, ) -> EgressResult: """Deny any unattested or semantically changed provider request.""" if request.context.enforcement_point is not EnforcementPoint.NETWORK_EGRESS: return self._deny("network_context_invalid") - if any(header.name.lower() == RECEIPT_HEADER for header in request.headers): - return self._deny("reserved_header_present") - if not agent_attestation: + receipts = [ + h.value for h in request.headers if h.name.lower() == RECEIPT_HEADER + ] + if not receipts: return self._deny("attestation_missing") + if ( + len(receipts) != 1 + or len(receipts[0]) > MAX_AGENT_ATTESTATION_BYTES * 4 // 3 + 4 + ): + return self._deny("attestation_malformed") + try: + agent_attestation = base64.b64decode( + receipts[0], altchars=b"-_", validate=True + ) + except (ValueError, binascii.Error): + return self._deny("attestation_malformed") + if ( + not agent_attestation + or len(agent_attestation) > MAX_AGENT_ATTESTATION_BYTES + ): + return self._deny("attestation_malformed") + request = request.model_copy( + update={ + "headers": tuple( + h for h in request.headers if h.name.lower() != RECEIPT_HEADER + ) + } + ) try: adapter = self._provider_adapters.resolve_request(request, timeout) entries = adapter.attested_entries(request, timeout) @@ -221,7 +249,17 @@ def process( if final_entries != entries: return self._deny("semantic_mutation_denied") timeout.raise_if_expired() - return gate_result + return gate_result.model_copy( + update={ + "request_mutations": RequestMutations( + replacement_body=gate_result.request_mutations.replacement_body, + header_mutations=( + *gate_result.request_mutations.header_mutations, + RemoveHeaderMutation(kind="remove", name=RECEIPT_HEADER), + ), + ) + } + ) except ReceiptVerificationError as error: return self._deny(error.reason_code) except TimeoutExpiredError: diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py index 8b10f593..74d449a6 100644 --- a/projects/egress-gate/src/egress_gate/admission/receipts.py +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -98,7 +98,7 @@ def issue_attestation( policy_fingerprint: str, now: int | None = None, ) -> bytes: - """Issue a retry-safe proof retained by the OpenShell supervisor.""" + """Issue a retry-safe approval receipt for a provider-context projection.""" if ( context.harness_version != "sdk-v1" or context.hook is not AdmissionHook.PROVIDER_CONTEXT @@ -140,7 +140,7 @@ def verify_attestation( policy_fingerprint: str, now: int | None = None, ) -> AgentAttestationClaimsV2: - """Verify a supervisor-supplied provider-context attestation.""" + """Verify a receipt against authoritative context and intercepted content.""" payload, signature = _decode_token( attestation, prefix=b"ag2", malformed_reason="attestation_malformed" ) diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py index 79a6d67f..38203ece 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE -# source: supervisor_middleware.proto +# source: egress_gate/bindings/supervisor_middleware.proto # Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor @@ -15,7 +15,7 @@ 33, 5, '', - 'supervisor_middleware.proto' + 'egress_gate/bindings/supervisor_middleware.proto' ) # @@protoc_insertion_point(imports) @@ -26,141 +26,81 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\x84\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xf1\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\x12\x19\n\x11\x61gent_attestation\x18\x08 \x01(\x0c\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xa9\x02\n\x11HttpResponseEvent\x12\x43\n\tpreflight\x18\x01 \x01(\x0b\x32..openshell.middleware.v1.HttpResponsePreflightH\x00\x12=\n\x04\x62ody\x18\x02 \x01(\x0b\x32-.openshell.middleware.v1.HttpResponseBodyUnitH\x00\x12\x41\n\x08trailers\x18\x04 \x01(\x0b\x32-.openshell.middleware.v1.HttpResponseTrailersH\x00\x12\x44\n\x0bsession_end\x18\x03 \x01(\x0b\x32-.openshell.middleware.v1.MiddlewareSessionEndH\x00\x42\x07\n\x05\x65vent\"\x8d\x02\n\x17HttpResponseEventResult\x12P\n\x10preflight_result\x18\x01 \x01(\x0b\x32\x34.openshell.middleware.v1.HttpResponsePreflightResultH\x00\x12\x46\n\x0b\x62ody_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.HttpResponseBodyResultH\x00\x12N\n\x0ftrailers_result\x18\x03 \x01(\x0b\x32\x33.openshell.middleware.v1.HttpResponseTrailersResultH\x00\x42\x08\n\x06result\"\x82\x03\n\x15HttpResponsePreflight\x12\x38\n\x07\x63ontext\x18\x01 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x02 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x13\n\x0bstatus_code\x18\x03 \x01(\r\x12\x34\n\x07headers\x18\x04 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x17\n\x0fmiddleware_name\x18\x05 \x01(\t\x12\'\n\x06\x63onfig\x18\x06 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x19\n\x11max_payload_bytes\x18\x07 \x01(\x04\x12K\n\x14permitted_body_modes\x18\x08 \x03(\x0e\x32-.openshell.middleware.v1.HttpResponseBodyMode\"\xe3\x03\n\x1bHttpResponsePreflightResult\x12\x42\n\x04skip\x18\x01 \x01(\x0b\x32\x32.openshell.middleware.v1.HttpResponsePreflightSkipH\x00\x12H\n\x07inspect\x18\x02 \x01(\x0b\x32\x35.openshell.middleware.v1.HttpResponsePreflightInspectH\x00\x12L\n\x0e\x62lock_delivery\x18\x07 \x01(\x0b\x32\x32.openshell.middleware.v1.HttpResponseBlockDeliveryH\x00\x12\x0e\n\x06reason\x18\x03 \x01(\t\x12\x13\n\x0breason_code\x18\x04 \x01(\t\x12\x32\n\x08\x66indings\x18\x05 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12T\n\x08metadata\x18\x06 \x03(\x0b\x32\x42.openshell.middleware.v1.HttpResponsePreflightResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x08\n\x06\x61\x63tion\"\x1b\n\x19HttpResponsePreflightSkip\"\x1b\n\x19HttpResponseBlockDelivery\"\xa3\x01\n\x1cHttpResponsePreflightInspect\x12@\n\tbody_mode\x18\x01 \x01(\x0e\x32-.openshell.middleware.v1.HttpResponseBodyMode\x12\x41\n\x10header_mutations\x18\x02 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\"Z\n\x14HttpResponseBodyUnit\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04\x64\x61ta\x18\x02 \x01(\x0cH\x00\x12\x15\n\rend_of_stream\x18\x03 \x01(\x08\x42\t\n\x07payload\"\xc6\x04\n\x16HttpResponseBodyResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12L\n\x0cpass_through\x18\x02 \x01(\x0b\x32\x34.openshell.middleware.v1.HttpResponseBodyPassThroughH\x00\x12G\n\ttransform\x18\x03 \x01(\x0b\x32\x32.openshell.middleware.v1.HttpResponseBodyTransformH\x00\x12L\n\x0e\x62lock_delivery\x18\x08 \x01(\x0b\x32\x32.openshell.middleware.v1.HttpResponseBlockDeliveryH\x00\x12P\n\x0eskip_remaining\x18\t \x01(\x0b\x32\x36.openshell.middleware.v1.HttpResponseBodySkipRemainingH\x00\x12\x0e\n\x06reason\x18\x04 \x01(\t\x12\x13\n\x0breason_code\x18\x05 \x01(\t\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x07 \x03(\x0b\x32=.openshell.middleware.v1.HttpResponseBodyResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\x08\n\x06\x61\x63tion\"\x1d\n\x1bHttpResponseBodyPassThrough\"\xc1\x01\n\x1dHttpResponseBodySkipRemaining\x12L\n\x0cpass_through\x18\x01 \x01(\x0b\x32\x34.openshell.middleware.v1.HttpResponseBodyPassThroughH\x00\x12G\n\ttransform\x18\x02 \x01(\x0b\x32\x32.openshell.middleware.v1.HttpResponseBodyTransformH\x00\x42\t\n\x07\x63urrent\":\n\x19HttpResponseBodyTransform\x12\x0e\n\x04\x64\x61ta\x18\x01 \x01(\x0cH\x00\x42\r\n\x0breplacement\"L\n\x14HttpResponseTrailers\x12\x34\n\x07headers\x18\x01 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\"\xbf\x02\n\x1aHttpResponseTrailersResult\x12\x42\n\x11trailer_mutations\x18\x01 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.HttpResponseTrailersResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xac\x01\n\x14MiddlewareSessionEnd\x12\x43\n\x06reason\x18\x01 \x01(\x0e\x32\x33.openshell.middleware.v1.MiddlewareSessionEndReason\x12O\n\x0eprotocol_error\x18\x02 \x01(\x0b\x32\x37.openshell.middleware.v1.MiddlewareSessionProtocolError\"\xca\x01\n\x1eMiddlewareSessionProtocolError\x12\x45\n\nweb_socket\x18\x01 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketProtocolErrorH\x00\x12W\n\x13middleware_exchange\x18\x02 \x01(\x0b\x32\x38.openshell.middleware.v1.MiddlewareExchangeProtocolErrorH\x00\x42\x08\n\x06\x64omain\"\x18\n\x16WebSocketProtocolError\"!\n\x1fMiddlewareExchangeProtocolError\"\xaf\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x44\n\x0bsession_end\x18\x04 \x01(\x0b\x32-.openshell.middleware.v1.MiddlewareSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xe5\x02\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0cJ\x04\x08\x05\x10\x06J\x04\x08\n\x10\x0e\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xc1\x01\n\x14HttpResponseBodyMode\x12\'\n#HTTP_RESPONSE_BODY_MODE_UNSPECIFIED\x10\x00\x12(\n$HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY\x10\x01\x12,\n(HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES\x10\x02\x12(\n$HTTP_RESPONSE_BODY_MODE_STREAM_BYTES\x10\x03*\xf9\x04\n\x1aMiddlewareSessionEndReason\x12-\n)MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12(\n$MIDDLEWARE_SESSION_END_REASON_NORMAL\x10\x01\x12\x37\n3MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT\x10\x02\x12/\n+MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*MIDDLEWARE_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x32\n.MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE\x10\x08\x12/\n+MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED\x10\n\x12\x35\n1MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT\x10\x0b*\xa4\x02\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02\x12\x31\n-SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE\x10\x03\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x04*\xd4\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x03*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xda\x04\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x32\x85\x01\n\x15HttpResponsePreReturn\x12l\n\x08\x45valuate\x12*.openshell.middleware.v1.HttpResponseEvent\x1a\x30.openshell.middleware.v1.HttpResponseEventResult(\x01\x30\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n0egress_gate/bindings/supervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\xcd\x01\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xb9\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02*\xa5\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xd4\x03\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'supervisor_middleware_pb2', _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'egress_gate.bindings.supervisor_middleware_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None - _globals['_HTTPRESPONSEPREFLIGHTRESULT_METADATAENTRY']._loaded_options = None - _globals['_HTTPRESPONSEPREFLIGHTRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_HTTPRESPONSEBODYRESULT_METADATAENTRY']._loaded_options = None - _globals['_HTTPRESPONSEBODYRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_HTTPRESPONSETRAILERSRESULT_METADATAENTRY']._loaded_options = None - _globals['_HTTPRESPONSETRAILERSRESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._loaded_options = None _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_options = b'8\001' _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._loaded_options = None _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._loaded_options = None - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_HTTPREQUESTRESULT_METADATAENTRY']._loaded_options = None _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_HTTPRESPONSEBODYMODE']._serialized_start=8241 - _globals['_HTTPRESPONSEBODYMODE']._serialized_end=8434 - _globals['_MIDDLEWARESESSIONENDREASON']._serialized_start=8437 - _globals['_MIDDLEWARESESSIONENDREASON']._serialized_end=9070 - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=9073 - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=9365 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=9368 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=9580 - _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=9583 - _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=9771 - _globals['_DECISION']._serialized_start=9773 - _globals['_DECISION']._serialized_end=9848 - _globals['_EXISTINGHEADERACTION']._serialized_start=9851 - _globals['_EXISTINGHEADERACTION']._serialized_end=10019 - _globals['_MIDDLEWAREMANIFEST']._serialized_start=116 - _globals['_MIDDLEWAREMANIFEST']._serialized_end=264 - _globals['_MIDDLEWAREBINDING']._serialized_start=267 - _globals['_MIDDLEWAREBINDING']._serialized_end=527 - _globals['_VALIDATECONFIGREQUEST']._serialized_start=529 - _globals['_VALIDATECONFIGREQUEST']._serialized_end=618 - _globals['_VALIDATECONFIGRESPONSE']._serialized_start=620 - _globals['_VALIDATECONFIGRESPONSE']._serialized_end=675 - _globals['_HTTPREQUESTEVALUATION']._serialized_start=678 - _globals['_HTTPREQUESTEVALUATION']._serialized_end=1047 - _globals['_HTTPHEADER']._serialized_start=1049 - _globals['_HTTPHEADER']._serialized_end=1090 - _globals['_HTTPRESPONSEEVENT']._serialized_start=1093 - _globals['_HTTPRESPONSEEVENT']._serialized_end=1390 - _globals['_HTTPRESPONSEEVENTRESULT']._serialized_start=1393 - _globals['_HTTPRESPONSEEVENTRESULT']._serialized_end=1662 - _globals['_HTTPRESPONSEPREFLIGHT']._serialized_start=1665 - _globals['_HTTPRESPONSEPREFLIGHT']._serialized_end=2051 - _globals['_HTTPRESPONSEPREFLIGHTRESULT']._serialized_start=2054 - _globals['_HTTPRESPONSEPREFLIGHTRESULT']._serialized_end=2537 - _globals['_HTTPRESPONSEPREFLIGHTRESULT_METADATAENTRY']._serialized_start=2480 - _globals['_HTTPRESPONSEPREFLIGHTRESULT_METADATAENTRY']._serialized_end=2527 - _globals['_HTTPRESPONSEPREFLIGHTSKIP']._serialized_start=2539 - _globals['_HTTPRESPONSEPREFLIGHTSKIP']._serialized_end=2566 - _globals['_HTTPRESPONSEBLOCKDELIVERY']._serialized_start=2568 - _globals['_HTTPRESPONSEBLOCKDELIVERY']._serialized_end=2595 - _globals['_HTTPRESPONSEPREFLIGHTINSPECT']._serialized_start=2598 - _globals['_HTTPRESPONSEPREFLIGHTINSPECT']._serialized_end=2761 - _globals['_HTTPRESPONSEBODYUNIT']._serialized_start=2763 - _globals['_HTTPRESPONSEBODYUNIT']._serialized_end=2853 - _globals['_HTTPRESPONSEBODYRESULT']._serialized_start=2856 - _globals['_HTTPRESPONSEBODYRESULT']._serialized_end=3438 - _globals['_HTTPRESPONSEBODYRESULT_METADATAENTRY']._serialized_start=2480 - _globals['_HTTPRESPONSEBODYRESULT_METADATAENTRY']._serialized_end=2527 - _globals['_HTTPRESPONSEBODYPASSTHROUGH']._serialized_start=3440 - _globals['_HTTPRESPONSEBODYPASSTHROUGH']._serialized_end=3469 - _globals['_HTTPRESPONSEBODYSKIPREMAINING']._serialized_start=3472 - _globals['_HTTPRESPONSEBODYSKIPREMAINING']._serialized_end=3665 - _globals['_HTTPRESPONSEBODYTRANSFORM']._serialized_start=3667 - _globals['_HTTPRESPONSEBODYTRANSFORM']._serialized_end=3725 - _globals['_HTTPRESPONSETRAILERS']._serialized_start=3727 - _globals['_HTTPRESPONSETRAILERS']._serialized_end=3803 - _globals['_HTTPRESPONSETRAILERSRESULT']._serialized_start=3806 - _globals['_HTTPRESPONSETRAILERSRESULT']._serialized_end=4125 - _globals['_HTTPRESPONSETRAILERSRESULT_METADATAENTRY']._serialized_start=2480 - _globals['_HTTPRESPONSETRAILERSRESULT_METADATAENTRY']._serialized_end=2527 - _globals['_MIDDLEWARESESSIONEND']._serialized_start=4128 - _globals['_MIDDLEWARESESSIONEND']._serialized_end=4300 - _globals['_MIDDLEWARESESSIONPROTOCOLERROR']._serialized_start=4303 - _globals['_MIDDLEWARESESSIONPROTOCOLERROR']._serialized_end=4505 - _globals['_WEBSOCKETPROTOCOLERROR']._serialized_start=4507 - _globals['_WEBSOCKETPROTOCOLERROR']._serialized_end=4531 - _globals['_MIDDLEWAREEXCHANGEPROTOCOLERROR']._serialized_start=4533 - _globals['_MIDDLEWAREEXCHANGEPROTOCOLERROR']._serialized_end=4566 - _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=4569 - _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=4872 - _globals['_WEBSOCKETPREFLIGHT']._serialized_start=4875 - _globals['_WEBSOCKETPREFLIGHT']._serialized_end=5198 - _globals['_WEBSOCKETSESSIONSTART']._serialized_start=5200 - _globals['_WEBSOCKETSESSIONSTART']._serialized_end=5253 - _globals['_WEBSOCKETMESSAGE']._serialized_start=5255 - _globals['_WEBSOCKETMESSAGE']._serialized_end=5336 - _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=5339 - _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=5657 - _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2480 - _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2527 - _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=5660 - _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=6023 - _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2480 - _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2527 - _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=6026 - _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=6223 - _globals['_REQUESTCONTEXT']._serialized_start=6226 - _globals['_REQUESTCONTEXT']._serialized_end=6386 - _globals['_HTTPREQUESTTARGET']._serialized_start=6388 - _globals['_HTTPREQUESTTARGET']._serialized_end=6496 - _globals['_PROCESS']._serialized_start=6498 - _globals['_PROCESS']._serialized_end=6555 - _globals['_AGENTCONVERSATIONTARGET']._serialized_start=6558 - _globals['_AGENTCONVERSATIONTARGET']._serialized_end=6721 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=6724 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=7081 - _globals['_AGENTCONVERSATIONRESULT']._serialized_start=7084 - _globals['_AGENTCONVERSATIONRESULT']._serialized_end=7471 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2480 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2527 - _globals['_FINDING']._serialized_start=7473 - _globals['_FINDING']._serialized_end=7564 - _globals['_WRITEHEADER']._serialized_start=7566 - _globals['_WRITEHEADER']._serialized_end=7676 - _globals['_REMOVEHEADER']._serialized_start=7678 - _globals['_REMOVEHEADER']._serialized_end=7706 - _globals['_HEADERMUTATION']._serialized_start=7709 - _globals['_HEADERMUTATION']._serialized_end=7850 - _globals['_HTTPREQUESTRESULT']._serialized_start=7853 - _globals['_HTTPREQUESTRESULT']._serialized_end=8238 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2480 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2527 - _globals['_SUPERVISORMIDDLEWARE']._serialized_start=10022 - _globals['_SUPERVISORMIDDLEWARE']._serialized_end=10624 - _globals['_HTTPRESPONSEPRERETURN']._serialized_start=10627 - _globals['_HTTPRESPONSEPRERETURN']._serialized_end=10760 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=3878 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=4063 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=4066 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=4231 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=4234 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=4812 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=4815 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=5003 + _globals['_DECISION']._serialized_start=5005 + _globals['_DECISION']._serialized_end=5080 + _globals['_EXISTINGHEADERACTION']._serialized_start=5083 + _globals['_EXISTINGHEADERACTION']._serialized_end=5251 + _globals['_MIDDLEWAREMANIFEST']._serialized_start=137 + _globals['_MIDDLEWAREMANIFEST']._serialized_end=285 + _globals['_MIDDLEWAREBINDING']._serialized_start=288 + _globals['_MIDDLEWAREBINDING']._serialized_end=493 + _globals['_VALIDATECONFIGREQUEST']._serialized_start=495 + _globals['_VALIDATECONFIGREQUEST']._serialized_end=584 + _globals['_VALIDATECONFIGRESPONSE']._serialized_start=586 + _globals['_VALIDATECONFIGRESPONSE']._serialized_end=641 + _globals['_HTTPREQUESTEVALUATION']._serialized_start=644 + _globals['_HTTPREQUESTEVALUATION']._serialized_end=986 + _globals['_HTTPHEADER']._serialized_start=988 + _globals['_HTTPHEADER']._serialized_end=1029 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1032 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1334 + _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1337 + _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1660 + _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1662 + _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1715 + _globals['_WEBSOCKETMESSAGE']._serialized_start=1717 + _globals['_WEBSOCKETMESSAGE']._serialized_end=1798 + _globals['_WEBSOCKETSESSIONEND']._serialized_start=1800 + _globals['_WEBSOCKETSESSIONEND']._serialized_end=1889 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1892 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2210 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2163 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2210 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2213 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2576 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2163 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2210 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2579 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2776 + _globals['_REQUESTCONTEXT']._serialized_start=2779 + _globals['_REQUESTCONTEXT']._serialized_end=2939 + _globals['_HTTPREQUESTTARGET']._serialized_start=2941 + _globals['_HTTPREQUESTTARGET']._serialized_end=3049 + _globals['_PROCESS']._serialized_start=3051 + _globals['_PROCESS']._serialized_end=3108 + _globals['_FINDING']._serialized_start=3110 + _globals['_FINDING']._serialized_end=3201 + _globals['_WRITEHEADER']._serialized_start=3203 + _globals['_WRITEHEADER']._serialized_end=3313 + _globals['_REMOVEHEADER']._serialized_start=3315 + _globals['_REMOVEHEADER']._serialized_end=3343 + _globals['_HEADERMUTATION']._serialized_start=3346 + _globals['_HEADERMUTATION']._serialized_end=3487 + _globals['_HTTPREQUESTRESULT']._serialized_start=3490 + _globals['_HTTPREQUESTRESULT']._serialized_end=3875 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2163 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2210 + _globals['_SUPERVISORMIDDLEWARE']._serialized_start=5254 + _globals['_SUPERVISORMIDDLEWARE']._serialized_end=5722 # @@protoc_insertion_point(module_scope) diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi index 7e4c4d3c..e7cae72e 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi @@ -9,42 +9,31 @@ from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union DESCRIPTOR: _descriptor.FileDescriptor -class HttpResponseBodyMode(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - HTTP_RESPONSE_BODY_MODE_UNSPECIFIED: _ClassVar[HttpResponseBodyMode] - HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY: _ClassVar[HttpResponseBodyMode] - HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES: _ClassVar[HttpResponseBodyMode] - HTTP_RESPONSE_BODY_MODE_STREAM_BYTES: _ClassVar[HttpResponseBodyMode] - -class MiddlewareSessionEndReason(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED: _ClassVar[MiddlewareSessionEndReason] - MIDDLEWARE_SESSION_END_REASON_NORMAL: _ClassVar[MiddlewareSessionEndReason] - MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT: _ClassVar[MiddlewareSessionEndReason] - MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD: _ClassVar[MiddlewareSessionEndReason] - MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL: _ClassVar[MiddlewareSessionEndReason] - MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE: _ClassVar[MiddlewareSessionEndReason] - MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR: _ClassVar[MiddlewareSessionEndReason] - MIDDLEWARE_SESSION_END_REASON_CANCELLATION: _ClassVar[MiddlewareSessionEndReason] - MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE: _ClassVar[MiddlewareSessionEndReason] - MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL: _ClassVar[MiddlewareSessionEndReason] - MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED: _ClassVar[MiddlewareSessionEndReason] - MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT: _ClassVar[MiddlewareSessionEndReason] - class SupervisorMiddlewareOperation(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: _ClassVar[SupervisorMiddlewareOperation] - SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE: _ClassVar[SupervisorMiddlewareOperation] - SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: _ClassVar[SupervisorMiddlewareOperation] class SupervisorMiddlewarePhase(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: _ClassVar[SupervisorMiddlewarePhase] SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: _ClassVar[SupervisorMiddlewarePhase] SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: _ClassVar[SupervisorMiddlewarePhase] - SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: _ClassVar[SupervisorMiddlewarePhase] + +class WebSocketSessionEndReason(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_CANCELLATION: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: _ClassVar[WebSocketSessionEndReason] class WebSocketPreflightAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () @@ -65,31 +54,23 @@ class ExistingHeaderAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): EXISTING_HEADER_ACTION_APPEND: _ClassVar[ExistingHeaderAction] EXISTING_HEADER_ACTION_OVERWRITE: _ClassVar[ExistingHeaderAction] EXISTING_HEADER_ACTION_SKIP: _ClassVar[ExistingHeaderAction] -HTTP_RESPONSE_BODY_MODE_UNSPECIFIED: HttpResponseBodyMode -HTTP_RESPONSE_BODY_MODE_HEADERS_ONLY: HttpResponseBodyMode -HTTP_RESPONSE_BODY_MODE_WHOLE_BODY_BYTES: HttpResponseBodyMode -HTTP_RESPONSE_BODY_MODE_STREAM_BYTES: HttpResponseBodyMode -MIDDLEWARE_SESSION_END_REASON_UNSPECIFIED: MiddlewareSessionEndReason -MIDDLEWARE_SESSION_END_REASON_NORMAL: MiddlewareSessionEndReason -MIDDLEWARE_SESSION_END_REASON_DOWNSTREAM_DISCONNECT: MiddlewareSessionEndReason -MIDDLEWARE_SESSION_END_REASON_POLICY_RELOAD: MiddlewareSessionEndReason -MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_DENIAL: MiddlewareSessionEndReason -MIDDLEWARE_SESSION_END_REASON_MIDDLEWARE_FAILURE: MiddlewareSessionEndReason -MIDDLEWARE_SESSION_END_REASON_PROTOCOL_ERROR: MiddlewareSessionEndReason -MIDDLEWARE_SESSION_END_REASON_CANCELLATION: MiddlewareSessionEndReason -MIDDLEWARE_SESSION_END_REASON_UPSTREAM_FAILURE: MiddlewareSessionEndReason -MIDDLEWARE_SESSION_END_REASON_POLICY_DENIAL: MiddlewareSessionEndReason -MIDDLEWARE_SESSION_END_REASON_STAGE_SKIPPED: MiddlewareSessionEndReason -MIDDLEWARE_SESSION_END_REASON_UPSTREAM_DISCONNECT: MiddlewareSessionEndReason SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: SupervisorMiddlewareOperation -SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_RESPONSE: SupervisorMiddlewareOperation -SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: SupervisorMiddlewarePhase -SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: SupervisorMiddlewarePhase +WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_CANCELLATION: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: WebSocketSessionEndReason WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED: WebSocketPreflightAction WEB_SOCKET_PREFLIGHT_ACTION_INSPECT: WebSocketPreflightAction WEB_SOCKET_PREFLIGHT_ACTION_SKIP: WebSocketPreflightAction @@ -115,22 +96,16 @@ class MiddlewareManifest(_message.Message): def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ..., expected_audience: _Optional[str] = ...) -> None: ... class MiddlewareBinding(_message.Message): - __slots__ = ("operation", "phase", "max_payload_bytes", "timeout", "harness", "hook", "schema_version") + __slots__ = ("operation", "phase", "max_payload_bytes", "timeout") OPERATION_FIELD_NUMBER: _ClassVar[int] PHASE_FIELD_NUMBER: _ClassVar[int] MAX_PAYLOAD_BYTES_FIELD_NUMBER: _ClassVar[int] TIMEOUT_FIELD_NUMBER: _ClassVar[int] - HARNESS_FIELD_NUMBER: _ClassVar[int] - HOOK_FIELD_NUMBER: _ClassVar[int] - SCHEMA_VERSION_FIELD_NUMBER: _ClassVar[int] operation: SupervisorMiddlewareOperation phase: SupervisorMiddlewarePhase max_payload_bytes: int timeout: str - harness: str - hook: str - schema_version: str - def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_payload_bytes: _Optional[int] = ..., timeout: _Optional[str] = ..., harness: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ...) -> None: ... + def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_payload_bytes: _Optional[int] = ..., timeout: _Optional[str] = ...) -> None: ... class ValidateConfigRequest(_message.Message): __slots__ = ("config", "middleware_name") @@ -149,7 +124,7 @@ class ValidateConfigResponse(_message.Message): def __init__(self, valid: _Optional[bool] = ..., reason: _Optional[str] = ...) -> None: ... class HttpRequestEvaluation(_message.Message): - __slots__ = ("phase", "context", "config", "target", "headers", "body", "middleware_name", "agent_attestation") + __slots__ = ("phase", "context", "config", "target", "headers", "body", "middleware_name") PHASE_FIELD_NUMBER: _ClassVar[int] CONTEXT_FIELD_NUMBER: _ClassVar[int] CONFIG_FIELD_NUMBER: _ClassVar[int] @@ -157,7 +132,6 @@ class HttpRequestEvaluation(_message.Message): HEADERS_FIELD_NUMBER: _ClassVar[int] BODY_FIELD_NUMBER: _ClassVar[int] MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] - AGENT_ATTESTATION_FIELD_NUMBER: _ClassVar[int] phase: SupervisorMiddlewarePhase context: RequestContext config: _struct_pb2.Struct @@ -165,8 +139,7 @@ class HttpRequestEvaluation(_message.Message): headers: _containers.RepeatedCompositeFieldContainer[HttpHeader] body: bytes middleware_name: str - agent_attestation: bytes - def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., body: _Optional[bytes] = ..., middleware_name: _Optional[str] = ..., agent_attestation: _Optional[bytes] = ...) -> None: ... + def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., body: _Optional[bytes] = ..., middleware_name: _Optional[str] = ...) -> None: ... class HttpHeader(_message.Message): __slots__ = ("name", "value") @@ -176,197 +149,6 @@ class HttpHeader(_message.Message): value: str def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... -class HttpResponseEvent(_message.Message): - __slots__ = ("preflight", "body", "trailers", "session_end") - PREFLIGHT_FIELD_NUMBER: _ClassVar[int] - BODY_FIELD_NUMBER: _ClassVar[int] - TRAILERS_FIELD_NUMBER: _ClassVar[int] - SESSION_END_FIELD_NUMBER: _ClassVar[int] - preflight: HttpResponsePreflight - body: HttpResponseBodyUnit - trailers: HttpResponseTrailers - session_end: MiddlewareSessionEnd - def __init__(self, preflight: _Optional[_Union[HttpResponsePreflight, _Mapping]] = ..., body: _Optional[_Union[HttpResponseBodyUnit, _Mapping]] = ..., trailers: _Optional[_Union[HttpResponseTrailers, _Mapping]] = ..., session_end: _Optional[_Union[MiddlewareSessionEnd, _Mapping]] = ...) -> None: ... - -class HttpResponseEventResult(_message.Message): - __slots__ = ("preflight_result", "body_result", "trailers_result") - PREFLIGHT_RESULT_FIELD_NUMBER: _ClassVar[int] - BODY_RESULT_FIELD_NUMBER: _ClassVar[int] - TRAILERS_RESULT_FIELD_NUMBER: _ClassVar[int] - preflight_result: HttpResponsePreflightResult - body_result: HttpResponseBodyResult - trailers_result: HttpResponseTrailersResult - def __init__(self, preflight_result: _Optional[_Union[HttpResponsePreflightResult, _Mapping]] = ..., body_result: _Optional[_Union[HttpResponseBodyResult, _Mapping]] = ..., trailers_result: _Optional[_Union[HttpResponseTrailersResult, _Mapping]] = ...) -> None: ... - -class HttpResponsePreflight(_message.Message): - __slots__ = ("context", "target", "status_code", "headers", "middleware_name", "config", "max_payload_bytes", "permitted_body_modes") - CONTEXT_FIELD_NUMBER: _ClassVar[int] - TARGET_FIELD_NUMBER: _ClassVar[int] - STATUS_CODE_FIELD_NUMBER: _ClassVar[int] - HEADERS_FIELD_NUMBER: _ClassVar[int] - MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] - CONFIG_FIELD_NUMBER: _ClassVar[int] - MAX_PAYLOAD_BYTES_FIELD_NUMBER: _ClassVar[int] - PERMITTED_BODY_MODES_FIELD_NUMBER: _ClassVar[int] - context: RequestContext - target: HttpRequestTarget - status_code: int - headers: _containers.RepeatedCompositeFieldContainer[HttpHeader] - middleware_name: str - config: _struct_pb2.Struct - max_payload_bytes: int - permitted_body_modes: _containers.RepeatedScalarFieldContainer[HttpResponseBodyMode] - def __init__(self, context: _Optional[_Union[RequestContext, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., status_code: _Optional[int] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., middleware_name: _Optional[str] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., max_payload_bytes: _Optional[int] = ..., permitted_body_modes: _Optional[_Iterable[_Union[HttpResponseBodyMode, str]]] = ...) -> None: ... - -class HttpResponsePreflightResult(_message.Message): - __slots__ = ("skip", "inspect", "block_delivery", "reason", "reason_code", "findings", "metadata") - class MetadataEntry(_message.Message): - __slots__ = ("key", "value") - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: str - def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... - SKIP_FIELD_NUMBER: _ClassVar[int] - INSPECT_FIELD_NUMBER: _ClassVar[int] - BLOCK_DELIVERY_FIELD_NUMBER: _ClassVar[int] - REASON_FIELD_NUMBER: _ClassVar[int] - REASON_CODE_FIELD_NUMBER: _ClassVar[int] - FINDINGS_FIELD_NUMBER: _ClassVar[int] - METADATA_FIELD_NUMBER: _ClassVar[int] - skip: HttpResponsePreflightSkip - inspect: HttpResponsePreflightInspect - block_delivery: HttpResponseBlockDelivery - reason: str - reason_code: str - findings: _containers.RepeatedCompositeFieldContainer[Finding] - metadata: _containers.ScalarMap[str, str] - def __init__(self, skip: _Optional[_Union[HttpResponsePreflightSkip, _Mapping]] = ..., inspect: _Optional[_Union[HttpResponsePreflightInspect, _Mapping]] = ..., block_delivery: _Optional[_Union[HttpResponseBlockDelivery, _Mapping]] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... - -class HttpResponsePreflightSkip(_message.Message): - __slots__ = () - def __init__(self) -> None: ... - -class HttpResponseBlockDelivery(_message.Message): - __slots__ = () - def __init__(self) -> None: ... - -class HttpResponsePreflightInspect(_message.Message): - __slots__ = ("body_mode", "header_mutations") - BODY_MODE_FIELD_NUMBER: _ClassVar[int] - HEADER_MUTATIONS_FIELD_NUMBER: _ClassVar[int] - body_mode: HttpResponseBodyMode - header_mutations: _containers.RepeatedCompositeFieldContainer[HeaderMutation] - def __init__(self, body_mode: _Optional[_Union[HttpResponseBodyMode, str]] = ..., header_mutations: _Optional[_Iterable[_Union[HeaderMutation, _Mapping]]] = ...) -> None: ... - -class HttpResponseBodyUnit(_message.Message): - __slots__ = ("sequence", "data", "end_of_stream") - SEQUENCE_FIELD_NUMBER: _ClassVar[int] - DATA_FIELD_NUMBER: _ClassVar[int] - END_OF_STREAM_FIELD_NUMBER: _ClassVar[int] - sequence: int - data: bytes - end_of_stream: bool - def __init__(self, sequence: _Optional[int] = ..., data: _Optional[bytes] = ..., end_of_stream: _Optional[bool] = ...) -> None: ... - -class HttpResponseBodyResult(_message.Message): - __slots__ = ("sequence", "pass_through", "transform", "block_delivery", "skip_remaining", "reason", "reason_code", "findings", "metadata") - class MetadataEntry(_message.Message): - __slots__ = ("key", "value") - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: str - def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... - SEQUENCE_FIELD_NUMBER: _ClassVar[int] - PASS_THROUGH_FIELD_NUMBER: _ClassVar[int] - TRANSFORM_FIELD_NUMBER: _ClassVar[int] - BLOCK_DELIVERY_FIELD_NUMBER: _ClassVar[int] - SKIP_REMAINING_FIELD_NUMBER: _ClassVar[int] - REASON_FIELD_NUMBER: _ClassVar[int] - REASON_CODE_FIELD_NUMBER: _ClassVar[int] - FINDINGS_FIELD_NUMBER: _ClassVar[int] - METADATA_FIELD_NUMBER: _ClassVar[int] - sequence: int - pass_through: HttpResponseBodyPassThrough - transform: HttpResponseBodyTransform - block_delivery: HttpResponseBlockDelivery - skip_remaining: HttpResponseBodySkipRemaining - reason: str - reason_code: str - findings: _containers.RepeatedCompositeFieldContainer[Finding] - metadata: _containers.ScalarMap[str, str] - def __init__(self, sequence: _Optional[int] = ..., pass_through: _Optional[_Union[HttpResponseBodyPassThrough, _Mapping]] = ..., transform: _Optional[_Union[HttpResponseBodyTransform, _Mapping]] = ..., block_delivery: _Optional[_Union[HttpResponseBlockDelivery, _Mapping]] = ..., skip_remaining: _Optional[_Union[HttpResponseBodySkipRemaining, _Mapping]] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... - -class HttpResponseBodyPassThrough(_message.Message): - __slots__ = () - def __init__(self) -> None: ... - -class HttpResponseBodySkipRemaining(_message.Message): - __slots__ = ("pass_through", "transform") - PASS_THROUGH_FIELD_NUMBER: _ClassVar[int] - TRANSFORM_FIELD_NUMBER: _ClassVar[int] - pass_through: HttpResponseBodyPassThrough - transform: HttpResponseBodyTransform - def __init__(self, pass_through: _Optional[_Union[HttpResponseBodyPassThrough, _Mapping]] = ..., transform: _Optional[_Union[HttpResponseBodyTransform, _Mapping]] = ...) -> None: ... - -class HttpResponseBodyTransform(_message.Message): - __slots__ = ("data",) - DATA_FIELD_NUMBER: _ClassVar[int] - data: bytes - def __init__(self, data: _Optional[bytes] = ...) -> None: ... - -class HttpResponseTrailers(_message.Message): - __slots__ = ("headers",) - HEADERS_FIELD_NUMBER: _ClassVar[int] - headers: _containers.RepeatedCompositeFieldContainer[HttpHeader] - def __init__(self, headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ...) -> None: ... - -class HttpResponseTrailersResult(_message.Message): - __slots__ = ("trailer_mutations", "reason", "reason_code", "findings", "metadata") - class MetadataEntry(_message.Message): - __slots__ = ("key", "value") - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: str - def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... - TRAILER_MUTATIONS_FIELD_NUMBER: _ClassVar[int] - REASON_FIELD_NUMBER: _ClassVar[int] - REASON_CODE_FIELD_NUMBER: _ClassVar[int] - FINDINGS_FIELD_NUMBER: _ClassVar[int] - METADATA_FIELD_NUMBER: _ClassVar[int] - trailer_mutations: _containers.RepeatedCompositeFieldContainer[HeaderMutation] - reason: str - reason_code: str - findings: _containers.RepeatedCompositeFieldContainer[Finding] - metadata: _containers.ScalarMap[str, str] - def __init__(self, trailer_mutations: _Optional[_Iterable[_Union[HeaderMutation, _Mapping]]] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... - -class MiddlewareSessionEnd(_message.Message): - __slots__ = ("reason", "protocol_error") - REASON_FIELD_NUMBER: _ClassVar[int] - PROTOCOL_ERROR_FIELD_NUMBER: _ClassVar[int] - reason: MiddlewareSessionEndReason - protocol_error: MiddlewareSessionProtocolError - def __init__(self, reason: _Optional[_Union[MiddlewareSessionEndReason, str]] = ..., protocol_error: _Optional[_Union[MiddlewareSessionProtocolError, _Mapping]] = ...) -> None: ... - -class MiddlewareSessionProtocolError(_message.Message): - __slots__ = ("web_socket", "middleware_exchange") - WEB_SOCKET_FIELD_NUMBER: _ClassVar[int] - MIDDLEWARE_EXCHANGE_FIELD_NUMBER: _ClassVar[int] - web_socket: WebSocketProtocolError - middleware_exchange: MiddlewareExchangeProtocolError - def __init__(self, web_socket: _Optional[_Union[WebSocketProtocolError, _Mapping]] = ..., middleware_exchange: _Optional[_Union[MiddlewareExchangeProtocolError, _Mapping]] = ...) -> None: ... - -class WebSocketProtocolError(_message.Message): - __slots__ = () - def __init__(self) -> None: ... - -class MiddlewareExchangeProtocolError(_message.Message): - __slots__ = () - def __init__(self) -> None: ... - class WebSocketSessionEvent(_message.Message): __slots__ = ("preflight", "session_start", "message", "session_end") PREFLIGHT_FIELD_NUMBER: _ClassVar[int] @@ -376,8 +158,8 @@ class WebSocketSessionEvent(_message.Message): preflight: WebSocketPreflight session_start: WebSocketSessionStart message: WebSocketMessage - session_end: MiddlewareSessionEnd - def __init__(self, preflight: _Optional[_Union[WebSocketPreflight, _Mapping]] = ..., session_start: _Optional[_Union[WebSocketSessionStart, _Mapping]] = ..., message: _Optional[_Union[WebSocketMessage, _Mapping]] = ..., session_end: _Optional[_Union[MiddlewareSessionEnd, _Mapping]] = ...) -> None: ... + session_end: WebSocketSessionEnd + def __init__(self, preflight: _Optional[_Union[WebSocketPreflight, _Mapping]] = ..., session_start: _Optional[_Union[WebSocketSessionStart, _Mapping]] = ..., message: _Optional[_Union[WebSocketMessage, _Mapping]] = ..., session_end: _Optional[_Union[WebSocketSessionEnd, _Mapping]] = ...) -> None: ... class WebSocketPreflight(_message.Message): __slots__ = ("session_id", "phase", "context", "target", "requested_subprotocols", "middleware_name", "config") @@ -413,6 +195,12 @@ class WebSocketMessage(_message.Message): binary: bytes def __init__(self, sequence: _Optional[int] = ..., text: _Optional[str] = ..., binary: _Optional[bytes] = ...) -> None: ... +class WebSocketSessionEnd(_message.Message): + __slots__ = ("reason",) + REASON_FIELD_NUMBER: _ClassVar[int] + reason: WebSocketSessionEndReason + def __init__(self, reason: _Optional[_Union[WebSocketSessionEndReason, str]] = ...) -> None: ... + class WebSocketPreflightDecision(_message.Message): __slots__ = ("action", "reason", "reason_code", "findings", "metadata") class MetadataEntry(_message.Message): @@ -509,73 +297,6 @@ class Process(_message.Message): ancestors: _containers.RepeatedScalarFieldContainer[str] def __init__(self, binary: _Optional[str] = ..., pid: _Optional[int] = ..., ancestors: _Optional[_Iterable[str]] = ...) -> None: ... -class AgentConversationTarget(_message.Message): - __slots__ = ("harness", "harness_version", "hook", "schema_version", "scheme", "host", "port", "path") - HARNESS_FIELD_NUMBER: _ClassVar[int] - HARNESS_VERSION_FIELD_NUMBER: _ClassVar[int] - HOOK_FIELD_NUMBER: _ClassVar[int] - SCHEMA_VERSION_FIELD_NUMBER: _ClassVar[int] - SCHEME_FIELD_NUMBER: _ClassVar[int] - HOST_FIELD_NUMBER: _ClassVar[int] - PORT_FIELD_NUMBER: _ClassVar[int] - PATH_FIELD_NUMBER: _ClassVar[int] - harness: str - harness_version: str - hook: str - schema_version: str - scheme: str - host: str - port: int - path: str - def __init__(self, harness: _Optional[str] = ..., harness_version: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ..., scheme: _Optional[str] = ..., host: _Optional[str] = ..., port: _Optional[int] = ..., path: _Optional[str] = ...) -> None: ... - -class AgentConversationEvaluation(_message.Message): - __slots__ = ("phase", "context", "config", "target", "middleware_name", "session_id", "turn_id", "request_body") - PHASE_FIELD_NUMBER: _ClassVar[int] - CONTEXT_FIELD_NUMBER: _ClassVar[int] - CONFIG_FIELD_NUMBER: _ClassVar[int] - TARGET_FIELD_NUMBER: _ClassVar[int] - MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] - SESSION_ID_FIELD_NUMBER: _ClassVar[int] - TURN_ID_FIELD_NUMBER: _ClassVar[int] - REQUEST_BODY_FIELD_NUMBER: _ClassVar[int] - phase: SupervisorMiddlewarePhase - context: RequestContext - config: _struct_pb2.Struct - target: AgentConversationTarget - middleware_name: str - session_id: str - turn_id: str - request_body: bytes - def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[AgentConversationTarget, _Mapping]] = ..., middleware_name: _Optional[str] = ..., session_id: _Optional[str] = ..., turn_id: _Optional[str] = ..., request_body: _Optional[bytes] = ...) -> None: ... - -class AgentConversationResult(_message.Message): - __slots__ = ("decision", "reason", "attestation", "findings", "metadata", "reason_code", "replacement_body", "has_replacement_body") - class MetadataEntry(_message.Message): - __slots__ = ("key", "value") - KEY_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - key: str - value: str - def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... - DECISION_FIELD_NUMBER: _ClassVar[int] - REASON_FIELD_NUMBER: _ClassVar[int] - ATTESTATION_FIELD_NUMBER: _ClassVar[int] - FINDINGS_FIELD_NUMBER: _ClassVar[int] - METADATA_FIELD_NUMBER: _ClassVar[int] - REASON_CODE_FIELD_NUMBER: _ClassVar[int] - REPLACEMENT_BODY_FIELD_NUMBER: _ClassVar[int] - HAS_REPLACEMENT_BODY_FIELD_NUMBER: _ClassVar[int] - decision: Decision - reason: str - attestation: bytes - findings: _containers.RepeatedCompositeFieldContainer[Finding] - metadata: _containers.ScalarMap[str, str] - reason_code: str - replacement_body: bytes - has_replacement_body: bool - def __init__(self, decision: _Optional[_Union[Decision, str]] = ..., reason: _Optional[str] = ..., attestation: _Optional[bytes] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ..., reason_code: _Optional[str] = ..., replacement_body: _Optional[bytes] = ..., has_replacement_body: _Optional[bool] = ...) -> None: ... - class Finding(_message.Message): __slots__ = ("type", "label", "count", "confidence", "severity") TYPE_FIELD_NUMBER: _ClassVar[int] diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py index a298fbcd..67ce935c 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py @@ -3,8 +3,8 @@ import grpc import warnings +from egress_gate.bindings import supervisor_middleware_pb2 as egress__gate_dot_bindings_dot_supervisor__middleware__pb2 from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 -from . import supervisor_middleware_pb2 as supervisor__middleware__pb2 GRPC_GENERATED_VERSION = '1.81.1' GRPC_VERSION = grpc.__version__ @@ -19,7 +19,7 @@ if _version_not_supported: raise RuntimeError( f'The grpc package installed is at version {GRPC_VERSION},' - + ' but the generated code in supervisor_middleware_pb2_grpc.py depends on' + + ' but the generated code in egress_gate/bindings/supervisor_middleware_pb2_grpc.py depends on' + f' grpcio>={GRPC_GENERATED_VERSION}.' + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' @@ -27,10 +27,9 @@ class SupervisorMiddlewareStub: - """SupervisorMiddleware discovers and configures one operator-run middleware. - It evaluates HTTP requests, HTTP responses, WebSocket messages, and supported - agent-harness requests at their declared phases. - Phase-specific services share the same registration. + """SupervisorMiddleware lets an operator-run service inspect and transform + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials. """ def __init__(self, channel): @@ -42,35 +41,29 @@ def __init__(self, channel): self.Describe = channel.unary_unary( '/openshell.middleware.v1.SupervisorMiddleware/Describe', request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, - response_deserializer=supervisor__middleware__pb2.MiddlewareManifest.FromString, + response_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.MiddlewareManifest.FromString, _registered_method=True) self.ValidateConfig = channel.unary_unary( '/openshell.middleware.v1.SupervisorMiddleware/ValidateConfig', - request_serializer=supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, - response_deserializer=supervisor__middleware__pb2.ValidateConfigResponse.FromString, + request_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, + response_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigResponse.FromString, _registered_method=True) self.EvaluateHttpRequest = channel.unary_unary( '/openshell.middleware.v1.SupervisorMiddleware/EvaluateHttpRequest', - request_serializer=supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, - response_deserializer=supervisor__middleware__pb2.HttpRequestResult.FromString, - _registered_method=True) - self.EvaluateAgentConversation = channel.unary_unary( - '/openshell.middleware.v1.SupervisorMiddleware/EvaluateAgentConversation', - request_serializer=supervisor__middleware__pb2.AgentConversationEvaluation.SerializeToString, - response_deserializer=supervisor__middleware__pb2.AgentConversationResult.FromString, + request_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, + response_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestResult.FromString, _registered_method=True) self.EvaluateWebSocketSession = channel.stream_stream( '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', - request_serializer=supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, - response_deserializer=supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + request_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + response_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, _registered_method=True) class SupervisorMiddlewareServicer: - """SupervisorMiddleware discovers and configures one operator-run middleware. - It evaluates HTTP requests, HTTP responses, WebSocket messages, and supported - agent-harness requests at their declared phases. - Phase-specific services share the same registration. + """SupervisorMiddleware lets an operator-run service inspect and transform + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials. """ def Describe(self, request, context): @@ -95,14 +88,6 @@ def EvaluateHttpRequest(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') - def EvaluateAgentConversation(self, request, context): - """EvaluateAgentConversation returns an allow, deny, or replacement decision for - one versioned, harness-native request before the harness commits or sends it. - """ - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - def EvaluateWebSocketSession(self, request_iterator, context): """EvaluateWebSocketSession opens one ordered, phase-specific stream for a single middleware stage and WebSocket upgrade attempt. The current @@ -122,27 +107,22 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): 'Describe': grpc.unary_unary_rpc_method_handler( servicer.Describe, request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, - response_serializer=supervisor__middleware__pb2.MiddlewareManifest.SerializeToString, + response_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.MiddlewareManifest.SerializeToString, ), 'ValidateConfig': grpc.unary_unary_rpc_method_handler( servicer.ValidateConfig, - request_deserializer=supervisor__middleware__pb2.ValidateConfigRequest.FromString, - response_serializer=supervisor__middleware__pb2.ValidateConfigResponse.SerializeToString, + request_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigRequest.FromString, + response_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigResponse.SerializeToString, ), 'EvaluateHttpRequest': grpc.unary_unary_rpc_method_handler( servicer.EvaluateHttpRequest, - request_deserializer=supervisor__middleware__pb2.HttpRequestEvaluation.FromString, - response_serializer=supervisor__middleware__pb2.HttpRequestResult.SerializeToString, - ), - 'EvaluateAgentConversation': grpc.unary_unary_rpc_method_handler( - servicer.EvaluateAgentConversation, - request_deserializer=supervisor__middleware__pb2.AgentConversationEvaluation.FromString, - response_serializer=supervisor__middleware__pb2.AgentConversationResult.SerializeToString, + request_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestEvaluation.FromString, + response_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestResult.SerializeToString, ), 'EvaluateWebSocketSession': grpc.stream_stream_rpc_method_handler( servicer.EvaluateWebSocketSession, - request_deserializer=supervisor__middleware__pb2.WebSocketSessionEvent.FromString, - response_serializer=supervisor__middleware__pb2.WebSocketSessionEventResult.SerializeToString, + request_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEvent.FromString, + response_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEventResult.SerializeToString, ), } generic_handler = grpc.method_handlers_generic_handler( @@ -153,10 +133,9 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class SupervisorMiddleware: - """SupervisorMiddleware discovers and configures one operator-run middleware. - It evaluates HTTP requests, HTTP responses, WebSocket messages, and supported - agent-harness requests at their declared phases. - Phase-specific services share the same registration. + """SupervisorMiddleware lets an operator-run service inspect and transform + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials. """ @staticmethod @@ -175,88 +154,7 @@ def Describe(request, target, '/openshell.middleware.v1.SupervisorMiddleware/Describe', google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, - supervisor__middleware__pb2.MiddlewareManifest.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) - - -class HttpResponsePreReturnStub: - """HttpResponsePreReturn evaluates one response for one middleware stage before - OpenShell returns it to the sandbox. - """ - - def __init__(self, channel): - """Constructor. - - Args: - channel: A grpc.Channel. - """ - self.Evaluate = channel.stream_stream( - '/openshell.middleware.v1.HttpResponsePreReturn/Evaluate', - request_serializer=supervisor__middleware__pb2.HttpResponseEvent.SerializeToString, - response_deserializer=supervisor__middleware__pb2.HttpResponseEventResult.FromString, - _registered_method=True) - - -class HttpResponsePreReturnServicer: - """HttpResponsePreReturn evaluates one response for one middleware stage before - OpenShell returns it to the sandbox. - """ - - def Evaluate(self, request_iterator, context): - """Evaluate starts with preflight and may continue with selected body units - and trailers. A body unit marked end_of_stream ends body inspection, not - the event stream. Trailers and one best-effort session_end may follow. - """ - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - -def add_HttpResponsePreReturnServicer_to_server(servicer, server): - rpc_method_handlers = { - 'Evaluate': grpc.stream_stream_rpc_method_handler( - servicer.Evaluate, - request_deserializer=supervisor__middleware__pb2.HttpResponseEvent.FromString, - response_serializer=supervisor__middleware__pb2.HttpResponseEventResult.SerializeToString, - ), - } - generic_handler = grpc.method_handlers_generic_handler( - 'openshell.middleware.v1.HttpResponsePreReturn', rpc_method_handlers) - server.add_generic_rpc_handlers((generic_handler,)) - server.add_registered_method_handlers('openshell.middleware.v1.HttpResponsePreReturn', rpc_method_handlers) - - - # This class is part of an EXPERIMENTAL API. -class HttpResponsePreReturn: - """HttpResponsePreReturn evaluates one response for one middleware stage before - OpenShell returns it to the sandbox. - """ - - @staticmethod - def Evaluate(request_iterator, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None): - return grpc.experimental.stream_stream( - request_iterator, - target, - '/openshell.middleware.v1.HttpResponsePreReturn/Evaluate', - supervisor__middleware__pb2.HttpResponseEvent.SerializeToString, - supervisor__middleware__pb2.HttpResponseEventResult.FromString, + egress__gate_dot_bindings_dot_supervisor__middleware__pb2.MiddlewareManifest.FromString, options, channel_credentials, insecure, @@ -282,8 +180,8 @@ def ValidateConfig(request, request, target, '/openshell.middleware.v1.SupervisorMiddleware/ValidateConfig', - supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, - supervisor__middleware__pb2.ValidateConfigResponse.FromString, + egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, + egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigResponse.FromString, options, channel_credentials, insecure, @@ -309,35 +207,8 @@ def EvaluateHttpRequest(request, request, target, '/openshell.middleware.v1.SupervisorMiddleware/EvaluateHttpRequest', - supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, - supervisor__middleware__pb2.HttpRequestResult.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) - - @staticmethod - def EvaluateAgentConversation(request, - target, - options=(), - channel_credentials=None, - call_credentials=None, - insecure=False, - compression=None, - wait_for_ready=None, - timeout=None, - metadata=None): - return grpc.experimental.unary_unary( - request, - target, - '/openshell.middleware.v1.SupervisorMiddleware/EvaluateAgentConversation', - supervisor__middleware__pb2.AgentConversationEvaluation.SerializeToString, - supervisor__middleware__pb2.AgentConversationResult.FromString, + egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, + egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestResult.FromString, options, channel_credentials, insecure, @@ -363,8 +234,8 @@ def EvaluateWebSocketSession(request_iterator, request_iterator, target, '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', - supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, - supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, options, channel_credentials, insecure, diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 5e2c4491..3b87609f 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -179,20 +179,20 @@ def serve( help="Write content-safe evaluation records as newline-delimited JSON.", ), ] = None, - require_agent_attestation: Annotated[ - bool, + admission_config: Annotated[ + Path | None, typer.Option( - "--require-agent-attestation/--no-require-agent-attestation", + "--admission-config", help=( - "Require a supervisor-held agent context attestation on HTTP " - "egress. Enabled by default; disable only for an " - "explicitly unmanaged deployment." + "Operator-owned JSON configuration for authenticated admission " + "and receipt-required egress." ), ), - ] = True, + ] = None, ) -> None: """Start the Egress Gate gRPC service and run until shutdown.""" options = _command_options(context) + from egress_gate.service.admission import AdmissionServerConfig from egress_gate.service.server import EgressGateServer try: @@ -231,14 +231,21 @@ def serve( if json_log is not None: configure_json_log(json_log) try: + admission = ( + AdmissionServerConfig.model_validate_json(admission_config.read_bytes()) + if admission_config is not None + else None + ) EgressGateServer( options.registry, timeout_middleware_processing=timeout_middleware_processing, - require_agent_attestation=require_agent_attestation, + admission=admission, ).serve_sync(listen) except EgressGateError as error: _render_egress_error("Egress Gate could not start", error) raise typer.Exit(code=1) from None + except (ValueError, OSError): + raise typer.BadParameter("Invalid admission service configuration") from None @app.command( diff --git a/projects/egress-gate/src/egress_gate/service/admission.py b/projects/egress-gate/src/egress_gate/service/admission.py new file mode 100644 index 00000000..3edca279 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/service/admission.py @@ -0,0 +1,137 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Authenticated HTTP admission beside the standard OpenShell middleware RPCs.""" + +from __future__ import annotations + +import base64 +import hmac +import json +import ssl +from pathlib import Path +from uuid import uuid4 + +from aiohttp import web +from pydantic import SecretStr, ValidationError + +from egress_gate.admission import ( + MAX_ADMISSION_BODY_BYTES, + AdmissionHook, + AdmissionProvenance, + HarnessAdmissionContext, + HarnessAdmissionRequest, +) +from egress_gate.base import StrictDomainModel +from egress_gate.errors import EgressGateError, TimeoutExpiredError +from egress_gate.request import HttpTarget +from egress_gate.service.servicer import EgressGateMiddleware +from egress_gate.string_validators import BoundedMetadataString + + +class AdmissionServerConfig(StrictDomainModel): + """Operator-owned configuration; never supplied by the sandbox application.""" + + listen: str + tls_certificate: Path + tls_private_key: Path + gateway_public_key: Path + gateway_issuer: str + gateway_audience: str + middleware_name: BoundedMetadataString + bearer_token: SecretStr + sandbox_id_file: Path + provider_target: HttpTarget + policy: dict[str, object] + + +class AdmissionCall(StrictDomainModel): + """Only the candidate and correlation identifiers are caller assertions.""" + + kind: AdmissionHook + session_id: BoundedMetadataString + submission_id: BoundedMetadataString + body: dict[str, object] + + +def create_admission_application( + middleware: EgressGateMiddleware, config: AdmissionServerConfig +) -> web.Application: + """Create one bounded endpoint; identity and policy come from the operator.""" + + async def admit(request: web.Request) -> web.Response: + authorizations = request.headers.getall("Authorization", []) + expected = f"Bearer {config.bearer_token.get_secret_value()}".encode() + if len(authorizations) != 1 or not hmac.compare_digest( + authorizations[0].encode(), expected + ): + raise web.HTTPUnauthorized(text="admission authentication failed") + try: + call = AdmissionCall.model_validate_json(await request.read()) + schema = call.body.get("schema_version") + if not isinstance(schema, str): + raise ValueError("missing schema") + # Setup learns the real ID after sandbox creation. Reading this small, + # host-owned file lets setup finish without an administrative HTTP API. + # Until it exists, admission is unavailable, never anonymously allowed. + sandbox_id = config.sandbox_id_file.read_text().strip() + context = HarnessAdmissionContext( + request_id=str(uuid4()), + sandbox_id=sandbox_id, + middleware_name=config.middleware_name, + harness="pi", + harness_version="sdk-v1", + hook=call.kind, + schema_version=schema, + provider_target=config.provider_target, + provider_adapter_schema="openai.request.v1", + ) + result = await middleware.admit( + HarnessAdmissionRequest( + request_body=json.dumps( + call.body, + ensure_ascii=False, + allow_nan=False, + separators=(",", ":"), + sort_keys=True, + ).encode(), + provenance=AdmissionProvenance( + session_id=call.session_id, submission_id=call.submission_id + ), + ), + context, + config.policy, + ) + except (ValidationError, ValueError): + raise web.HTTPBadRequest(text="invalid admission request") from None + except (OSError, EgressGateError, TimeoutExpiredError): + raise web.HTTPServiceUnavailable( + text="admission is not provisioned" + ) from None + return web.json_response( + { + "decision": result.decision.value, + "reason_code": result.reason_code, + "replacement": ( + json.loads(result.replacement_body) + if result.replacement_body is not None + else None + ), + "receipt": ( + base64.urlsafe_b64encode(result.attestation).decode("ascii") + if result.attestation is not None + else None + ), + } + ) + + application = web.Application(client_max_size=MAX_ADMISSION_BODY_BYTES) + application.router.add_post("/v1/admission", admit) + return application + + +def admission_tls_context(config: AdmissionServerConfig) -> ssl.SSLContext: + """Use operator-provisioned TLS, with no insecure fallback.""" + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(config.tls_certificate, config.tls_private_key) + return context diff --git a/projects/egress-gate/src/egress_gate/service/authentication.py b/projects/egress-gate/src/egress_gate/service/authentication.py new file mode 100644 index 00000000..844d6952 --- /dev/null +++ b/projects/egress-gate/src/egress_gate/service/authentication.py @@ -0,0 +1,51 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Verify the upstream OpenShell extension-token contract at the RPC boundary.""" + +from __future__ import annotations + +from collections.abc import Iterable + +import jwt + +from egress_gate.bindings import supervisor_middleware_pb2 as pb2 + + +class GatewayAuthentication: + """A single operator-provisioned gateway key, issuer, and exact audience.""" + + def __init__(self, public_key: bytes, issuer: str, audience: str) -> None: + self._public_key = public_key + self._issuer = issuer + self.audience = audience + + def verify( + self, metadata: Iterable[tuple[str, str | bytes]], request: object + ) -> None: + """Raise on unauthenticated calls or a forged supervisor request context.""" + values = [value for key, value in metadata if key == "authorization"] + if len(values) != 1 or not isinstance(values[0], str): + raise ValueError("authentication required") + scheme, separator, token = values[0].partition(" ") + if scheme != "Bearer" or not separator: + raise ValueError("authentication required") + if jwt.get_unverified_header(token).get("typ") != "openshell-ext+jwt": + raise ValueError("incorrect token type") + claims = jwt.decode( + token, + self._public_key, + algorithms=["EdDSA"], + issuer=self._issuer, + audience=self.audience, + options={"require": ["iss", "aud", "exp", "iat", "caller_kind"]}, + ) + kind = claims["caller_kind"] + if kind not in ("gateway", "supervisor"): + raise ValueError("invalid caller") + if isinstance(request, pb2.HttpRequestEvaluation) and ( + kind != "supervisor" + or not request.context.sandbox_id + or claims.get("sandbox_id") != request.context.sandbox_id + ): + raise ValueError("sandbox identity mismatch") diff --git a/projects/egress-gate/src/egress_gate/service/server.py b/projects/egress-gate/src/egress_gate/service/server.py index a1c2e6fb..00210ea5 100644 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ b/projects/egress-gate/src/egress_gate/service/server.py @@ -10,6 +10,8 @@ from typing import Protocol, runtime_checkable import grpc +import jwt +from aiohttp import web from google.protobuf.message import DecodeError from egress_gate.bindings import supervisor_middleware_pb2_grpc as pb2_grpc @@ -21,6 +23,12 @@ from egress_gate.errors import EgressGateError, ErrorCode from egress_gate.gates.registry import GateRegistry from egress_gate.logging import get_logger +from egress_gate.service.admission import ( + AdmissionServerConfig, + admission_tls_context, + create_admission_application, +) +from egress_gate.service.authentication import GatewayAuthentication from egress_gate.service.servicer import EgressGateMiddleware DEFAULT_LISTEN_ADDRESS = "127.0.0.1:50051" @@ -34,12 +42,23 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - require_agent_attestation: bool = False, + admission: AdmissionServerConfig | None = None, ) -> None: + self._admission = admission + self._authentication = ( + GatewayAuthentication( + admission.gateway_public_key.read_bytes(), + admission.gateway_issuer, + admission.gateway_audience, + ) + if admission is not None + else None + ) self._middleware = EgressGateMiddleware( registry, timeout_middleware_processing=timeout_middleware_processing, - require_agent_attestation=require_agent_attestation, + require_agent_attestation=admission is not None, + expected_audience=admission.gateway_audience if admission else "", ) def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: @@ -51,11 +70,39 @@ def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: async def serve_async(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: """Serve asynchronously until termination, then close owned resources.""" - server = _create_grpc_server(self._middleware) + server = _create_grpc_server(self._middleware, self._authentication) + runner: web.AppRunner | None = None try: try: requested_port = _validated_listen_port(listen) - bound_port = server.add_insecure_port(listen) + if self._admission is None: + bound_port = server.add_insecure_port(listen) + else: + config = self._admission + bound_port = server.add_secure_port( + listen, + grpc.ssl_server_credentials( + [ + ( + config.tls_private_key.read_bytes(), + config.tls_certificate.read_bytes(), + ) + ] + ), + ) + runner = web.AppRunner( + create_admission_application(self._middleware, config), + access_log=None, + ) + await runner.setup() + http_port = _validated_listen_port(config.listen) + http_host = config.listen.rsplit(":", 1)[0].strip("[]") + await web.TCPSite( + runner, + http_host, + http_port, + ssl_context=admission_tls_context(config), + ).start() if bound_port != requested_port: raise EgressGateError(ErrorCode.SERVER_BIND_FAILED) _LOGGER.info( @@ -72,7 +119,11 @@ async def serve_async(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: try: await _stop_grpc_server(server) finally: - await self._middleware.close() + try: + if runner is not None: + await runner.cleanup() + finally: + await self._middleware.close() _LOGGER = get_logger(__name__) @@ -80,9 +131,10 @@ async def serve_async(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: def _create_grpc_server( middleware: EgressGateMiddleware, + authentication: GatewayAuthentication | None = None, ) -> grpc.aio.Server: server = grpc.aio.server( - interceptors=(_MalformedProtobufInterceptor(),), + interceptors=(_MalformedProtobufInterceptor(authentication),), maximum_concurrent_rpcs=MAX_CONCURRENT_RPCS, options=(("grpc.max_receive_message_length", MAX_RECEIVE_MESSAGE_BYTES),), ) @@ -91,7 +143,10 @@ def _create_grpc_server( class _MalformedProtobufInterceptor(grpc.aio.ServerInterceptor): - """Map protobuf decoding failures to the public invalid-input contract.""" + """Authenticate RPCs and map decoding failures to content-safe statuses.""" + + def __init__(self, authentication: GatewayAuthentication | None = None) -> None: + self._authentication = authentication async def intercept_service( self, @@ -120,6 +175,15 @@ async def invoke_safely( request: object, context: grpc.aio.ServicerContext[object, object], ) -> object: + if self._authentication is not None: + try: + self._authentication.verify( + context.invocation_metadata() or (), request + ) + except (ValueError, jwt.PyJWTError): + await context.abort( + grpc.StatusCode.UNAUTHENTICATED, "authentication failed" + ) if request is _MALFORMED_PROTOBUF: await context.abort( grpc.StatusCode.INVALID_ARGUMENT, diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index 5bf22573..bf6679c5 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -12,23 +12,20 @@ from collections.abc import Callable, Iterable from concurrent.futures import Future, ThreadPoolExecutor from threading import Lock -from typing import Literal, Never, Protocol, TypedDict, TypeVar +from typing import Never, Protocol, TypedDict, TypeVar import grpc from google.protobuf import json_format from google.protobuf.message import Message from egress_gate.admission import ( - MAX_ADMISSION_BODY_BYTES, PI_HARNESS_VERSION, RECEIPT_HEADER, - AdmissionDecision, - AdmissionHook, - AdmissionProvenance, AttestedEgressProcessor, HarnessAdmissionContext, HarnessAdmissionProcessor, HarnessAdmissionRequest, + HarnessAdmissionResult, ReceiptAuthority, create_pi_adapter_registry, create_provider_adapter_registry, @@ -41,7 +38,6 @@ DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, LIMIT_REASON, LIMIT_REASON_CODE, - MAX_AGENT_ATTESTATION_BYTES, MAX_BODY_BYTES, MAX_CONCURRENT_PROCESSING, MAX_PROTO_CONFIG_BYTES, @@ -92,12 +88,6 @@ ) -def _require_harness_version(value: str) -> Literal["sdk-v1"]: - if value == PI_HARNESS_VERSION: - return value - raise ValueError("invalid admission harness version") - - class EgressGateMiddleware(pb2_grpc.SupervisorMiddlewareServicer): """Validate, prepare, resolve, and run Egress Gate policies.""" @@ -107,6 +97,7 @@ def __init__( *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, require_agent_attestation: bool = False, + expected_audience: str = "", ) -> None: registry.configuration_json_schema() self._registry = registry @@ -117,6 +108,7 @@ def __init__( self._receipt_authority = ReceiptAuthority() self._admission_adapters = create_pi_adapter_registry() self._require_agent_attestation = require_agent_attestation + self._expected_audience = expected_audience self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING) self._processing_executor = ThreadPoolExecutor( max_workers=MAX_CONCURRENT_PROCESSING, @@ -147,26 +139,13 @@ async def Describe( return pb2.MiddlewareManifest( name=SERVICE_NAME, service_version=SERVICE_VERSION, + expected_audience=self._expected_audience, bindings=[ pb2.MiddlewareBinding( operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST, phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, max_payload_bytes=MAX_BODY_BYTES, ), - *( - pb2.MiddlewareBinding( - operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION, - phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, - max_payload_bytes=MAX_ADMISSION_BODY_BYTES, - harness=harness, - hook=hook, - schema_version=schema_version, - ) - for harness, hook, schema_version in ( - self._admission_adapters.bindings - ) - if self._require_agent_attestation - ), ], ) @@ -192,93 +171,24 @@ async def EvaluateHttpRequest( """Resolve the prepared pipeline and evaluate one current request.""" return await self._evaluate_rpc(request, context) - async def EvaluateAgentConversation( + async def admit( self, - request: pb2.AgentConversationEvaluation, - context: grpc.aio.ServicerContext[ - pb2.AgentConversationEvaluation, - pb2.AgentConversationResult, - ], - ) -> pb2.AgentConversationResult: - """Evaluate one supervisor-stamped agent admission request.""" + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + policy: dict[str, object], + ) -> HarnessAdmissionResult: + """Evaluate a candidate with HTTP-service-owned identity and policy.""" + if not self._require_agent_attestation: + raise ValueError("admission is disabled") timeout = Timeout.from_seconds(self._timeout_middleware_processing_seconds) return await self._run_in_worker( - lambda: self._evaluate_agent_admission(request, timeout), - timeout=timeout, - ) - - def _evaluate_agent_admission( - self, - request: pb2.AgentConversationEvaluation, - timeout: Timeout, - ) -> pb2.AgentConversationResult: - try: - if not self._require_agent_attestation: - raise ValueError("agent admission is disabled") - if request.phase != pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: - raise ValueError("invalid admission phase") - if len(request.request_body) > MAX_ADMISSION_BODY_BYTES: - raise ValueError("admission request body is too large") - hook = AdmissionHook(request.target.hook) - target = HttpTarget( - scheme=request.target.scheme, - host=request.target.host, - port=request.target.port, - method="POST", - path=request.target.path, - query="", - ) - provenance = AdmissionProvenance( - session_id=request.session_id, - submission_id=request.turn_id, - ) - processor = HarnessAdmissionProcessor( - self._policy.processor_for( - _mapping_from_proto(request.config), timeout=timeout - ), + lambda: HarnessAdmissionProcessor( + self._policy.processor_for(policy, timeout=timeout), self._admission_adapters, self._receipt_authority, - ) - result = processor.process( - HarnessAdmissionRequest( - request_body=request.request_body, - provenance=provenance, - ), - HarnessAdmissionContext( - request_id=request.context.request_id, - sandbox_id=request.context.sandbox_id, - middleware_name=request.middleware_name, - harness=request.target.harness, - harness_version=_require_harness_version( - request.target.harness_version - ), - hook=hook, - schema_version=request.target.schema_version, - provider_target=target, - provider_adapter_schema="openai.request.v1", - ), - timeout=timeout, - ) - response = pb2.AgentConversationResult( - decision=( - pb2.DECISION_DENY - if result.decision is AdmissionDecision.DENY - else pb2.DECISION_ALLOW - ), - reason_code=result.reason_code or "", - attestation=result.attestation or b"", - replacement_body=result.replacement_body or b"", - has_replacement_body=result.replacement_body is not None, - ) - response.findings.extend( - _finding_to_proto(item) for item in result.findings - ) - return response - except Exception: - return pb2.AgentConversationResult( - decision=pb2.DECISION_DENY, - reason_code="admission_unavailable", - ) + ).process(request, context, timeout=timeout), + timeout=timeout, + ) def _validate_config( self, @@ -397,7 +307,6 @@ def _prepare_and_process( harness_version=PI_HARNESS_VERSION, ).process( domain_request, - agent_attestation=request.agent_attestation, timeout=timeout, ) if any( @@ -620,7 +529,6 @@ def _validate_evaluation_envelope(request: pb2.HttpRequestEvaluation) -> None: or request.target.ByteSize() > MAX_PROTO_TARGET_BYTES or len(request.headers) > MAX_PROTO_HEADERS or _encoded_headers_size(request.headers) > MAX_PROTO_HEADERS_BYTES - or len(request.agent_attestation) > MAX_AGENT_ATTESTATION_BYTES ): raise EgressGateError(ErrorCode.REQUEST_ENVELOPE_INVALID) diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index f53df31b..5dd661e5 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -5,6 +5,7 @@ from __future__ import annotations +import base64 import json from pathlib import Path from typing import Literal @@ -338,11 +339,19 @@ def _egress( request: HttpRequest, attestation: bytes | None, ): - return processor.process( - request, - agent_attestation=attestation or b"", - timeout=Timeout.from_seconds(1), - ) + if attestation: + request = request.model_copy( + update={ + "headers": ( + *request.headers, + HttpHeader( + name=RECEIPT_HEADER, + value=base64.urlsafe_b64encode(attestation).decode(), + ), + ) + } + ) + return processor.process(request, timeout=Timeout.from_seconds(1)) def _admit_provider_request( @@ -872,7 +881,7 @@ def test_qwen_replay_fields_fail_closed_unless_explicitly_supported(mutation) -> assert result.reason_code == "provider_shape_unsupported" -def test_workload_receipt_header_is_reserved_in_managed_flow() -> None: +def test_duplicate_receipt_header_is_denied_in_managed_flow() -> None: admission, egress, _ = _processors() request = _provider_request( "safe", @@ -882,4 +891,4 @@ def test_workload_receipt_header_is_reserved_in_managed_flow() -> None: result = _egress(egress, request, admitted.attestation) - assert result.reason_code == "reserved_header_present" + assert result.reason_code == "attestation_malformed" diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index 907b871d..5771d02b 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -5,7 +5,6 @@ from __future__ import annotations -import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -14,12 +13,6 @@ from google.protobuf import empty_pb2, json_format, message_factory from google.protobuf.message import Message -from egress_gate.admission import ( - PiMessageV1, - PiProviderContextV1, - UserContextEntryV1, - canonical_json_bytes, -) from egress_gate.bindings import supervisor_middleware_pb2 as pb2 from egress_gate.bindings import supervisor_middleware_pb2_grpc as pb2_grpc from egress_gate.errors import EgressGateError, ErrorCode @@ -160,138 +153,13 @@ async def test_generated_stub_round_trip_covers_manifest_and_gate_actions() -> N assert denied.reason_code == "egress_gate_regex_denied" -@pytest.mark.asyncio -async def test_generated_stub_returns_no_attestation_for_append_time_allow() -> None: - body = canonical_json_bytes( - PiMessageV1( - schema_version="openshell.pi-message.v1", origin="user", text="safe" - ) - ) - request = pb2.AgentConversationEvaluation( - phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, - context=pb2.RequestContext(request_id="admission-1", sandbox_id="sandbox"), - config=_config(action_kind="detect"), - target=pb2.AgentConversationTarget( - harness="pi", - harness_version="sdk-v1", - hook="user_message", - schema_version="openshell.pi-message.v1", - scheme="https", - host="provider.invalid", - port=443, - path="/v1/chat/completions", - ), - middleware_name="pi-egress", - session_id="session-1", - turn_id="submission-1", - request_body=body, - ) - middleware = EgressGateMiddleware( - create_builtin_registry(), require_agent_attestation=True - ) - async with _running_stub(middleware) as (stub, _): - response = await stub.EvaluateAgentConversation(request) - - assert response.decision == pb2.DECISION_ALLOW - assert response.attestation == b"" - assert response.has_replacement_body is False - assert not response.metadata - - -@pytest.mark.asyncio -async def test_agent_admission_is_unavailable_when_managed_mode_is_off() -> None: - request = pb2.AgentConversationEvaluation( - phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT - ) - middleware = EgressGateMiddleware(create_builtin_registry()) - async with _running_stub(middleware) as (stub, _): - response = await stub.EvaluateAgentConversation(request) - - assert response.decision == pb2.DECISION_DENY - assert response.reason_code == "admission_unavailable" - - -@pytest.mark.asyncio -async def test_trusted_agent_attestation_is_verified_for_http_egress() -> None: - pi_body = canonical_json_bytes( - PiProviderContextV1( - schema_version="openshell.pi-provider-context.v1", - entries=(UserContextEntryV1(role="user", text="safe"),), - ) - ) - admission = pb2.AgentConversationEvaluation( - phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, - context=pb2.RequestContext(request_id="admission-2", sandbox_id="sandbox"), - config=_config(action_kind="detect"), - target=pb2.AgentConversationTarget( - harness="pi", - harness_version="sdk-v1", - hook="provider_context", - schema_version="openshell.pi-provider-context.v1", - scheme="https", - host="provider.invalid", - port=443, - path="/v1/chat/completions", - ), - middleware_name="pi-egress", - session_id="session-1", - turn_id="submission-2", - request_body=pi_body, - ) - provider_body = json.dumps( - { - "model": "fixture-model", - "messages": [ - {"role": "system", "content": "system"}, - {"role": "user", "content": "safe"}, - ], - "temperature": 0, - "max_completion_tokens": 128, - "tool_choice": "auto", - "stream": True, - "stream_options": {"include_usage": True}, - "store": False, - "prompt_cache_key": "session-1", - }, - separators=(",", ":"), - ).encode() - middleware = EgressGateMiddleware( - create_builtin_registry(), require_agent_attestation=True - ) - async with _running_stub(middleware) as (stub, _): - admitted = await stub.EvaluateAgentConversation(admission) - network = _evaluation(provider_body, action_kind="detect") - network.context.request_id = "network-2" - network.target.host = "provider.invalid" - network.target.path = "/v1/chat/completions" - network.middleware_name = "pi-egress" - network.headers.append( - pb2.HttpHeader(name="content-type", value="application/json") - ) - network.agent_attestation = admitted.attestation - allowed = await stub.EvaluateHttpRequest(network) - missing = _evaluation(provider_body, action_kind="detect") - missing.target.host = "provider.invalid" - missing.target.path = "/v1/chat/completions" - missing.middleware_name = "pi-egress" - missing.headers.append( - pb2.HttpHeader(name="content-type", value="application/json") - ) - denied = await stub.EvaluateHttpRequest(missing) - - assert allowed.decision == pb2.DECISION_ALLOW - assert not allowed.header_mutations - assert denied.decision == pb2.DECISION_DENY - assert denied.reason_code == "attestation_missing" - - @pytest.mark.asyncio async def test_unmanaged_http_rejects_the_reserved_header() -> None: middleware = EgressGateMiddleware(create_builtin_registry()) request = _evaluation(b"safe", action_kind="detect") request.headers.append( pb2.HttpHeader( - name="X-OpenShell-Middleware-Egress-Receipt", + name="X-Egress-Admission", value="eg1.untrusted", ) ) diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py new file mode 100644 index 00000000..c8697715 --- /dev/null +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -0,0 +1,262 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Real local HTTP/RPC transport tests; model traffic uses checked-in fixtures.""" + +from __future__ import annotations + +import json +import time +from collections.abc import AsyncIterator +from contextlib import asynccontextmanager +from pathlib import Path + +import grpc +import jwt +import pytest +import yaml +from aiohttp import web +from aiohttp.test_utils import TestClient, TestServer +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from google.protobuf import empty_pb2, json_format, message_factory + +from egress_gate.admission import RECEIPT_HEADER +from egress_gate.bindings import supervisor_middleware_pb2 as pb +from egress_gate.bindings import supervisor_middleware_pb2_grpc as rpc +from egress_gate.gates import create_builtin_registry +from egress_gate.service.admission import ( + AdmissionServerConfig, + create_admission_application, +) +from egress_gate.service.authentication import GatewayAuthentication +from egress_gate.service.server import _create_grpc_server +from egress_gate.service.servicer import EgressGateMiddleware + +PROJECT = Path(__file__).resolve().parents[2] +AUDIENCE = "urn:openshell:extension:middleware:pi-egress" +AUTHORIZATION = {"Authorization": "Bearer test-admission-credential"} + + +@asynccontextmanager +async def _clients( + directory: Path, +) -> AsyncIterator[ + tuple[ + TestClient[web.Request, web.Application], + rpc.SupervisorMiddlewareStub, + AdmissionServerConfig, + str, + ] +]: + key = Ed25519PrivateKey.generate() + public = key.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ) + (directory / "public.pem").write_bytes(public) + (directory / "sandbox-id").write_text("sandbox") + policy = yaml.safe_load( + (PROJECT / "examples/pi-attested-admission/policy.yaml").read_text() + )["network_middlewares"]["pi_egress_gate"]["config"] + config = AdmissionServerConfig.model_validate( + { + "listen": "127.0.0.1:9443", + "tls_certificate": directory / "unused.crt", + "tls_private_key": directory / "unused.key", + "gateway_public_key": directory / "public.pem", + "gateway_issuer": "openshell-gateway:fixture", + "gateway_audience": AUDIENCE, + "middleware_name": "pi-egress", + "bearer_token": "test-admission-credential", + "sandbox_id_file": directory / "sandbox-id", + "provider_target": { + "scheme": "https", + "host": "provider.test", + "port": 443, + "method": "POST", + "path": "/v1/chat/completions", + "query": "", + }, + "policy": policy, + } + ) + token = jwt.encode( + { + "iss": config.gateway_issuer, + "aud": AUDIENCE, + "iat": int(time.time()), + "exp": int(time.time()) + 60, + "caller_kind": "supervisor", + "sandbox_id": "sandbox", + }, + key, + algorithm="EdDSA", + headers={"typ": "openshell-ext+jwt"}, + ) + middleware = EgressGateMiddleware( + create_builtin_registry(), + require_agent_attestation=True, + expected_audience=AUDIENCE, + ) + server = _create_grpc_server( + middleware, GatewayAuthentication(public, config.gateway_issuer, AUDIENCE) + ) + port = server.add_insecure_port("127.0.0.1:0") + await server.start() + channel = grpc.aio.insecure_channel(f"127.0.0.1:{port}") + async with TestClient( + TestServer(create_admission_application(middleware, config)) + ) as client: + try: + yield client, rpc.SupervisorMiddlewareStub(channel), config, token + finally: + await channel.close() + await server.stop(0) + await middleware.close() + + +def _call(text: str, *, kind: str = "user_message") -> dict[str, object]: + body: dict[str, object] = { + "schema_version": "openshell.pi-message.v1", + "origin": "user", + "text": text, + } + if kind == "provider_context": + body = { + "schema_version": "openshell.pi-provider-context.v1", + "entries": [{"role": "user", "text": text}], + } + return { + "kind": kind, + "session_id": "session", + "submission_id": "submission", + "body": body, + } + + +def _network(config: AdmissionServerConfig, receipt: str) -> pb.HttpRequestEvaluation: + fixtures = json.loads( + (PROJECT / "tests/admission/fixtures/pi-openai-completions.json").read_text() + ) + body = fixtures["user_request"] + body["messages"][1]["content"] = "safe" + request = pb.HttpRequestEvaluation( + phase=pb.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + context=pb.RequestContext(sandbox_id="sandbox", request_id="network"), + target=pb.HttpRequestTarget(**config.provider_target.model_dump()), + middleware_name="pi-egress", + body=json.dumps(body).encode(), + headers=[ + pb.HttpHeader(name="content-type", value="application/json"), + pb.HttpHeader(name=RECEIPT_HEADER, value=receipt), + ], + ) + json_format.ParseDict(config.policy, request.config) + return request + + +@pytest.mark.asyncio +async def test_http_admission_allow_deny_replace_and_authentication( + tmp_path: Path, +) -> None: + async with _clients(tmp_path) as (client, _, config, _): + denied_auth = await client.post("/v1/admission", json=_call("safe")) + assert denied_auth.status == 401 + for text, expected in ( + ("safe", "allow"), + ("DENY_THIS", "deny"), + ("REDACT_THIS", "replace"), + ): + response = await client.post( + "/v1/admission", json=_call(text), headers=AUTHORIZATION + ) + assert response.status == 200 + result = await response.json() + assert result["decision"] == expected, result["reason_code"] + assert result["receipt"] is None + if expected == "replace": + assert result["replacement"]["text"] == "[REDACTED]" + forged = {**_call("safe"), "sandbox_id": "somebody-else"} + response = await client.post( + "/v1/admission", json=forged, headers=AUTHORIZATION + ) + assert response.status == 400 + config.sandbox_id_file.unlink() + response = await client.post( + "/v1/admission", json=_call("safe"), headers=AUTHORIZATION + ) + assert response.status == 503 + + +@pytest.mark.asyncio +async def test_http_receipt_is_verified_and_stripped_by_standard_authenticated_rpc( + tmp_path: Path, +) -> None: + async with _clients(tmp_path) as (client, stub, config, token): + response = await client.post( + "/v1/admission", + json=_call("safe", kind="provider_context"), + headers=AUTHORIZATION, + ) + result = await response.json() + assert result["decision"] == "allow", result["reason_code"] + assert result["receipt"] + request = _network(config, result["receipt"]) + metadata = (("authorization", f"Bearer {token}"),) + allowed = await stub.EvaluateHttpRequest(request, metadata=metadata) + assert allowed.decision == pb.DECISION_ALLOW + assert allowed.header_mutations[-1].remove.name == RECEIPT_HEADER + with pytest.raises(grpc.aio.AioRpcError) as failure: + await stub.EvaluateHttpRequest(request) + assert failure.value.code() == grpc.StatusCode.UNAUTHENTICATED + request.context.sandbox_id = "forged" + with pytest.raises(grpc.aio.AioRpcError) as failure: + await stub.EvaluateHttpRequest(request, metadata=metadata) + assert failure.value.code() == grpc.StatusCode.UNAUTHENTICATED + request.context.sandbox_id = "sandbox" + request.headers.pop() + denied = await stub.EvaluateHttpRequest(request, metadata=metadata) + assert denied.reason_code == "attestation_missing" + request.headers.add(name=RECEIPT_HEADER, value=result["receipt"]) + request.headers.add(name=RECEIPT_HEADER, value=result["receipt"]) + denied = await stub.EvaluateHttpRequest(request, metadata=metadata) + assert denied.reason_code == "attestation_malformed" + empty = message_factory.GetMessageClass( + empty_pb2.DESCRIPTOR.message_types_by_name["Empty"] + )() + manifest = await stub.Describe(empty, metadata=metadata) + assert manifest.expected_audience == AUDIENCE + assert len(manifest.bindings) == 1 + + +@pytest.mark.parametrize("change", ["issuer", "audience", "expired", "type", "key"]) +def test_gateway_authentication_rejects_invalid_trust_claims(change: str) -> None: + key = Ed25519PrivateKey.generate() + public = key.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ) + claims = { + "iss": "trusted-gateway", + "aud": AUDIENCE, + "iat": int(time.time()) - 10, + "exp": int(time.time()) + 60, + "caller_kind": "supervisor", + "sandbox_id": "sandbox", + } + if change == "issuer": + claims["iss"] = "some-other-gateway" + elif change == "audience": + claims["aud"] = "another-service" + elif change == "expired": + claims["exp"] = int(time.time()) - 1 + token = jwt.encode( + claims, + Ed25519PrivateKey.generate() if change == "key" else key, + algorithm="EdDSA", + headers={"typ": "JWT" if change == "type" else "openshell-ext+jwt"}, + ) + request = pb.HttpRequestEvaluation(context=pb.RequestContext(sandbox_id="sandbox")) + with pytest.raises((ValueError, jwt.PyJWTError)): + GatewayAuthentication(public, "trusted-gateway", AUDIENCE).verify( + (("authorization", f"Bearer {token}"),), request + ) diff --git a/projects/egress-gate/tests/service/test_server.py b/projects/egress-gate/tests/service/test_server.py index 9578f4bd..ac64a986 100644 --- a/projects/egress-gate/tests/service/test_server.py +++ b/projects/egress-gate/tests/service/test_server.py @@ -161,7 +161,7 @@ async def record_close(middleware: EgressGateMiddleware) -> None: closed.append(middleware) server = EgressGateServer(create_builtin_registry()) - monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) + monkeypatch.setattr(server_module, "_create_grpc_server", lambda *args: fake_server) monkeypatch.setattr(EgressGateMiddleware, "close", record_close) await server.serve_async("127.0.0.1:50053") @@ -184,7 +184,7 @@ async def record_close(middleware: EgressGateMiddleware) -> None: closed.append(middleware) server = EgressGateServer(create_builtin_registry()) - monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) + monkeypatch.setattr(server_module, "_create_grpc_server", lambda *args: fake_server) monkeypatch.setattr(EgressGateMiddleware, "close", record_close) with pytest.raises(EgressGateError) as error: diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index f9636a9f..ce38c18f 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -24,7 +24,6 @@ DEFAULT_DENY_REASON_CODE, LIMIT_REASON, LIMIT_REASON_CODE, - MAX_AGENT_ATTESTATION_BYTES, MAX_BODY_BYTES, MAX_PROTO_CONFIG_BYTES, MAX_PROTO_CONTEXT_BYTES, @@ -131,33 +130,23 @@ def test_manifest_leaves_the_gateway_rpc_timeout_to_the_operator() -> None: assert manifest.bindings[0].timeout == "" -def test_managed_manifest_advertises_every_pi_admission_binding() -> None: +def test_managed_manifest_uses_only_upstream_http_binding() -> None: middleware = EgressGateMiddleware( - create_builtin_registry(), require_agent_attestation=True + create_builtin_registry(), + require_agent_attestation=True, + expected_audience="urn:test", ) try: manifest = asyncio.run(middleware.Describe(object(), Mock())) finally: asyncio.run(middleware.close()) - - agent_bindings = [ - binding - for binding in manifest.bindings - if binding.operation == pb2.SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION - ] - assert [ - (binding.harness, binding.hook, binding.schema_version) - for binding in agent_bindings - ] == [ - ("pi", "user_message", "openshell.pi-message.v1"), - ("pi", "compaction_summary", "openshell.pi-message.v1"), - ("pi", "branch_summary", "openshell.pi-message.v1"), - ("pi", "extension_message", "openshell.pi-message.v1"), - ("pi", "tool_result", "openshell.pi-tool-result.v1"), - ("pi", "assistant_message", "openshell.pi-assistant-message.v1"), - ("pi", "bash_execution", "openshell.pi-bash-execution.v1"), - ("pi", "provider_context", "openshell.pi-provider-context.v1"), - ] + assert manifest.expected_audience == "urn:test" + assert len(manifest.bindings) == 1 + assert ( + manifest.bindings[0].operation + == pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST + ) + assert not hasattr(pb2.HttpRequestEvaluation(), "agent_attestation") def test_copied_proto_remains_the_current_five_field_finding_contract() -> None: @@ -250,13 +239,6 @@ def test_evaluation_enforces_exact_encoded_transport_boundaries() -> None: with pytest.raises(EgressGateError): servicer_module._validate_evaluation_envelope(request) - request = _request(body=b"") - request.agent_attestation = b"x" * MAX_AGENT_ATTESTATION_BYTES - servicer_module._validate_evaluation_envelope(request) - request.agent_attestation += b"x" - with pytest.raises(EgressGateError): - servicer_module._validate_evaluation_envelope(request) - def test_request_adapter_builds_the_full_domain_request() -> None: domain = servicer_module._request_from_proto(_request(b"bytes")) diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 1a354aab..f7131da8 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -74,9 +74,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, - require_agent_attestation: bool = False, + admission: object = None, ) -> None: - del registry, require_agent_attestation + del registry, admission self.timeout_middleware_processing = timeout_middleware_processing def serve_sync(self, listen: str) -> None: @@ -103,7 +103,7 @@ def serve_sync(self, listen: str) -> None: assert "s for seconds or ms for milliseconds" in serve_help assert "Minimum 10ms" in serve_help assert "RPC timeout" in serve_help - assert "--require-agent-attestation" in serve_help + assert "--admission-config" in serve_help assert "--json-log" in serve_help assert "--require-" + "pi-attestation" not in serve_help @@ -550,9 +550,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, - require_agent_attestation: bool = False, + admission: object = None, ) -> None: - del registry, timeout_middleware_processing, require_agent_attestation + del registry, timeout_middleware_processing, admission def serve_sync(self, listen: str) -> None: calls.append(listen) diff --git a/projects/egress-gate/uv.lock b/projects/egress-gate/uv.lock index ef9e1ad1..59845603 100644 --- a/projects/egress-gate/uv.lock +++ b/projects/egress-gate/uv.lock @@ -2,6 +2,146 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "aiohappyeyeballs" +version = "2.7.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ce/f4/eec0465c2f67b2664688d0240b3212d5196fd89e741df67ddb81f8d35658/aiohappyeyeballs-2.7.1.tar.gz", hash = "sha256:065665c041c42a5938ed220bdcd7230f22527fbec085e1853d2402c8a3615d9d", size = 24757, upload-time = "2026-07-01T17:11:55.501Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/71/43/1947f06babed6b3f1d7f38b0c767f52df66bfb2bc10b468c4a7de9eceff2/aiohappyeyeballs-2.7.1-py3-none-any.whl", hash = "sha256:9243213661e29250eb41368e5daa826fc017156c3b8a11440826b2e3ed376472", size = 15038, upload-time = "2026-07-01T17:11:54.055Z" }, +] + +[[package]] +name = "aiohttp" +version = "3.14.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "aiohappyeyeballs" }, + { name = "aiosignal" }, + { name = "attrs" }, + { name = "frozenlist" }, + { name = "multidict" }, + { name = "propcache" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, + { name = "yarl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/58/d9/22ce5786ac0c1653ae8b6c23bded02c1686d11f0dbb45b31ce128e0df985/aiohttp-3.14.3.tar.gz", hash = "sha256:9491196535a88924a60afd5b5f434b5b203b6cc616250878dbdb223a8f7844bc", size = 7971213, upload-time = "2026-07-23T01:57:27.037Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f8/5c/b3e4ff8ad43a8afef9602c5e90285936da1beaea8b029016b793891f03c3/aiohttp-3.14.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:e568e14940c09955aa51f4e645b6daa18a581c5dcfcd73744dcc86a856e3ced3", size = 764250, upload-time = "2026-07-23T01:52:48.525Z" }, + { url = "https://files.pythonhosted.org/packages/0e/da/f1b384465e51449d844056b75070461da03a9a23e6c1747003695bf4172a/aiohttp-3.14.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:54cfcdee2770dac994417cbb0ee1f3eb0e7cb6b30c79bf44f2c02ff79ec5124a", size = 516281, upload-time = "2026-07-23T01:52:51.047Z" }, + { url = "https://files.pythonhosted.org/packages/b9/3f/01264f820ee2e3712a827892b1cd6ff80f3300c1fcbffbb45714a915d47a/aiohttp-3.14.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:21c016079415ed3fd676963e9793700a566d85dbbd6bfc564b9b2d209147dcc8", size = 514742, upload-time = "2026-07-23T01:52:53.779Z" }, + { url = "https://files.pythonhosted.org/packages/9e/8d/a71c6f2db52ac1ed142b133f7feddaa6b70539c3f4de24d7e226c95b794c/aiohttp-3.14.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d6088ec9894113802bddb3c09e974929aed2c7b3a8c456219b8aab4481f1a239", size = 1780613, upload-time = "2026-07-23T01:52:56.948Z" }, + { url = "https://files.pythonhosted.org/packages/a5/11/3dd9b3fb3a170f6ec9011b5291d876a6fab4086714c9e158600edf01b4fd/aiohttp-3.14.3-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:16ea7e24c309fb7c0bbd505d149abe4fe4dccfb8db911db7dbec0921bc889a6f", size = 1737688, upload-time = "2026-07-23T01:52:59.294Z" }, + { url = "https://files.pythonhosted.org/packages/6d/3e/834c26918be7d88068822b40e0db30fca50b5f4fe79104aa16a93f1d74e6/aiohttp-3.14.3-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:56f355e79f71aef2a85c80305cc915f894b170dba76de5fe84f6351939b83c06", size = 1845742, upload-time = "2026-07-23T01:53:01.641Z" }, + { url = "https://files.pythonhosted.org/packages/cc/c9/49ab8572df7d66bc13d11e31f781292badb04180dd87ba98733066c6aed7/aiohttp-3.14.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:18c441d0a8fca6de8d1f546849b9f0ab20d435993e2c5b59562b2fae6be2f929", size = 1928412, upload-time = "2026-07-23T01:53:04.018Z" }, + { url = "https://files.pythonhosted.org/packages/a5/b9/2b8f0c0ce09c87a1daf80fd483431b56b1435d3f62789bc86f572e1245de/aiohttp-3.14.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:53e7b4ce82b54a8bcc71b3b67a5cbd177ca1d7f592cbc92cd38b7349f73482db", size = 1786220, upload-time = "2026-07-23T01:53:06.481Z" }, + { url = "https://files.pythonhosted.org/packages/85/00/9c45f81de11710460edfa1dc81317b6e882703b160926c879a9d20da9fcc/aiohttp-3.14.3-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:f55119f7bf25f49ed210f6096090715da24f2943c62102448915fde3c62877ce", size = 1637231, upload-time = "2026-07-23T01:53:10.258Z" }, + { url = "https://files.pythonhosted.org/packages/19/ce/967d628e910756f3539c6107cb7844a1b69440dcb3029a5ee7871b09ab63/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:9aa6e61fdf20105c4144e755bd586008ff450791d67b1c8146fdc15959c4d51c", size = 1753161, upload-time = "2026-07-23T01:53:13.817Z" }, + { url = "https://files.pythonhosted.org/packages/11/b2/0c3d4114f0aee4f580f5b3b4eb71b24d7a23b834ea506a4dfebe76513f35/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ccd4893707b3e2a13e39c90d43cf80edf2e4d0457935bcc103bf2346214c3f15", size = 1756356, upload-time = "2026-07-23T01:53:16.211Z" }, + { url = "https://files.pythonhosted.org/packages/63/5d/99e7d91c82f1399d1ae2a854e080bd1493fbc31e5e959dbc4ec33dac3bec/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:b2466434105a4e03113c36ec775cc2ebe6676b62eae326fa670bb607ef788c1c", size = 1819846, upload-time = "2026-07-23T01:53:18.289Z" }, + { url = "https://files.pythonhosted.org/packages/ad/05/d5e1cb6480eeffd3f901d40a2c5e2d1e7effdc797837da3b490272699f13/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:ba59d59aba08ac02fc03b0c8983ccd5ee39a199d0552ce9e6d2b4845b34d59ae", size = 1628531, upload-time = "2026-07-23T01:53:23.86Z" }, + { url = "https://files.pythonhosted.org/packages/c9/90/b934682bcaefae18a9e04f3dff5b68522ba810906358ae5029b68110ea3b/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:ed099d105449c4f9e84f24af203cd131349d4761d8813fa7e02c32e7128cd910", size = 1832712, upload-time = "2026-07-23T01:53:27.551Z" }, + { url = "https://files.pythonhosted.org/packages/21/df/6061679faaf81fac746e7307c7adb71e858071a5d34c27583afefc64f543/aiohttp-3.14.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:152516815ef926786a0b6ae2b8f1fd2e0c71582dee0b435636865316fd4891b7", size = 1775014, upload-time = "2026-07-23T01:53:30.223Z" }, + { url = "https://files.pythonhosted.org/packages/8a/1d/f854878bbc69b88faefe924b619a34a6f59ec05fd387c77690667eaa75eb/aiohttp-3.14.3-cp311-cp311-win32.whl", hash = "sha256:a4af35c443e0b1a1bd6a8af3f3485d7fda15c142751a00f3ff8090f0b93346fa", size = 456006, upload-time = "2026-07-23T01:53:34.97Z" }, + { url = "https://files.pythonhosted.org/packages/73/0c/2af9d1674baccd1dbd47282a93d660a22e57ef6167c856deb24b4214fbab/aiohttp-3.14.3-cp311-cp311-win_amd64.whl", hash = "sha256:e1e74298bab6ee0d6e749ed4fd1901c7e604bdda32c03d787a2cc71c46d0433d", size = 481069, upload-time = "2026-07-23T01:53:39.673Z" }, + { url = "https://files.pythonhosted.org/packages/8e/76/88401ff3fc95e85c5fc38d588f36f55e61ecb64343b2bc8d69326f453cc0/aiohttp-3.14.3-cp311-cp311-win_arm64.whl", hash = "sha256:03cd2bde3d7f085b64e549c985f4bb928cad7e8ecf5323bfca320db548d81b39", size = 453021, upload-time = "2026-07-23T01:53:43.749Z" }, + { url = "https://files.pythonhosted.org/packages/18/d4/eb96299230e20acf2efae207cb8d69051f1f68e357e5ea5e479bf6fb097a/aiohttp-3.14.3-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:39aded8c7f3b935b54aab1d8d73c70ec0ee2d3ec3b943e0e86611bc150ba47f5", size = 754690, upload-time = "2026-07-23T01:53:47.332Z" }, + { url = "https://files.pythonhosted.org/packages/88/11/e7a70a209eb9a067c0d3212b518a0134e3484f5178c7533878b6b514d469/aiohttp-3.14.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:5bcb6ff3fdab1258a192679ff1a05d44f59626430aa05cd1a9d2447423599228", size = 509484, upload-time = "2026-07-23T01:53:51.159Z" }, + { url = "https://files.pythonhosted.org/packages/30/07/4bbc222cc8dbe31d4c3e8a5baad2286e4d42026ac0c570027b89afce6344/aiohttp-3.14.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:617105e2c3018ee38d0c8ce5ee3c84f621a6d8b9f723202aacaff28449ca91ee", size = 511949, upload-time = "2026-07-23T01:53:55.083Z" }, + { url = "https://files.pythonhosted.org/packages/54/b9/42e74c46b7b7c794b995bbc1f573fb48950c38b19d8600c62a6804ee2d67/aiohttp-3.14.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f631fe87a6f30df5fbe6d79640b25e4cffb38c31c7fb6f10871517b84b0f8c1a", size = 1765282, upload-time = "2026-07-23T01:53:59.662Z" }, + { url = "https://files.pythonhosted.org/packages/6b/ed/62bc4d74363ad346d518e0720363a949f63e2e23439a79eb5813d4d29bb3/aiohttp-3.14.3-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a94dbaae5ae27bd849c93570669bff91e0510f33a80805738e3de72a7be0447b", size = 1741511, upload-time = "2026-07-23T01:54:04.063Z" }, + { url = "https://files.pythonhosted.org/packages/d0/9f/181e8a8bc79e47d13c7fc4540bd7a3b729d9505609c61f392a8dd2fbfe55/aiohttp-3.14.3-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8f2f1c4c032c7cedd7d8da6f54c97b70266c6570c3108d3fdffee7188bb70529", size = 1810680, upload-time = "2026-07-23T01:54:09.882Z" }, + { url = "https://files.pythonhosted.org/packages/5c/9a/dec94d6ad694552fe3424e3f1928d7a606a5d9d9433a04e7ecdd9d38ae7f/aiohttp-3.14.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea05e1f97ceea523942d9b2a7d7c0359d781d683d6b043f5943a602b14da4787", size = 1905646, upload-time = "2026-07-23T01:54:13.475Z" }, + { url = "https://files.pythonhosted.org/packages/52/b7/7cd31f29d6055bd711ae6e669367fba6f5ae9de463910a793e30556a8db7/aiohttp-3.14.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:543906c127fb1d929b95076db19b83fa2d46751006ff1e23b093aa5ac4d8db42", size = 1792122, upload-time = "2026-07-23T01:54:15.752Z" }, + { url = "https://files.pythonhosted.org/packages/66/73/10b1ef93afa61f4963c746257b70ced619cf31a4798671de5fdb2608501d/aiohttp-3.14.3-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0a5ff2dfbb9ce645fa5b8ef3e02c6c0b9cc3f6030ff863d0c51fffc50cb5541b", size = 1591127, upload-time = "2026-07-23T01:54:19.489Z" }, + { url = "https://files.pythonhosted.org/packages/49/ed/3b203fa6de1b338c14acdc06bf6ca9b043b7944f005966958c2ced932cde/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:041badb8f84396357c4d3ad26de6afd7a32b112f43d3c63045c0c8278cfd2043", size = 1725210, upload-time = "2026-07-23T01:54:24.129Z" }, + { url = "https://files.pythonhosted.org/packages/28/b7/1c2aab8c706436dcc28598452488ac9cd7c409da815237c28c27d58993e6/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:530125ee1163c4219af35dc3aa1206e541e7b31b6efc1a3f93b70a136f65d427", size = 1764848, upload-time = "2026-07-23T01:54:27.973Z" }, + { url = "https://files.pythonhosted.org/packages/54/50/94c28f08b131c4bf10984ea2c7a536c9920608bb2d6e7f95642c30cc87b7/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c8653fd547c93a61aadc612007790f5555cdd18946fa48cf45e26d8ea4ea473d", size = 1777102, upload-time = "2026-07-23T01:54:31.775Z" }, + { url = "https://files.pythonhosted.org/packages/13/d4/e7d09ba7d345fb2d74440fd2fa033c5e079fac05552927705986f41a364f/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:89176250f686cb9853c0fb7ead90e639e915b84a6f43eedc2a4e7ec21f1037f0", size = 1580205, upload-time = "2026-07-23T01:54:34.518Z" }, + { url = "https://files.pythonhosted.org/packages/a3/84/072a91d68e1e1eb587985b54baab94221277f877e8ef274fc213a0ceae28/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:3a26434dafe408229ff3403458ca58de24fb51936504decac49ce6755f77e59d", size = 1797219, upload-time = "2026-07-23T01:54:36.995Z" }, + { url = "https://files.pythonhosted.org/packages/e0/eb/aad34e897e668424d6e995da5dff8a4a09af93363d3392488772957a63aa/aiohttp-3.14.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d1558173930a5a8d3069cee5c92fc91c87c4dbcb099debbb3622053717145a19", size = 1768629, upload-time = "2026-07-23T01:54:40.103Z" }, + { url = "https://files.pythonhosted.org/packages/b6/2b/6bb88ddba0fecd9122aa3ebcad25996cf6c083a4a7040dbb3a4f97972af6/aiohttp-3.14.3-cp312-cp312-win32.whl", hash = "sha256:16100ad3ab8d649fdfbee87602d9d2dcdca9df0b9eda8a1b5fdc0d41f96da559", size = 451481, upload-time = "2026-07-23T01:54:42.547Z" }, + { url = "https://files.pythonhosted.org/packages/76/9b/f2f8f108da17ecef2cc3efc424e8b7ad3782b1a8360f7b8eae8ced84f6ea/aiohttp-3.14.3-cp312-cp312-win_amd64.whl", hash = "sha256:33a2d7c28d33797a2e99923dffa63f83d908a19b6bf26cfe80fa790aa5e1a75a", size = 476845, upload-time = "2026-07-23T01:54:44.853Z" }, + { url = "https://files.pythonhosted.org/packages/3e/44/28dac80a8941b604f4da10ce21097614ca1bf905ce93dca28d8d7de9c1e7/aiohttp-3.14.3-cp312-cp312-win_arm64.whl", hash = "sha256:362a3fd481769cac1a824514bcd86fda51c65e8fe6e051099e008fddde6db17c", size = 448050, upload-time = "2026-07-23T01:54:47.087Z" }, + { url = "https://files.pythonhosted.org/packages/57/be/5afd201cc0ab139029aadb75392efe85a293403d9dd3a3226161c21ce00c/aiohttp-3.14.3-cp313-cp313-android_21_arm64_v8a.whl", hash = "sha256:2e9878ae68e4a5f1c0abe4dd497dbc3d51946f5837b56759e2a02e78fa90ef86", size = 506269, upload-time = "2026-07-23T01:54:49.075Z" }, + { url = "https://files.pythonhosted.org/packages/22/09/dec8189d62b45ade009f6792a2264b942a90cb88aeaf181239933cd72c3c/aiohttp-3.14.3-cp313-cp313-android_21_x86_64.whl", hash = "sha256:f3d2669fe7dec7fc359ecdb5984b29b50d85d5d00f8c1cb61de4f4a24ee42627", size = 515166, upload-time = "2026-07-23T01:54:51.894Z" }, + { url = "https://files.pythonhosted.org/packages/28/24/2854869d29ed8a8b19d74f9ec6629515f7e04d02dd329d9d179201e58e47/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:cc7cb243a68167172f48c1fd43cee91ec4b1d40cefd190edd43369d1a6bc9c82", size = 486263, upload-time = "2026-07-23T01:54:54.223Z" }, + { url = "https://files.pythonhosted.org/packages/d4/dd/57187c8be2a35aea65eaee3bd2c3dcbbcf0204f5106c89637e3610380cd1/aiohttp-3.14.3-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:78253b573e6ffab5028924fc98bc281aae05445969982a10864bc360dea2016c", size = 492299, upload-time = "2026-07-23T01:54:56.236Z" }, + { url = "https://files.pythonhosted.org/packages/b9/11/06ae6ed8f0d414edf4068861e233d8fe23ee699bfd4b3ceb8663db948a62/aiohttp-3.14.3-cp313-cp313-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:7041d52c3a7fa20c9e8c182b534704abb19502c8bdcbde7ab23bfda6f642394f", size = 502235, upload-time = "2026-07-23T01:54:58.377Z" }, + { url = "https://files.pythonhosted.org/packages/7e/a3/559639c34a345d2cf7c52dff6838119f2eaf29eb508227b5b83f573af813/aiohttp-3.14.3-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ac74facc01463f138b0da5580329cfcc82818dea5656e83ddcd11268fc12ff80", size = 750883, upload-time = "2026-07-23T01:55:00.65Z" }, + { url = "https://files.pythonhosted.org/packages/91/cd/41e131f13afd1e7b0172a9d9eda085ef90eb8439f41f0d279db81ed3ae60/aiohttp-3.14.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:d6218d92e450824e9b4881f44e8c09f1853b490f9a64130801024a4793b1b3b0", size = 508473, upload-time = "2026-07-23T01:55:02.945Z" }, + { url = "https://files.pythonhosted.org/packages/bc/6b/e7f13410d391c6e55b4c007a8de024355389d7d459e3d64c42b2d33617e5/aiohttp-3.14.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:11fb37ef075669eee52ab1928fbf6e1741fada40409fa309ebde9607a962aebf", size = 509190, upload-time = "2026-07-23T01:55:05.173Z" }, + { url = "https://files.pythonhosted.org/packages/97/21/6464573e53d69672cc1eada3e5c5cb2d2efa82701e8305a0f2047a576967/aiohttp-3.14.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:55bdcc472aafe2de4a253045cc128007a64f1e0264fb675791e132ea5edaa3bd", size = 1761478, upload-time = "2026-07-23T01:55:07.383Z" }, + { url = "https://files.pythonhosted.org/packages/1a/81/d217043a4c17fbce360905e3b2bdd20139ebc9a2de836d035d179c4da006/aiohttp-3.14.3-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c39846c3aad97a8530c89d7a3869a8f8e9e3762c6ac0504481e5c80948f7e807", size = 1735092, upload-time = "2026-07-23T01:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/a1/66/e13a02d0eeb1a9a502402a977abb4e4abff9fe4051c26f80558c57a7c975/aiohttp-3.14.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5895ef58c4620afe02fa16044f023dc4dafec08158f9d08874a46a7dbc0341b8", size = 1800546, upload-time = "2026-07-23T01:55:12.012Z" }, + { url = "https://files.pythonhosted.org/packages/26/5e/57d42fca1d18cb5acc1cad945d017fabc5d6ae71d8a08ad66be8dc3ee544/aiohttp-3.14.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa9467a8113aa69d3d7c55a70ef0b7c636010a40993f3df9d9d0d73b3eb7ef24", size = 1895250, upload-time = "2026-07-23T01:55:14.357Z" }, + { url = "https://files.pythonhosted.org/packages/ca/1c/7da8d08e74d56f00070822f9638ff3f1c563f8ad87d1efa996c87bfc8644/aiohttp-3.14.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d7d2deec16eeedf55f2c7cf75b521ea3856a5177e123844f8fd0f114ce252cb5", size = 1789289, upload-time = "2026-07-23T01:55:16.668Z" }, + { url = "https://files.pythonhosted.org/packages/cd/0f/cf16bcf56896981c1a0319f5d5db9337994b5165730c48a8fa07e9b34be6/aiohttp-3.14.3-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dd54d0e8717de95939766febac482ac0474d8ac3b048115f9f2b1d23a16e7db4", size = 1586706, upload-time = "2026-07-23T01:55:18.913Z" }, + { url = "https://files.pythonhosted.org/packages/fe/6f/76eac12a7f2480e1e304f842efdb07db33256b0d9165b866b6ef0806c202/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:df82f3787c940c94986b34222d59c9e38843fba85139f36e85255a82ad5355a9", size = 1724652, upload-time = "2026-07-23T01:55:21.296Z" }, + { url = "https://files.pythonhosted.org/packages/39/b6/19c8c592baeeb94b75f966547d40c02ac7590902306ec5863d5c027cf506/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:42a67efc36300d052fb4508a53e8b6901b9284b599ae63945c377569c5fcc1e1", size = 1756239, upload-time = "2026-07-23T01:55:23.705Z" }, + { url = "https://files.pythonhosted.org/packages/dc/c9/4e9383150296f97f873b680c4de8fb2cd88608fb9f48c79edcb111611abc/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:7a75aa63cbf9b21cfaf60dc2657e19df2c2867d91707d653fee171ffeedd1371", size = 1769161, upload-time = "2026-07-23T01:55:26.082Z" }, + { url = "https://files.pythonhosted.org/packages/aa/1e/147bdc6cc5de5f3ab011be8bf5d6e786633249f22c20bae06f85e45f5387/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:e92eb8acc45eb6a9f4935071a77edf5b85cc6f8dfad5cd99e97653c26593cdde", size = 1578759, upload-time = "2026-07-23T01:55:28.846Z" }, + { url = "https://files.pythonhosted.org/packages/fd/31/78388a9d6040ece2e11df62ea229a822cf5e52d238374b220ae9975b2623/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b014a6ed7cf912e787149fdc529166d3ceabac23f26efeea3158c9aba2354e7e", size = 1792025, upload-time = "2026-07-23T01:55:31.457Z" }, + { url = "https://files.pythonhosted.org/packages/03/51/a3d29fdf2c25d796746af8ad6fe56a45d6256c38b0a8a2ed752e1160b3a2/aiohttp-3.14.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:3d4f72af88ac2474bb5bca640030320e3d38a0163a1d7533500e87be458eef71", size = 1768477, upload-time = "2026-07-23T01:55:33.87Z" }, + { url = "https://files.pythonhosted.org/packages/29/a6/442e18b5afeade534d877a2dc3c3e392aff8d49787890b0cf84790410267/aiohttp-3.14.3-cp313-cp313-win32.whl", hash = "sha256:5f08ec777f35ee70720233b8b9811d3bb5d728137f30ac91b7457709c3261ac0", size = 451069, upload-time = "2026-07-23T01:55:36.121Z" }, + { url = "https://files.pythonhosted.org/packages/9d/69/3d876ac02659f271cf7f6769f14a8e3de5b6e888ed8b5a7e998086a4cec8/aiohttp-3.14.3-cp313-cp313-win_amd64.whl", hash = "sha256:dff9461ec275f22135650d5ba4b4931a11f3958df7dfbb8db630000d4dee0883", size = 476518, upload-time = "2026-07-23T01:55:38.303Z" }, + { url = "https://files.pythonhosted.org/packages/b2/0e/50d6e6471cd31edce8b282bdec59375a3a69124d8a989a0b1313355cae52/aiohttp-3.14.3-cp313-cp313-win_arm64.whl", hash = "sha256:ddcac3c6b382e81f1dd0499199d4136b877beb4cb5ef770bbbfba56c4b8f55d2", size = 447676, upload-time = "2026-07-23T01:55:40.451Z" }, + { url = "https://files.pythonhosted.org/packages/c8/20/887fdcf832326571b370ffc347b3e70abe101096f3720126aac161b1d872/aiohttp-3.14.3-cp314-cp314-android_24_arm64_v8a.whl", hash = "sha256:49f7325beb0f85ef4aef5f48f490269575f83e6e2acad00a1d80b807eb027062", size = 509067, upload-time = "2026-07-23T01:55:42.618Z" }, + { url = "https://files.pythonhosted.org/packages/ad/a3/92cec936f78cc4bf0fa5554ebe593b73459d94e3c62303e1902a4cccb6f7/aiohttp-3.14.3-cp314-cp314-android_24_x86_64.whl", hash = "sha256:e3be98a7c30b8c25d573dafba7171d66dfb05ee6a9070fc46535464ff97700a6", size = 514774, upload-time = "2026-07-23T01:55:44.937Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/2a0c38df3fc557620b6a5acd98364af050053b6285b4dc7ee74100c63c18/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:614c61d478b83953e261d02bb2df750f17227cd33ef8002945bf5aebbde21919", size = 488134, upload-time = "2026-07-23T01:55:47.135Z" }, + { url = "https://files.pythonhosted.org/packages/48/d6/d51b7d4bf309af3693940d8ffd2b9ed0b682434ef85959b7c9c137f60cf8/aiohttp-3.14.3-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:1caa7b0d05f3e3a36f87788c59e970a7ee1cefcfcbb924a9f138c4a6551c9cb7", size = 494201, upload-time = "2026-07-23T01:55:49.451Z" }, + { url = "https://files.pythonhosted.org/packages/3f/5a/8f624384e5f1efabb5229b94157eb966b021e97bdb188c62860c2ae243c2/aiohttp-3.14.3-cp314-cp314-ios_13_0_x86_64_iphonesimulator.whl", hash = "sha256:dfa68deb2a443bdaa3ea5297b0699c1464f08aef3812b486d1348eee61b07dc0", size = 502766, upload-time = "2026-07-23T01:55:51.656Z" }, + { url = "https://files.pythonhosted.org/packages/a6/26/4ff0164370deec18fb19254ee4ab10b7a73304ac0c860b13f5f84663759b/aiohttp-3.14.3-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e72ee89e28d907a18f46959b4eb0bb06701cc7f8cf4366e00029e2ccfaaf5924", size = 756557, upload-time = "2026-07-23T01:55:53.964Z" }, + { url = "https://files.pythonhosted.org/packages/97/a3/7056b86dc0d9ec709ea9777eae3b0161428f943372f8b98c01c11593b682/aiohttp-3.14.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ad4c8b7488d745d2ca4838ebd8ae5ba9b56341d30b1da43640e4ce87f9f49646", size = 510168, upload-time = "2026-07-23T01:55:56.22Z" }, + { url = "https://files.pythonhosted.org/packages/85/ed/0357a015892fd68058bf2d39d3fd1958e459b997a7db30aaa6aaa434ae96/aiohttp-3.14.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:db332af25642007330fca8be5c4d194caf2bea7a7fc84415aff3497af5dfee6b", size = 512957, upload-time = "2026-07-23T01:55:58.437Z" }, + { url = "https://files.pythonhosted.org/packages/47/d1/8aba53f15ccb2238405f5e9d30e2a8ca44f93878c26e7165ade00d374b1c/aiohttp-3.14.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:25bd2708db6bdf6a6630dd37bdcdfcb47c4434d22ac69c64665b802910140b30", size = 1750149, upload-time = "2026-07-23T01:56:00.856Z" }, + { url = "https://files.pythonhosted.org/packages/49/bd/40c3fee327529284375c6701cbb0fa4600cc2e8432af1378f897e2ef7d3a/aiohttp-3.14.3-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:cef89a58e628c4efcac3275c2d68083f82426dcdc89c1492a6f654f9f7ea6ab9", size = 1707685, upload-time = "2026-07-23T01:56:03.371Z" }, + { url = "https://files.pythonhosted.org/packages/2a/a3/ca0cc6724cca8114b05694abd916060758c79894c3aa5b012cdadc1bc28e/aiohttp-3.14.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c23ec8ee9d5ab2f5421f9c7fffce208435607af27fd46d4a44e031954352838f", size = 1803911, upload-time = "2026-07-23T01:56:05.817Z" }, + { url = "https://files.pythonhosted.org/packages/95/b5/85b099c299c3ffd38ad9b3e43694c8a346934e4a30c88c4fd5a841234f77/aiohttp-3.14.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e2667f0bbe7eb6c74eae5e9691441ad186e5845ca3cff63230fc09c4e7514f5d", size = 1876929, upload-time = "2026-07-23T01:56:08.413Z" }, + { url = "https://files.pythonhosted.org/packages/d5/b7/1da684a04175473fa4cddbf9a2f572e79514c3fd27a74597f43057d4f3da/aiohttp-3.14.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:18cb43369747b2ae007bd2655fb8e63a099c2ff1d207962943636dac989b3147", size = 1761112, upload-time = "2026-07-23T01:56:10.918Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/bc4b55e3e5cb175fd69c53c90d60d2f47797cb343da5106e23863dc4dba4/aiohttp-3.14.3-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d77640cc618c1d99fc4f8589c0f24a730adfa54eb1e57ef7bf0c8dfb78da898c", size = 1583500, upload-time = "2026-07-23T01:56:13.613Z" }, + { url = "https://files.pythonhosted.org/packages/2a/e8/13a9d957a1ee40837f46aa30f0f4c657e673ad86a2e6362a9f9be20d26d9/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:53e5179d8abb5710f8e83ba207c41c8d1261fcffd4616500e15ca2b7a33be10a", size = 1713940, upload-time = "2026-07-23T01:56:15.969Z" }, + { url = "https://files.pythonhosted.org/packages/38/05/d33c680c1bcf1c7e130f9cbfc1fc02fe8bb0c4af2a94a53dd5fb56131e5c/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:cd817772b2fcf2b8c0905795318485f9ec16eae60b29feb7f4c77085311637f0", size = 1724413, upload-time = "2026-07-23T01:56:18.591Z" }, + { url = "https://files.pythonhosted.org/packages/85/1d/af798d306f7a74b6a632dbcabcf62a4c91391b7582d2a8c6d7712e2cc54e/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:4e3ac92d90e92773b2362d506068e9a948192bd553e743c5b2429e28527c8661", size = 1770748, upload-time = "2026-07-23T01:56:21.074Z" }, + { url = "https://files.pythonhosted.org/packages/a8/92/ad720d472556a995049206867765e9410969684f86ee09423ff9969044c1/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:3f42e9b78301f11c8f861746175d8b9c1ccef713fcad9eab396e2f6db8ed4a22", size = 1577564, upload-time = "2026-07-23T01:56:23.475Z" }, + { url = "https://files.pythonhosted.org/packages/60/ad/0ed7586cbef7a884e23a752fa2bb987a122e6a5dd50dab109258d0a95193/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:9d9edccfe496b476db5f398d97b865e9a6752bcf8aec4eef8390ce20fb64bb41", size = 1782080, upload-time = "2026-07-23T01:56:25.994Z" }, + { url = "https://files.pythonhosted.org/packages/97/ea/dbaed0d73e8a69aad653b045dab451c67c2454bb731a37b45a86593e9422/aiohttp-3.14.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:1c5ec8fb1bcc31a8466f74aaf26c345d5c386fa4bd08a3f0eb9c7a4a3fe8b5bf", size = 1745813, upload-time = "2026-07-23T01:56:28.604Z" }, + { url = "https://files.pythonhosted.org/packages/81/1b/6893d4bc57e434fc93a6c9217c637d967a0b651d989f6e3265179375754a/aiohttp-3.14.3-cp314-cp314-win32.whl", hash = "sha256:38901a84da3ce22249f6e860bf8f90d141bcab7da090cc398f8bb58c0e44b7da", size = 455872, upload-time = "2026-07-23T01:56:31.031Z" }, + { url = "https://files.pythonhosted.org/packages/f5/8b/c7baa1ba1eda4db6989baefe5de6d99834921b84ebd7918624febcb9f290/aiohttp-3.14.3-cp314-cp314-win_amd64.whl", hash = "sha256:8b3b60de05f3dcb6f6a00f818bb2ec781cee4de0645f59ccaf99b1d1823b6100", size = 481030, upload-time = "2026-07-23T01:56:33.365Z" }, + { url = "https://files.pythonhosted.org/packages/22/8c/c29d067df825a2df88ca432db848aa2fe8199598359cc06c12b09320cac9/aiohttp-3.14.3-cp314-cp314-win_arm64.whl", hash = "sha256:1576145bdceeb92382d899751e12743a3a5b8e460a841e3e50543859e54864dc", size = 453669, upload-time = "2026-07-23T01:56:35.731Z" }, + { url = "https://files.pythonhosted.org/packages/6a/a4/9c033beb355d39b6147980597ec9645e4729243f686ee4dc73945de72030/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:8800c996b01c2772a783e3e46f3e1abd5823029adca0df54231960de9bfefa5b", size = 791403, upload-time = "2026-07-23T01:56:37.972Z" }, + { url = "https://files.pythonhosted.org/packages/80/ca/87c32a0a7704583cfc49660bd817889bae5b830bf53b5dcb4e92145ac2da/aiohttp-3.14.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ebe8e504f058fe91223351cecd2d9d6946c9d241bb0250d898ffbdf584cc72b0", size = 526413, upload-time = "2026-07-23T01:56:40.523Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d8/8ec0e471248c500acdce2be3f46db8fb62b5eb60efef072529cc85ee1d26/aiohttp-3.14.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30402d03a7c0ff52bce290b57e564e9079fd9d0cb545c8aba73f86a103162d2e", size = 532135, upload-time = "2026-07-23T01:56:42.876Z" }, + { url = "https://files.pythonhosted.org/packages/fe/45/f8919fd936e8b79fcd9bda7b6d8e62613462a713f4f17987fd7c34399142/aiohttp-3.14.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9fc7b5bfec6573f3ae844f457fdde5adeb713f8b8e4a81ad64fc207b49383716", size = 1922742, upload-time = "2026-07-23T01:56:45.528Z" }, + { url = "https://files.pythonhosted.org/packages/f6/ec/9ca76b28a27525b0cc53e20842e0228b022f301ce1f436b7d814b4aaf2df/aiohttp-3.14.3-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8a5fd34f7f7410d1730d5c2ba873cacb2eed3fede366feb268a70ba22581ed8f", size = 1787371, upload-time = "2026-07-23T01:56:48.045Z" }, + { url = "https://files.pythonhosted.org/packages/b1/04/6acdbf17315f7b55f1937e3387acb89a3cddeb4995689553d064af8e92ab/aiohttp-3.14.3-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:270d3dace9ca2f10f0da5d8ebe519b7a310fc6112ed916e32df5866df0888553", size = 1912623, upload-time = "2026-07-23T01:56:50.605Z" }, + { url = "https://files.pythonhosted.org/packages/86/e6/438b0c79ca6f45eb9fd9817dd4c01a91919a38c0de5ee9e05e2b4dc0ece7/aiohttp-3.14.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3ae5b3a59436d089b5395d910121a390feed4d00578eb95a0fd1a329fe963100", size = 2005515, upload-time = "2026-07-23T01:56:53.153Z" }, + { url = "https://files.pythonhosted.org/packages/bb/6b/62cbd6577758699525f5c712d1ddef57d9875fbab0ae8d5f5a202fd598f8/aiohttp-3.14.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2498f0fe69ead802f9675beca44a7c21c62fdaa4ec5145ea1c3ad6edbee29f85", size = 1879906, upload-time = "2026-07-23T01:56:55.818Z" }, + { url = "https://files.pythonhosted.org/packages/00/95/18bcbf830a21dc3aae24d8f6b6feaf3db1d2090242d00a7868db2ffb0b67/aiohttp-3.14.3-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a0dc483c00da8b673abbb367eb6f8d8f4bcec30eb58529ea13cb42e7fd2dfa33", size = 1675849, upload-time = "2026-07-23T01:56:58.861Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/47f4968659c5e23606c3790c80fc624e691c153d036148449ee84d31b287/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:c7d3a97c678d34fc5b59da671ee9cd630096ddc643e7b5a30d54a2a6f3574d3f", size = 1843496, upload-time = "2026-07-23T01:57:01.591Z" }, + { url = "https://files.pythonhosted.org/packages/64/af/38c33c4dd82fddcb4e56c4653b6f1072a8edbc6b7fa15809f14932c41e2d/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8fb78a83c9e5f741ca3a68cfb455c1f5bb83b4e7249a3848b3cd78d0a8563b0", size = 1827746, upload-time = "2026-07-23T01:57:05.131Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9d/0537cda4885ac8f5b7053d164dd06312f4c483a4edcb8ee5b8aaf2a989bf/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:74ab5b6a9fb13e873e5a90946588baecaf488745e1db1a4a5c433f971f035098", size = 1853810, upload-time = "2026-07-23T01:57:08.043Z" }, + { url = "https://files.pythonhosted.org/packages/19/fe/26f9c5e6458385aa86497836b0dea6fb2f027827d63f37c7856cce9286ee/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:bd52f811e65f6fb634b1047159657c98f52b407f8efec907bcfc09da9a4c0a25", size = 1668895, upload-time = "2026-07-23T01:57:10.837Z" }, + { url = "https://files.pythonhosted.org/packages/ec/4c/618b1db9b9ba079b8875d2cdf78e7c4a3bf72903bd5850fee7dd9544600a/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f0f177d1b195b9e06376cfd7d308d8a1b920909a609d03ac82a8c73bbb16d3b9", size = 1883833, upload-time = "2026-07-23T01:57:13.672Z" }, + { url = "https://files.pythonhosted.org/packages/94/c6/bd959bd1e4771f9fd944e9e436224c48c77b018b73b519b5aad346335bcc/aiohttp-3.14.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:498c6c623134f8e09a3c4e60bcd607a0b4590dd7dbf08dd40851b27cbb520ccb", size = 1844251, upload-time = "2026-07-23T01:57:16.593Z" }, + { url = "https://files.pythonhosted.org/packages/5e/19/08d41839658bdd44a0ed2480f3891705ecb487ce28c0dde62c9040c997e0/aiohttp-3.14.3-cp314-cp314t-win32.whl", hash = "sha256:b304db572b4368edd8dda8a2274f73156fe15558fca4a917cb8a09fc47af5963", size = 474180, upload-time = "2026-07-23T01:57:19.306Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/3cd6ef0a2b2851f7ab913b5b079334781bd50ff56a323e4454063377a080/aiohttp-3.14.3-cp314-cp314t-win_amd64.whl", hash = "sha256:b20032766aedf6261c7a566585a40867d092ac03a0d81592d5370ef9b054f99b", size = 500528, upload-time = "2026-07-23T01:57:21.762Z" }, + { url = "https://files.pythonhosted.org/packages/a4/37/cfd1ed540a4d318da025590d96b728e63713c09e9377950fc655dadeb856/aiohttp-3.14.3-cp314-cp314t-win_arm64.whl", hash = "sha256:2e1161602f45a54de2ce0905243a95f58cb42dcd378402f3697f5e0b21e9d2e7", size = 469280, upload-time = "2026-07-23T01:57:24.241Z" }, +] + +[[package]] +name = "aiosignal" +version = "1.4.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "frozenlist" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/61/62/06741b579156360248d1ec624842ad0edf697050bbaf7c3e46394e106ad1/aiosignal-1.4.0.tar.gz", hash = "sha256:f47eecd9468083c2029cc99945502cb7708b082c232f9aca65da147157b251c7", size = 25007, upload-time = "2025-07-03T22:54:43.528Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fb/76/641ae371508676492379f16e2fa48f4e2c11741bd63c48be4b12a6b09cba/aiosignal-1.4.0-py3-none-any.whl", hash = "sha256:053243f8b92b990551949e63930a839ff0cf0b0ebbe0597b0f3fb19e1a0fe82e", size = 7490, upload-time = "2025-07-03T22:54:42.156Z" }, +] + [[package]] name = "annotated-doc" version = "0.0.4" @@ -20,6 +160,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, ] +[[package]] +name = "attrs" +version = "26.1.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/9a/8e/82a0fe20a541c03148528be8cac2408564a6c9a0cc7e9171802bc1d26985/attrs-26.1.0.tar.gz", hash = "sha256:d03ceb89cb322a8fd706d4fb91940737b6642aa36998fe130a9bc96c985eff32", size = 952055, upload-time = "2026-03-19T14:22:25.026Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/64/b4/17d4b0b2a2dc85a6df63d1157e028ed19f90d4cd97c36717afef2bc2f395/attrs-26.1.0-py3-none-any.whl", hash = "sha256:c647aa4a12dfbad9333ca4e71fe62ddc36f4e63b2d260a37a8b83d2f043ac309", size = 67548, upload-time = "2026-03-19T14:22:23.645Z" }, +] + [[package]] name = "boolean-py" version = "5.0" @@ -323,10 +472,12 @@ name = "egress-gate" version = "0.1.0" source = { editable = "." } dependencies = [ + { name = "aiohttp" }, { name = "cryptography" }, { name = "grpcio" }, { name = "protobuf" }, { name = "pydantic" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pyyaml" }, { name = "regex" }, { name = "rich" }, @@ -345,10 +496,12 @@ dev = [ [package.metadata] requires-dist = [ + { name = "aiohttp", specifier = ">=3.12,<4" }, { name = "cryptography", specifier = ">=50,<51" }, { name = "grpcio", specifier = ">=1.81.1,<2" }, { name = "protobuf", specifier = ">=7.36,<8" }, { name = "pydantic", specifier = ">=2.11,<3" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.10,<3" }, { name = "pyyaml", specifier = ">=6,<7" }, { name = "regex", specifier = ">=2026.7.19,<2027" }, { name = "rich", specifier = ">=14,<16" }, @@ -374,6 +527,111 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, ] +[[package]] +name = "frozenlist" +version = "1.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/2d/f5/c831fac6cc817d26fd54c7eaccd04ef7e0288806943f7cc5bbf69f3ac1f0/frozenlist-1.8.0.tar.gz", hash = "sha256:3ede829ed8d842f6cd48fc7081d7a41001a56f1f38603f9d49bf3020d59a31ad", size = 45875, upload-time = "2025-10-06T05:38:17.865Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bc/03/077f869d540370db12165c0aa51640a873fb661d8b315d1d4d67b284d7ac/frozenlist-1.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:09474e9831bc2b2199fad6da3c14c7b0fbdd377cce9d3d77131be28906cb7d84", size = 86912, upload-time = "2025-10-06T05:35:45.98Z" }, + { url = "https://files.pythonhosted.org/packages/df/b5/7610b6bd13e4ae77b96ba85abea1c8cb249683217ef09ac9e0ae93f25a91/frozenlist-1.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:17c883ab0ab67200b5f964d2b9ed6b00971917d5d8a92df149dc2c9779208ee9", size = 50046, upload-time = "2025-10-06T05:35:47.009Z" }, + { url = "https://files.pythonhosted.org/packages/6e/ef/0e8f1fe32f8a53dd26bdd1f9347efe0778b0fddf62789ea683f4cc7d787d/frozenlist-1.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:fa47e444b8ba08fffd1c18e8cdb9a75db1b6a27f17507522834ad13ed5922b93", size = 50119, upload-time = "2025-10-06T05:35:48.38Z" }, + { url = "https://files.pythonhosted.org/packages/11/b1/71a477adc7c36e5fb628245dfbdea2166feae310757dea848d02bd0689fd/frozenlist-1.8.0-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:2552f44204b744fba866e573be4c1f9048d6a324dfe14475103fd51613eb1d1f", size = 231067, upload-time = "2025-10-06T05:35:49.97Z" }, + { url = "https://files.pythonhosted.org/packages/45/7e/afe40eca3a2dc19b9904c0f5d7edfe82b5304cb831391edec0ac04af94c2/frozenlist-1.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:957e7c38f250991e48a9a73e6423db1bb9dd14e722a10f6b8bb8e16a0f55f695", size = 233160, upload-time = "2025-10-06T05:35:51.729Z" }, + { url = "https://files.pythonhosted.org/packages/a6/aa/7416eac95603ce428679d273255ffc7c998d4132cfae200103f164b108aa/frozenlist-1.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:8585e3bb2cdea02fc88ffa245069c36555557ad3609e83be0ec71f54fd4abb52", size = 228544, upload-time = "2025-10-06T05:35:53.246Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3d/2a2d1f683d55ac7e3875e4263d28410063e738384d3adc294f5ff3d7105e/frozenlist-1.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:edee74874ce20a373d62dc28b0b18b93f645633c2943fd90ee9d898550770581", size = 243797, upload-time = "2025-10-06T05:35:54.497Z" }, + { url = "https://files.pythonhosted.org/packages/78/1e/2d5565b589e580c296d3bb54da08d206e797d941a83a6fdea42af23be79c/frozenlist-1.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c9a63152fe95756b85f31186bddf42e4c02c6321207fd6601a1c89ebac4fe567", size = 247923, upload-time = "2025-10-06T05:35:55.861Z" }, + { url = "https://files.pythonhosted.org/packages/aa/c3/65872fcf1d326a7f101ad4d86285c403c87be7d832b7470b77f6d2ed5ddc/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:b6db2185db9be0a04fecf2f241c70b63b1a242e2805be291855078f2b404dd6b", size = 230886, upload-time = "2025-10-06T05:35:57.399Z" }, + { url = "https://files.pythonhosted.org/packages/a0/76/ac9ced601d62f6956f03cc794f9e04c81719509f85255abf96e2510f4265/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:f4be2e3d8bc8aabd566f8d5b8ba7ecc09249d74ba3c9ed52e54dc23a293f0b92", size = 245731, upload-time = "2025-10-06T05:35:58.563Z" }, + { url = "https://files.pythonhosted.org/packages/b9/49/ecccb5f2598daf0b4a1415497eba4c33c1e8ce07495eb07d2860c731b8d5/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:c8d1634419f39ea6f5c427ea2f90ca85126b54b50837f31497f3bf38266e853d", size = 241544, upload-time = "2025-10-06T05:35:59.719Z" }, + { url = "https://files.pythonhosted.org/packages/53/4b/ddf24113323c0bbcc54cb38c8b8916f1da7165e07b8e24a717b4a12cbf10/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:1a7fa382a4a223773ed64242dbe1c9c326ec09457e6b8428efb4118c685c3dfd", size = 241806, upload-time = "2025-10-06T05:36:00.959Z" }, + { url = "https://files.pythonhosted.org/packages/a7/fb/9b9a084d73c67175484ba2789a59f8eebebd0827d186a8102005ce41e1ba/frozenlist-1.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:11847b53d722050808926e785df837353bd4d75f1d494377e59b23594d834967", size = 229382, upload-time = "2025-10-06T05:36:02.22Z" }, + { url = "https://files.pythonhosted.org/packages/95/a3/c8fb25aac55bf5e12dae5c5aa6a98f85d436c1dc658f21c3ac73f9fa95e5/frozenlist-1.8.0-cp311-cp311-win32.whl", hash = "sha256:27c6e8077956cf73eadd514be8fb04d77fc946a7fe9f7fe167648b0b9085cc25", size = 39647, upload-time = "2025-10-06T05:36:03.409Z" }, + { url = "https://files.pythonhosted.org/packages/0a/f5/603d0d6a02cfd4c8f2a095a54672b3cf967ad688a60fb9faf04fc4887f65/frozenlist-1.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:ac913f8403b36a2c8610bbfd25b8013488533e71e62b4b4adce9c86c8cea905b", size = 44064, upload-time = "2025-10-06T05:36:04.368Z" }, + { url = "https://files.pythonhosted.org/packages/5d/16/c2c9ab44e181f043a86f9a8f84d5124b62dbcb3a02c0977ec72b9ac1d3e0/frozenlist-1.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:d4d3214a0f8394edfa3e303136d0575eece0745ff2b47bd2cb2e66dd92d4351a", size = 39937, upload-time = "2025-10-06T05:36:05.669Z" }, + { url = "https://files.pythonhosted.org/packages/69/29/948b9aa87e75820a38650af445d2ef2b6b8a6fab1a23b6bb9e4ef0be2d59/frozenlist-1.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:78f7b9e5d6f2fdb88cdde9440dc147259b62b9d3b019924def9f6478be254ac1", size = 87782, upload-time = "2025-10-06T05:36:06.649Z" }, + { url = "https://files.pythonhosted.org/packages/64/80/4f6e318ee2a7c0750ed724fa33a4bdf1eacdc5a39a7a24e818a773cd91af/frozenlist-1.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:229bf37d2e4acdaf808fd3f06e854a4a7a3661e871b10dc1f8f1896a3b05f18b", size = 50594, upload-time = "2025-10-06T05:36:07.69Z" }, + { url = "https://files.pythonhosted.org/packages/2b/94/5c8a2b50a496b11dd519f4a24cb5496cf125681dd99e94c604ccdea9419a/frozenlist-1.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f833670942247a14eafbb675458b4e61c82e002a148f49e68257b79296e865c4", size = 50448, upload-time = "2025-10-06T05:36:08.78Z" }, + { url = "https://files.pythonhosted.org/packages/6a/bd/d91c5e39f490a49df14320f4e8c80161cfcce09f1e2cde1edd16a551abb3/frozenlist-1.8.0-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:494a5952b1c597ba44e0e78113a7266e656b9794eec897b19ead706bd7074383", size = 242411, upload-time = "2025-10-06T05:36:09.801Z" }, + { url = "https://files.pythonhosted.org/packages/8f/83/f61505a05109ef3293dfb1ff594d13d64a2324ac3482be2cedc2be818256/frozenlist-1.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96f423a119f4777a4a056b66ce11527366a8bb92f54e541ade21f2374433f6d4", size = 243014, upload-time = "2025-10-06T05:36:11.394Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cb/cb6c7b0f7d4023ddda30cf56b8b17494eb3a79e3fda666bf735f63118b35/frozenlist-1.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3462dd9475af2025c31cc61be6652dfa25cbfb56cbbf52f4ccfe029f38decaf8", size = 234909, upload-time = "2025-10-06T05:36:12.598Z" }, + { url = "https://files.pythonhosted.org/packages/31/c5/cd7a1f3b8b34af009fb17d4123c5a778b44ae2804e3ad6b86204255f9ec5/frozenlist-1.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:c4c800524c9cd9bac5166cd6f55285957fcfc907db323e193f2afcd4d9abd69b", size = 250049, upload-time = "2025-10-06T05:36:14.065Z" }, + { url = "https://files.pythonhosted.org/packages/c0/01/2f95d3b416c584a1e7f0e1d6d31998c4a795f7544069ee2e0962a4b60740/frozenlist-1.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d6a5df73acd3399d893dafc71663ad22534b5aa4f94e8a2fabfe856c3c1b6a52", size = 256485, upload-time = "2025-10-06T05:36:15.39Z" }, + { url = "https://files.pythonhosted.org/packages/ce/03/024bf7720b3abaebcff6d0793d73c154237b85bdf67b7ed55e5e9596dc9a/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:405e8fe955c2280ce66428b3ca55e12b3c4e9c336fb2103a4937e891c69a4a29", size = 237619, upload-time = "2025-10-06T05:36:16.558Z" }, + { url = "https://files.pythonhosted.org/packages/69/fa/f8abdfe7d76b731f5d8bd217827cf6764d4f1d9763407e42717b4bed50a0/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:908bd3f6439f2fef9e85031b59fd4f1297af54415fb60e4254a95f75b3cab3f3", size = 250320, upload-time = "2025-10-06T05:36:17.821Z" }, + { url = "https://files.pythonhosted.org/packages/f5/3c/b051329f718b463b22613e269ad72138cc256c540f78a6de89452803a47d/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:294e487f9ec720bd8ffcebc99d575f7eff3568a08a253d1ee1a0378754b74143", size = 246820, upload-time = "2025-10-06T05:36:19.046Z" }, + { url = "https://files.pythonhosted.org/packages/0f/ae/58282e8f98e444b3f4dd42448ff36fa38bef29e40d40f330b22e7108f565/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:74c51543498289c0c43656701be6b077f4b265868fa7f8a8859c197006efb608", size = 250518, upload-time = "2025-10-06T05:36:20.763Z" }, + { url = "https://files.pythonhosted.org/packages/8f/96/007e5944694d66123183845a106547a15944fbbb7154788cbf7272789536/frozenlist-1.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:776f352e8329135506a1d6bf16ac3f87bc25b28e765949282dcc627af36123aa", size = 239096, upload-time = "2025-10-06T05:36:22.129Z" }, + { url = "https://files.pythonhosted.org/packages/66/bb/852b9d6db2fa40be96f29c0d1205c306288f0684df8fd26ca1951d461a56/frozenlist-1.8.0-cp312-cp312-win32.whl", hash = "sha256:433403ae80709741ce34038da08511d4a77062aa924baf411ef73d1146e74faf", size = 39985, upload-time = "2025-10-06T05:36:23.661Z" }, + { url = "https://files.pythonhosted.org/packages/b8/af/38e51a553dd66eb064cdf193841f16f077585d4d28394c2fa6235cb41765/frozenlist-1.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:34187385b08f866104f0c0617404c8eb08165ab1272e884abc89c112e9c00746", size = 44591, upload-time = "2025-10-06T05:36:24.958Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1dc65480ab147339fecc70797e9c2f69d9cea9cf38934ce08df070fdb9cb/frozenlist-1.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:fe3c58d2f5db5fbd18c2987cba06d51b0529f52bc3a6cdc33d3f4eab725104bd", size = 40102, upload-time = "2025-10-06T05:36:26.333Z" }, + { url = "https://files.pythonhosted.org/packages/2d/40/0832c31a37d60f60ed79e9dfb5a92e1e2af4f40a16a29abcc7992af9edff/frozenlist-1.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:8d92f1a84bb12d9e56f818b3a746f3efba93c1b63c8387a73dde655e1e42282a", size = 85717, upload-time = "2025-10-06T05:36:27.341Z" }, + { url = "https://files.pythonhosted.org/packages/30/ba/b0b3de23f40bc55a7057bd38434e25c34fa48e17f20ee273bbde5e0650f3/frozenlist-1.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:96153e77a591c8adc2ee805756c61f59fef4cf4073a9275ee86fe8cba41241f7", size = 49651, upload-time = "2025-10-06T05:36:28.855Z" }, + { url = "https://files.pythonhosted.org/packages/0c/ab/6e5080ee374f875296c4243c381bbdef97a9ac39c6e3ce1d5f7d42cb78d6/frozenlist-1.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f21f00a91358803399890ab167098c131ec2ddd5f8f5fd5fe9c9f2c6fcd91e40", size = 49417, upload-time = "2025-10-06T05:36:29.877Z" }, + { url = "https://files.pythonhosted.org/packages/d5/4e/e4691508f9477ce67da2015d8c00acd751e6287739123113a9fca6f1604e/frozenlist-1.8.0-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:fb30f9626572a76dfe4293c7194a09fb1fe93ba94c7d4f720dfae3b646b45027", size = 234391, upload-time = "2025-10-06T05:36:31.301Z" }, + { url = "https://files.pythonhosted.org/packages/40/76/c202df58e3acdf12969a7895fd6f3bc016c642e6726aa63bd3025e0fc71c/frozenlist-1.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:eaa352d7047a31d87dafcacbabe89df0aa506abb5b1b85a2fb91bc3faa02d822", size = 233048, upload-time = "2025-10-06T05:36:32.531Z" }, + { url = "https://files.pythonhosted.org/packages/f9/c0/8746afb90f17b73ca5979c7a3958116e105ff796e718575175319b5bb4ce/frozenlist-1.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:03ae967b4e297f58f8c774c7eabcce57fe3c2434817d4385c50661845a058121", size = 226549, upload-time = "2025-10-06T05:36:33.706Z" }, + { url = "https://files.pythonhosted.org/packages/7e/eb/4c7eefc718ff72f9b6c4893291abaae5fbc0c82226a32dcd8ef4f7a5dbef/frozenlist-1.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f6292f1de555ffcc675941d65fffffb0a5bcd992905015f85d0592201793e0e5", size = 239833, upload-time = "2025-10-06T05:36:34.947Z" }, + { url = "https://files.pythonhosted.org/packages/c2/4e/e5c02187cf704224f8b21bee886f3d713ca379535f16893233b9d672ea71/frozenlist-1.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29548f9b5b5e3460ce7378144c3010363d8035cea44bc0bf02d57f5a685e084e", size = 245363, upload-time = "2025-10-06T05:36:36.534Z" }, + { url = "https://files.pythonhosted.org/packages/1f/96/cb85ec608464472e82ad37a17f844889c36100eed57bea094518bf270692/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ec3cc8c5d4084591b4237c0a272cc4f50a5b03396a47d9caaf76f5d7b38a4f11", size = 229314, upload-time = "2025-10-06T05:36:38.582Z" }, + { url = "https://files.pythonhosted.org/packages/5d/6f/4ae69c550e4cee66b57887daeebe006fe985917c01d0fff9caab9883f6d0/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:517279f58009d0b1f2e7c1b130b377a349405da3f7621ed6bfae50b10adf20c1", size = 243365, upload-time = "2025-10-06T05:36:40.152Z" }, + { url = "https://files.pythonhosted.org/packages/7a/58/afd56de246cf11780a40a2c28dc7cbabbf06337cc8ddb1c780a2d97e88d8/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:db1e72ede2d0d7ccb213f218df6a078a9c09a7de257c2fe8fcef16d5925230b1", size = 237763, upload-time = "2025-10-06T05:36:41.355Z" }, + { url = "https://files.pythonhosted.org/packages/cb/36/cdfaf6ed42e2644740d4a10452d8e97fa1c062e2a8006e4b09f1b5fd7d63/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:b4dec9482a65c54a5044486847b8a66bf10c9cb4926d42927ec4e8fd5db7fed8", size = 240110, upload-time = "2025-10-06T05:36:42.716Z" }, + { url = "https://files.pythonhosted.org/packages/03/a8/9ea226fbefad669f11b52e864c55f0bd57d3c8d7eb07e9f2e9a0b39502e1/frozenlist-1.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:21900c48ae04d13d416f0e1e0c4d81f7931f73a9dfa0b7a8746fb2fe7dd970ed", size = 233717, upload-time = "2025-10-06T05:36:44.251Z" }, + { url = "https://files.pythonhosted.org/packages/1e/0b/1b5531611e83ba7d13ccc9988967ea1b51186af64c42b7a7af465dcc9568/frozenlist-1.8.0-cp313-cp313-win32.whl", hash = "sha256:8b7b94a067d1c504ee0b16def57ad5738701e4ba10cec90529f13fa03c833496", size = 39628, upload-time = "2025-10-06T05:36:45.423Z" }, + { url = "https://files.pythonhosted.org/packages/d8/cf/174c91dbc9cc49bc7b7aab74d8b734e974d1faa8f191c74af9b7e80848e6/frozenlist-1.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:878be833caa6a3821caf85eb39c5ba92d28e85df26d57afb06b35b2efd937231", size = 43882, upload-time = "2025-10-06T05:36:46.796Z" }, + { url = "https://files.pythonhosted.org/packages/c1/17/502cd212cbfa96eb1388614fe39a3fc9ab87dbbe042b66f97acb57474834/frozenlist-1.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:44389d135b3ff43ba8cc89ff7f51f5a0bb6b63d829c8300f79a2fe4fe61bcc62", size = 39676, upload-time = "2025-10-06T05:36:47.8Z" }, + { url = "https://files.pythonhosted.org/packages/d2/5c/3bbfaa920dfab09e76946a5d2833a7cbdf7b9b4a91c714666ac4855b88b4/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:e25ac20a2ef37e91c1b39938b591457666a0fa835c7783c3a8f33ea42870db94", size = 89235, upload-time = "2025-10-06T05:36:48.78Z" }, + { url = "https://files.pythonhosted.org/packages/d2/d6/f03961ef72166cec1687e84e8925838442b615bd0b8854b54923ce5b7b8a/frozenlist-1.8.0-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:07cdca25a91a4386d2e76ad992916a85038a9b97561bf7a3fd12d5d9ce31870c", size = 50742, upload-time = "2025-10-06T05:36:49.837Z" }, + { url = "https://files.pythonhosted.org/packages/1e/bb/a6d12b7ba4c3337667d0e421f7181c82dda448ce4e7ad7ecd249a16fa806/frozenlist-1.8.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:4e0c11f2cc6717e0a741f84a527c52616140741cd812a50422f83dc31749fb52", size = 51725, upload-time = "2025-10-06T05:36:50.851Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/d1fed0ffe2c2ccd70b43714c6cab0f4188f09f8a67a7914a6b46ee30f274/frozenlist-1.8.0-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:b3210649ee28062ea6099cfda39e147fa1bc039583c8ee4481cb7811e2448c51", size = 284533, upload-time = "2025-10-06T05:36:51.898Z" }, + { url = "https://files.pythonhosted.org/packages/c9/1f/fb1685a7b009d89f9bf78a42d94461bc06581f6e718c39344754a5d9bada/frozenlist-1.8.0-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:581ef5194c48035a7de2aefc72ac6539823bb71508189e5de01d60c9dcd5fa65", size = 292506, upload-time = "2025-10-06T05:36:53.101Z" }, + { url = "https://files.pythonhosted.org/packages/e6/3b/b991fe1612703f7e0d05c0cf734c1b77aaf7c7d321df4572e8d36e7048c8/frozenlist-1.8.0-cp313-cp313t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:3ef2d026f16a2b1866e1d86fc4e1291e1ed8a387b2c333809419a2f8b3a77b82", size = 274161, upload-time = "2025-10-06T05:36:54.309Z" }, + { url = "https://files.pythonhosted.org/packages/ca/ec/c5c618767bcdf66e88945ec0157d7f6c4a1322f1473392319b7a2501ded7/frozenlist-1.8.0-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:5500ef82073f599ac84d888e3a8c1f77ac831183244bfd7f11eaa0289fb30714", size = 294676, upload-time = "2025-10-06T05:36:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/7c/ce/3934758637d8f8a88d11f0585d6495ef54b2044ed6ec84492a91fa3b27aa/frozenlist-1.8.0-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:50066c3997d0091c411a66e710f4e11752251e6d2d73d70d8d5d4c76442a199d", size = 300638, upload-time = "2025-10-06T05:36:56.758Z" }, + { url = "https://files.pythonhosted.org/packages/fc/4f/a7e4d0d467298f42de4b41cbc7ddaf19d3cfeabaf9ff97c20c6c7ee409f9/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5c1c8e78426e59b3f8005e9b19f6ff46e5845895adbde20ece9218319eca6506", size = 283067, upload-time = "2025-10-06T05:36:57.965Z" }, + { url = "https://files.pythonhosted.org/packages/dc/48/c7b163063d55a83772b268e6d1affb960771b0e203b632cfe09522d67ea5/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:eefdba20de0d938cec6a89bd4d70f346a03108a19b9df4248d3cf0d88f1b0f51", size = 292101, upload-time = "2025-10-06T05:36:59.237Z" }, + { url = "https://files.pythonhosted.org/packages/9f/d0/2366d3c4ecdc2fd391e0afa6e11500bfba0ea772764d631bbf82f0136c9d/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:cf253e0e1c3ceb4aaff6df637ce033ff6535fb8c70a764a8f46aafd3d6ab798e", size = 289901, upload-time = "2025-10-06T05:37:00.811Z" }, + { url = "https://files.pythonhosted.org/packages/b8/94/daff920e82c1b70e3618a2ac39fbc01ae3e2ff6124e80739ce5d71c9b920/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:032efa2674356903cd0261c4317a561a6850f3ac864a63fc1583147fb05a79b0", size = 289395, upload-time = "2025-10-06T05:37:02.115Z" }, + { url = "https://files.pythonhosted.org/packages/e3/20/bba307ab4235a09fdcd3cc5508dbabd17c4634a1af4b96e0f69bfe551ebd/frozenlist-1.8.0-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:6da155091429aeba16851ecb10a9104a108bcd32f6c1642867eadaee401c1c41", size = 283659, upload-time = "2025-10-06T05:37:03.711Z" }, + { url = "https://files.pythonhosted.org/packages/fd/00/04ca1c3a7a124b6de4f8a9a17cc2fcad138b4608e7a3fc5877804b8715d7/frozenlist-1.8.0-cp313-cp313t-win32.whl", hash = "sha256:0f96534f8bfebc1a394209427d0f8a63d343c9779cda6fc25e8e121b5fd8555b", size = 43492, upload-time = "2025-10-06T05:37:04.915Z" }, + { url = "https://files.pythonhosted.org/packages/59/5e/c69f733a86a94ab10f68e496dc6b7e8bc078ebb415281d5698313e3af3a1/frozenlist-1.8.0-cp313-cp313t-win_amd64.whl", hash = "sha256:5d63a068f978fc69421fb0e6eb91a9603187527c86b7cd3f534a5b77a592b888", size = 48034, upload-time = "2025-10-06T05:37:06.343Z" }, + { url = "https://files.pythonhosted.org/packages/16/6c/be9d79775d8abe79b05fa6d23da99ad6e7763a1d080fbae7290b286093fd/frozenlist-1.8.0-cp313-cp313t-win_arm64.whl", hash = "sha256:bf0a7e10b077bf5fb9380ad3ae8ce20ef919a6ad93b4552896419ac7e1d8e042", size = 41749, upload-time = "2025-10-06T05:37:07.431Z" }, + { url = "https://files.pythonhosted.org/packages/f1/c8/85da824b7e7b9b6e7f7705b2ecaf9591ba6f79c1177f324c2735e41d36a2/frozenlist-1.8.0-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:cee686f1f4cadeb2136007ddedd0aaf928ab95216e7691c63e50a8ec066336d0", size = 86127, upload-time = "2025-10-06T05:37:08.438Z" }, + { url = "https://files.pythonhosted.org/packages/8e/e8/a1185e236ec66c20afd72399522f142c3724c785789255202d27ae992818/frozenlist-1.8.0-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:119fb2a1bd47307e899c2fac7f28e85b9a543864df47aa7ec9d3c1b4545f096f", size = 49698, upload-time = "2025-10-06T05:37:09.48Z" }, + { url = "https://files.pythonhosted.org/packages/a1/93/72b1736d68f03fda5fdf0f2180fb6caaae3894f1b854d006ac61ecc727ee/frozenlist-1.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:4970ece02dbc8c3a92fcc5228e36a3e933a01a999f7094ff7c23fbd2beeaa67c", size = 49749, upload-time = "2025-10-06T05:37:10.569Z" }, + { url = "https://files.pythonhosted.org/packages/a7/b2/fabede9fafd976b991e9f1b9c8c873ed86f202889b864756f240ce6dd855/frozenlist-1.8.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:cba69cb73723c3f329622e34bdbf5ce1f80c21c290ff04256cff1cd3c2036ed2", size = 231298, upload-time = "2025-10-06T05:37:11.993Z" }, + { url = "https://files.pythonhosted.org/packages/3a/3b/d9b1e0b0eed36e70477ffb8360c49c85c8ca8ef9700a4e6711f39a6e8b45/frozenlist-1.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:778a11b15673f6f1df23d9586f83c4846c471a8af693a22e066508b77d201ec8", size = 232015, upload-time = "2025-10-06T05:37:13.194Z" }, + { url = "https://files.pythonhosted.org/packages/dc/94/be719d2766c1138148564a3960fc2c06eb688da592bdc25adcf856101be7/frozenlist-1.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0325024fe97f94c41c08872db482cf8ac4800d80e79222c6b0b7b162d5b13686", size = 225038, upload-time = "2025-10-06T05:37:14.577Z" }, + { url = "https://files.pythonhosted.org/packages/e4/09/6712b6c5465f083f52f50cf74167b92d4ea2f50e46a9eea0523d658454ae/frozenlist-1.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:97260ff46b207a82a7567b581ab4190bd4dfa09f4db8a8b49d1a958f6aa4940e", size = 240130, upload-time = "2025-10-06T05:37:15.781Z" }, + { url = "https://files.pythonhosted.org/packages/f8/d4/cd065cdcf21550b54f3ce6a22e143ac9e4836ca42a0de1022da8498eac89/frozenlist-1.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:54b2077180eb7f83dd52c40b2750d0a9f175e06a42e3213ce047219de902717a", size = 242845, upload-time = "2025-10-06T05:37:17.037Z" }, + { url = "https://files.pythonhosted.org/packages/62/c3/f57a5c8c70cd1ead3d5d5f776f89d33110b1addae0ab010ad774d9a44fb9/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2f05983daecab868a31e1da44462873306d3cbfd76d1f0b5b69c473d21dbb128", size = 229131, upload-time = "2025-10-06T05:37:18.221Z" }, + { url = "https://files.pythonhosted.org/packages/6c/52/232476fe9cb64f0742f3fde2b7d26c1dac18b6d62071c74d4ded55e0ef94/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:33f48f51a446114bc5d251fb2954ab0164d5be02ad3382abcbfe07e2531d650f", size = 240542, upload-time = "2025-10-06T05:37:19.771Z" }, + { url = "https://files.pythonhosted.org/packages/5f/85/07bf3f5d0fb5414aee5f47d33c6f5c77bfe49aac680bfece33d4fdf6a246/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:154e55ec0655291b5dd1b8731c637ecdb50975a2ae70c606d100750a540082f7", size = 237308, upload-time = "2025-10-06T05:37:20.969Z" }, + { url = "https://files.pythonhosted.org/packages/11/99/ae3a33d5befd41ac0ca2cc7fd3aa707c9c324de2e89db0e0f45db9a64c26/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:4314debad13beb564b708b4a496020e5306c7333fa9a3ab90374169a20ffab30", size = 238210, upload-time = "2025-10-06T05:37:22.252Z" }, + { url = "https://files.pythonhosted.org/packages/b2/60/b1d2da22f4970e7a155f0adde9b1435712ece01b3cd45ba63702aea33938/frozenlist-1.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:073f8bf8becba60aa931eb3bc420b217bb7d5b8f4750e6f8b3be7f3da85d38b7", size = 231972, upload-time = "2025-10-06T05:37:23.5Z" }, + { url = "https://files.pythonhosted.org/packages/3f/ab/945b2f32de889993b9c9133216c068b7fcf257d8595a0ac420ac8677cab0/frozenlist-1.8.0-cp314-cp314-win32.whl", hash = "sha256:bac9c42ba2ac65ddc115d930c78d24ab8d4f465fd3fc473cdedfccadb9429806", size = 40536, upload-time = "2025-10-06T05:37:25.581Z" }, + { url = "https://files.pythonhosted.org/packages/59/ad/9caa9b9c836d9ad6f067157a531ac48b7d36499f5036d4141ce78c230b1b/frozenlist-1.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:3e0761f4d1a44f1d1a47996511752cf3dcec5bbdd9cc2b4fe595caf97754b7a0", size = 44330, upload-time = "2025-10-06T05:37:26.928Z" }, + { url = "https://files.pythonhosted.org/packages/82/13/e6950121764f2676f43534c555249f57030150260aee9dcf7d64efda11dd/frozenlist-1.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d1eaff1d00c7751b7c6662e9c5ba6eb2c17a2306ba5e2a37f24ddf3cc953402b", size = 40627, upload-time = "2025-10-06T05:37:28.075Z" }, + { url = "https://files.pythonhosted.org/packages/c0/c7/43200656ecc4e02d3f8bc248df68256cd9572b3f0017f0a0c4e93440ae23/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:d3bb933317c52d7ea5004a1c442eef86f426886fba134ef8cf4226ea6ee1821d", size = 89238, upload-time = "2025-10-06T05:37:29.373Z" }, + { url = "https://files.pythonhosted.org/packages/d1/29/55c5f0689b9c0fb765055629f472c0de484dcaf0acee2f7707266ae3583c/frozenlist-1.8.0-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:8009897cdef112072f93a0efdce29cd819e717fd2f649ee3016efd3cd885a7ed", size = 50738, upload-time = "2025-10-06T05:37:30.792Z" }, + { url = "https://files.pythonhosted.org/packages/ba/7d/b7282a445956506fa11da8c2db7d276adcbf2b17d8bb8407a47685263f90/frozenlist-1.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:2c5dcbbc55383e5883246d11fd179782a9d07a986c40f49abe89ddf865913930", size = 51739, upload-time = "2025-10-06T05:37:32.127Z" }, + { url = "https://files.pythonhosted.org/packages/62/1c/3d8622e60d0b767a5510d1d3cf21065b9db874696a51ea6d7a43180a259c/frozenlist-1.8.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:39ecbc32f1390387d2aa4f5a995e465e9e2f79ba3adcac92d68e3e0afae6657c", size = 284186, upload-time = "2025-10-06T05:37:33.21Z" }, + { url = "https://files.pythonhosted.org/packages/2d/14/aa36d5f85a89679a85a1d44cd7a6657e0b1c75f61e7cad987b203d2daca8/frozenlist-1.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:92db2bf818d5cc8d9c1f1fc56b897662e24ea5adb36ad1f1d82875bd64e03c24", size = 292196, upload-time = "2025-10-06T05:37:36.107Z" }, + { url = "https://files.pythonhosted.org/packages/05/23/6bde59eb55abd407d34f77d39a5126fb7b4f109a3f611d3929f14b700c66/frozenlist-1.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:2dc43a022e555de94c3b68a4ef0b11c4f747d12c024a520c7101709a2144fb37", size = 273830, upload-time = "2025-10-06T05:37:37.663Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3f/22cff331bfad7a8afa616289000ba793347fcd7bc275f3b28ecea2a27909/frozenlist-1.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cb89a7f2de3602cfed448095bab3f178399646ab7c61454315089787df07733a", size = 294289, upload-time = "2025-10-06T05:37:39.261Z" }, + { url = "https://files.pythonhosted.org/packages/a4/89/5b057c799de4838b6c69aa82b79705f2027615e01be996d2486a69ca99c4/frozenlist-1.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:33139dc858c580ea50e7e60a1b0ea003efa1fd42e6ec7fdbad78fff65fad2fd2", size = 300318, upload-time = "2025-10-06T05:37:43.213Z" }, + { url = "https://files.pythonhosted.org/packages/30/de/2c22ab3eb2a8af6d69dc799e48455813bab3690c760de58e1bf43b36da3e/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:168c0969a329b416119507ba30b9ea13688fafffac1b7822802537569a1cb0ef", size = 282814, upload-time = "2025-10-06T05:37:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/59/f7/970141a6a8dbd7f556d94977858cfb36fa9b66e0892c6dd780d2219d8cd8/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:28bd570e8e189d7f7b001966435f9dac6718324b5be2990ac496cf1ea9ddb7fe", size = 291762, upload-time = "2025-10-06T05:37:46.657Z" }, + { url = "https://files.pythonhosted.org/packages/c1/15/ca1adae83a719f82df9116d66f5bb28bb95557b3951903d39135620ef157/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:b2a095d45c5d46e5e79ba1e5b9cb787f541a8dee0433836cea4b96a2c439dcd8", size = 289470, upload-time = "2025-10-06T05:37:47.946Z" }, + { url = "https://files.pythonhosted.org/packages/ac/83/dca6dc53bf657d371fbc88ddeb21b79891e747189c5de990b9dfff2ccba1/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:eab8145831a0d56ec9c4139b6c3e594c7a83c2c8be25d5bcf2d86136a532287a", size = 289042, upload-time = "2025-10-06T05:37:49.499Z" }, + { url = "https://files.pythonhosted.org/packages/96/52/abddd34ca99be142f354398700536c5bd315880ed0a213812bc491cff5e4/frozenlist-1.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:974b28cf63cc99dfb2188d8d222bc6843656188164848c4f679e63dae4b0708e", size = 283148, upload-time = "2025-10-06T05:37:50.745Z" }, + { url = "https://files.pythonhosted.org/packages/af/d3/76bd4ed4317e7119c2b7f57c3f6934aba26d277acc6309f873341640e21f/frozenlist-1.8.0-cp314-cp314t-win32.whl", hash = "sha256:342c97bf697ac5480c0a7ec73cd700ecfa5a8a40ac923bd035484616efecc2df", size = 44676, upload-time = "2025-10-06T05:37:52.222Z" }, + { url = "https://files.pythonhosted.org/packages/89/76/c615883b7b521ead2944bb3480398cbb07e12b7b4e4d073d3752eb721558/frozenlist-1.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:06be8f67f39c8b1dc671f5d83aaefd3358ae5cdcf8314552c57e7ed3e6475bdd", size = 49451, upload-time = "2025-10-06T05:37:53.425Z" }, + { url = "https://files.pythonhosted.org/packages/e0/a3/5982da14e113d07b325230f95060e2169f5311b1017ea8af2a29b374c289/frozenlist-1.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:102e6314ca4da683dca92e3b1355490fed5f313b768500084fbe6371fddfdb79", size = 42507, upload-time = "2025-10-06T05:37:54.513Z" }, + { url = "https://files.pythonhosted.org/packages/9a/9a/e35b4a917281c0b8419d4207f4334c8e8c5dbf4f3f5f9ada73958d937dcc/frozenlist-1.8.0-py3-none-any.whl", hash = "sha256:0c18a16eab41e82c295618a77502e17b195883241c563b00f0aa5106fc4eaa0d", size = 13409, upload-time = "2025-10-06T05:38:16.721Z" }, +] + [[package]] name = "grpcio" version = "1.82.1" @@ -539,6 +797,164 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, ] +[[package]] +name = "multidict" +version = "6.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/14/95/989c1b5ca17b72128661530cd6e351a0a83cda9a4d6c036e9ed976c18931/multidict-6.8.0.tar.gz", hash = "sha256:5cd4637ce76312ba1e05eb9c5193fec231f64fee0944e135fa1e951242355b37", size = 122412, upload-time = "2026-09-09T13:57:57.967Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d4/37/90216392620b6ef8704eb0bc055141745de396121067e98f1f72bdac33c3/multidict-6.8.0-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:1b8986d4313dcee7c932837d16a535f1840b827bac1ea7c5c4c80751d0423794", size = 85033, upload-time = "2026-09-09T13:53:18.786Z" }, + { url = "https://files.pythonhosted.org/packages/2b/bb/e01b8cf906479b2fa046e992b84a9d9f39c1ed4058acc353004cd9a04df9/multidict-6.8.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:2622fe114c0bd66ca5c461859357587f5a5e35ee5ff49fc5643d1bc78dbb41c6", size = 51008, upload-time = "2026-09-09T13:53:20.044Z" }, + { url = "https://files.pythonhosted.org/packages/c5/da/35d70c920812d9ddc6f295f6426457665194335fbc35aa0b96716aba219f/multidict-6.8.0-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:26a7aafc992e78872e2c8c1f7248c0e01139cf9020a7781b0c064fa566832712", size = 50232, upload-time = "2026-09-09T13:53:21.356Z" }, + { url = "https://files.pythonhosted.org/packages/91/6b/4c988a7c0daa4fbffc6080ed3c37b3a67cf225ba1de69d10a19ca1dd8d0d/multidict-6.8.0-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:05c2e90c5289c5f7436ba2c25812a5fbdaa1c1bc11c8d8d3bbf64f5cd7c633dd", size = 271678, upload-time = "2026-09-09T13:53:22.716Z" }, + { url = "https://files.pythonhosted.org/packages/73/2b/22c7de8a72fc5c36390e8049d86d842b032ac7c87ade035a3dafb7df4ffc/multidict-6.8.0-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7941ef106ca1f2c62314a13c7ed913bcf49641f3efdc12864d588e17870920ac", size = 270283, upload-time = "2026-09-09T13:53:24.235Z" }, + { url = "https://files.pythonhosted.org/packages/ce/f4/c1428318f945c57c016ba690338af41f87f18a7d3a7ef3227b1440a2c169/multidict-6.8.0-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a5a7ee1217949ddd43c6b7bcf70d5c22193bb50e8c695386de5905325e93ce9f", size = 245306, upload-time = "2026-09-09T13:53:25.656Z" }, + { url = "https://files.pythonhosted.org/packages/8b/92/a37f7519fb32b0bf43b0540292effe60edaf0691959214b227795bd3d56a/multidict-6.8.0-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6b62b7e0025aa48dec11e125e655d1157985a5fdcec04b1ad500101ad072b891", size = 279567, upload-time = "2026-09-09T13:53:27.162Z" }, + { url = "https://files.pythonhosted.org/packages/51/fe/a93c2ce417401863cc88ecf6561577625c140990d412e37f347a1c03a144/multidict-6.8.0-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:ea880d441be7c510106bc56064be39266d948aef94ad4955e8784690019a5d9f", size = 282526, upload-time = "2026-09-09T13:53:28.927Z" }, + { url = "https://files.pythonhosted.org/packages/f2/9d/6bb4f84fdd82acfa09dc312ac133e7f76cbf2370004447f2a90e65e5d63f/multidict-6.8.0-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ab83fdd8cf307353edba9c427c17a3a021c2522d690f5633dd9f72d28b48ccca", size = 272604, upload-time = "2026-09-09T13:53:30.595Z" }, + { url = "https://files.pythonhosted.org/packages/3a/97/df0a30a4d786d313f24b39cb96edaaa3bbfe83a0b309577c81a797783ec5/multidict-6.8.0-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3d1f48582686a0a3b81e9b43234766cc96697df72081af3f48107bd3f34d34e5", size = 250786, upload-time = "2026-09-09T13:53:32.15Z" }, + { url = "https://files.pythonhosted.org/packages/6f/72/e59a917680d00214ba41f9fec19a8bec48f3bdf62656bc4377f37ae30947/multidict-6.8.0-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:563661919f603374c40cf45ffcd25535c12b8954203569a2ab1cee5265871cf4", size = 265419, upload-time = "2026-09-09T13:53:33.943Z" }, + { url = "https://files.pythonhosted.org/packages/47/21/0eb8868982ff07c1a2faaef7502ee0be32ef247dc1bf27881c51e7f4b20d/multidict-6.8.0-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:2ba9933e8f35fe4a70f540b837254c4055da82dc3a9e500a8f95e61498083a15", size = 258934, upload-time = "2026-09-09T13:53:35.662Z" }, + { url = "https://files.pythonhosted.org/packages/fa/a6/0586396716faf950c10ffbe733e4a57b4eeda9f7073e60c09bd4a05a766e/multidict-6.8.0-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:68d40b2bace413f3231f5729d3fcfb1837fd31c4907e241b5d43211bfd76f3c2", size = 273168, upload-time = "2026-09-09T13:53:37.131Z" }, + { url = "https://files.pythonhosted.org/packages/31/79/7197af20190d0d832be3b18582be46c224f64f5ba1cd35d32068d6af31ed/multidict-6.8.0-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:a9e246f67ac038568b854ed7c5578e4c6af1f742359901a8fcc3603ff1358df6", size = 275884, upload-time = "2026-09-09T13:53:38.579Z" }, + { url = "https://files.pythonhosted.org/packages/f7/f7/af60573e25ffecc09e805464580d579338cf1a44332ab52757038435ed60/multidict-6.8.0-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:29b6e7bc4442a56cf8e0dc1cabf3fdc77cd533568d6829fc76a1effd2ce332ec", size = 246967, upload-time = "2026-09-09T13:53:40.172Z" }, + { url = "https://files.pythonhosted.org/packages/6b/02/4459f8c5025ab034d3d9af9a34bbde11319016cff2f327362c8ec43a80a1/multidict-6.8.0-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:82780eb8bf59e8fb25dd081fde6e058805045d6374a7f2f877effc826ca4434b", size = 272358, upload-time = "2026-09-09T13:53:41.868Z" }, + { url = "https://files.pythonhosted.org/packages/51/99/680d3522ab51a77094d31d7958c9f5989499a01ffbe5aebe11d25212b385/multidict-6.8.0-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:2196ba6df392c3574acadd14ef87550f3611349c8618564de324b806a7a31cee", size = 267450, upload-time = "2026-09-09T13:53:43.646Z" }, + { url = "https://files.pythonhosted.org/packages/4a/04/d0c773805b0aea171287b01a82e4c28c59ef2c2d93e8047394765181363f/multidict-6.8.0-cp311-cp311-win32.whl", hash = "sha256:b8b7aa75146266fd3e2a2437cf69ae188688c04ab8665b163d4257b46c1e0c83", size = 46847, upload-time = "2026-09-09T13:53:45.161Z" }, + { url = "https://files.pythonhosted.org/packages/71/f8/1a959771a4dcd3224bd7bb40054f66b98ba5b20d6b74fd273f548f887e0a/multidict-6.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:b03ca066b47b18b205cc080dca6f76cbd159f8cdd33a02a0700164c13b37e463", size = 51549, upload-time = "2026-09-09T13:53:46.448Z" }, + { url = "https://files.pythonhosted.org/packages/64/7c/3a74b11599a9d8f3cfbb78b9c5cac3ff3cdc17278e4c00329c7c18dcaff9/multidict-6.8.0-cp311-cp311-win_arm64.whl", hash = "sha256:54af1266710cb0f305127ae0b970aff8d208057f8a29cd6e1db99b0114947035", size = 48020, upload-time = "2026-09-09T13:53:47.767Z" }, + { url = "https://files.pythonhosted.org/packages/13/83/a4621577679149ea001806f5963f3fc687c391c1bd5217157be2278863f5/multidict-6.8.0-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:22a310ad37672a261e55a8b5e28d0ae08cfb68abb1f46418ccd19835c3b8e836", size = 84146, upload-time = "2026-09-09T13:53:49.163Z" }, + { url = "https://files.pythonhosted.org/packages/09/00/236b063f3e606055a3a9ba8faa5d40e6c688b059a58056b055f213476f46/multidict-6.8.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:bb8c7da8c861391f7ae48e3593762be2dabe405109e01aec520fbe1a6d15d14b", size = 51049, upload-time = "2026-09-09T13:53:50.46Z" }, + { url = "https://files.pythonhosted.org/packages/91/9d/954b139bfa969855f2d4cb5ae7b7d44dd7106f754305b6e21a9068213aa7/multidict-6.8.0-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:10456943903744ae1249728161c96bd9d2f7eb5ee17fcc2ffda2dc32e1bb36c7", size = 49362, upload-time = "2026-09-09T13:53:51.878Z" }, + { url = "https://files.pythonhosted.org/packages/d7/8a/8774f5b3f6d5266ecd1117876e04b405f0f1ce19aa750b35a826efe6cfe4/multidict-6.8.0-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:658f5a1895b804423d97b22d06fc0d0b171c7c01dcc3aa9c8faf0c0e26a249a5", size = 278619, upload-time = "2026-09-09T13:53:53.44Z" }, + { url = "https://files.pythonhosted.org/packages/db/47/736080fec911ed9f2dd57ccab5a8145e4f17c4987de0bfc27bee20e4d170/multidict-6.8.0-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90c10b22860dbd09982d0b8993b66231a861bea2993d4a817ff35273f6ea285a", size = 283771, upload-time = "2026-09-09T13:53:55.048Z" }, + { url = "https://files.pythonhosted.org/packages/4c/d5/b7f41f59b0583f092602308a5e7c16ec5efd00d60214b22511e89a38dd19/multidict-6.8.0-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:088b04a66b3c1fce6fe4d771ec184a0426262d0b86709c908477b4ac7965df40", size = 262108, upload-time = "2026-09-09T13:53:56.631Z" }, + { url = "https://files.pythonhosted.org/packages/54/b2/a52dc06c6e2598672308e3d392fd85b837b23c25dda459bedaea84985080/multidict-6.8.0-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9caef53b20a105c0d66518a34be2f71b2783de8d091767575ef86f6ea422236d", size = 289899, upload-time = "2026-09-09T13:53:58.415Z" }, + { url = "https://files.pythonhosted.org/packages/a9/21/00cda7983f37d119b86f1f89d5b4cf771ecb6d0fedeb9a0971758d6d6d4a/multidict-6.8.0-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a5e1583c14775580da05641240ce0d93f36ce3ddef3d5083a827468b0bcfe874", size = 293025, upload-time = "2026-09-09T13:53:59.973Z" }, + { url = "https://files.pythonhosted.org/packages/c6/c7/4544cc02e45bbfac4d8788b05379bb360021fd8c53fa74b0f624126ac188/multidict-6.8.0-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:003a3bddb32915c3f67096ea41d24e53edf710edb65a1f5d0c70ab40b0e4d20b", size = 287410, upload-time = "2026-09-09T13:54:01.652Z" }, + { url = "https://files.pythonhosted.org/packages/43/1a/7abed90b8eba381842235bfa6f4d730204fd7deb374fc87e3ec9b2c2b4ac/multidict-6.8.0-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:35977263d9bf506dbc65349f63b3b8c91606d4abc110990945e3b94bc671319c", size = 255878, upload-time = "2026-09-09T13:54:03.366Z" }, + { url = "https://files.pythonhosted.org/packages/25/3e/73fae10e15fc4d711975337caff7e494c87de5d0189afe3518b21b945326/multidict-6.8.0-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:e9dc7b4ff6ef184504b49ef9a4113d49a646653b2ce89f5f48c1f57cdf6ba081", size = 277831, upload-time = "2026-09-09T13:54:04.963Z" }, + { url = "https://files.pythonhosted.org/packages/c5/cf/01cfc81492933331147004861bdff201d8adeba8485ecd8f490e755fe7e8/multidict-6.8.0-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:887f9a975996032c686719eb7b3e1e7942fab5079c2b778bbd9afe9a9d78244f", size = 275096, upload-time = "2026-09-09T13:54:06.661Z" }, + { url = "https://files.pythonhosted.org/packages/de/59/e9a3773b17297fa1e38fd4b3c6f5f2f458380796be62eca7d0d77c250618/multidict-6.8.0-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:f3071e6515cc63714d014da8f738ae9fa3997c476203f3cd46de380c2376ed7b", size = 279803, upload-time = "2026-09-09T13:54:08.389Z" }, + { url = "https://files.pythonhosted.org/packages/64/9d/2d712a2605b3971908e3b4f5eb6f98c353d9991e106f684d0e08ae581814/multidict-6.8.0-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:c5f3a2af441670d80ce5fdf13b6c1b421fc1fc7fc5182d58ac7486738bb2b742", size = 284595, upload-time = "2026-09-09T13:54:10.17Z" }, + { url = "https://files.pythonhosted.org/packages/58/6c/21aded8586e552b29892268c576e5745d1a894c5451c9866ca3c06b7ec50/multidict-6.8.0-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:31e8901637e20ccb3cf8f8848b5d0f7a00462bf5b34f7cf3dcbb2753b18e8b39", size = 252641, upload-time = "2026-09-09T13:54:11.811Z" }, + { url = "https://files.pythonhosted.org/packages/2a/70/56a415ae0a45e5eae2ec817d46aeb72a1ae777863621c85f1f39d329275b/multidict-6.8.0-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:13967dca8b2f33230a1427b52438326bb1c9101a1df22a3309ed3fcbbb3c96f0", size = 283369, upload-time = "2026-09-09T13:54:13.59Z" }, + { url = "https://files.pythonhosted.org/packages/08/7e/7b7cd611fd94bf2f6bd16244c50495867ba394d5baaf8e6e487d39494ab3/multidict-6.8.0-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:ad474c11d851b6fc97cb625e4822bc0cbd567fc07dc2602e28faec5a36b42bbb", size = 281653, upload-time = "2026-09-09T13:54:15.174Z" }, + { url = "https://files.pythonhosted.org/packages/33/4a/b19a5892ef2ef6c68ae278b4f1504b82e01037baedd92c55d37e55ecad00/multidict-6.8.0-cp312-cp312-win32.whl", hash = "sha256:7bb0dad75068fee80fcb60f88569722c199d8656a16706702dc6e3b786819c90", size = 47936, upload-time = "2026-09-09T13:54:16.638Z" }, + { url = "https://files.pythonhosted.org/packages/29/00/1952f9f282aa71e7c3db3a6b47afb689d0ddf283dbded7e6326a91d421c9/multidict-6.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:7d26dc8f070c0ec5579e987fa615ffd6883086106eefdff9e10d160fc5630630", size = 51723, upload-time = "2026-09-09T13:54:18.05Z" }, + { url = "https://files.pythonhosted.org/packages/49/b5/c9d57dbafe25b8f3460ce2961c968539a81ff7a70160c44dcfd4255cbcd1/multidict-6.8.0-cp312-cp312-win_arm64.whl", hash = "sha256:e6ec7d37841609a691b96a10b4fde386c7cd93ebbb939f59c9f23325ee788395", size = 48492, upload-time = "2026-09-09T13:54:19.42Z" }, + { url = "https://files.pythonhosted.org/packages/84/1f/d7112c2dd7db02677097be72fb65542f51a5aa73cb472b87ec211ba9e0dd/multidict-6.8.0-cp313-cp313-android_24_x86_64.whl", hash = "sha256:ec0a4d066356054d569a66e0a94691a2058b680be5e710298f61db11a3c4609f", size = 54197, upload-time = "2026-09-09T13:54:20.814Z" }, + { url = "https://files.pythonhosted.org/packages/ae/24/876015abbcb4a179d946579eb77b778eb5a948fc8381bc7928ba895bc051/multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:714597cb5d5e15a8a449d2ae23c45b486a9e8fa33c462c7a33d7f35b65d92943", size = 47787, upload-time = "2026-09-09T13:54:22.51Z" }, + { url = "https://files.pythonhosted.org/packages/06/c1/ceb7d25f8a567599db2eb19b08cac58d67ff553cff42dcadbea9aba56a20/multidict-6.8.0-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:e0db3a4d1e264e225037a6023888972c25206a96e016021a5bea41c9a939f2a9", size = 48815, upload-time = "2026-09-09T13:54:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/18/e3/e1c6e9c3818c34b782f23ce5fdba3eaa34ec6750dc53078dfac80fa59be7/multidict-6.8.0-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:27747162712e85c84598d364425dbf1714ff335bdb6ba3171c4e5081196e8916", size = 83484, upload-time = "2026-09-09T13:54:25.674Z" }, + { url = "https://files.pythonhosted.org/packages/4a/a0/c23f78a4badee9a5b3e760495c661c62a92c340a1dfd00f829cd16e256bb/multidict-6.8.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:847d6082ae694dc95e548acb201bc100e1cfa96513bc71fdcb86f709dad6c435", size = 50763, upload-time = "2026-09-09T13:54:27.135Z" }, + { url = "https://files.pythonhosted.org/packages/c4/fe/db552d402a3f6b650f5d3ae11b82b93833836aebb51bcda22d8691121129/multidict-6.8.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:88a6df88567680504ae28bfa7a1f2f64243d91e79a40b2c92ef42efc531e23da", size = 49029, upload-time = "2026-09-09T13:54:28.483Z" }, + { url = "https://files.pythonhosted.org/packages/01/b4/546853fba19dcef77cdf91fc173faf0b02284a49106cf250511166b4ec5c/multidict-6.8.0-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:560b211fc3bd4a1e1c6de44f6d38113bf5b410dfc89a4c0d2a3c0edbf1a0dfb8", size = 278863, upload-time = "2026-09-09T13:54:30.145Z" }, + { url = "https://files.pythonhosted.org/packages/ee/3f/4b52dac7db547936eb762123ac1d99df23f92fdb358bae600e322f611247/multidict-6.8.0-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:202436df907c15adbb94360296c425ea53cf8968a5d2cff9b5b9790ae1972b33", size = 283915, upload-time = "2026-09-09T13:54:31.937Z" }, + { url = "https://files.pythonhosted.org/packages/fd/6e/c0dfbf170e49a91bcb9ce850d51cb98357f3033c5227529200ca7625853e/multidict-6.8.0-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:c46a08bf070d6849fed483e9d9833f9d06aecb8382ed985be0b38508b3ae958e", size = 260704, upload-time = "2026-09-09T13:54:33.529Z" }, + { url = "https://files.pythonhosted.org/packages/91/02/56973a060ab8dfc2e80bb6797682f6577aff7123cdb1de1a568670ae3499/multidict-6.8.0-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cd560498ae8e1bcc955643c1d78eb8e338226d07a983c656ea8c4443d3eec0f", size = 290243, upload-time = "2026-09-09T13:54:35.408Z" }, + { url = "https://files.pythonhosted.org/packages/d5/67/69112989f131bdea4a87b74e82cb0a2daf37880cd92b0e6f0420020adceb/multidict-6.8.0-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:758233648ac47b07c575224c4eadd73c8929c3b4c31e2afcfea935fde1cda735", size = 291131, upload-time = "2026-09-09T13:54:37.205Z" }, + { url = "https://files.pythonhosted.org/packages/c2/75/9435f68b0cfc442d4917de85c26f2b2e1292630883414a25576083fa2469/multidict-6.8.0-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:122adc7c46ac1e31ecfc7f81b2530533dccafdba70f5d741649f87e336c63384", size = 287551, upload-time = "2026-09-09T13:54:38.835Z" }, + { url = "https://files.pythonhosted.org/packages/13/08/2ee4838081d6587849611aa7ec722c4cb2469e912fd0eaee980e7bac064c/multidict-6.8.0-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8125e60f3c70e323ac07dd8b3635f7b3bbc5c3a9ac04ae5988f668ff7ae28a18", size = 254591, upload-time = "2026-09-09T13:54:40.806Z" }, + { url = "https://files.pythonhosted.org/packages/94/f1/05673b51191f77f4198b8e4b35f16ea71c0300c72ca8aa027a66a61b6edc/multidict-6.8.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:83ff054b04915be5c15680da6c6012474a2cc2bf534129a0e8c6a99f17ba7238", size = 278204, upload-time = "2026-09-09T13:54:42.672Z" }, + { url = "https://files.pythonhosted.org/packages/45/4f/b6cf74322b3fbd3e011a1e903730191922291a7779f6d404114c2189b806/multidict-6.8.0-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:930c6058047410e3edff445f5a6e4457f2e089042dede00e2d18ce06f3ceae2e", size = 275600, upload-time = "2026-09-09T13:54:44.348Z" }, + { url = "https://files.pythonhosted.org/packages/1b/ab/958bbb04377159ff03c7314cd9d8a48dd6fc4f78c840589c22ab155ee9c7/multidict-6.8.0-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:13e26f59f0eecfc5f67c663ad550ffdaf62c0f657547cde387f6c86af1c9449e", size = 279793, upload-time = "2026-09-09T13:54:46.086Z" }, + { url = "https://files.pythonhosted.org/packages/a0/3a/706605ab0dfc4179748ee7949829e63c6f14ae28667aceeefaf2c701807f/multidict-6.8.0-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:fd789a294d8e098528be29b2669b83005ce569339f8cef167fc0274c3115c34c", size = 284751, upload-time = "2026-09-09T13:54:47.793Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a5/567e36c013ad023546de633079c6b22101dd43226b193cba00e6399703be/multidict-6.8.0-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:3126f2a96704505aa4e92a72d6e8a5d7f29d40a987ced8bf69e29d71dfc71fbc", size = 250812, upload-time = "2026-09-09T13:54:49.509Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5f/6b0b64aa0cd346b07831dabaa6ccda0e73014c5df044b68baa763f0f0552/multidict-6.8.0-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:23c9ee89967b6a9b4048acb3b93b660ed714ce9c8bf3bbe652959bc120dc02dc", size = 281606, upload-time = "2026-09-09T13:54:51.288Z" }, + { url = "https://files.pythonhosted.org/packages/31/8c/b846b6796f26d496efb07fedef2b69f6de533da32a56f12d236722a96157/multidict-6.8.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:7a62e302fc8cd6aa8972207e7e951d1fdee7c1dda18568305041d19f0e2c00f5", size = 281733, upload-time = "2026-09-09T13:54:53.05Z" }, + { url = "https://files.pythonhosted.org/packages/f0/f3/bf14a39d4af5697fd9404baaf70a0aeeb82d258b95de5cb16b1a7f98ae6f/multidict-6.8.0-cp313-cp313-win32.whl", hash = "sha256:093167d22a8c95af30f597b8a5686f20a14512989942d4be804d119899caca20", size = 47738, upload-time = "2026-09-09T13:54:54.676Z" }, + { url = "https://files.pythonhosted.org/packages/19/0a/598511a5741a3cb374971b3b02eda8a09896118ba528a54795f7e7e8bfb4/multidict-6.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:f25b61a708bd276e8cbb6afcbbf1b8e793a3be70ba0a842d0b8692020f83b706", size = 51609, upload-time = "2026-09-09T13:54:56.38Z" }, + { url = "https://files.pythonhosted.org/packages/fd/b7/6f5c1bd4ffe42d4a6db0f2f65491d4088e9c25c990358fb31a614621d664/multidict-6.8.0-cp313-cp313-win_arm64.whl", hash = "sha256:bb36381e1f9f9d06eba2f10bdd438e5d20c07d5b55e1a3eee30b9f44cbf52316", size = 48280, upload-time = "2026-09-09T13:54:58.03Z" }, + { url = "https://files.pythonhosted.org/packages/ab/85/153341590e233a967c1d6791a83402d01693dec0f4c1f695606ef16c7ed2/multidict-6.8.0-cp314-cp314-android_24_x86_64.whl", hash = "sha256:f8b09b25e0f4dc2ea9e2adbb1cc3ba11a94d6fa3dd978ae659c8743052e1afbc", size = 53758, upload-time = "2026-09-09T13:54:59.563Z" }, + { url = "https://files.pythonhosted.org/packages/fd/ff/44f72d516ece0398683ef52061797d83a74b16b8c1e4587408e97959d783/multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:1f57c414be82490bc0e0305fdb834186229b2d9b6a35fa0afd1eb1a772d125ab", size = 47495, upload-time = "2026-09-09T13:55:01.382Z" }, + { url = "https://files.pythonhosted.org/packages/50/5f/6e118f761b024dd35d26c2fe7ba41572bb0e8ac5f8cfccbbcbc2ff76da4e/multidict-6.8.0-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:00be37bde741bf60871082cd347a093218c44886e99231b7516671c70f2c280d", size = 48540, upload-time = "2026-09-09T13:55:02.989Z" }, + { url = "https://files.pythonhosted.org/packages/e8/4b/3eed744491b32f0e318e7db89dc06858732362f706e8d045fa9ab51a343a/multidict-6.8.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:e37b744849fb631bb52e3dadde35ffeee365a6c41cf71257b5b7acc9cd83fd38", size = 83130, upload-time = "2026-09-09T13:55:04.554Z" }, + { url = "https://files.pythonhosted.org/packages/6d/de/95c2c0ddcccb9a41ffbaa5df8ea059a8ff81916b7617a8847ecd89ed8061/multidict-6.8.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:c2b2a96cf1dd99fe7867be4c013314225f4d5786e6685906e29932d42aca6f11", size = 50574, upload-time = "2026-09-09T13:55:06.387Z" }, + { url = "https://files.pythonhosted.org/packages/f5/b7/f4f4989594f99bc121ad9277090c4e49819b08ab1a96e132b628a9e10b7d/multidict-6.8.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bea7df027015856ba5d0a88e3b4777ff8cb5c66b58fc108050fe79d4dd9d4d2d", size = 48786, upload-time = "2026-09-09T13:55:08.131Z" }, + { url = "https://files.pythonhosted.org/packages/b2/86/f1d86a0222f31fb3df8eef3d6c9abf7e8d65d49edd8d0d7e7afaf23d23cc/multidict-6.8.0-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d3da668e903c934ed0b587ecacfed6901f6ae6384a6e975887592b61845e78bc", size = 276670, upload-time = "2026-09-09T13:55:09.803Z" }, + { url = "https://files.pythonhosted.org/packages/03/50/6945c50f86a978b2bcace9ca344165ff80883be47d984489bbba8fa0ab20/multidict-6.8.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:64eaeda36ee8d88f9e8616a587a8c66a663283cf6e0dcf013c1ddd8c758e4aef", size = 279339, upload-time = "2026-09-09T13:55:11.685Z" }, + { url = "https://files.pythonhosted.org/packages/ee/2c/e649889ba23fd1f4442a85427b99d9e6261226b2ac31914aa7f5b241d947/multidict-6.8.0-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:ac746cb365bac1c462da9e3e6ab8904a8efe2217a56b0b2e3d9480f41d2b2602", size = 252549, upload-time = "2026-09-09T13:55:13.527Z" }, + { url = "https://files.pythonhosted.org/packages/e5/f8/1023b66e011b1395fb160dabb0f0608ef67e569f0bdb2c1d5ac9b2f2adc6/multidict-6.8.0-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:18f0e06360c3e451a3ab800355773c8d125a758238d780c800b0ee5e90ee903c", size = 286203, upload-time = "2026-09-09T13:55:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/7e/6c/48aea545cbda6d0444848ec23d988c13b86538a00a1b7d3868cc2382ff94/multidict-6.8.0-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:69708fecaa88bcb2341397b49fc95057a835b02a3670c551b37f95dd79e64e3a", size = 285039, upload-time = "2026-09-09T13:55:16.928Z" }, + { url = "https://files.pythonhosted.org/packages/68/2a/066123b17291671bf67d2a5c65ee81a48de53913bd1b1578791519eacdb0/multidict-6.8.0-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9606f583e7acaf61e7b3f56074e14037b9af7cb194590edfc0114b3ae5931ff7", size = 281075, upload-time = "2026-09-09T13:55:19.155Z" }, + { url = "https://files.pythonhosted.org/packages/47/20/4f0b2c485da2e8a659cc677717a3745872918c9c85064491a1ef75d7a3bf/multidict-6.8.0-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1f66fe6a021173d0d47968491791966b9f3e6d61115f2491744aa0c07a6e67af", size = 250431, upload-time = "2026-09-09T13:55:21.07Z" }, + { url = "https://files.pythonhosted.org/packages/ff/c7/b9a288901577aa0b82c33c64d52246c88076d260ad7b6c16b021ca0f8e99/multidict-6.8.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:be007d1aee2cbd530347dcafedb400891a3b5f1bd7135f95cf5d5b330b5219ee", size = 273891, upload-time = "2026-09-09T13:55:22.887Z" }, + { url = "https://files.pythonhosted.org/packages/da/51/0ba50cab2cfd067988de2abb73f23076ac727fe18d03f1368a59def64727/multidict-6.8.0-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:8457aff3c12a89a8e1c4674de5c777857fbc429f40fe117a3d29538547cbc364", size = 265262, upload-time = "2026-09-09T13:55:24.77Z" }, + { url = "https://files.pythonhosted.org/packages/0f/d6/e5be1117dbca6eb9ce231142b7e20599418bb3500147db51bf844ce8afcb/multidict-6.8.0-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:95c27b4f3f04320fc44e338573f40c5c956b504a7fcf081a157fd0b02579311c", size = 278033, upload-time = "2026-09-09T13:55:26.67Z" }, + { url = "https://files.pythonhosted.org/packages/d2/28/cad0afaec3caa56ea2c1ceed43c164d62ad3e83e950daf0d0c87bcf9dca7/multidict-6.8.0-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:d244cf6b52b5ba1c34c3832f4652a668ebb36d95949b96eed9a1c54d916a90dd", size = 281717, upload-time = "2026-09-09T13:55:28.569Z" }, + { url = "https://files.pythonhosted.org/packages/c9/d2/025702df0b69b856db70a4d66f77622f51c3d99771ec9a07f3ca80f7e098/multidict-6.8.0-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:5bbbb696c8024475b1877d14ce20d5f1cc05b8f6d786cea0fe3aa7fedc02e891", size = 247124, upload-time = "2026-09-09T13:55:30.497Z" }, + { url = "https://files.pythonhosted.org/packages/b4/96/9dddca563f06a921956389c0bc9b894355b98b0bdf62299e2560c50afb6d/multidict-6.8.0-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:cbd86f9787c5e2f5fd27d8b21458222f107347c6731c4e93dde68f554b466a2d", size = 275954, upload-time = "2026-09-09T13:55:32.57Z" }, + { url = "https://files.pythonhosted.org/packages/ec/91/8b2f1f2a774a955665f268340a2b59db7020c5f12baac02ae9ef1b1660cf/multidict-6.8.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2f8a4b0b4d639d525928c7f30de527bfdf9ead6e44a5e8cb9c50aced5e4590cb", size = 275508, upload-time = "2026-09-09T13:55:34.368Z" }, + { url = "https://files.pythonhosted.org/packages/b6/1a/e2cabdfc0880a61a99d2b8bc361035036fb5a2c6af31ea3fa054ba1065c5/multidict-6.8.0-cp314-cp314-win32.whl", hash = "sha256:8890c89d662560e51c55ac1304d6f919b23942abe9ae1127cb1de9aa6132fa52", size = 46938, upload-time = "2026-09-09T13:55:36.057Z" }, + { url = "https://files.pythonhosted.org/packages/b9/7c/11234bcba62c22a58f2ba168499cfe3531f49de3edd5090d04a8c6cdc936/multidict-6.8.0-cp314-cp314-win_amd64.whl", hash = "sha256:45cc39ba50fb0754a4359b90f8229ae08598fe2266abe3521b4e5a9ba916534a", size = 50291, upload-time = "2026-09-09T13:55:37.698Z" }, + { url = "https://files.pythonhosted.org/packages/ab/61/793668439df924752a8137d6db0de97ed1add494779b01e4764dfc60571b/multidict-6.8.0-cp314-cp314-win_arm64.whl", hash = "sha256:d0264f8d5cb0a803f650a6a8572dfa0cd1e099a2234c588dc8fb220b415b865f", size = 47622, upload-time = "2026-09-09T13:55:39.335Z" }, + { url = "https://files.pythonhosted.org/packages/9c/b8/3c091b929e6b5b2f6e0eba2232178e76d4503c8b96b92dfc281ff1d823be/multidict-6.8.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:1969971900b0871530f9b62280dcc2d75688e74d2a69262bc01faf2b96c78f04", size = 88789, upload-time = "2026-09-09T13:55:41.086Z" }, + { url = "https://files.pythonhosted.org/packages/9e/d7/3df83fab22dd64615db71e3b3cc1346b581d1459719637ce52144f9f6558/multidict-6.8.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8180b635290a75af8478f1b3e9810135381ae24833293fe77b85c1c21ff842ab", size = 53399, upload-time = "2026-09-09T13:55:42.685Z" }, + { url = "https://files.pythonhosted.org/packages/2d/78/41bd04c04b0aed16540c4856c9e012afc1c254298da154398308df05e26a/multidict-6.8.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:4261863fc8b5ab1b815ede94e592e94c6af5b04616014929057e61859e7382a9", size = 51597, upload-time = "2026-09-09T13:55:44.569Z" }, + { url = "https://files.pythonhosted.org/packages/2f/6b/7bc4cdddf624e1e7e0231734b1331729ea46df10d7c8fd3fce79756e7d0e/multidict-6.8.0-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0b143d53590e89f43153d81d505a8448d4d57354354385aef8a51d67ffefa27e", size = 264391, upload-time = "2026-09-09T13:55:46.548Z" }, + { url = "https://files.pythonhosted.org/packages/dc/b6/d2a946e5938771e92c39354563e535ef6bc6dfe399dd4307c6df8dfea183/multidict-6.8.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:da1c112c5784ccd9d32cd90be6739fee32644e874eff6ae8f0497cba3e352e58", size = 264680, upload-time = "2026-09-09T13:55:49.915Z" }, + { url = "https://files.pythonhosted.org/packages/33/6b/3f9e981c42e7eb9329918523f0f9362ceb0ac3ee0ee1165c28f674249d75/multidict-6.8.0-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:f7eefd0233a7c33ca980a5cfef26f1e9b5e2137839e752a99963696729f12d91", size = 235420, upload-time = "2026-09-09T13:55:51.92Z" }, + { url = "https://files.pythonhosted.org/packages/bc/e1/a3a33a039fb6d381800ae5d1d587b697b8c27fcdfe48819420f08703acba/multidict-6.8.0-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:348bb85e2038b40c007383616d73f734869063772372519549ebd7da1723d1a4", size = 270309, upload-time = "2026-09-09T13:55:54.023Z" }, + { url = "https://files.pythonhosted.org/packages/95/5d/8b06724a957f2e480f159b9550988a67810fbe9555a09c5f6a2a4b829607/multidict-6.8.0-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:095f62ea4e7a3be2f6c567ab695ce10e950f2adb905c1bec82281593e0b2d2ad", size = 275169, upload-time = "2026-09-09T13:55:55.948Z" }, + { url = "https://files.pythonhosted.org/packages/ab/32/8f3dfe2ffa5d0df2a95f71e63c2f11fe3b5e1771f26ef73bb1af84de83f8/multidict-6.8.0-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:be569fff1d85cd29391c431c5641c8772acb75bbdc61e60a8e82fceb9023d385", size = 264900, upload-time = "2026-09-09T13:55:57.803Z" }, + { url = "https://files.pythonhosted.org/packages/6b/73/d5829fc00a055d6ab445e0876346ee9cdee670766cd4190dc0a496188c0f/multidict-6.8.0-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3533a03e4e789baf6a286e7b0b1b6da3f3d7c3eab569686ee29ee1d8b52e2cb4", size = 242486, upload-time = "2026-09-09T13:56:00.002Z" }, + { url = "https://files.pythonhosted.org/packages/b7/58/e8d7874038e31e0533182d1c3c5331a856b9c849a71bb26a21850e8c91e1/multidict-6.8.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:1bdb9b8fba5a9aef673ec90db3f55b1ce743f2fbdea4d37dc04d14ccdfc153ff", size = 259916, upload-time = "2026-09-09T13:56:01.802Z" }, + { url = "https://files.pythonhosted.org/packages/4d/f8/1b56a7401acda20efc016440f4fad3bef66c4aee54ca080ec143881ebb0d/multidict-6.8.0-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:f8d7b66c9e09c0bb0add2b5895e646b62a0849e71155066f215523de6b95cbe6", size = 251209, upload-time = "2026-09-09T13:56:03.767Z" }, + { url = "https://files.pythonhosted.org/packages/bd/5b/68d67a9e302b0645a747ba910c30eb41f2834fcdc1d85f53eae2dfceee0a/multidict-6.8.0-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:563d6500ca80dac7bba6f48a78e0ffd87e21a7d4d24642c6503a2ddccd70c110", size = 264505, upload-time = "2026-09-09T13:56:05.795Z" }, + { url = "https://files.pythonhosted.org/packages/18/13/4dc304ba2c5f5307b474ab2ce1ed1f6b02b0b4e233c182e3981ed436c2e3/multidict-6.8.0-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:346ac52e56bcda320c0dcdfdd081947ed7cada33afea4e2284bef7b0733bff9b", size = 264916, upload-time = "2026-09-09T13:56:09.079Z" }, + { url = "https://files.pythonhosted.org/packages/dc/0f/7b1f729d18369915009185201be5d0b8df0e525340fe6a600d2f8441d6cf/multidict-6.8.0-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4ee953a5ebaeed38dc21cc032ed17a9d9782802e00042200497ab4b01b0bf7c0", size = 236839, upload-time = "2026-09-09T13:56:11.273Z" }, + { url = "https://files.pythonhosted.org/packages/22/d1/eba1b88b18b7019d9136303fe77909257c40fabde5aaf138a4d900b6ce3c/multidict-6.8.0-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:2f79cc3e8039a8cf5c77e0811b0807953fd52d0863b9b76970b20d696dc64a78", size = 265307, upload-time = "2026-09-09T13:56:13.379Z" }, + { url = "https://files.pythonhosted.org/packages/aa/a6/6c1e4106faa27118ac612f4d664eaf909de252634785286262a627108e58/multidict-6.8.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:43a4b56555bbcf8af161e7c7682bd93eec10f068c95844511864c018c8e5e13b", size = 259041, upload-time = "2026-09-09T13:56:15.666Z" }, + { url = "https://files.pythonhosted.org/packages/c0/bc/ecfb8b6faa8e158a71b03bdf7f947f30e0bc5d899cc357573a76ab7bb1e5/multidict-6.8.0-cp314-cp314t-win32.whl", hash = "sha256:48ea524a25a1cd5972cf293bc95713918cba0bcd6fa9b992d906c857c546abe2", size = 50628, upload-time = "2026-09-09T13:56:17.837Z" }, + { url = "https://files.pythonhosted.org/packages/30/7f/e27fb699b70ad24dbd02ddee604658acb36f907c03c045baffe4ea774501/multidict-6.8.0-cp314-cp314t-win_amd64.whl", hash = "sha256:d0be2b832435001bc623ca7f1499ca1a853d4f082fb61221a80ce71132f50b26", size = 55592, upload-time = "2026-09-09T13:56:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/eb/7a/76de70b2f6733696803f1ee56abe44a3757a52777383032c7373d3fea0f4/multidict-6.8.0-cp314-cp314t-win_arm64.whl", hash = "sha256:62b8e291a4f7edbf7cde7a43d831d893ba443a1b627498b53581943b0e348feb", size = 50300, upload-time = "2026-09-09T13:56:21.516Z" }, + { url = "https://files.pythonhosted.org/packages/ce/32/4de7320ae032dc768090d11f708d2d386df3db04cb6b8b0db0230cfc66c3/multidict-6.8.0-cp315-cp315-android_24_x86_64.whl", hash = "sha256:e192018b732f7b168e6604cbdf40fa8e05c996693b9eb445a0d8a73f4b77c5d3", size = 53761, upload-time = "2026-09-09T13:56:23.192Z" }, + { url = "https://files.pythonhosted.org/packages/5c/45/ecb641309dc2cdc6040f18e22c68eb5e94398f9404c4365d810f4292e053/multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b25426f9f6ed402835617c8f23609a47045f91ecff365eb6734817e039a8ed25", size = 47505, upload-time = "2026-09-09T13:56:24.902Z" }, + { url = "https://files.pythonhosted.org/packages/eb/68/87d6161b9fef11943e0b894203da3fff561933ca3c9b2952b6e7100e9c9f/multidict-6.8.0-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:fa6c2880709c84457de104385b704fc28860f27e442ad13966fc4af8e714fe9c", size = 48549, upload-time = "2026-09-09T13:56:26.574Z" }, + { url = "https://files.pythonhosted.org/packages/97/f7/d852d2276407640cdbd29fe11cac6e93f70f59542cba174ef9d146738946/multidict-6.8.0-cp315-cp315-macosx_10_15_universal2.whl", hash = "sha256:eabb03dc3e4ed6333ecd1cc9826ec80e7a98b5506deeb832d7260c8e44166d23", size = 83157, upload-time = "2026-09-09T13:56:28.227Z" }, + { url = "https://files.pythonhosted.org/packages/14/e3/16fe7ffa6090591d83cf6bc2486e77ce891705fb6d0191823140928311b5/multidict-6.8.0-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:59e539c4eb4d3a53b0e630a6ba2b2f2824732b5e73f90e30a280f12fde157b15", size = 50578, upload-time = "2026-09-09T13:56:30Z" }, + { url = "https://files.pythonhosted.org/packages/d0/0c/e38e41c1087a599f86ff58a01f358abf7c4db3c26a3e90eebb3e02193ef1/multidict-6.8.0-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:835d5a90b11d1f5f8200ff3cc8316bded76eebebc92436398947a27657e645e7", size = 48815, upload-time = "2026-09-09T13:56:32.056Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f0/eb691f42af8e7775992f57904ec75dc356fc7cdc896e5f30879decdd26f2/multidict-6.8.0-cp315-cp315-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:d2d236b8a44ae91536a12ebcb996bdb31cf27425f36b4d05c87f2ba2716050ba", size = 274804, upload-time = "2026-09-09T13:56:36.741Z" }, + { url = "https://files.pythonhosted.org/packages/87/05/28472ccfeb43c00a043c0385ca4294da21a5957859fb7860e2ebdb3e3011/multidict-6.8.0-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bb9a60b7faa5d37c426fa91cf4d6738182a1f2755b9fab7c9c64cd466c4ce51e", size = 279693, upload-time = "2026-09-09T13:56:38.531Z" }, + { url = "https://files.pythonhosted.org/packages/f3/a1/2b4fe73e5fecff807b47650a155c391a103136428cb21d6ba8e39c5912b5/multidict-6.8.0-cp315-cp315-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:0ef606c15cac6c90279acf34120784b6f36662cbf382defd3955cd8f1115336b", size = 254969, upload-time = "2026-09-09T13:56:40.407Z" }, + { url = "https://files.pythonhosted.org/packages/44/e0/c97d1822783dfe52e02fd150fa3f02eb22410211a9e2615f71541803ed4b/multidict-6.8.0-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:68186a2d4051c8ffd17be33553bea2ec9bbc8ef860fe2980a221d96126296f31", size = 286392, upload-time = "2026-09-09T13:56:42.234Z" }, + { url = "https://files.pythonhosted.org/packages/2f/d9/772f1339e1d051236bcc137b0eac2b4aaaa0bbb56aaf924e9aaba901d9c1/multidict-6.8.0-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2cc66abb85e2108c9ff8a1c0d20fa260bf690bbb33caef4ff3ecb2c2cbdfff5d", size = 285348, upload-time = "2026-09-09T13:56:44.255Z" }, + { url = "https://files.pythonhosted.org/packages/06/ae/cd045747e4680362e02955a82c468e95b5e4d319e3a79574b3fb677de568/multidict-6.8.0-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:69b3e519a132bb943b0daae15fc8c2168706b17f826481d32a32a5e784b129e3", size = 282721, upload-time = "2026-09-09T13:56:46.088Z" }, + { url = "https://files.pythonhosted.org/packages/35/14/0802d9a3aae4ef21eaa39adbd729a380fa095932105e1424e417b53e783f/multidict-6.8.0-cp315-cp315-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e41226ecf607f062fe34a2f4cf64ad3a89e3a0180dc800b463b6b14c06dd10dc", size = 253168, upload-time = "2026-09-09T13:56:48.07Z" }, + { url = "https://files.pythonhosted.org/packages/b1/64/3f92298bab8fbe1332e708863fb55b66e755be6f416b3459720d48b33af9/multidict-6.8.0-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:610c7637bc36b90f39e6c66f710f93d57018f83d53e1e187caaa218c6892b95f", size = 274209, upload-time = "2026-09-09T13:56:50.023Z" }, + { url = "https://files.pythonhosted.org/packages/08/c2/2001ac0eac1a8b7390a5902d7115f66d4f256268057a502200b6ab12dad7/multidict-6.8.0-cp315-cp315-musllinux_1_2_armv7l.whl", hash = "sha256:65c85c79f5a2c04fbbc18f006c014674dc5fdf270cb978d8862c82c6f694e60c", size = 268044, upload-time = "2026-09-09T13:56:52.033Z" }, + { url = "https://files.pythonhosted.org/packages/0d/90/78a9e26c85f89abd562a67f7fcbaef9007fd5c37bb9efac19f1cf604e7c2/multidict-6.8.0-cp315-cp315-musllinux_1_2_i686.whl", hash = "sha256:628ff11e6720f90acd0c305dfa3339f04a783a20de8cda6ac333ba46447261e8", size = 274806, upload-time = "2026-09-09T13:56:53.975Z" }, + { url = "https://files.pythonhosted.org/packages/3d/71/713bd445421b21531234c1f3630b768192cb9d80c8b1c5b05c5b505ff4c0/multidict-6.8.0-cp315-cp315-musllinux_1_2_ppc64le.whl", hash = "sha256:0935971bffd0b479fc90c4811ca787703e93fcb6afea939a375dfc80285ab368", size = 281890, upload-time = "2026-09-09T13:56:55.848Z" }, + { url = "https://files.pythonhosted.org/packages/de/a5/1387c538663e2dc8c27bbc7cd6955cb66de0f55c780cf7cd0fc06a1a16ca/multidict-6.8.0-cp315-cp315-musllinux_1_2_riscv64.whl", hash = "sha256:9442b14eec262a1f74369bbd07e75bc5155105164649a4b9fbc1ebc7b8fb0b14", size = 249749, upload-time = "2026-09-09T13:56:58.01Z" }, + { url = "https://files.pythonhosted.org/packages/91/15/104296c9d70896b9759ce0812aa4899fab76d8b16bb32dcc5a78ab547c89/multidict-6.8.0-cp315-cp315-musllinux_1_2_s390x.whl", hash = "sha256:397599503b718f0137f26d3f6532d6955069cd2e5917c47ef581495bc2529ff8", size = 276138, upload-time = "2026-09-09T13:57:03.591Z" }, + { url = "https://files.pythonhosted.org/packages/f7/0a/f2a0c2658e9d7ff5964ec2820a02054558636fafd663230ddc8310b8ed39/multidict-6.8.0-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:9e37024b41d7a7e7e9cce14b248d54707c21c2a2ea30a47b71bdcefcafec00f2", size = 277077, upload-time = "2026-09-09T13:57:06.024Z" }, + { url = "https://files.pythonhosted.org/packages/98/50/bc46566caffba5c1c4a510519156371edf7c4ecd35c9ef917d0c1803487d/multidict-6.8.0-cp315-cp315-win32.whl", hash = "sha256:071da134651b04a8507dfb331ac0988f376337c2aea59486bf20989fb5b5a64e", size = 46930, upload-time = "2026-09-09T13:57:08.009Z" }, + { url = "https://files.pythonhosted.org/packages/6f/1a/cafb31049ecc1a6ce52bcc69fa436cca239adc057b1718a0c49044848663/multidict-6.8.0-cp315-cp315-win_amd64.whl", hash = "sha256:3bafff8598f0528017ddc74194e5451d5c22d046c98935f8f86247b0f286e4f8", size = 50294, upload-time = "2026-09-09T13:57:09.986Z" }, + { url = "https://files.pythonhosted.org/packages/6b/51/00e037da14cd1d894b123e0bbe62de5c561679a6ab23ab1c009f2965dcda/multidict-6.8.0-cp315-cp315-win_arm64.whl", hash = "sha256:e886ef8c9879105fe4fc99417447b3a5f35d1131412ce839470bd2089fe2043f", size = 47626, upload-time = "2026-09-09T13:57:11.738Z" }, + { url = "https://files.pythonhosted.org/packages/52/f7/aeb947982197e8b4f5c4da3961ee473ea5a050b94a6ff3b88baf64621401/multidict-6.8.0-cp315-cp315t-macosx_10_15_universal2.whl", hash = "sha256:883284137e25318ed9735b742ae46341a864888fae28e8b6314c4f84da080f08", size = 88801, upload-time = "2026-09-09T13:57:13.957Z" }, + { url = "https://files.pythonhosted.org/packages/35/d8/593948c016c3f850e3cd56a4e0144151eb409d2b8690f0c0ce7f7d33dbea/multidict-6.8.0-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:ca52b9ec80851366197577154c862c4c4c7036ca76ae94cef5cb59c5cfeab944", size = 53376, upload-time = "2026-09-09T13:57:15.94Z" }, + { url = "https://files.pythonhosted.org/packages/58/b9/097a05bca533027c0477b6a90bf927dbbb4b23cc9090bbb37a2e972af8d5/multidict-6.8.0-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:91fa75d0a693832106d98f66c849f034f21c828d14437f1fb97d3784aab89e84", size = 51629, upload-time = "2026-09-09T13:57:17.685Z" }, + { url = "https://files.pythonhosted.org/packages/fe/07/938ed21967f12380d0b8861645fb65a942f3669e31d5163ed94d23103b61/multidict-6.8.0-cp315-cp315t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:307c1acd812fe897e7fbe10c6758822e8c04be4e7c60a9f54901cdf8b5ab8bc3", size = 261967, upload-time = "2026-09-09T13:57:19.752Z" }, + { url = "https://files.pythonhosted.org/packages/89/e8/e66bf843fd29c01712dde9edeb9f4ad0ffab06ab4ada4b721ad7bc73b3d5/multidict-6.8.0-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5caf684986a2490628f059a99dd107b566a2d34cf947f8eb8387e0500a1f90c5", size = 265923, upload-time = "2026-09-09T13:57:21.784Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f8/e9be849b225af28a8eee2c6bfea23594a777c753fe97e2ff7e2180c8935a/multidict-6.8.0-cp315-cp315t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:77745725125d01fd613b6db043362aa7c6bfbfdb23d45dbfc3d92bf58160af62", size = 239380, upload-time = "2026-09-09T13:57:24.3Z" }, + { url = "https://files.pythonhosted.org/packages/ab/67/4dbad08f5081978c591afae9e836ec9ddae90e9e76be6d6ce10757483dc4/multidict-6.8.0-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8daafaa0b2eb43f76898ced78b1e0fb91b38c4fa50da516c18067f2a2d578c20", size = 271591, upload-time = "2026-09-09T13:57:26.611Z" }, + { url = "https://files.pythonhosted.org/packages/92/3f/e9c97222d7e104e54e556f118ec7d091ab41a0c10c630f2b97e5b43f5404/multidict-6.8.0-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:c68e0c0649d17c2d0339e3674e86a4aeba4a7e6b21c1e394cf947a95433b31d0", size = 276091, upload-time = "2026-09-09T13:57:28.997Z" }, + { url = "https://files.pythonhosted.org/packages/26/ca/728e7ce05ac9c0303554e7162e74d91fe49e65bad7dfbb377f783dd32c0a/multidict-6.8.0-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d8a5ac357ac283490a8d1899b0383355fd1f8634b14ba0d59e4c0dd97db85556", size = 266493, upload-time = "2026-09-09T13:57:31.256Z" }, + { url = "https://files.pythonhosted.org/packages/8f/74/7c658d2769863af16fb7d7c6be50b29659892a06a632858863eee3a31842/multidict-6.8.0-cp315-cp315t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:46029e6e27a3ec0dc55b53f58df82d10f04c5e111f78248279b530bedad2c30a", size = 245302, upload-time = "2026-09-09T13:57:33.464Z" }, + { url = "https://files.pythonhosted.org/packages/2d/2c/d4350a20a0e8c66a447d694e8713438262665203fe826c3f4e385f052b72/multidict-6.8.0-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:8d1046b5427dcafe6e8a0e07527dd74f1ee694006160162f53f3a17f15aad3b4", size = 261016, upload-time = "2026-09-09T13:57:35.651Z" }, + { url = "https://files.pythonhosted.org/packages/1f/78/83df999c8beb72a012cfac42f2b833c4a48f8e836fd4407747b355a2430e/multidict-6.8.0-cp315-cp315t-musllinux_1_2_armv7l.whl", hash = "sha256:f1f4a220db6ed7c8fd16b6d644ffd1f082651693204daf3275e049fadc849e39", size = 255021, upload-time = "2026-09-09T13:57:37.758Z" }, + { url = "https://files.pythonhosted.org/packages/fb/13/f2c0a2dac6d91f74aa124f3e9f07ec497ceae5ed2df2753d249601cd7262/multidict-6.8.0-cp315-cp315t-musllinux_1_2_i686.whl", hash = "sha256:029897732a9c798737457e382bf84e8c64237eff224a90aea2639f4413c45e4e", size = 263066, upload-time = "2026-09-09T13:57:39.897Z" }, + { url = "https://files.pythonhosted.org/packages/59/1d/730008d4639ace731bbb1399e1ac13cbdf506f7d6fb861d75044ffb3994d/multidict-6.8.0-cp315-cp315t-musllinux_1_2_ppc64le.whl", hash = "sha256:29be9fd289e9ab8f480996ea2f686e1654b80242033843cb11691688329423f1", size = 266510, upload-time = "2026-09-09T13:57:42.39Z" }, + { url = "https://files.pythonhosted.org/packages/ab/ca/bec67a5d206dc5748e50c93f6f71deec14305c3657cfe250c3887caf7839/multidict-6.8.0-cp315-cp315t-musllinux_1_2_riscv64.whl", hash = "sha256:29631224698de1e42abc8fa7658d830e0aed0029785144b5832b695da5adef2f", size = 239423, upload-time = "2026-09-09T13:57:44.304Z" }, + { url = "https://files.pythonhosted.org/packages/9e/db/5f153fe51fbac7d80f3bb8bd6fab8db8b6cd061e7a11371676dfed3712bc/multidict-6.8.0-cp315-cp315t-musllinux_1_2_s390x.whl", hash = "sha256:962f18c59a000f30b084ea2e6b8001521bb315efd4e5f10acf9fb36f366b7882", size = 266902, upload-time = "2026-09-09T13:57:46.297Z" }, + { url = "https://files.pythonhosted.org/packages/89/0f/9efca48a351551de4dc0c183f523109dbe87c645a4732d5f1c70b4880dca/multidict-6.8.0-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:c60e50bc5b07faac92fd3a20fa21cc8cf3e3f7204d2867b206c73293ebc19101", size = 260887, upload-time = "2026-09-09T13:57:48.268Z" }, + { url = "https://files.pythonhosted.org/packages/df/d8/bb879a62e0809448e53f6237e71670066ecf3bbc5896a7a6705b6628d86a/multidict-6.8.0-cp315-cp315t-win32.whl", hash = "sha256:fc5460940f50dff00731b4132366840ba9685286ea88ea104b661899084f3fea", size = 50533, upload-time = "2026-09-09T13:57:50.31Z" }, + { url = "https://files.pythonhosted.org/packages/fe/62/3e5308d8871636e4b9620e4b3acfcf2b5caf79b19d317690ec13f7fc8b57/multidict-6.8.0-cp315-cp315t-win_amd64.whl", hash = "sha256:b367c342327717d644db4c0ddb37ceb655c84822215ea0773a3a36911b74b71d", size = 55572, upload-time = "2026-09-09T13:57:52.301Z" }, + { url = "https://files.pythonhosted.org/packages/b9/cc/d3c10e10ee3bb7a7b4abbb3157306b2ce7e0018c9c2d16b32b468739d2b7/multidict-6.8.0-cp315-cp315t-win_arm64.whl", hash = "sha256:0c1c4debad7337627b86837abdf0237ca3cb3d7e17de7eab0177c263878546d4", size = 50322, upload-time = "2026-09-09T13:57:54.099Z" }, + { url = "https://files.pythonhosted.org/packages/b1/ee/be4e1a4b7a2b27f4fb6936510d4bebcb41b0562c946930ad26916e069cf9/multidict-6.8.0-py3-none-any.whl", hash = "sha256:75daa15ca16d6285eb2e104b2f05ee6f8d9836c68da3ce5c85f615a0450eed0e", size = 16297, upload-time = "2026-09-09T13:57:56.106Z" }, +] + [[package]] name = "packageurl-python" version = "0.17.6" @@ -630,6 +1046,117 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "propcache" +version = "0.5.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ec/44/c87281c333769159c50594f22610f77398a47ccbfbbf23074e744e86f87c/propcache-0.5.2.tar.gz", hash = "sha256:01c4fc7480cd0598bb4b57022df55b9ca296da7fc5a8760bd8451a7e63a7d427", size = 50208, upload-time = "2026-05-08T21:02:12.199Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/f1/8a8cc1c2c7e7934ab77e0163414f736fadbc0f5e8dd9673b952355ac175b/propcache-0.5.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:74b70780220e2dd89175ca24b81b68b67c83db499ae611e7f2313cb329801c78", size = 90744, upload-time = "2026-05-08T20:59:45.799Z" }, + { url = "https://files.pythonhosted.org/packages/c2/f4/651b1225e976bd1a2ba5cfba0c29d096581c2636b437e3a9a7ab6276270a/propcache-0.5.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:a4840ab0ae0216d952f4b53dc6d0b992bfc2bedbfe360bdd9b548bc184c08959", size = 52033, upload-time = "2026-05-08T20:59:47.408Z" }, + { url = "https://files.pythonhosted.org/packages/15/a8/8ede85d6aa1f79fc7dc2f8fd2c8d65920b8272c3892903c8a1affde48cfb/propcache-0.5.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:c6844ba6364fb12f403928a82cfd295ab103a2b315c77c747b2dbe4a41894ea7", size = 52754, upload-time = "2026-05-08T20:59:49.202Z" }, + { url = "https://files.pythonhosted.org/packages/7d/fe/b3551b41bbc2f5b5bb088fc6920567cd43101253e68fbaa261339eb96fe1/propcache-0.5.2-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2293949b855ce597f2826452d17c2d545fb5622379c4ea6fdf525e9b8e8a2511", size = 57573, upload-time = "2026-05-08T20:59:50.778Z" }, + { url = "https://files.pythonhosted.org/packages/83/27/ab851ebd1b7172e3e161f5f8d39e315d54a91bea246f01f4d872d3376aef/propcache-0.5.2-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:0fd59b5af35f74da48d905dcbad55449ba13be91823cb05a9bd590bbf5b61660", size = 60645, upload-time = "2026-05-08T20:59:52.227Z" }, + { url = "https://files.pythonhosted.org/packages/95/7d/466b3d18022e9897cbda9c735c493c5bd747d7a4c6f5ea1480b4cec434b6/propcache-0.5.2-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:29f9309a2e42b0d273be006fdb4be2d6c39a47f6f57d8fb1cf9f81481df81b66", size = 61563, upload-time = "2026-05-08T20:59:53.866Z" }, + { url = "https://files.pythonhosted.org/packages/27/1b/16ab7f2cf2041da2f60d156ba64c2484eadf9168075b4ff43c3ef60045af/propcache-0.5.2-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5aaa2b923c1944ac8febd6609cb373540a5563e7cbcb0fd770f75dace2eb817b", size = 58888, upload-time = "2026-05-08T20:59:55.457Z" }, + { url = "https://files.pythonhosted.org/packages/0a/67/bb777ffd907633563bf35fd859c4ce97b0512c32f4633cf5d1eb7c33512b/propcache-0.5.2-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:66ea454f095ddf5b6b14f56c064c0941c4788be11e18d2464cf643bf7203ff67", size = 59253, upload-time = "2026-05-08T20:59:57.075Z" }, + { url = "https://files.pythonhosted.org/packages/b9/42/64f8d90b73fd9cdc1499b48057ff6d9cd2a98a25734c9bb62ecf07e87061/propcache-0.5.2-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:95f1e3f4760d404b13c9976c0229b2b49a3c8e2c62a9ce92efdd2b11ada75e3f", size = 57558, upload-time = "2026-05-08T20:59:58.602Z" }, + { url = "https://files.pythonhosted.org/packages/eb/02/dba5bc03c9041f2092ea55a449caf5dfe68352c6654511b29ba0654ddb69/propcache-0.5.2-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:85341b12b9d55bad0bded24cac341bb34289469e03a11f3f583ea1cc1db0326c", size = 55007, upload-time = "2026-05-08T20:59:59.837Z" }, + { url = "https://files.pythonhosted.org/packages/14/c0/43f649c7aa2a77a3b100d84e9dea3a483120ecb608bfe36ce49eaff517fe/propcache-0.5.2-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:26a4dca084132874e639895c3135dfad5eb20bae209f62d1aeb31b03e601c3c0", size = 60355, upload-time = "2026-05-08T21:00:01.144Z" }, + { url = "https://files.pythonhosted.org/packages/83/c0/435dafd27f1cb4a495381dae60e25883ccfe4020bb72818e8184c1678092/propcache-0.5.2-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:3b199b9b2b3d6a7edf3183ba8a9a137a22b97f7df525feb5ae1eccf026d2a9c6", size = 59057, upload-time = "2026-05-08T21:00:02.401Z" }, + { url = "https://files.pythonhosted.org/packages/53/ae/6e292df9135d659944e96cb3389258e4a663e5b2b5f6c217ef0ddc8d2f73/propcache-0.5.2-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:e59bc9e66329185b93dab73f210f1a37f81cb40f321501db8017c9aea15dba27", size = 61938, upload-time = "2026-05-08T21:00:03.638Z" }, + { url = "https://files.pythonhosted.org/packages/0b/42/314ebc50d8159055411fd6b0bda322ff510e4b1f7d2e4927940ad0f6af20/propcache-0.5.2-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:552ffadf6ad409844bc5919c42a0a83d88314cedddaea0e41e80a8b8fffe881f", size = 59731, upload-time = "2026-05-08T21:00:04.881Z" }, + { url = "https://files.pythonhosted.org/packages/b8/9b/2da6dee38871c3c8772fabc2758325a5c9077d6d18c597737dc04dd884cd/propcache-0.5.2-cp311-cp311-win32.whl", hash = "sha256:cd416c1de191973c52ff1a12a57446bfc7642797b282d7caf2162d7d1b8aa9a0", size = 38966, upload-time = "2026-05-08T21:00:06.511Z" }, + { url = "https://files.pythonhosted.org/packages/42/4e/f17363fb58c0afe05b067361cb6d86ed2d29de6506779a27547c4d183075/propcache-0.5.2-cp311-cp311-win_amd64.whl", hash = "sha256:44e488ef40dbb452700b2b1f8188934121f6648f52c295055662d2191959ff82", size = 42135, upload-time = "2026-05-08T21:00:08.088Z" }, + { url = "https://files.pythonhosted.org/packages/c6/eb/6af6685077d22e8b33358d3c548e3282706a0b3cd85044ffba4e5dd08e3b/propcache-0.5.2-cp311-cp311-win_arm64.whl", hash = "sha256:54adaa85a22078d1e306304a40984dc5be99d599bf3dc0a24dc98f7daeab89ab", size = 38381, upload-time = "2026-05-08T21:00:09.692Z" }, + { url = "https://files.pythonhosted.org/packages/4a/cb/e27bc2b2737a0bb49962b275efa051e8f1c35a936df7d5139b6b658b7dc9/propcache-0.5.2-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:806719138ecd720339a12410fb9614ac9b2b2d3a5fdf8235d56981c36f4039ba", size = 95887, upload-time = "2026-05-08T21:00:11.277Z" }, + { url = "https://files.pythonhosted.org/packages/e6/13/b8ae04c59392f8d11c6cd9fb4011d1dc7c86b81225c770280300e259ffe1/propcache-0.5.2-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:db2b80ea58eab4f86b2beec3cc8b39e8ff9276ac20e96b7cce43c8ae84cd6b5a", size = 54654, upload-time = "2026-05-08T21:00:12.604Z" }, + { url = "https://files.pythonhosted.org/packages/2c/7d/49777a3e20b55863d4794384a38acd460c04157b0a00f8602b0d508b8431/propcache-0.5.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:e5cbfac9f61484f7e9f3597775500cd3ebe8274e9b050c38f9525c77c97520bf", size = 55190, upload-time = "2026-05-08T21:00:13.935Z" }, + { url = "https://files.pythonhosted.org/packages/44/c7/085d0cd63062e84044e3f05797749c3f8e3938ff3aeb0eb2f69d43fafc91/propcache-0.5.2-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbc581d2814337da56222fab8dc5f161cd798a434e49bac27930aaef798e144", size = 59995, upload-time = "2026-05-08T21:00:15.526Z" }, + { url = "https://files.pythonhosted.org/packages/9c/42/32cf8e3009e92b2645cf1e944f701e8ea4e924dffde1ee26db860bcbf7e4/propcache-0.5.2-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:857187f381f88c8e2fa2fe56ab94879d011b883d5a2ee5a1b60a8cd2a06846d9", size = 63422, upload-time = "2026-05-08T21:00:16.824Z" }, + { url = "https://files.pythonhosted.org/packages/9e/1b/f112433f99fc979431b87a39ef169e3f8df070d99a72792c56d6937ac48b/propcache-0.5.2-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:178b4a2cdaac1818e2bf1c5a99b94383fa73ea5382e032a48dec07dc5668dc42", size = 64342, upload-time = "2026-05-08T21:00:18.362Z" }, + { url = "https://files.pythonhosted.org/packages/14/15/5574111ae50dd6e879456888c0eadd4c5a869959775854e18e18a6b345f3/propcache-0.5.2-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6f328175a2cde1f0ff2c4ed8ce968b9dcfb55f3a7153f39e2957ed994da13476", size = 61639, upload-time = "2026-05-08T21:00:19.692Z" }, + { url = "https://files.pythonhosted.org/packages/cc/da/4d775080b1490c0ae604acda868bd71aabe3a89ed16f2aa4339eb8a283e7/propcache-0.5.2-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5671d09a36b06d0fd4a3da0fccbcae360e9b1570924171a15e9e0997f0249fba", size = 61588, upload-time = "2026-05-08T21:00:21.155Z" }, + { url = "https://files.pythonhosted.org/packages/04/ac/f076982cbe2195ee9cf32de5a1e46951d9fb399fc207f390562dd0fd8fb2/propcache-0.5.2-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:80168e2ebe4d3ec6599d10ad8f520304ae1cad9b6c5a95372aef1b66b7bfb53a", size = 60029, upload-time = "2026-05-08T21:00:22.713Z" }, + { url = "https://files.pythonhosted.org/packages/70/60/189be62e0dd898dce3b331e1b8c7a543cd3a405ac0c81fe8ee8a9d5d77e1/propcache-0.5.2-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:45f11346f884bc47444f6e6647131055844134c3175b629f84952e2b5cd62b64", size = 56774, upload-time = "2026-05-08T21:00:24.001Z" }, + { url = "https://files.pythonhosted.org/packages/ea/9e/93377b9c7939c1ffae98f878dee955efadfd638078bc86dbc21f9d52f651/propcache-0.5.2-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:8e778ebd44ef4f66ed60a0416b06b489687db264a9c0b3620362f26489492913", size = 63532, upload-time = "2026-05-08T21:00:25.545Z" }, + { url = "https://files.pythonhosted.org/packages/14/f9/590ef6cfb9b8028d516d287812ece32bb0bc5f11fbb9c8bf6b2e6313fec8/propcache-0.5.2-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:c0cb9ed24c8964e172768d455a38254c2dd8a552905729ce006cad3d3dda59b1", size = 61592, upload-time = "2026-05-08T21:00:27.186Z" }, + { url = "https://files.pythonhosted.org/packages/b4/5e/70958b3034c297a630bba2f17ca7abc2d5f39a803ad7e370ab79d1ecd022/propcache-0.5.2-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:1d1ad32d9d4355e2be65574fd0bfd3677e7066b009cd5b9b2dee8aa6a6393b33", size = 64788, upload-time = "2026-05-08T21:00:28.8Z" }, + { url = "https://files.pythonhosted.org/packages/12/fd/77fe5936d8c3086ca9048f7f415f122ed82e53884a9ec193646b42deef06/propcache-0.5.2-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:c80f4ba3e8f00189165999a742ee526ebeccedf6c3f7beb0c7df821e9772435a", size = 62514, upload-time = "2026-05-08T21:00:30.098Z" }, + { url = "https://files.pythonhosted.org/packages/cf/74/66bd798b5b3be70aa1b391f5cc9d6a0a5532d7fd3b19ec0b213e72e6ad9d/propcache-0.5.2-cp312-cp312-win32.whl", hash = "sha256:8c7972d8f193740d9175f0998ab38717e6cd322d5935c5b0fef8c0d323fd9031", size = 39018, upload-time = "2026-05-08T21:00:31.622Z" }, + { url = "https://files.pythonhosted.org/packages/61/7c/5c0d34aa3024694d6dcb9271cdbdd08c4e47c1c0ad95ec7e7bc74cdea145/propcache-0.5.2-cp312-cp312-win_amd64.whl", hash = "sha256:d9ee8826a7d47863a08ac44e1a5f611a462eefc3a194b492da242128bec75b42", size = 42322, upload-time = "2026-05-08T21:00:32.918Z" }, + { url = "https://files.pythonhosted.org/packages/4d/91/875812f1a3feb20ceba818ef39fbe4d92f1081e04ac815c822496d0d038b/propcache-0.5.2-cp312-cp312-win_arm64.whl", hash = "sha256:2800a4a8ead6b28cccd1ec54b59346f0def7922ee1c7598e8499c733cfbb7c84", size = 38172, upload-time = "2026-05-08T21:00:35.124Z" }, + { url = "https://files.pythonhosted.org/packages/c5/09/f049e45385503fe67db75a6b6186a7b9f0c3930366dc960522c312a825b1/propcache-0.5.2-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:099aaf4b4d1a02265b92a977edf00b5c4f63b3b17ac6de39b0d637c9cac0188a", size = 94457, upload-time = "2026-05-08T21:00:36.355Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/83d1d05655baf63113731bd5a1008435e14f8d1e5a06cbe4ec5b23ad7a31/propcache-0.5.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:68ce1c44c7a813a7f71ea04315a8c7b330b63db99d059a797a4651bb6f69f117", size = 53835, upload-time = "2026-05-08T21:00:38.072Z" }, + { url = "https://files.pythonhosted.org/packages/a9/12/a6ba6482bb5ea3260c000c9b20881c95fa11c6b30173715668259f844ed7/propcache-0.5.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:fc299c129490f55f254cd90be0deca4764e36e9a7c08b4aa588479a3bbed3098", size = 54545, upload-time = "2026-05-08T21:00:39.319Z" }, + { url = "https://files.pythonhosted.org/packages/a9/19/7fa086f5764c59ec8a8e157cd93aa8497acc00aba9dcdec56bfffb32602d/propcache-0.5.2-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a6ae2198be502c10f09b2516e7b5d019816924bc3183a43ce792a7bd6625e6f4", size = 59886, upload-time = "2026-05-08T21:00:40.621Z" }, + { url = "https://files.pythonhosted.org/packages/a1/e4/5d7663dc8235956c8f5281698a3af1d351d8820341ddd890f59d9a9127f2/propcache-0.5.2-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:6041d31504dc1779d700e1edcfb08eea334b357620b06681a4eabb57a74e574e", size = 63261, upload-time = "2026-05-08T21:00:41.775Z" }, + { url = "https://files.pythonhosted.org/packages/4a/4a/15a03adee24d6350da4292caeac44c34c033d2afe5e87eb370f38854560f/propcache-0.5.2-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f7eabc04151c78a9f4d5bbb5f1faf571e4defeb4b585e0fe95b60ff2dbe4d3d7", size = 64184, upload-time = "2026-05-08T21:00:43.018Z" }, + { url = "https://files.pythonhosted.org/packages/8b/c6/979176efdaa3d239e36d503d5af63a0a773b36662ed8f52e5b6a6d9fd40e/propcache-0.5.2-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4db0ba63d693afd40d249bd93f842b5f144f8fcbb83de05660373bcf30517b1d", size = 61534, upload-time = "2026-05-08T21:00:44.507Z" }, + { url = "https://files.pythonhosted.org/packages/c8/22/63e8cd1bae4c2d2be6493b6b7d10566ddafad88137cfbc99964a1119853c/propcache-0.5.2-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1dbcf7675229b35d31abb6547d8ebc8c27a830ac3f9a794edff6254873ec7c0a", size = 61500, upload-time = "2026-05-08T21:00:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/60/5a/28e5d9acbac1cc9ccb67045e8c1b943aa8d79fdf39c93bd73cacd68008ea/propcache-0.5.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d310c013aad2c72f1c3f2f8dd3279d460a858c551f97aeb8c63e4693cca7b4d2", size = 59994, upload-time = "2026-05-08T21:00:47.093Z" }, + { url = "https://files.pythonhosted.org/packages/f3/40/db650677f554a95b9c01a7c9d93d629e93a15562f5deb4573c9ee136fed2/propcache-0.5.2-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:06187263ddad280d05b4d8a8b3bb7d164cbebd469236544a42e6d9b28ac6a4fa", size = 56884, upload-time = "2026-05-08T21:00:48.376Z" }, + { url = "https://files.pythonhosted.org/packages/80/45/70b39b89516ff8b96bf732fa6fded8cef20f293cb1508690101c3c07ec51/propcache-0.5.2-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:3115559b8effafd63b142ea5ed53d63a16ea6469cbc63dce4ee194b42db5d853", size = 63464, upload-time = "2026-05-08T21:00:49.954Z" }, + { url = "https://files.pythonhosted.org/packages/f9/e2/fa59d3a89eac5534293124af4f1d0d0ada091ce4a0ab4610ce03fd2bdd8d/propcache-0.5.2-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:c60462af8e6dc30c35407c7237ea908d777b22862bbee27bc4699c0d8bcdc45a", size = 61588, upload-time = "2026-05-08T21:00:51.281Z" }, + { url = "https://files.pythonhosted.org/packages/0b/97/efb547a55c4bc7381cfb202d6a2239ac621045277bc1ea5dfd3a7f0516c0/propcache-0.5.2-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:40314bca9ac559716fe374094fc81c11dcc34b64fd6c585360f5775690505704", size = 64667, upload-time = "2026-05-08T21:00:52.602Z" }, + { url = "https://files.pythonhosted.org/packages/92/56/f5c7d9b4b7595d5127da38974d791b2153f3d1eae6c674af3583ace92ad3/propcache-0.5.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:cfa21e036ce1e1db2be04ba3b85d2df1bb1702fa01932d984c5464c665228ff4", size = 62463, upload-time = "2026-05-08T21:00:54.303Z" }, + { url = "https://files.pythonhosted.org/packages/bd/3b/484a3a65fc9f9f60c41dcd17b428bace5389544e2c680994534a20755066/propcache-0.5.2-cp313-cp313-win32.whl", hash = "sha256:f156a3529f38063b6dbaf356e15602a7f95f8055b1295a438433a6386f10463d", size = 38621, upload-time = "2026-05-08T21:00:55.808Z" }, + { url = "https://files.pythonhosted.org/packages/1c/fd/3f0f10dba4dabad3bf53102be007abf55481067952bde0fdddff439e7c61/propcache-0.5.2-cp313-cp313-win_amd64.whl", hash = "sha256:dfed59d0a5aeb01e242e66ff0300bc4a265a7c05f612d30016f0b60b1017d757", size = 41649, upload-time = "2026-05-08T21:00:57.061Z" }, + { url = "https://files.pythonhosted.org/packages/90/ec/6ce619cc32bb500a482f811f9cd509368b4e58e638d13f2c68f370d6b475/propcache-0.5.2-cp313-cp313-win_arm64.whl", hash = "sha256:ba338430e87ceb9c8f0cf754de38a9860560261e56c00376debd628698a7364f", size = 37636, upload-time = "2026-05-08T21:00:58.646Z" }, + { url = "https://files.pythonhosted.org/packages/1b/82/c1d268bbbf2ef981c5bf0fbbe746db617c66e3bcefe431a1aa8943fbe23a/propcache-0.5.2-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:a592f5f3da71c8691c788c13cb6734b6d17663d2e1cb8caddf0673d01ef8847d", size = 98872, upload-time = "2026-05-08T21:00:59.889Z" }, + { url = "https://files.pythonhosted.org/packages/f4/d4/52c871e73e864e6b34c0e2d58ac1ec5ccd149497ddc7ad2137ae98323a35/propcache-0.5.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:6a997d0489e9668a384fcfd5061b857aa5361de73191cac204d04b889cfbbafa", size = 56257, upload-time = "2026-05-08T21:01:01.195Z" }, + { url = "https://files.pythonhosted.org/packages/67/f0/9b90ca2a210b3d09bcfcd96ecd0f55545c091535abce2a45de2775cfd357/propcache-0.5.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:10734b5484ea113152ee25a91dccedf81631791805d2c9ccb054958e51842c94", size = 56696, upload-time = "2026-05-08T21:01:02.941Z" }, + { url = "https://files.pythonhosted.org/packages/9d/0e/6e9d4ba07c8e56e21ddec1e75f12148142b21ca83a51871babce095334f4/propcache-0.5.2-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cafca7e56c12bb02ae16d283742bef25a61122e9dab2b5b3f2ccbe589ce32164", size = 62378, upload-time = "2026-05-08T21:01:04.475Z" }, + { url = "https://files.pythonhosted.org/packages/65/19/c10badaa463dde8a27ce884f8ee2ec37e6035b7c9f5ff0c8f74f06f08dac/propcache-0.5.2-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f064f8d2b59177878b7615df1735cd8fe3462ed6be8c7b217d17a276489c2b7f", size = 65283, upload-time = "2026-05-08T21:01:05.959Z" }, + { url = "https://files.pythonhosted.org/packages/b0/b6/93bea99ca80e19cef6512a8580e5b7857bbe09422d9daa7fd4ef5723306c/propcache-0.5.2-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f78abfa8dfc32376fd1aacf597b2f2fbbe0ea751419aee718af5d4f82537ef8c", size = 66616, upload-time = "2026-05-08T21:01:07.228Z" }, + { url = "https://files.pythonhosted.org/packages/83/e4/5c7462e50625f051f37fb38b8224f7639f667184bbd34424ec83819bb1b7/propcache-0.5.2-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7467da8a9822bf1a55336f877340c5bcbd3c482afc43a99771169f74a26dedc", size = 63773, upload-time = "2026-05-08T21:01:08.514Z" }, + { url = "https://files.pythonhosted.org/packages/ca/b6/99238894047b13c823be25027e736626cd414a52a5e30d2c3347c2733529/propcache-0.5.2-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a6ddc6ac9e25de626c1f129c1b467d7ecd33ce2237d3fd0c4e429feef0a7ee1f", size = 63664, upload-time = "2026-05-08T21:01:09.874Z" }, + { url = "https://files.pythonhosted.org/packages/85/1e/a3a1a63116a2b8edb415a8bb9a6f0c34bd03830b1e18e8ce2904e1dc1cf4/propcache-0.5.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:2f22cbbac9e26a8e864c0985ff1268d5d939d53d9d9411a9824279097e03a2cb", size = 62643, upload-time = "2026-05-08T21:01:11.132Z" }, + { url = "https://files.pythonhosted.org/packages/e4/03/893cf147de2fc6543c5eaa07ad833170e7e2a2385725bbebe8c0503723bb/propcache-0.5.2-cp313-cp313t-musllinux_1_2_armv7l.whl", hash = "sha256:fc76378c62a0f04d0cd82fbb1a2cd2d7e28fcb40d5873f28a6c44e388aaa2751", size = 59595, upload-time = "2026-05-08T21:01:12.387Z" }, + { url = "https://files.pythonhosted.org/packages/86/3b/04c1a2e12c57766568ba75ba72b3bf2042818d4c1425fab6fc07155c7cff/propcache-0.5.2-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:acd2c8edba48e31e58a363b8cf4e5c7db3b04b3f9e371f601df30d9b0d244836", size = 65711, upload-time = "2026-05-08T21:01:13.676Z" }, + { url = "https://files.pythonhosted.org/packages/1c/34/80f8d0099f8d6bacc4de1624c85672681c8cd1149ca2da0e38fd120b817f/propcache-0.5.2-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:452b5065457eb9991ec5eb38ff41d6cd4c991c9ac7c531c4d5849ae473a9a13f", size = 64247, upload-time = "2026-05-08T21:01:14.936Z" }, + { url = "https://files.pythonhosted.org/packages/f3/1a/8b08f3a5f1037e9e370c55883ceeeee0f6dd0416fb2d2d67b8bfc91f2a79/propcache-0.5.2-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:3430bb2bfe1331885c427745a751e774ee679fd4344f80b97bf879815fe8fa55", size = 67102, upload-time = "2026-05-08T21:01:16.281Z" }, + { url = "https://files.pythonhosted.org/packages/34/68/8bdb7bb7756d76e005490649d10e4a8369e610c74d619f71e1aedf889e9c/propcache-0.5.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:cef6cea3922890dd6c9654971001fa797b526c16ab5e1e46c05fd6f877be7568", size = 64964, upload-time = "2026-05-08T21:01:17.57Z" }, + { url = "https://files.pythonhosted.org/packages/0a/aa/50fb0b5d3968b61a510926ff8b8465f1d6e976b3ab74496d7a4b9fc42515/propcache-0.5.2-cp313-cp313t-win32.whl", hash = "sha256:72d61e16dd78228b58c5d47be830ff3da7e5f139abdf0aef9d86cde1c5cf2191", size = 42546, upload-time = "2026-05-08T21:01:18.946Z" }, + { url = "https://files.pythonhosted.org/packages/ae/4c/0ddbae64321bd4a95bcbfc19307238016b5b1fee645c84626c8d539e5b74/propcache-0.5.2-cp313-cp313t-win_amd64.whl", hash = "sha256:0958834041a0166d343b8d2cedcd8bcbaeb4fdbe0cf08320c5379f143c3be6e7", size = 46330, upload-time = "2026-05-08T21:01:20.162Z" }, + { url = "https://files.pythonhosted.org/packages/00/d9/9cddc8efb78d8af264c5ec9f6d10b62f57c515feda8d321595f56010fb23/propcache-0.5.2-cp313-cp313t-win_arm64.whl", hash = "sha256:6de8bd93ddde9b992cf2b2e0d796d501a19026b5b9fd87356d7d0779531a8d96", size = 40521, upload-time = "2026-05-08T21:01:21.399Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ea/23ee535d90ce8bcc465a3028eb3cc0ce3bd1005f4bb27710b30587de798d/propcache-0.5.2-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:46088abff4cba581dea21ae0467a480526cb25aa5f3c269e909f800328bc3999", size = 94662, upload-time = "2026-05-08T21:01:22.683Z" }, + { url = "https://files.pythonhosted.org/packages/b5/06/c5a52f419b5d8972f8d46a7577476090d8e3263ff589ce40b5ca4968d5be/propcache-0.5.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fc88b26f08d634f7bc819a7852e5214f5802641ab8d9fd5326892292eee1993e", size = 53928, upload-time = "2026-05-08T21:01:23.986Z" }, + { url = "https://files.pythonhosted.org/packages/63/b1/4260d67d6bd85e58a66b72d54ce15d5de789b6f3870cc6bedf8ff9667401/propcache-0.5.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97797ebb098e670a2f92dd66f32897e30d7615b14e7f59711de23e30a9072539", size = 54650, upload-time = "2026-05-08T21:01:25.305Z" }, + { url = "https://files.pythonhosted.org/packages/70/06/2f46c318e3307cd7a6a7481def374ce838c0fe20084b39dd54b0879d0e99/propcache-0.5.2-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba57fffe4ac99c5d30076161b5866336d97600769bad35cc68f7774b15298a4e", size = 59912, upload-time = "2026-05-08T21:01:26.545Z" }, + { url = "https://files.pythonhosted.org/packages/4c/29/fe1aebec2ce57ab985a9c382bded1124431f85078113aa222c5d278430d4/propcache-0.5.2-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:583c19759d9eec1e5b69e2fbef36a7d9c326041be9746cb822d335c8cedc2979", size = 63300, upload-time = "2026-05-08T21:01:27.937Z" }, + { url = "https://files.pythonhosted.org/packages/b4/18/2334b26768b6c82be8c69e83671b767d5ef426aa09b0cba6c2ea47816774/propcache-0.5.2-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:d0326e2e5e1f3163fa306c834e48e8d490e5fae607a097a40c0648109b47ba80", size = 64208, upload-time = "2026-05-08T21:01:29.484Z" }, + { url = "https://files.pythonhosted.org/packages/2b/76/7f1bfd6afff4c5e38e36a3c6d68eb5f4b7311ea80baf693db78d95b603c4/propcache-0.5.2-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e00820e192c8dbebcafb383ebbf99030895f09905e7a0eb2e0340a0bcc2bc825", size = 61633, upload-time = "2026-05-08T21:01:31.068Z" }, + { url = "https://files.pythonhosted.org/packages/c4/46/b3ff8aba2b4953a3e50de2cf72f1b5748b8eca93b15f3dc2c84339084c09/propcache-0.5.2-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:c66afea89b1e43725731d2004732a046fe6fe955d51f952c3e95a7314a284a39", size = 61724, upload-time = "2026-05-08T21:01:32.374Z" }, + { url = "https://files.pythonhosted.org/packages/c5/01/814cfcafbcff954f94c01cf30e097ddc88a076b5440fbcf4570753437d40/propcache-0.5.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d4dc37dec6c6cdad0b57881a5658fd14fbf53e333b1a86cf86559f190e1d9ec4", size = 60069, upload-time = "2026-05-08T21:01:33.67Z" }, + { url = "https://files.pythonhosted.org/packages/da/68/5c6f7622d510cc666a300687e06fd060c1a43361c0c9b20d284f06d8096a/propcache-0.5.2-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:5570dbcc97571c15f68068e529c92715a12f8d54030e272d264b377e22bd17a5", size = 57099, upload-time = "2026-05-08T21:01:34.915Z" }, + { url = "https://files.pythonhosted.org/packages/55/27/9cb0b4c679124085327957d42521c99dba04c88c90c3e55a6f0b633ebccc/propcache-0.5.2-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f814362777a9f841adddb200ecdf8f5cb1e5a3c4b7a86378edbd6ccb26edd702", size = 63391, upload-time = "2026-05-08T21:01:36.231Z" }, + { url = "https://files.pythonhosted.org/packages/f0/9d/7258aaa5bdf60fc6f27591eef6fe52768cb0beda7140be477c8b12c9794a/propcache-0.5.2-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:196913dea116aeb5a2ba95af4ddcb7ea85559ae07d8eee8751688310d09168c3", size = 61626, upload-time = "2026-05-08T21:01:37.545Z" }, + { url = "https://files.pythonhosted.org/packages/8e/0d/41c602003e8a9b16fe1e7eadf62c7bfba9d5474370b24200bf48b315f45f/propcache-0.5.2-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:6e7b8719005dd1175be4ab1cd25e9b98659a5e0347331506ec6760d2773a7fb5", size = 64781, upload-time = "2026-05-08T21:01:38.83Z" }, + { url = "https://files.pythonhosted.org/packages/8b/f3/38e66b1856e9bd079deea015bc4a55f7767c0e4db2f7dcf69e7e680ba4ce/propcache-0.5.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:51f96d685ab16e88cab128cd37a52c5da540809c8b879fa047731bfcb4ad35a4", size = 62570, upload-time = "2026-05-08T21:01:40.415Z" }, + { url = "https://files.pythonhosted.org/packages/95/ca/bbfe9b910ce57dde8bb4876b4520fc02a4e89497c10de26be936758a3aaa/propcache-0.5.2-cp314-cp314-win32.whl", hash = "sha256:cc6fc3cc62e8501d3ed62894425040d2728ecddb1ed072737a5c70bd537aa9f0", size = 39436, upload-time = "2026-05-08T21:01:41.654Z" }, + { url = "https://files.pythonhosted.org/packages/61/d2/45c9defbaa1ea297035d9d4cce9e8f80daafbf19319c6007f157c6256ea9/propcache-0.5.2-cp314-cp314-win_amd64.whl", hash = "sha256:81e3a30b0bb60caa22033dd0f8a3618d1d67356212514f62c57db75cb0ef410c", size = 42373, upload-time = "2026-05-08T21:01:43.041Z" }, + { url = "https://files.pythonhosted.org/packages/44/68/9ea5103f41d5217d7d6ec24db90018e23aebec070c3f9a6e54d12b841fd8/propcache-0.5.2-cp314-cp314-win_arm64.whl", hash = "sha256:0d2c9bf8528f135dbb805ce027567e09164f7efa51a2be07458a2c0420f292d0", size = 38554, upload-time = "2026-05-08T21:01:44.336Z" }, + { url = "https://files.pythonhosted.org/packages/8a/81/fadf555f42d3b762eea8a53950b0489fdc0aa9da5f8ed9e10ce0a4e01b48/propcache-0.5.2-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:4bc8ff1feffc6a61c7002ffe84634c41b822e104990ae009f44a0834430070bb", size = 99395, upload-time = "2026-05-08T21:01:45.883Z" }, + { url = "https://files.pythonhosted.org/packages/f5/c9/c61e134a686949cf7971af3a390148b1156f7be81c73bc0cd12c873e2d48/propcache-0.5.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:79aa3ff0a9b566633b642fa9caf7e21ed1c13d6feca718187873f199e1514078", size = 56653, upload-time = "2026-05-08T21:01:47.307Z" }, + { url = "https://files.pythonhosted.org/packages/cb/73/daf935ea7048ddd7ec8eec5345b4a40b619d2d178b3c0a0900796bc3c794/propcache-0.5.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:1b31822f4474c4036bae62de9402710051d431a606d6a0f907fec79935a071aa", size = 56914, upload-time = "2026-05-08T21:01:48.573Z" }, + { url = "https://files.pythonhosted.org/packages/79/9f/aba959b435ea18617edd7cf0a7ad0b9c574b8fc7e3d2cd55fb59cb255d33/propcache-0.5.2-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:13fef48778b5a2a756523fdb781326b028ca75e32858b04f2cdd19f394564917", size = 62567, upload-time = "2026-05-08T21:01:49.903Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a1/859942de9a791ff42f6141736f5b37749b8f53e65edfa49638c67dd67e6a/propcache-0.5.2-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:8b73ab70f1a3351fbc71f663b3e645af6dd0329100c353081cf69c37433fc6fe", size = 65542, upload-time = "2026-05-08T21:01:51.204Z" }, + { url = "https://files.pythonhosted.org/packages/b5/61/315bc0fd6c0fc7f80a528b8afd209e5fc4a875ea79571b91b8f50f442907/propcache-0.5.2-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5538d2c13d93e4698af7e092b57bc7298fd35d1d58e656ae18f23ee0d0378e03", size = 66845, upload-time = "2026-05-08T21:01:52.539Z" }, + { url = "https://files.pythonhosted.org/packages/47/f7/9f8122e3132e8e354ac41975ef8f1099be7d5a16bc7ae562734e993665c0/propcache-0.5.2-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cd645f03898405cabe694fb8bc35241e3a9c332ec85627584fe3de201452b335", size = 63985, upload-time = "2026-05-08T21:01:53.847Z" }, + { url = "https://files.pythonhosted.org/packages/c8/54/c317819ec157cbf6f35df9df9657a6f82daf34d5faf15948b2f639c2192e/propcache-0.5.2-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a473b3440261e0c60706e732b2ed2f517857344fc21bf48fdfe211e2d98eb285", size = 63999, upload-time = "2026-05-08T21:01:55.179Z" }, + { url = "https://files.pythonhosted.org/packages/5a/56/387e3f7dfce0a9233df41fb888aa1c30222cb4bbbf09537c02dd9bd85fe2/propcache-0.5.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:7afa37062e6650640e932e4cc9297d81f9f42d9944029cc386b8247dea4da837", size = 62779, upload-time = "2026-05-08T21:01:57.489Z" }, + { url = "https://files.pythonhosted.org/packages/a1/9c/596784cb5824ed61ee960d3f8655a3f0993e107c6e98ab6c818b7fb92ccb/propcache-0.5.2-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:8a90efd5777e996e42d568db9ac740b944d691e565cbfd31b2f7832f9184b2b8", size = 59796, upload-time = "2026-05-08T21:01:58.736Z" }, + { url = "https://files.pythonhosted.org/packages/c2/3d/1a6cfa1726a48542c1e8784a0761421476a5b68e09b7f36bf95eb954aaba/propcache-0.5.2-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:f19bb891234d72535764d703bfed1153cc34f4214d5bd7150aee1eec9e8f4366", size = 66023, upload-time = "2026-05-08T21:02:00.228Z" }, + { url = "https://files.pythonhosted.org/packages/e4/0e/05fd6990369477076e4e280bcb970de760fddf0161a46e988bc95f7940ec/propcache-0.5.2-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:32775082acd2d807ee3db715c7770d38767b817870acfa08c29e057f3c4d5b56", size = 64448, upload-time = "2026-05-08T21:02:01.888Z" }, + { url = "https://files.pythonhosted.org/packages/cd/86/5f8da315a4309c62c10c0b2516b17492d5d3bbe1bb862b96604db67e2a37/propcache-0.5.2-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:9282fb1a3bccd038da9f768b927b24a0c753e466c086b7c4f3c6982851eefb2d", size = 67329, upload-time = "2026-05-08T21:02:03.484Z" }, + { url = "https://files.pythonhosted.org/packages/da/d3/3368efe79ab21f0cdf86ef49895811c9cc933131d4cde1f28a624e22e712/propcache-0.5.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cc49723e2f60d6b32a0f0b08a3fd6d13203c07f1cd9566cfce0f12a917c967a2", size = 65172, upload-time = "2026-05-08T21:02:04.745Z" }, + { url = "https://files.pythonhosted.org/packages/d5/07/127e8b0bacfb325396196f9d976a22453049b89b9b2b08477cc3145faa44/propcache-0.5.2-cp314-cp314t-win32.whl", hash = "sha256:2d7aa89ebca5acc98cba9d1472d976e394782f587bad6661003602a619fd1821", size = 43813, upload-time = "2026-05-08T21:02:06.025Z" }, + { url = "https://files.pythonhosted.org/packages/88/fb/46dad6c0ae49ed230ab1b16c890c2b6314e2403e6c412976f4a72d64a527/propcache-0.5.2-cp314-cp314t-win_amd64.whl", hash = "sha256:d447bb0b3054be5818458fbb171208b1d9ff11eba14e18ca18b90cbb45767370", size = 47764, upload-time = "2026-05-08T21:02:07.353Z" }, + { url = "https://files.pythonhosted.org/packages/e7/c4/a47d0a63aa309d10d59ede6e9d4cff03a344a79d1f0f4cd0cd74997b53e0/propcache-0.5.2-cp314-cp314t-win_arm64.whl", hash = "sha256:fe67a3d11cd9b4efabfa45c3d00ffba2b26811442a73a581a94b67c2b5faccf6", size = 41140, upload-time = "2026-05-08T21:02:09.065Z" }, + { url = "https://files.pythonhosted.org/packages/3a/ed/1cdcab6ba3d6ab7feca11fc14f0eeea80755bb53ef4e892079f31b10a25f/propcache-0.5.2-py3-none-any.whl", hash = "sha256:be1ddfcbb376e3de5d2e2db1d58d6d67463e6b4f9f040c000de8e300295465fe", size = 14036, upload-time = "2026-05-08T21:02:10.673Z" }, +] + [[package]] name = "protobuf" version = "7.36.0" @@ -792,6 +1319,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, ] +[[package]] +name = "pyjwt" +version = "2.13.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/3b/81/58d0ac84e1ef3a3843791d6954d94c0b33d526c75eeb1efbce9d0a4c4077/pyjwt-2.13.0.tar.gz", hash = "sha256:41571c89ca91598c79e8ef18a2d07367d4810fbbd6f637794879baf1b7703423", size = 107515, upload-time = "2026-05-21T19:54:36.618Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a3/5e/ecf12fdb62546d64385c158514e9b2b671f7832108ef2ecd2020ce0af2d1/pyjwt-2.13.0-py3-none-any.whl", hash = "sha256:66adcc2aff09b3f1bbd95fc1e1577df8ac8723c978552fd43304c8a290ac5728", size = 31274, upload-time = "2026-05-21T19:54:35.362Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pyparsing" version = "3.3.2" @@ -1193,3 +1734,102 @@ sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e wheels = [ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, ] + +[[package]] +name = "yarl" +version = "1.24.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "multidict" }, + { name = "propcache" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/31/33/ebe9e3d1f86c7a0b51094c0a146392045ca1631d2664889539dec8088a33/yarl-1.24.5.tar.gz", hash = "sha256:e81b83143bee16329c23db3c1b2d82b29892fcbcb849186d2f6e98a5abe9a57f", size = 228679, upload-time = "2026-07-20T02:07:45.435Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/fe/db/3cb5df059756a45761cc3dee8fd25ec82b83a6585ea3542b969fda850f99/yarl-1.24.5-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:2c1fe720934a16ea8e7146175cba2126f87f54912c8c5435e7f7c7a51ef808d3", size = 135043, upload-time = "2026-07-20T02:04:52.39Z" }, + { url = "https://files.pythonhosted.org/packages/44/f8/767d6bd5a03db63bc467df2fb56d6fafeae9667d74aea92cd6af399f828b/yarl-1.24.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:c687ed078e145f5fd53a14854beff320e1d2ab76df03e2009c98f39a0f68f39a", size = 96942, upload-time = "2026-07-20T02:04:54.26Z" }, + { url = "https://files.pythonhosted.org/packages/ce/97/10b939c44d7b28d1dbc389cfc7012306d1ea8dba01eaef44b39fffaee52a/yarl-1.24.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:709f1efed56c4a145793c046cd4939f9959bcd818979a787b77d8e09c57a0840", size = 97046, upload-time = "2026-07-20T02:04:56.638Z" }, + { url = "https://files.pythonhosted.org/packages/5b/7a/b410dbe39b6255c55fb2a2bcee96eb844d0789235ddc381a889a90dc72d6/yarl-1.24.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:874019bd513008b009f58657134e5d0c5e030b3559bd0553976837adf52fe966", size = 110512, upload-time = "2026-07-20T02:04:58.955Z" }, + { url = "https://files.pythonhosted.org/packages/83/c7/da591971f78a5617e1f21f5699858ebccd836fe181a6493788ffc91ba69b/yarl-1.24.5-cp311-cp311-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:a4582acf7ef76482f6f511ebaf1946dae7f2e85ec4728b81a678c01df63bd723", size = 102454, upload-time = "2026-07-20T02:05:00.623Z" }, + { url = "https://files.pythonhosted.org/packages/c4/8e/73b0ed4de47289a78a96045d76d1cfe5e41848bf0da59ce25b2ec87ee05d/yarl-1.24.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:2cabe6546e41dabe439999a23fcb5246e0c3b595b4315b96ef755252be90caeb", size = 117617, upload-time = "2026-07-20T02:05:02.325Z" }, + { url = "https://files.pythonhosted.org/packages/cf/14/b744747bc4f57a8d55bd744df463457524583e1e9f7538b5ace0346ab92e/yarl-1.24.5-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:17f57620f5475b3c69109376cc87e42a7af5db13c9398e4292772a706ff10780", size = 116135, upload-time = "2026-07-20T02:05:04.05Z" }, + { url = "https://files.pythonhosted.org/packages/66/ca/95aa4d0e5b7ea4f20e4d577c42d001ed9df207569fdb063cc5ed4ebb496b/yarl-1.24.5-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:570fec8fbd22b032733625f03f10b7ff023bc399213db15e72a7acaef28c2f4e", size = 111935, upload-time = "2026-07-20T02:05:05.738Z" }, + { url = "https://files.pythonhosted.org/packages/72/0d/d2ad8d6b147832d177a4e720ba1962fe686eb0913b74503b3eca094b8bba/yarl-1.24.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:5fede79c6f73ff2c3ef822864cb1ada23196e62756df53bc6231d351a49516a2", size = 110010, upload-time = "2026-07-20T02:05:07.471Z" }, + { url = "https://files.pythonhosted.org/packages/50/18/eb335e4120903903f4865041355ae46256a2406eb2865bc24827f4f27b61/yarl-1.24.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ccf9aca873b767977c73df497a85dbedee4ee086ae9ae49dc461333b9b79f58", size = 110058, upload-time = "2026-07-20T02:05:09.246Z" }, + { url = "https://files.pythonhosted.org/packages/44/70/97353add32c62ad6f206d948ac5a5ee84398225e534dc6ed6433d1b335b6/yarl-1.24.5-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:ad5d8201d310b031e6cd839d9bac2d4e5a01533ce5d3d5b50b7de1ef3af1de61", size = 103308, upload-time = "2026-07-20T02:05:11.31Z" }, + { url = "https://files.pythonhosted.org/packages/68/39/5e7398d4b6f6b3c9062823ebc60802df5b272e3fe9e788f9734c6ee46c85/yarl-1.24.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:841f0852f48fefea3b12c9dfec00704dfa3aef5215d0e3ce564bb3d7cd8d57c6", size = 116898, upload-time = "2026-07-20T02:05:13.099Z" }, + { url = "https://files.pythonhosted.org/packages/e4/c9/09e52f2239e8b96357eccca05915382e4ba5405ebfb623b6036040d99654/yarl-1.24.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:9baafc71b04f8f4bb0703b21d6fc9f0c30b346c636a532ff16ec8491a5ea4b1f", size = 109400, upload-time = "2026-07-20T02:05:14.821Z" }, + { url = "https://files.pythonhosted.org/packages/4b/6a/e94133d4c2d1a14d2384310bf3e79d9cf32c9d1eae1c6f034fb80d098fa1/yarl-1.24.5-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:d897129df1a22b12aeed2c2c98df0785a2e8e6e0bde87b389491d0025c187077", size = 115934, upload-time = "2026-07-20T02:05:17.78Z" }, + { url = "https://files.pythonhosted.org/packages/4e/3c/34955ed967b976fc38edcbb6d538dee79dbda4cb7fc7f72a0907a7c78e0f/yarl-1.24.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:dd625535328fd9882374356269227670189adfcc6a2d90284f323c05862eecbd", size = 112178, upload-time = "2026-07-20T02:05:19.675Z" }, + { url = "https://files.pythonhosted.org/packages/f5/46/d7bd3a8859d47dcfaffd7127af7076032a7da278a9a02e17b5f37bfb6712/yarl-1.24.5-cp311-cp311-win_amd64.whl", hash = "sha256:f4239bbec5a3577ddb49e4b50aeb32d8e5792098262ae2f63723f916a29b1a25", size = 97544, upload-time = "2026-07-20T02:05:21.523Z" }, + { url = "https://files.pythonhosted.org/packages/01/69/c1bfd21e32c638974ea2c542a0b8c53ef1fa9eff336020f5d014f9503ff2/yarl-1.24.5-cp311-cp311-win_arm64.whl", hash = "sha256:3ac6aff147deb9c09461b2d4bbdf6256831198f5d8a23f5d37138213090b6d8a", size = 93359, upload-time = "2026-07-20T02:05:23.493Z" }, + { url = "https://files.pythonhosted.org/packages/1b/84/71d051c850b5af41d168c679d9eb67eb7c55283ac4ee131673edf134bc4e/yarl-1.24.5-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:d693396e5aea78db03decd60aec9ece16c9b40ba00a587f089615ff4e718a81d", size = 136035, upload-time = "2026-07-20T02:05:25.489Z" }, + { url = "https://files.pythonhosted.org/packages/03/4d/8ad27f9a1b7e69313cca5d695b925b48efe51208d3490e0844bae97cabc0/yarl-1.24.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:3363fcc96e665878946ad7a106b9a13eac0541766a690ef287c0232ac768b6ec", size = 97642, upload-time = "2026-07-20T02:05:27.429Z" }, + { url = "https://files.pythonhosted.org/packages/ea/b4/05b4131c407006cd1e410e9c6539f16a0945724677e5364447313c15ea3e/yarl-1.24.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:9d399bdcfb4a0f659b9b3788bbc89babe63d9a6a65aacdf4d4e7065ff2e6316c", size = 97323, upload-time = "2026-07-20T02:05:29.441Z" }, + { url = "https://files.pythonhosted.org/packages/20/16/e618c875c73e0e39611f20a581b3d5e8d59b8857bf001bee3263044c6deb/yarl-1.24.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:90333fd89b43c0d08ac85f3f1447593fc2c66de18c3d6378d7125ea118dc7a54", size = 107741, upload-time = "2026-07-20T02:05:31.367Z" }, + { url = "https://files.pythonhosted.org/packages/d9/9a/c4defeaf3ed33fcb346aacf9c6e971a8d4e2bde04a0310e79abb208e7965/yarl-1.24.5-cp312-cp312-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:665b0a2c463cc9423dd647e0bfd9f4ccc9b50f768c55304d5e9f80b177c1de12", size = 103570, upload-time = "2026-07-20T02:05:33.303Z" }, + { url = "https://files.pythonhosted.org/packages/5f/e7/0e0e0de5865ebd5914537ef486f36c727a59865c3ac0cf5ff1b32aececbf/yarl-1.24.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e006d3a974c4ee19512e5f058abedb6eef36a5e553c14812bdeba1758d812e6d", size = 115815, upload-time = "2026-07-20T02:05:35.292Z" }, + { url = "https://files.pythonhosted.org/packages/2b/27/ca56b700cb170aba25a3893b75355b213935657dc5714d2383354a270e62/yarl-1.24.5-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:e7d42c531243450ef0d4d9c172e7ed6ef052640f195629065041b5add4e058d1", size = 116025, upload-time = "2026-07-20T02:05:37.503Z" }, + { url = "https://files.pythonhosted.org/packages/d6/d0/d56c859b8222116f5d68459199f48359e0bf121b6f65a69bf329b3602ba0/yarl-1.24.5-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f08c7513ecef5aad65687bfdf6bc601ae9fccd04a42904501f8f7141abad9eb9", size = 109835, upload-time = "2026-07-20T02:05:39.506Z" }, + { url = "https://files.pythonhosted.org/packages/70/a2/3a35557e4d1a79425040eba202ccaf08bdc8717680fc77e2498a1ad2e0a5/yarl-1.24.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6c95b17fe34ed802f17e205112e6e10db92275c34fee290aa9bdc55a9c724027", size = 108884, upload-time = "2026-07-20T02:05:41.584Z" }, + { url = "https://files.pythonhosted.org/packages/e4/35/ef4c26356b7913c68983bac2d72a4212b3347af551cb8d250b99b5ed7b7f/yarl-1.24.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:56b149b22de33b23b0c6077ab9518c6dcb538ad462e1830e68d06591ccf6e38b", size = 107308, upload-time = "2026-07-20T02:05:43.697Z" }, + { url = "https://files.pythonhosted.org/packages/d5/91/ff0dc66c2ccf3e0153ab97ff61eabab4400e6a5264af427ab30cd69f1857/yarl-1.24.5-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a8fe66b8f300da93798025a785a5b90b42f3810dc2b72283ff84a41aaaebc293", size = 103646, upload-time = "2026-07-20T02:05:45.895Z" }, + { url = "https://files.pythonhosted.org/packages/74/f0/33b9271c7f881766359d58266fa0811d2e5210ed860e28da7dc6d7786344/yarl-1.24.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:377fe3732edbaf78ee74efdf2c9f49f6e99f20e7f9d2649fda3eb4badd77d76e", size = 115305, upload-time = "2026-07-20T02:05:47.832Z" }, + { url = "https://files.pythonhosted.org/packages/ef/65/fd79fb1868c4a80db8661091de525bf430f63c3bea1b20e8b6a84fc7d359/yarl-1.24.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e8ffa78582120024f476a611d7befc123cee59e47e8309d470cf667d806e613b", size = 108404, upload-time = "2026-07-20T02:05:49.604Z" }, + { url = "https://files.pythonhosted.org/packages/ff/ba/dbabe6b262f17a816c70cfc09558dbf03ece3ec76684d02f911a3d3a189c/yarl-1.24.5-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:daba5e594f06114e37db186efd2dd916609071e59daca901a0a2e71f02b142ce", size = 115940, upload-time = "2026-07-20T02:05:51.741Z" }, + { url = "https://files.pythonhosted.org/packages/a5/43/fab2d1dad9d340a268cdde63756a123d069723efff6a372d123fa74a9517/yarl-1.24.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:65be18ec59496c13908f02a2472751d9ef840b4f3fb5726f129306bf6a2a7bba", size = 110006, upload-time = "2026-07-20T02:05:53.554Z" }, + { url = "https://files.pythonhosted.org/packages/c4/27/41eb51bbd1b8d89546b83897cfb0164f1e109304fd408dbb151b639eec0f/yarl-1.24.5-cp312-cp312-win_amd64.whl", hash = "sha256:a929d878fec099030c292803b31e5d5540a7b6a31e6a3cc76cb4685fc2a2f51b", size = 97618, upload-time = "2026-07-20T02:05:55.57Z" }, + { url = "https://files.pythonhosted.org/packages/3c/25/b2553764b3d65db711d8f45416351ec4f420847558eb669edcbcaadf5780/yarl-1.24.5-cp312-cp312-win_arm64.whl", hash = "sha256:7ce27823052e2013b597e0c738b13e7e36b8ccb9400df8959417b052ab0fd92c", size = 93018, upload-time = "2026-07-20T02:05:57.554Z" }, + { url = "https://files.pythonhosted.org/packages/e1/63/64ef361967cc983573149dc1515d531db5da8a4c92d22bb833d59e01b313/yarl-1.24.5-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:79af890482fc94648e8cde4c68620378f7fef60932710fa17a66abc039244da2", size = 135075, upload-time = "2026-07-20T02:05:59.671Z" }, + { url = "https://files.pythonhosted.org/packages/bb/89/55920fd853ce43e608adbc3962456f0d649d6bb15250dc2988321da0fe1c/yarl-1.24.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:46c2f213e23a04b93a392942d782eb9e413e6ef6bf7c8c53884e599a5c174dcb", size = 97225, upload-time = "2026-07-20T02:06:01.769Z" }, + { url = "https://files.pythonhosted.org/packages/15/f0/7688d3f2cfff7590df2af38ec46d969f4281a4dddb08a9ad2eafbcdddf98/yarl-1.24.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:92ab3e11448f2ff7bf53c5a26eff0edc086898ec8b21fb154b85839ce1d88075", size = 96751, upload-time = "2026-07-20T02:06:03.676Z" }, + { url = "https://files.pythonhosted.org/packages/05/1a/a851a0f94aaaf379dd4f901bfc80f634280bec51eb260b47363e2a4cd62e/yarl-1.24.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ebb0ec7f17803063d5aeb982f3b1bd2b2f4e4fae6751226cbd6ba1fcfe9e63ff", size = 107960, upload-time = "2026-07-20T02:06:05.699Z" }, + { url = "https://files.pythonhosted.org/packages/6c/a8/faea066c12f9c77ca0de90641f1655f9dd7b412477bf28c76d692f3aecff/yarl-1.24.5-cp313-cp313-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:82632daed195dcc8ea664e8556dc9bdbd671960fb3776bd92806ce05792c2448", size = 103500, upload-time = "2026-07-20T02:06:07.556Z" }, + { url = "https://files.pythonhosted.org/packages/fb/9c/1e67084c2a6e2f2db0e3be798328cb3be42c0119b621d25461479a224d21/yarl-1.24.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:53e549287ef628fecba270045c9701b0c564563a9b0577d24a4ec75b8ab8040f", size = 115780, upload-time = "2026-07-20T02:06:09.599Z" }, + { url = "https://files.pythonhosted.org/packages/58/86/1f94664e147474337e3359f52012cf3d02f825f694317b178bfba1078c62/yarl-1.24.5-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fcd3b77e2f17bbe4ca56ec7bcb07992647d19d0b9c05d84886dcd6f9eb810afd", size = 115308, upload-time = "2026-07-20T02:06:11.352Z" }, + { url = "https://files.pythonhosted.org/packages/0a/43/8e55ae7538ba5f28ccb3c845c6dd4549cf7016d5992e5326512519107cdd/yarl-1.24.5-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d46b86567dd4e248c6c159fcbcdcce01e0a5c8a7cd2334a0fff759d0fa075b16", size = 110574, upload-time = "2026-07-20T02:06:13.129Z" }, + { url = "https://files.pythonhosted.org/packages/ce/ba/a889ec8765cedcf2ac44dcb02d6a21e4861399b243b263c5f2dde27ee740/yarl-1.24.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7f72c74aa99359e27a2ee8d6613fefa28b5f76a983c083074dfc2aaa4ab46213", size = 109914, upload-time = "2026-07-20T02:06:15.243Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c3/e45f821af67b791c2dbbe4a9f4137a1d33f8d386654a05a0c3f47bdfa25d/yarl-1.24.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:3f45789ce415a7ec0820dc4f82925f9b5f7732070be1dec1f5f23ec381435a24", size = 107712, upload-time = "2026-07-20T02:06:17.443Z" }, + { url = "https://files.pythonhosted.org/packages/02/00/2ab0f42c9857fcb490bfaa6647b14540b53d241ab209f23220b958cc5832/yarl-1.24.5-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:6e73e7fe93f17a7b191f52ec9da9dd8c06a8fe735a1ecbd13b97d1c723bff385", size = 104251, upload-time = "2026-07-20T02:06:19.259Z" }, + { url = "https://files.pythonhosted.org/packages/7a/70/709d9a286e98af2c7fd8e4e6cada658b5c0e30d87dd7e2a63c2fb5767217/yarl-1.24.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:4a36f9becdd4c5c52a20c3e9484128b070b1dcfc8944c006f3a528295a359a9c", size = 115319, upload-time = "2026-07-20T02:06:21.207Z" }, + { url = "https://files.pythonhosted.org/packages/5c/6c/3eaa515142991fe84cfc483ff986492211f1978f90161ccefdbec919d09b/yarl-1.24.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:7bcbe0fcf850eae67b6b01749815a4f7161c560a844c769ad7b48fcd99f791c4", size = 109163, upload-time = "2026-07-20T02:06:23.006Z" }, + { url = "https://files.pythonhosted.org/packages/bb/64/711dafce66c323a3144d470547a71c5384c57623308ac8bb5e4b903ac148/yarl-1.24.5-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:24e861e9630e0daddcb9191fb187f60f034e17a4426f8101279f0c475cd74144", size = 115435, upload-time = "2026-07-20T02:06:24.923Z" }, + { url = "https://files.pythonhosted.org/packages/cf/f3/9b9d0e6d84bea851eb1ba99e4bdc755b86fd813e49ec86dfe42f26befdef/yarl-1.24.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9335a099ad87287c37fe5d1a982ff392fa5efe5d14b40a730b1ec1d6a41382b4", size = 110691, upload-time = "2026-07-20T02:06:26.973Z" }, + { url = "https://files.pythonhosted.org/packages/86/e4/62a06b7e87c4246ac76b7c2da136f972eb4a3a1fc94abb07e7022d6fdb0a/yarl-1.24.5-cp313-cp313-win_amd64.whl", hash = "sha256:2dbe06fc16bc91502bca713704022182e5729861ae00277c3a23354b40929740", size = 97454, upload-time = "2026-07-20T02:06:29.163Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c9/5fc8025b318ab10db413b61056bd0d95c557a70e8df4210c7511f866329c/yarl-1.24.5-cp313-cp313-win_arm64.whl", hash = "sha256:6b8536851f9f65e7f00c7a1d49ba7f2be0ffe2c11555367fc9f50d9f842410a1", size = 92813, upload-time = "2026-07-20T02:06:31.113Z" }, + { url = "https://files.pythonhosted.org/packages/a9/08/5f3085fef9564217074db9dd8573de1795bc82cde61a7ad10b6a7234a569/yarl-1.24.5-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:2729fcfc4f6a596fb0c50f32090400aa9367774ac296a00387e65098c0befa76", size = 135680, upload-time = "2026-07-20T02:06:33.273Z" }, + { url = "https://files.pythonhosted.org/packages/98/35/ba9436e579bd48a8801f2021d842d9ab4994c26e4c7dd3a4c1f1bcb57a9e/yarl-1.24.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:ff330d3c30db4eb6b01d79e29d2d0b407a7ecad39cfd9ec993ece57396a2ec0d", size = 97395, upload-time = "2026-07-20T02:06:35.259Z" }, + { url = "https://files.pythonhosted.org/packages/18/a9/a07f76f3c44e02b25cc743af5ef93eef27f7013eadca770451b6a6ccb5db/yarl-1.24.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:e42d75862735da90e7fc5a7b23db0c976f737113a54b3c9777a9b665e9cbff75", size = 97223, upload-time = "2026-07-20T02:06:37.216Z" }, + { url = "https://files.pythonhosted.org/packages/77/f7/a9a1d6fa7dd9e388f95b30f6ad3ec4e285f6c8f61f44ce16070c3fcfe414/yarl-1.24.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a3732e66413163e72508da9eff9ce9d2846fde51fae45d3605393d3e6cd303e9", size = 108777, upload-time = "2026-07-20T02:06:39.292Z" }, + { url = "https://files.pythonhosted.org/packages/2f/44/e0b86c302471fabd6f02808ecf2ac52b8412b624787849d4bf2cdb466f6f/yarl-1.24.5-cp314-cp314-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:5b8ee53be440a0cffc991a27be3057e0530122548dbe7c0892df08822fce5ede", size = 103119, upload-time = "2026-07-20T02:06:41.456Z" }, + { url = "https://files.pythonhosted.org/packages/d1/16/9c16d180bf8faaf223225eb50e1245870ff1ae0e302a27153988e65c51fd/yarl-1.24.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:af3aefa655adb5869491fa907e652290386800ae99cc50095cba71e2c6aefdca", size = 116471, upload-time = "2026-07-20T02:06:43.696Z" }, + { url = "https://files.pythonhosted.org/packages/d2/8d/b219b9df28a02ce95cfbdd41d2f7caa5669d0ff979c1c9975697145e33c5/yarl-1.24.5-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:2120b96872df4a117cde97d270bac96aea7cc52205d305cf4611df694a487027", size = 115974, upload-time = "2026-07-20T02:06:45.874Z" }, + { url = "https://files.pythonhosted.org/packages/9b/e8/f20557aca240d88e69850ad1ee91756821d094bb1310565c04d25c6682a2/yarl-1.24.5-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:66410eb6345d467151934b49bfa70fb32f5b35a6140baa40ad97d6436abea2e9", size = 110830, upload-time = "2026-07-20T02:06:47.852Z" }, + { url = "https://files.pythonhosted.org/packages/db/18/199b85109a53eeca64ee19c9cca228287e8e4ab0cc1a09b28f530e65cce0/yarl-1.24.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4af7b7e1be0a69bee8210735fe6dcfc38879adfac6d62e789d53ba432d1ffa41", size = 110054, upload-time = "2026-07-20T02:06:49.84Z" }, + { url = "https://files.pythonhosted.org/packages/aa/2f/ed28147f8cd7f48c49367c90713b30a555284b6105a6a56f3a05568da795/yarl-1.24.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:fa139875ff98ab97da323cfadfaff08900d1ad42f1b5087b0b812a55c5a06373", size = 108312, upload-time = "2026-07-20T02:06:51.835Z" }, + { url = "https://files.pythonhosted.org/packages/c5/c5/55e16ae0a5c227cea8df1c6871ba57d614a34243146c05729caf2a1bd9c5/yarl-1.24.5-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:0055afc45e864b92729ac7600e2d102c17bef060647e74bca75fa84d66b9ff36", size = 103662, upload-time = "2026-07-20T02:06:54.061Z" }, + { url = "https://files.pythonhosted.org/packages/8d/ea/dbd7c2caec459c9a426f18b02688ecbfb58620d0f6a3422d24769fbaf8ab/yarl-1.24.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:f0e466ed7511fe9d459a819edbc6c2585c0b6eabde9fa8a8947552468a7a6ef0", size = 116090, upload-time = "2026-07-20T02:06:56.015Z" }, + { url = "https://files.pythonhosted.org/packages/06/84/39ce4ce3059e07fece5fbdbee8c4053406af9aca911ce9fa5f8548aab6af/yarl-1.24.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:f141474e85b7e54998ec5180530a7cda99ab29e282fa50e0756d89981a9b43c5", size = 109523, upload-time = "2026-07-20T02:06:57.926Z" }, + { url = "https://files.pythonhosted.org/packages/a9/8b/71ff44137b405c64a7788075669c24010019f57a7464b78c3a6cbee539d9/yarl-1.24.5-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:e2935f8c39e3b03e83519292d78f075189978f3f4adc15a78144c7c8e2a1cba5", size = 116084, upload-time = "2026-07-20T02:06:59.868Z" }, + { url = "https://files.pythonhosted.org/packages/62/c0/423078fdd4042e1862c11f0ffd977a0ffa393783c12bee94685923bc189e/yarl-1.24.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:9d1216a7f6f77836617dba35687c5b78a4170afc3c3f18fc788f785ba26565c4", size = 111006, upload-time = "2026-07-20T02:07:01.907Z" }, + { url = "https://files.pythonhosted.org/packages/cf/52/6daa2ee9d95e5c98b8128f8df91eb692eb423ab274b8cf08db52152fad26/yarl-1.24.5-cp314-cp314-win_amd64.whl", hash = "sha256:5ba4f78df2bcc19f764a4b26a8a4f5049c110090ad5825993aacb052bf8003ad", size = 99215, upload-time = "2026-07-20T02:07:03.852Z" }, + { url = "https://files.pythonhosted.org/packages/ec/0e/464a847d7359e0da75dd9fc5c1d1aa35d0159ea31e5f8e66a3c1c29ff3d0/yarl-1.24.5-cp314-cp314-win_arm64.whl", hash = "sha256:9e4e16c73d717c5cf27626c524d0a2e261ad20e46932b2670f64ad5dde23e26f", size = 94566, upload-time = "2026-07-20T02:07:06.074Z" }, + { url = "https://files.pythonhosted.org/packages/e2/55/e03acc4446772660bc335e86e41ef31e4d0d838fd641531a11a5ee33b493/yarl-1.24.5-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:e1ae548a9d901adca07899a4147a7c826bbcc06239d3ce9a59f57886a28a4c88", size = 142533, upload-time = "2026-07-20T02:07:08.284Z" }, + { url = "https://files.pythonhosted.org/packages/ae/71/4acd3a1fc7cf14345cdb302665ecd2097f62c365b4f14ca17d4f37775cf9/yarl-1.24.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:ff405d91509d88e8d44129cd87b18d70acd1f0c1aeabd7bc3c46792b1fe2acba", size = 100776, upload-time = "2026-07-20T02:07:10.197Z" }, + { url = "https://files.pythonhosted.org/packages/ff/0b/cfb76b7fe99686db264bff829779a539d923e7564ffd7ef18da6c54c3774/yarl-1.24.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:47e98aab9d8d82ff682e7b0b5dded33bf138a32b817fcf7fa3b27b2d7c412928", size = 100913, upload-time = "2026-07-20T02:07:12.357Z" }, + { url = "https://files.pythonhosted.org/packages/8b/3f/7116e782992abbd4fb6948488aec72078895e929a23078290739e8396fce/yarl-1.24.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f0a658a6d3fafee5c6f63c58f3e785c8c43c93fbc02bf9f2b6663f8185e0971f", size = 106507, upload-time = "2026-07-20T02:07:14.173Z" }, + { url = "https://files.pythonhosted.org/packages/33/90/d4d2d73ee78229cc889872eb8e085d8f5c6f51abdb178409fd9b23cf74fd/yarl-1.24.5-cp314-cp314t-manylinux2014_armv7l.manylinux_2_17_armv7l.manylinux_2_31_armv7l.whl", hash = "sha256:4377407001ca3c057773f44d8ddd6358fa5f691407c1ba92210bd3cf8d9e4c95", size = 99219, upload-time = "2026-07-20T02:07:16.019Z" }, + { url = "https://files.pythonhosted.org/packages/3e/fa/a6df1a9bccd644eec00abee0dff4277416222cec435330fd1f2858523ec1/yarl-1.24.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:7c0494a31a1ac5461a226e7947a9c9b78c44e1dc7185164fa7e9651557a5d9bc", size = 111804, upload-time = "2026-07-20T02:07:18.141Z" }, + { url = "https://files.pythonhosted.org/packages/8a/9e/7b2a1f4bcc20e9447156dd2b1c4d01f70d9df0759025ee7d09a84ffae134/yarl-1.24.5-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a7cff474ab7cd149765bb784cf6d78b32e18e20473fb7bda860bce98ab58e9da", size = 110943, upload-time = "2026-07-20T02:07:20.06Z" }, + { url = "https://files.pythonhosted.org/packages/08/ff/22c92affb0f9b623ca753d27d968b5625b868f12c6378d049d55ae247643/yarl-1.24.5-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:cbb833ccacdb5519eff9b8b71ee618cc2801c878e77e288775d77c3a2ced858a", size = 108251, upload-time = "2026-07-20T02:07:22.217Z" }, + { url = "https://files.pythonhosted.org/packages/45/44/5769b96298c1e195fb412997b6090af2a84105cf59c17613558a2d011d1f/yarl-1.24.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:82f75e05912e84b7a0fe57075d9c59de3cb352b928330f2eb69b2e1f54c3e1f0", size = 106025, upload-time = "2026-07-20T02:07:24.083Z" }, + { url = "https://files.pythonhosted.org/packages/4c/40/009e8e791fd9762c0e1567e69248acb4f49064597e1680874c16dd8bb798/yarl-1.24.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:16a2f5010280020e90f5330257e6944bc33e73593b136cc5a241e6c1dc292498", size = 106573, upload-time = "2026-07-20T02:07:26.248Z" }, + { url = "https://files.pythonhosted.org/packages/20/c6/b7480578f8a0a80946f36ad6df547ecec704f9ba69d2de60f8aa6f1c1cbf/yarl-1.24.5-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:ffcd54362564dc1a30fb74d8b8a6e5a6b11ebd5e27266adc3b7427a21a6c9104", size = 100751, upload-time = "2026-07-20T02:07:28.098Z" }, + { url = "https://files.pythonhosted.org/packages/d4/27/4476f3360b91a48c5cf125e91f59a3bd35299d84a431a258d57f5977bb11/yarl-1.24.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0465ec8cedc2349b97a6b595ace64084a50c6e839eca40aa0626f38b8350e331", size = 111643, upload-time = "2026-07-20T02:07:30.88Z" }, + { url = "https://files.pythonhosted.org/packages/4c/4b/5cdd3e5ee944e8af31e52f6cd3d3af5fd7b937e036ccbbba2c9ffebede95/yarl-1.24.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:4db9aecb141cb7a5447171b57aa1ed3a8fee06af40b992ffc31206c0b0121550", size = 106312, upload-time = "2026-07-20T02:07:33.06Z" }, + { url = "https://files.pythonhosted.org/packages/18/86/f406b0c2a6f99575de2da671ef47aa06f89a5be83a27a46971c3b86cecdb/yarl-1.24.5-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:f540c013589084679a6c7fac07096b10159737918174f5dfc5e11bf5bca4dfe6", size = 110379, upload-time = "2026-07-20T02:07:35.155Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/9f3adfbd3b30b4fa0f7ccb3a83eba2c1152d3fff554d535e640ba0f7ba2b/yarl-1.24.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a61834fb15d81322d872eaafd333838ae7c9cea84067f232656f75965933d047", size = 108497, upload-time = "2026-07-20T02:07:37.35Z" }, + { url = "https://files.pythonhosted.org/packages/dd/37/91eb2e5ca883a529c1b390348a74cd9fc0512171727f547ce70bfe02be5c/yarl-1.24.5-cp314-cp314t-win_amd64.whl", hash = "sha256:5c88e5815a49d289e599f3513aa7fde0bc2092ff188f99c940f007f90f53d104", size = 102450, upload-time = "2026-07-20T02:07:39.578Z" }, + { url = "https://files.pythonhosted.org/packages/bf/f4/ed5c402ac8fde4403ed3366c2716bfddc8a6677ebd59f3d62772cc7fe468/yarl-1.24.5-cp314-cp314t-win_arm64.whl", hash = "sha256:cf139c02f5f23ef6532040a30ff662c00a318c952334f211046b8e60b7f17688", size = 97222, upload-time = "2026-07-20T02:07:41.55Z" }, + { url = "https://files.pythonhosted.org/packages/61/02/962c1cbfc401a30c1d034dc67ff395f64b52302c6d62de556c1fca99acc0/yarl-1.24.5-py3-none-any.whl", hash = "sha256:a33700d13d9b7d84fd10947b09ff69fb9a792e519c8cb9764a3ca70baa6c23a7", size = 58612, upload-time = "2026-07-20T02:07:43.461Z" }, +] From f6f2687ac363ba6d4c01004ab471828fb4c3bc48 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 17:55:40 +0000 Subject: [PATCH 51/70] feat(egress-gate): replace fork launcher with a controlled upstream Pi app --- .github/workflows/egress-gate.yml | 5 + .../pi-attested-admission/.env.example | 7 +- .../examples/pi-attested-admission/README.md | 462 ++-- .../app/package-lock.json | 2218 +++++++++++++++++ .../pi-attested-admission/app/package.json | 24 + .../app/src/admission.ts | 295 +++ .../pi-attested-admission/app/src/cli.ts | 117 + .../pi-attested-admission/app/src/network.ts | 11 + .../pi-attested-admission/app/src/session.ts | 414 +++ .../pi-attested-admission/app/src/verify.ts | 108 + .../app/test/admission.test.ts | 112 + .../app/test/session.test.ts | 429 ++++ .../{runtime-extension => app}/tsconfig.json | 11 +- .../pi-attested-admission/bind-sandbox.py | 25 + .../examples/pi-attested-admission/demo.sh | 944 +------ .../gateway-middleware.toml.example | 6 - .../examples/pi-attested-admission/model.json | 17 + .../pi-attested-admission/models.json | 68 - .../pi-attested-admission/policy.yaml | 21 +- .../examples/pi-attested-admission/prepare.py | 243 ++ .../project/.pi/skills/review/SKILL.md | 6 + .../pi-attested-admission/project/AGENTS.md | 4 + .../pi-attested-admission/project/notes.txt | 2 + .../provider-profile.yaml | 22 - .../openshell-context-admission.ts | 464 ---- .../runtime-extension/openshell-pi.ts | 48 - .../runtime-extension/package.json | 4 - .../pi-attested-admission/sandbox/Dockerfile | 21 +- .../pi-attested-admission/settings.json | 5 - projects/egress-gate/scripts/check.sh | 3 + .../js/openshell-context-admission.test.mjs | 321 --- .../tests/test_pi_example_commands.py | 598 +---- 32 files changed, 4446 insertions(+), 2589 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/package-lock.json create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/package.json create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/src/admission.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/src/network.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/src/session.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/test/admission.test.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts rename projects/egress-gate/examples/pi-attested-admission/{runtime-extension => app}/tsconfig.json (60%) create mode 100644 projects/egress-gate/examples/pi-attested-admission/bind-sandbox.py delete mode 100644 projects/egress-gate/examples/pi-attested-admission/gateway-middleware.toml.example create mode 100644 projects/egress-gate/examples/pi-attested-admission/model.json delete mode 100644 projects/egress-gate/examples/pi-attested-admission/models.json create mode 100644 projects/egress-gate/examples/pi-attested-admission/prepare.py create mode 100644 projects/egress-gate/examples/pi-attested-admission/project/.pi/skills/review/SKILL.md create mode 100644 projects/egress-gate/examples/pi-attested-admission/project/AGENTS.md create mode 100644 projects/egress-gate/examples/pi-attested-admission/project/notes.txt delete mode 100644 projects/egress-gate/examples/pi-attested-admission/provider-profile.yaml delete mode 100644 projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts delete mode 100644 projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts delete mode 100644 projects/egress-gate/examples/pi-attested-admission/runtime-extension/package.json delete mode 100644 projects/egress-gate/examples/pi-attested-admission/settings.json delete mode 100644 projects/egress-gate/tests/js/openshell-context-admission.test.mjs diff --git a/.github/workflows/egress-gate.yml b/.github/workflows/egress-gate.yml index b16f6c91..cdb40953 100644 --- a/.github/workflows/egress-gate.yml +++ b/.github/workflows/egress-gate.yml @@ -44,6 +44,11 @@ jobs: echo "UV_CACHE_DIR=$RUNNER_TEMP/egress-gate-uv-cache" >> "$GITHUB_ENV" echo "UV_PROJECT_ENVIRONMENT=$RUNNER_TEMP/egress-gate-venv" >> "$GITHUB_ENV" + - name: Set up Node for the upstream Pi example + uses: actions/setup-node@v6 + with: + node-version: "22.22.2" + - name: Install locked dependencies run: uv sync --frozen diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example index d9a72595..388732b5 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.env.example +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -1,5 +1,4 @@ -EGRESS_GATE_HOST_IP=YOUR_HOST_IPV4 -PI_MODELS_PATH=./models.json +# Host address reachable from Docker sandboxes (Linux Docker bridge default). +EGRESS_GATE_HOST_IP=172.17.0.1 +# Real key for the HTTPS endpoint/model in model.json. Never copied into the image. PI_MODEL_API_KEY=your-provider-key -# Optional: omit this to start Pi in an empty workspace. -# PI_WORKSPACE_PATH=/absolute/path/to/your/project diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 99f06794..16977017 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,341 +1,195 @@ -# Pi attested-admission example +# Pi admission without forks -This example runs the normal forked Pi CLI inside OpenShell and sends admitted -conversation context to the configured NVIDIA inference endpoint. One -endpoint-scoped provider and credential serve all configured models. +A small, real Pi-powered coding assistant runs in unmodified OpenShell. +Egress Gate approves content **before** it enters the assistant's live history +or Pi's saved session. It can deny text or replace it; a second check at the +network boundary prevents sending an unapproved user/tool context. -The example demonstrates the same policy, identified by the same fingerprint, -at three checkpoints: before Pi appends history, immediately before Pi sends -its complete provider context, and again at provider egress before OpenShell -attaches credentials. +This is an application built from Pi's public APIs, not the stock Pi CLI. +It keeps real tools, tool continuations, project instructions, explicit skills, +and manual/automatic compaction. Neither Pi nor OpenShell needs a patch. -- `DENY_THIS` is rejected before Pi adds a user message or tool result to its - live context. -- `REDACT_THIS` becomes `[REDACTED]` before Pi adds or sends it. +## Try it -The redaction case makes one real request to your configured endpoint and may -incur charges from that provider. +Prerequisites: **Linux x86_64**, running Docker, Python 3.11+, uv 0.11+, +curl, and a real key for a text-only OpenAI-compatible Chat Completions model. +The checked-in model uses NVIDIA's inference endpoint and requires access to it. +Edit [model.json](model.json) to use another compatible HTTPS endpoint/model. -## Before you start +From this directory: -Use these matching fork branches: - -- [Pi `johnny/before-user-message-commit`](https://github.com/johnnygreco/pi/tree/johnny/before-user-message-commit) -- [OpenShell `openshell/pi-egress-admission`](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) -- [OpenShell Research integration branch](https://github.com/NVIDIA/OpenShell-Research/tree/johnny/pi-attested-admission) - -You do not need to clone the Pi or OpenShell forks manually. The first -`./demo.sh prepare` clones both into the ignored local workspace -`projects/egress-gate/.workspaces/pi-attested-admission/`. Later runs update -them with fast-forward-only pulls, so the fork contents never appear as -OpenShell Research changes. To reuse a checkout elsewhere, set `PI_REPO` or -`OPENSHELL_REPO` to its absolute path. - -The OpenShell gateway needs a running compute backend. On macOS, start Docker -Desktop and wait until `docker info` succeeds before running the gateway; -Podman is also supported. Building the gateway also requires Z3 (`brew install -z3` on macOS or `libz3-dev` on Debian and Ubuntu). The fork recommends `mise` -2026.4.25 or newer. - -From the `OpenShell-Research` checkout, change to the example directory. Run -all remaining commands there: - -```shell -cd projects/egress-gate/examples/pi-attested-admission -``` - -Create the local configuration file and replace every example value: - -```shell +```sh cp .env.example .env -# Edit .env before continuing. -``` - -Every `demo.sh` invocation loads this file automatically. The values remain -local to the script and its child commands; they are not added to your current -shell. Set `PI_EGRESS_ENV_FILE` to use a configuration file elsewhere. - -If the model endpoint does not require authentication, set -`PI_MODEL_API_KEY=unused`. - -`PI_WORKSPACE_PATH` is optional. Set it to the absolute path of a project you -want Pi to work on. The reset step uploads its contents to `/sandbox/workspace` -using the project's normal `.gitignore` rules. If you omit it, Pi starts in an -empty `/sandbox/workspace`; no local files are copied. In either case, Pi starts -there, so project instructions, extensions, skills, prompts, and session -grouping follow its ordinary current-directory behavior. - -`PI_MODELS_PATH` points to a standard Pi `models.json`. Relative paths are -resolved from this example directory. The checked-in [models.json](models.json) -defines one `attested-provider`, its endpoint and credential reference, and -these models: - -| Model ID | Pi transport | -| --- | --- | -| `azure/anthropic/claude-opus-5` | OpenAI Chat Completions | -| `azure/openai/gpt-5.6-sol` | OpenAI Responses | -| `nvidia/qwen/qwen3.8-flash-next` | OpenAI Chat Completions | - -Pi starts with the reasoning-capable Qwen model and `high` thinking from -[settings.json](settings.json). Use Pi's normal model picker to switch among all -three without creating another OpenShell provider. Qwen uses Chat Completions -reasoning controls, and GPT-5.6 Sol uses Responses reasoning. -The endpoint's Opus 5 alias currently rejects explicit adaptive-thinking -controls, so it runs with the endpoint's default thinking behavior. - -To use another catalog for the same endpoint, copy `models.json`, edit it using -Pi's documented JSON format, and set `PI_MODELS_PATH` to that file. OpenShell -pins network and credential access independently of Pi. To change endpoints, -update the matching host and port explicitly in `models.json`, `policy.yaml`, -and `provider-profile.yaml`. The automated `verify` cases target the checked-in -NVIDIA endpoint and model catalog; the script does not parse arbitrary catalogs -to adapt those checks. - -`EGRESS_GATE_HOST_IP` is the address OpenShell uses to reach Egress Gate on this -machine. It must be a reachable, non-loopback IPv4 address; do not use -`127.0.0.1`. The provider's `baseUrl` in `models.json` is the model endpoint Pi -will call. A model server running on this machine must likewise use a hostname -or address reachable from the sandbox rather than `localhost`. - -The example checks in ordinary Pi and OpenShell configuration files. It uploads -`models.json` and `settings.json` unchanged to Pi's standard -`~/.pi/agent` directory. The only generated configuration is a copy of -`gateway-middleware.toml.example` with `EGRESS_GATE_HOST_IP` substituted for its -documented placeholder. If an action needs configuration that is missing, the -script prints the values required by that action and stops before doing work. - -Preview the complete workflow before running anything: - -```shell -./demo.sh --print all -``` - -The walkthrough lists the terminal sequence and configuration visible to the -current shell. To inspect the exact commands for one action, use its name—for -example, `./demo.sh --print prepare` or `./demo.sh --print launch`. - -## Run the example - -Prepare the forks and build and package the locally modified Pi agent core and -coding agent: - -```shell +# Edit .env: set PI_MODEL_API_KEY and the host address reachable from Docker. +# Edit model.json if using a different endpoint/model. ./demo.sh prepare ``` -The updates use fast-forward-only pulls and stop instead of merging divergent -local work. +Preparation downloads checksum-verified OpenShell **0.0.116** binaries, installs +locked Pi **0.85.1** packages in a pinned Node image, and creates local TLS and +configuration under the project's ignored `.workspaces/pi-no-fork/` directory. +No fork clones, Rust build, global Pi install, or existing gateway are needed. +The current launcher is deliberately Linux-only; other platforms are not tested. -Keep Egress Gate running in one terminal: +Keep these two terminals open: -```shell title="Terminal 1: Egress Gate" +```sh +# Terminal 1 ./demo.sh serve ``` -This starts a managed-harness-only Egress Gate instance and writes content-safe -evaluation records to `/tmp/pi-egress-runtime/egress-gate.jsonl`. Set -`EGRESS_GATE_LOG` to use another path. Request bodies, headers, and message text -are not written to this log. - -Start the matching OpenShell gateway in a second terminal: - -```shell title="Terminal 2: OpenShell gateway" +```sh +# Terminal 2 ./demo.sh gateway ``` -The example uses its own gateway name and passes it explicitly to every -OpenShell command. It does not depend on or change your globally selected -OpenShell gateway. - -After the gateway reports that it is ready, create the demo sandbox from a -third terminal. `reset` is deliberately named: it deletes any prior demo -sandbox and its sessions before uploading the current runtime, configuration, -and workspace. +Then create the sandbox and start a session: -```shell title="Terminal 3: Pi" -./demo.sh reset +```sh +# Terminal 3 +./demo.sh setup +./demo.sh launch ``` -Run the complete non-interactive verification: +The gateway is isolated on port **17672**. Egress Gate listens on **50051** +(authenticated middleware gRPC) and **5443** (authenticated admission HTTPS). +Allow access only from this host/sandbox network. TLS certificates last 30 days. +If these ports are occupied, stop the conflicting demo before starting this one. -```shell title="Terminal 3: verify the example" -./demo.sh verify -``` +Every action is inspectable without executing it, loading `.env`, or printing +secrets: -`verify` runs the real packaged Pi and OpenShell sandbox. It checks a denied -prompt without a session write, a persisted redaction with a successful model -response, an unauthenticated call -to the admission bridge, a raw provider request without an admission handle, -the stock Pi binary without the runtime adapter, and best-effort tool-result -cases. Each case uses a fresh session and prints one `PASS` or `SKIP` line. Tool -cases can skip because choosing to call a tool is model-dependent; the other -cases are required. There is no mock fallback. Run `./demo.sh --print verify` -to inspect every underlying sandbox command. +```sh +./demo.sh --print prepare +./demo.sh --print setup +./demo.sh --print launch +``` -To explore interactively afterward, launch Pi: +Printed commands name credential environment variables; OpenShell reads their +values on the host. The generated admission configuration contains a private +bearer token and must remain outside the image and repository. -```shell title="Terminal 3: Pi" -./demo.sh launch -``` +## What to try -This runs Pi's standard CLI with a trusted runtime extension. Unlike ordinary -user and project extensions, a runtime extension is installed by the launcher, -supplies mandatory runtime boundaries, and is not affected by -`--no-extensions`. Pi still owns argument parsing, the TUI, settings, ordinary -extensions, tools, model selection, compaction, and session storage. `launch` -only enters the existing sandbox; it does not replace the sandbox or Pi's -state. Exit and run `launch` again to use Pi's normal `/resume` flow and -persistent JSONL sessions. Run `reset` only when you intentionally want a fresh -sandbox or need to apply a new runtime, policy, model configuration, credential, -or workspace snapshot. - -The sandbox image adds the `fd` and `rg` executables used by Pi's standard -`find` and `grep` tools. Pi itself still comes from the prepared fork package -and starts without restrictive CLI flags. Its standard user and project -resource discovery, extension loading, tools, model picker, thinking controls, -compaction, and session manager remain active. OpenShell's filesystem and -network policy still apply to every process in the sandbox; arbitrary package -downloads are intentionally outside this endpoint-focused example. - -The example registers an endpoint-specific provider profile using the host-side -`PI_MODEL_API_KEY`. Its `delivery: proxy` setting keeps the credential and any -resolver placeholder out of the sandbox. Pi sends the non-secret placeholder -declared by `models.json`; after admission and middleware processing succeed, -the OpenShell supervisor replaces that authorization header with the real, -endpoint-bound credential immediately before forwarding the request. - -At the Pi prompt, submit both of these in the same session: +Type these into the running application: ```text -Reply with exactly: DENY_THIS +Hello. Briefly describe what you can do. +Please repeat REDACT_THIS. +/history +DENY_THIS +/history +/skill:review +/compact +/history +/exit ``` -```text -Reply with exactly: REDACT_THIS +`DENY_THIS` and `REDACT_THIS` are harmless, literal demonstration markers defined +in [policy.yaml](policy.yaml), not magic Pi/OpenShell features or real secrets. +The first is denied; the second becomes `[REDACTED]` before insertion. +Policy detection is only as good as its configured rules. + +The selected project is `/sandbox/project`, copied from [project/](project/). +The same directory scopes Pi's resource loader, tools, and session store. +Its `AGENTS.md` and skill metadata are admitted as system context. +`/skill:review` loads and renders the actual skill before user-message admission. +The skill asks the model to read the real `notes.txt`; that result is admitted +before the next model call. `cwd` is convenient scoping, not an access-control +boundary: OpenShell's filesystem policy supplies that boundary. + +Responses and tool progress are buffered, not streamed into the transcript. +`/history` shows only admitted active context. The startup message names the +Pi JSONL file under `/sandbox/sessions`. Compaction retains the latest whole turn; +older **approved** entries remain in the append-only file. Automatic compaction +uses the same summary path at a completed-turn context threshold. Ctrl-C cancels +the current operation. An unfinished tool batch that cannot be safely closed +requires a new session. + +## Verify and clean up + +```sh +./demo.sh verify +./demo.sh cleanup ``` -The first submission is denied without starting a model request. The submitted -text is not appended, but Pi does not restore it to the editor after denial. -The second makes a request containing `[REDACTED]`. +Verification uses the **real configured model** and can incur several model +calls and normal provider charges. It checks a raw request without a receipt, +deny/redact history, a real skill/tool continuation, and manual and automatic +compaction. It exits unsuccessfully on any missing capability or failed check; +it does not skip checks or substitute a mock model. Deterministic failure and +pending-admission tests live in [app/test/](app/test/). -To exercise tool-result admission without putting the marker in the user -message, ask Pi: +Cleanup deletes only this demo sandbox and its provider instances/profiles. +**Sandbox files and sessions are deleted and are not recoverable by this script.** +Copy out anything wanted first. Stop the two foreground services with Ctrl-C. +Downloaded artifacts and private host configuration remain in the ignored state +directory for inspection/reuse. Run setup again to create a fresh sandbox. +Changes to model/policy/project files require prepare, a service restart, and a +fresh sandbox (cleanup then setup). -```text -Use bash to print the concatenation of DENY_ and THIS, then tell me the output. -``` +## How the pieces fit -The tool runs, but its result is replaced by Pi's protocol-safe blocked result -before it enters live context. Repeat with `REDACT_` and `THIS` to see the tool -result admitted as `[REDACTED]`. - -Pi uses its standard session manager and JSONL session location, and exposes -the active path to tools as `PI_SESSION_FILE`. Every supported history origin -passes the same generic append boundary before it reaches that history: user -messages, tool results, finalized assistant output, summaries, extension -messages, and bash executions. `launch` preserves the history; `reset` and -`cleanup` delete it with the sandbox. - -## How it works - -1. Pi exposes a provider-neutral `runCli()` entrypoint and a `RuntimeExtension` - interface for mandatory `ContextAdmission` hooks. The TypeScript - [openshell-pi.ts](runtime-extension/openshell-pi.ts) launcher calls that - entrypoint with the OpenShell adapter from - [openshell-context-admission.ts](runtime-extension/openshell-context-admission.ts). - `prepare` type-checks both files against the packaged Pi API and compiles - them to JavaScript for the sandbox. Pi otherwise starts normally, including - standard project and user extension discovery. -2. Pi calls that boundary before each supported message reaches live or - persisted history. Assistant text and tool calls are admitted when the - assistant message is finalized, after streamed output has already been - displayed. Assistant thinking is outside this append-time envelope; request - policy scans it at egress, but the context attestation does not hash it. - Assistant tool calls are inspectable and denyable but immutable; a redaction - targeting one fails closed. -3. OpenShell gives the launched runtime an inherited descriptor containing its - per-exec bridge token. The launcher reads and closes the descriptor and - deletes its environment name before Pi or its extensions start. The external - adapter sends the token with the exact context addition to OpenShell's - sandbox-local bridge. Egress Gate applies `policy.yaml` and returns allow, - deny, or a complete replacement. This append-time checkpoint returns no - attestation or handle. -4. Immediately before every provider request, Pi passes the exact outbound - context through admission. This includes normal turns, retries, compaction, - branch summaries, and contexts restored from a prior session. The adapter - applies per-entry replacements and obtains one fresh handle bound to one hash - of the complete ordered user/tool context. System/developer and assistant - content is scanned by request policy at egress but is not included in that - attested context hash. -5. OpenShell keeps the signed whole-context attestation and gives Pi only the - opaque handle, which the adapter keeps outside Pi messages. At egress, - OpenShell strips the handle and supplies the attestation only to the - configured Egress Gate stage. Egress Gate verifies the same ordered entries - before and after request policy runs, before OpenShell injects the - proxy-delivered model credential. - -The two Pi checkpoints serve different purposes. Append-time admission keeps -the UI, live context, and session file consistent with policy. Provider-context -admission covers every entry actually selected for the request, including -history introduced by retries, compaction, continuations, or session restore. -The egress checkpoint is the enforcement boundary: without a matching fresh -attestation, OpenShell does not attach the credential or forward the request. - -This division is intentional. The Pi fork contributes only reusable harness -primitives: generic append admission for every supported history origin, -including finalized assistant output, summaries, extension messages, and bash -executions; admission of the exact provider context; an outbound-header -transformation; and a standard-CLI entrypoint that accepts those hooks. -OpenShell contributes the sandbox-local bridge, signed attestations, -attestation-to-request binding, middleware enforcement, and post-policy -credential delivery. The TypeScript files under -`runtime-extension/` are the reusable integration layer that translates between -those generic Pi hooks and the OpenShell protocol; no OpenShell-specific code is -built into Pi. - -The supervisor mints a separate admission token for each `sandbox exec`, accepts -it only while that process is running, and rejects bridge calls without a valid -token. Because the launcher consumes the descriptor before starting Pi, tool -subprocesses receive neither the descriptor nor its environment name. Setting -`OPENSHELL_AGENT_ADMISSION_REQUIRE_CALLER_TOKEN=false` when starting the -OpenShell supervisor disables this check for debugging; this example keeps the -secure default. - -## Current scope - -The attestation adapter supports normal text turns, text tool results, queued -steering and follow-up messages, retries, automatic model continuations, -compaction, branch summaries, and restored sessions using the OpenAI Chat -Completions and Responses wire formats. Image inputs are outside this example's -current scope and fail closed. - -Provider-context admission runs before Pi's transport-specific history -rewrites. Switching transports with existing tool history or sending orphaned -tool calls may therefore fail closed. Start a fresh session when switching -transports, and complete each tool-call/result sequence before sending. - -Admission handles and their attestations expire after 300 seconds. Pi refreshes -them immediately before ordinary requests, but a provider retry that begins -more than five minutes later is denied. An Egress Gate started with -`--require-agent-attestation` serves managed harnesses only; an ordinary client -using the same middleware registration is denied because it has no attestation. - -The per-exec token remains in the Pi process's memory. A same-user process that -can read that memory could copy it; the sandbox's process isolation and ptrace -restrictions reduce this residual risk but do not make the token hardware-bound. - -## Cleanup - -Exit Pi, but leave the OpenShell gateway running while cleanup deletes the -sandbox and provider: - -```shell -./demo.sh cleanup +```text +Pi application Egress Gate (outside sandbox) + candidate ------------------> policy: allow / replace / deny + approved entry <-------------+ + | + +--> live context + Pi JSONL + | + next user/tool context ------> policy + signed receipt + model request + receipt + | + v +OpenShell supervisor ----------> verify actual request + policy + | strip receipt header + v +attach real provider key --> model ``` -If the gateway is unavailable, `cleanup` stops before changing anything and -prints the exact commands needed to restart the local services. Cleanup goes -through OpenShell so sandbox and provider state are removed consistently. - -Then stop the OpenShell gateway and Egress Gate with `Ctrl-C`. For another -session in the same prepared sandbox, use `./demo.sh launch` instead of cleanup. +[session.ts](app/src/session.ts) is the only owner of writable history. It uses +Pi's model calls, tool implementations, resource loader, summarizer, and +`SessionManager`; it does not instantiate an autonomous `AgentSession` with +unchecked insertion paths. Finalized assistant text and tool calls are admitted +before execution. Tool output, missing-tool/argument/execution errors, rendered +skills, and completed summaries all pass the same boundary. + +[admission.ts](app/src/admission.ts) translates these candidates into the existing +Egress Gate schemas. A provider-context replacement is rejected: silently +redacting only the outbound request would leave saved history inconsistent. + +[prepare.py](prepare.py) is **trusted host-side operator code**, not the +in-sandbox harness. It provisions policy, TLS, and endpoint-bound provider +profiles. Setup reads the actual sandbox ID from OpenShell and binds the +admission credential to it. The application cannot supply an authoritative +sandbox ID or choose a policy. Upstream credential delivery gives it placeholders, +not the real model/admission secrets. Placeholders are usable capabilities, not +proof of which code used them; the application removes them from child-process +environments as hygiene, not a security boundary. + +The service uses standard OpenShell RPCs and verifies the gateway's signed +extension JWT, including the supervisor's sandbox identity. Its additional +HTTPS listener accepts candidates. There is no loopback bridge, custom RPC, +custom OpenShell protobuf field, or second model proxy. + +## Honest boundaries + +- The local history property holds for this controlled application's write + paths. It is not protection against a compromised application or arbitrary + same-authority code rewriting local files. +- Receipts bind the **ordered user/tool text projection**, destination, sandbox, + policy and expiry—not the full HTTP body, system prompt, assistant history, + model parameters, or proof that an extension ran. Request policy still checks + the intercepted body. Receipts are reusable for identical content for up to + five minutes; service restarts invalidate them. +- One text-only Chat Completions model, sequential tools, and new sessions. + No TUI/RPC parity, third-party extensions, resume/branching, images, reasoning + payloads, WebSockets, or model switching. Unsupported content fails closed. +- Redaction can change ordinary text, not executable tool arguments or call + identifiers. Admitting a tool result cannot reverse tool side effects. + Bash output is bounded before Pi's unchecked spill-to-file behavior. +- There is one final Egress Gate middleware binding. Do not append another + middleware that rewrites receipt-covered content afterward. + +See the [architecture and evidence guide](../../docs/architecture/admission.md) +for the exact contract and the Dev Note narrative. diff --git a/projects/egress-gate/examples/pi-attested-admission/app/package-lock.json b/projects/egress-gate/examples/pi-attested-admission/app/package-lock.json new file mode 100644 index 00000000..286ecaee --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/package-lock.json @@ -0,0 +1,2218 @@ +{ + "name": "pi-admission-example", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "pi-admission-example", + "dependencies": { + "@earendil-works/pi-agent-core": "0.85.1", + "@earendil-works/pi-ai": "0.85.1", + "@earendil-works/pi-coding-agent": "0.85.1", + "undici": "8.9.0" + }, + "devDependencies": { + "@types/node": "22.19.19", + "typescript": "5.9.3" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@anthropic-ai/sdk": { + "version": "0.123.0", + "resolved": "https://registry.npmjs.org/@anthropic-ai/sdk/-/sdk-0.123.0.tgz", + "integrity": "sha512-Y9oX9mPNGZClHQOFqrWRk43Srcu/UHuPq3rfxxOq7JgW0gi+lJA2MAOK4Ul3k/+AUrwRWFJvd0tK3oC0Pw25dw==", + "license": "MIT", + "dependencies": { + "json-schema-to-ts": "^3.1.1", + "standardwebhooks": "^1.0.0" + }, + "bin": { + "anthropic-ai-sdk": "bin/cli" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + }, + "peerDependenciesMeta": { + "zod": { + "optional": true + } + } + }, + "node_modules/@aws-crypto/sha256-browser": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", + "integrity": "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-js": "^5.2.0", + "@aws-crypto/supports-web-crypto": "^5.2.0", + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "@aws-sdk/util-locate-window": "^3.0.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/sha256-js": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-js/-/sha256-js-5.2.0.tgz", + "integrity": "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@aws-crypto/supports-web-crypto": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/supports-web-crypto/-/supports-web-crypto-5.2.0.tgz", + "integrity": "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-crypto/util": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/util/-/util-5.2.0.tgz", + "integrity": "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.222.0", + "@smithy/util-utf8": "^2.0.0", + "tslib": "^2.6.2" + } + }, + "node_modules/@aws-sdk/client-bedrock-runtime": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-bedrock-runtime/-/client-bedrock-runtime-3.1048.0.tgz", + "integrity": "sha512-u+NT61JZEkRFtpL0CAw1N1dwxnaLgwVXQl/zjJxTGgLyS/jTIdg2SdoEoCTHxgDyCnqa1HEi9QOoE9/pYRNpOQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/credential-provider-node": "^3.972.42", + "@aws-sdk/eventstream-handler-node": "^3.972.16", + "@aws-sdk/middleware-eventstream": "^3.972.12", + "@aws-sdk/middleware-websocket": "^3.972.19", + "@aws-sdk/token-providers": "3.1048.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/fetch-http-handler": "^5.4.2", + "@smithy/node-http-handler": "^4.7.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/core": { + "version": "3.977.9", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.977.9.tgz", + "integrity": "sha512-reqPFEQrZxDZpeGj4PFMepBeR5LGYHRqq/L0motTzgFkCRBA4rFdaVXDSLYyGHhxVz7sT2PDnPN9CluGSfgyJA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@aws-sdk/xml-builder": "^3.972.40", + "@aws/lambda-invoke-store": "^0.3.0", + "@smithy/core": "^3.33.3", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "bowser": "^2.11.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-env": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.70.tgz", + "integrity": "sha512-H404B7dJl2mCrBqahDEYsanB0xhdDp6tXnXcTUnXmmpy2Q3J0Ho0bUajZ2jr/RdwzCyS59Gi8xXIFwPLGBl6Uw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http": { + "version": "3.972.72", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.72.tgz", + "integrity": "sha512-X98zYOrVOeuosCX+6ktf29FC2N2GHPLia7qv6mzPzTc+RPAuHWCDS++Z6JK7eGYqb/v6uaW7bAXaOvDBfol+0w==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-http/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-ini": { + "version": "3.973.15", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.973.15.tgz", + "integrity": "sha512-Rykg6s5ceBuynMOGWgoowO4N+27JfnqXAnVaSunZl0hOO1XodSrxGNz6sCEbnmS0lAfQZDKyb3fbr46gSuv6Sg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-login": "^3.972.77", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-login": { + "version": "3.972.77", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.77.tgz", + "integrity": "sha512-Jb59xfEISoN5mmbnA+HYqdtrSX3CgCtJoof+V5D8/TgUI56W63GEEd5Y58WijU3Ou6+WEgaLD1feVzaRXV5IDQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-node": { + "version": "3.972.82", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.82.tgz", + "integrity": "sha512-znDkEOGXB8W3kG1LJUKP3foBZY/9qLM0eil/DxWXSp37XsdsRLQHE/d/OaCGGVgKpA6znR38h/+INk8do1FjiA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/credential-provider-env": "^3.972.70", + "@aws-sdk/credential-provider-http": "^3.972.72", + "@aws-sdk/credential-provider-ini": "^3.973.15", + "@aws-sdk/credential-provider-process": "^3.972.70", + "@aws-sdk/credential-provider-sso": "^3.973.14", + "@aws-sdk/credential-provider-web-identity": "^3.972.76", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/credential-provider-imds": "^4.4.16", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-process": { + "version": "3.972.70", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.70.tgz", + "integrity": "sha512-2ry03fGRJr4sV3jI+ocjj5JqALnFD6ymM5KiNCDZMvq8bX2GSbE0vji4aM43TVCl2nXqqLRZaUxdq/KeWRAY4Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso": { + "version": "3.973.14", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.973.14.tgz", + "integrity": "sha512-jkhg/8ocAAoc0RFyLMhCw+/zZh7gystQgd4F4hznNa8P4Cc501PQmxd+jGLiMHodPJ+7Zv/3znM62gZojyasmA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/token-providers": "3.1116.0", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-sso/node_modules/@aws-sdk/token-providers": { + "version": "3.1116.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1116.0.tgz", + "integrity": "sha512-ygIivKqh8aHzNkucOCXHyIBgBpLPfrSI0mCqXF+vLBsPTUKqj0VSqAY0GFPe7lQl4HntjOcQ+KSyS7oUV2C54Q==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/credential-provider-web-identity": { + "version": "3.972.76", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.76.tgz", + "integrity": "sha512-d3AGyVu759PGr35mEB2s22xxlNEA5rpdxtSPJthfPFJvoQ8dt357iVPECqWfUxXp1toJAvKmbtcIYVGigaGsCA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/nested-clients": "^3.997.44", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/eventstream-handler-node": { + "version": "3.972.34", + "resolved": "https://registry.npmjs.org/@aws-sdk/eventstream-handler-node/-/eventstream-handler-node-3.972.34.tgz", + "integrity": "sha512-cTeVzpu1xEAkryTZBYhGwnQ6gOGyp8ZYZvmn0Sg/nI/ABmy/CRHHxPDJDUi9PxwxUtGGaatvfRUB3FCgT/rSWw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-eventstream": { + "version": "3.972.29", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-eventstream/-/middleware-eventstream-3.972.29.tgz", + "integrity": "sha512-dlRzHCgyB8W6hLuDC5pcT5q+ziPt00n4QGgGBE17ucLVU4zMa6lsbuUdQ2Pm75Z5VA8GF+R/+SgrRcaTdIzSIQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/middleware-websocket": { + "version": "3.972.52", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-websocket/-/middleware-websocket-3.972.52.tgz", + "integrity": "sha512-vsPPM+nMbKJlUCFU+eoGZbdxdxDIAX9LbpjSXaR5Ufpmqgp8TdYQnoExhLu4T3umW/JIIPny1ydbhWidZZYokQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">= 14.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients": { + "version": "3.997.44", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.997.44.tgz", + "integrity": "sha512-NhEgryjlBF9w38ZXqGymQV28IhkYa1mKhlbYnqIis57AYwWGVYfUPgg/qC2rLRqOUfblxx++irvju10kVTa8Vw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.977.9", + "@aws-sdk/signature-v4-multi-region": "^3.996.46", + "@aws-sdk/types": "^3.974.5", + "@smithy/core": "^3.33.3", + "@smithy/fetch-http-handler": "^5.7.2", + "@smithy/node-http-handler": "^4.11.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/nested-clients/node_modules/@smithy/node-http-handler": { + "version": "4.12.1", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.12.1.tgz", + "integrity": "sha512-ThMkboGeONWXAelq9FvGsuJC4rOi+qyC4/zhUF58xYpxUg5sQKx2VXZYJmtNjr4dSuBJ1HeJXETQILCz3wOHvw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@aws-sdk/signature-v4-multi-region": { + "version": "3.996.46", + "resolved": "https://registry.npmjs.org/@aws-sdk/signature-v4-multi-region/-/signature-v4-multi-region-3.996.46.tgz", + "integrity": "sha512-L+2xZTye/2T96f3lwCws0Zw6GG2JHZW9e8FpVgGBeeExSKyeoZ6CWRpBml/7DNiK/O26jrgPM9F+Ay8VkgzUWQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/types": "^3.974.5", + "@smithy/signature-v4": "^5.6.12", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/token-providers": { + "version": "3.1048.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1048.0.tgz", + "integrity": "sha512-k0y/GcuesuSfWyUM0WamrGyeZmltRYaPbHO82UDA6mZ/doB+FOHKutikPAtSXMn/hDz970cF+iRuuiYO9VEbAA==", + "license": "Apache-2.0", + "dependencies": { + "@aws-sdk/core": "^3.974.11", + "@aws-sdk/nested-clients": "^3.997.9", + "@aws-sdk/types": "^3.973.8", + "@smithy/core": "^3.24.2", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/types": { + "version": "3.974.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.974.5.tgz", + "integrity": "sha512-LkwLL2BLbC6wNNm4JaH9mbEqBMdOZCct6VAYqhdN4U1xrWM+fUJQEfbHwQgDypapOWTRtlk25akb5afM0P8CIQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/util-locate-window": { + "version": "3.965.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-locate-window/-/util-locate-window-3.965.10.tgz", + "integrity": "sha512-ycwH6Zd2GhuSqdXX9ihbCjeGTB6xOJs+O3+Jb8/zDG9978XU80qs75dfkPJRMNKe5MvBZPuNeFpd4JZKPoUF4g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws-sdk/xml-builder": { + "version": "3.972.40", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.40.tgz", + "integrity": "sha512-wlFmCIGUlwF4zx/kncw+bmxTQh1HeSJq4mYV/V5cZUSJadDP3kXvGW8Rn21cimj/7y9ju+47oYWXi97vF7czaA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, + "node_modules/@aws/lambda-invoke-store": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@aws/lambda-invoke-store/-/lambda-invoke-store-0.3.0.tgz", + "integrity": "sha512-sl4Bm6yiMNYrZKkqqDFWN0UfnWhlS8ivKxrYl+6t0gCLrqr8y3B2IqZZbFRkfaVVp7C/baApyh71P+LeE1A2sQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@earendil-works/chord": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/chord/-/chord-0.85.1.tgz", + "integrity": "sha512-VDlkEC3dhCzQ5fcyH1OhG19dq+6jCn+rqc/iXFivwDYGR5anwo2RCiXij9PpHhqNR5GuhhE+Er69Zi1Sn4eY6w==", + "license": "MIT", + "dependencies": { + "esbuild": "0.28.1" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-agent-core": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-agent-core/-/pi-agent-core-0.85.1.tgz", + "integrity": "sha512-hIXIP3eAWueAYiAl8aMvWCvvZ8Q5gT3Dip5bE5uJyIGh4+YlWRjtMLI4BaeoXoSs93zndjue61u1B/vhefLnuA==", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.85.1", + "@earendil-works/pi-ai": "^0.85.1", + "@earendil-works/pi-telemetry": "^0.85.1", + "diff": "8.0.4", + "ignore": "7.0.5", + "typebox": "1.3.7", + "yaml": "2.9.0" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-ai": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-ai/-/pi-ai-0.85.1.tgz", + "integrity": "sha512-+VgVIJDkDO2efYJKEEqvPTH4zmnIaXdAppGbO+vKFA9qy5PdhFiAenuFAkU+oiCSfOC4dMHDyrjdQeL4ZoC5CQ==", + "license": "MIT", + "dependencies": { + "@anthropic-ai/sdk": "0.123.0", + "@aws-sdk/client-bedrock-runtime": "3.1048.0", + "@earendil-works/pi-telemetry": "^0.85.1", + "@google/genai": "1.52.0", + "@smithy/node-http-handler": "4.7.3", + "http-proxy-agent": "7.0.2", + "https-proxy-agent": "7.0.6", + "openai": "6.40.0", + "partial-json": "0.1.7", + "typebox": "1.3.7" + }, + "bin": { + "pi-ai": "dist/cli.js" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-coding-agent": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-coding-agent/-/pi-coding-agent-0.85.1.tgz", + "integrity": "sha512-FGRN+OHbWaefBPGaTggAdLjrIHW+s2PzLyglz/5dfLzb9of7uuXMXYC0fJIeZTw+shS32o2cuQ9jF7YSDuL/oQ==", + "license": "MIT", + "dependencies": { + "@earendil-works/chord": "^0.85.1", + "@earendil-works/pi-agent-core": "^0.85.1", + "@earendil-works/pi-ai": "^0.85.1", + "@earendil-works/pi-tui": "^0.85.1", + "@silvia-odwyer/photon-node": "0.3.4", + "chalk": "5.6.2", + "cross-spawn": "7.0.6", + "diff": "8.0.4", + "grok-mermaid": "0.2.2", + "highlight.js": "10.7.3", + "hosted-git-info": "9.0.3", + "ignore": "7.0.5", + "jiti": "2.7.0", + "minimatch": "10.2.5", + "proper-lockfile": "4.1.2", + "semver": "7.8.0", + "typebox": "1.3.7", + "undici": "8.9.0", + "yaml": "2.9.0" + }, + "bin": { + "pi": "dist/bundle/cli.js" + }, + "engines": { + "node": ">=22.19.0" + }, + "optionalDependencies": { + "@mariozechner/clipboard": "0.3.9" + } + }, + "node_modules/@earendil-works/pi-telemetry": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-telemetry/-/pi-telemetry-0.85.1.tgz", + "integrity": "sha512-Bg/YN6kA7Swja/NQxka8xFdecb4E/auIEGF2G5A25EaQXhRnPj300/7/KpgsDDMYUzHTDAv4RyUxaQPJKW81Rw==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@earendil-works/pi-tui": { + "version": "0.85.1", + "resolved": "https://registry.npmjs.org/@earendil-works/pi-tui/-/pi-tui-0.85.1.tgz", + "integrity": "sha512-OIzw9efInmO4WOBnD4TxcTdBjmzvYJpzslkgoUro946nEGoYWg5rwv1p4fDt3/JvMx9QybryUCUwlm7j8Dreig==", + "license": "MIT", + "dependencies": { + "get-east-asian-width": "1.6.0", + "marked": "18.0.5" + }, + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.1.tgz", + "integrity": "sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.1.tgz", + "integrity": "sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.1.tgz", + "integrity": "sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.1.tgz", + "integrity": "sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.1.tgz", + "integrity": "sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.1.tgz", + "integrity": "sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.1.tgz", + "integrity": "sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.1.tgz", + "integrity": "sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.1.tgz", + "integrity": "sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.1.tgz", + "integrity": "sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.1.tgz", + "integrity": "sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.1.tgz", + "integrity": "sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==", + "cpu": [ + "loong64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.1.tgz", + "integrity": "sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==", + "cpu": [ + "mips64el" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.1.tgz", + "integrity": "sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==", + "cpu": [ + "ppc64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.1.tgz", + "integrity": "sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==", + "cpu": [ + "riscv64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.1.tgz", + "integrity": "sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==", + "cpu": [ + "s390x" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.1.tgz", + "integrity": "sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.1.tgz", + "integrity": "sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.1.tgz", + "integrity": "sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.1.tgz", + "integrity": "sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.1.tgz", + "integrity": "sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/openharmony-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.1.tgz", + "integrity": "sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.1.tgz", + "integrity": "sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.1.tgz", + "integrity": "sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.1.tgz", + "integrity": "sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.1.tgz", + "integrity": "sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=18" + } + }, + "node_modules/@google/genai": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/@google/genai/-/genai-1.52.0.tgz", + "integrity": "sha512-gwSvbpiN/17O9TbsqSsE/OzZcpv5Fo4RQjdngGgogtuB9RsyJ8ZHhX5KjHj1bp5N9snN2eK8LDGXSaWW2hof8Q==", + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "google-auth-library": "^10.3.0", + "p-retry": "^4.6.2", + "protobufjs": "^7.5.4", + "ws": "^8.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "@modelcontextprotocol/sdk": "^1.25.2" + }, + "peerDependenciesMeta": { + "@modelcontextprotocol/sdk": { + "optional": true + } + } + }, + "node_modules/@mariozechner/clipboard": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard/-/clipboard-0.3.9.tgz", + "integrity": "sha512-ABnA53mdfkGZwOFUdZNv2S0CWGO/EIuPj8Vv9xmBFmSYg/qFc7ihO6q5FcQjvoE67kZpWkEc4AhD6B/os04yuA==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">= 10" + }, + "optionalDependencies": { + "@mariozechner/clipboard-darwin-arm64": "0.3.9", + "@mariozechner/clipboard-darwin-universal": "0.3.9", + "@mariozechner/clipboard-darwin-x64": "0.3.9", + "@mariozechner/clipboard-linux-arm64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-arm64-musl": "0.3.9", + "@mariozechner/clipboard-linux-riscv64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-gnu": "0.3.9", + "@mariozechner/clipboard-linux-x64-musl": "0.3.9", + "@mariozechner/clipboard-win32-arm64-msvc": "0.3.9", + "@mariozechner/clipboard-win32-x64-msvc": "0.3.9" + } + }, + "node_modules/@mariozechner/clipboard-darwin-arm64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-arm64/-/clipboard-darwin-arm64-0.3.9.tgz", + "integrity": "sha512-BfgV7vCEWZwJwZJw03r6bP5+tf0iI/ANuQYCxi9RNn7FrWB3yzGuMKCrNLRl6V761vXRdL8+OqZ0wd4TqlsNOQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-universal": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-universal/-/clipboard-darwin-universal-0.3.9.tgz", + "integrity": "sha512-BGGR4iA9Z2shAjI65eI5xtyb3LYNlDW9X3gxKxDbqtbnREohsrqznov6zpKoIrsRWpzlYVEdKphS7ksJ0/ndSQ==", + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-darwin-x64": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-darwin-x64/-/clipboard-darwin-x64-0.3.9.tgz", + "integrity": "sha512-4kURmCbS6nt8uYhtmWpUcJWyPHfmAr5dTpXD1nO3pIfa+TSQ9DbrGOYCKH+aEFW47XhQ4Vp8ZTszie+wfFvDKg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-gnu/-/clipboard-linux-arm64-gnu-0.3.9.tgz", + "integrity": "sha512-g59OkUGP2DDfCOIKypHeYgv2M55u/cKvXa5dSxFbEJ34XvIQMdcVmpKCkGUro3ZgefXiGVdwguvTMQGpHWzIXw==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-arm64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-arm64-musl/-/clipboard-linux-arm64-musl-0.3.9.tgz", + "integrity": "sha512-AGuJdgKsmJdm4Pych7kv3sqe591ERRaAHW3xjLooiFzn8J+PxUyof++7YZrB5Y5tpnTO+K18Og3taj2NpluCRQ==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-riscv64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-riscv64-gnu/-/clipboard-linux-riscv64-gnu-0.3.9.tgz", + "integrity": "sha512-DXBEAiuMpk7dhS1a9NzNxVAFi1vaKoPu7rQNgY8LIDLGrK3lnIp3nT10DUum+PKVJoJppIP+NAA8IZe4DMNDPw==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-gnu": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-gnu/-/clipboard-linux-x64-gnu-0.3.9.tgz", + "integrity": "sha512-WORrMLd6EpElEME7JRKfSaY34nW1P5LbdgK5YNCS1ncG2LqmITsSMEJ8nh2mpvxb3TxqbOOKgY7k9eMJYlW9Mw==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-linux-x64-musl": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-linux-x64-musl/-/clipboard-linux-x64-musl-0.3.9.tgz", + "integrity": "sha512-/DHn+1DrfL6oRaPPWXaOKvonFFrni666fxd+zFqiQEfvBH0tsHVWjq9iqBk0oDp0qaPA72lIMy5BptxISBEhZQ==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-arm64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-arm64-msvc/-/clipboard-win32-arm64-msvc-0.3.9.tgz", + "integrity": "sha512-O5FHD3ErkMwMhNzAfu3ggy0ug4z7btZuoQgwwxlzPrwV2bxlD6WDpqBY4NCgICAgZdDKdp+loUEKVAVt8aYnhQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@mariozechner/clipboard-win32-x64-msvc": { + "version": "0.3.9", + "resolved": "https://registry.npmjs.org/@mariozechner/clipboard-win32-x64-msvc/-/clipboard-win32-x64-msvc-0.3.9.tgz", + "integrity": "sha512-ihQC3EufqEY81vhXBgVBtK4prL+wc62zJsSvxrgz7K1hsdt6OObz6v9p3Rn1OG3GJksTTKMJF0u/guMISHPhSA==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.5.tgz", + "integrity": "sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.1.tgz", + "integrity": "sha512-vW1GmwMZNnL+gMRaovlh9yZX74kc+TTU3FObkkurpMaRtBfLP3ldjS9KQWlwZgraRE0+dheEEoAxdzcJQ8eXZg==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.1.tgz", + "integrity": "sha512-GpptLrs57adMSuHi3VNj0mAF8dwh36LMaYF6XyJ6JMWlVsc+t42tm1HSEDmOs3A8fC9yyeisgLhsTVQokOZ0zw==", + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.1" + } + }, + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==", + "license": "BSD-3-Clause" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.2.tgz", + "integrity": "sha512-b1UQwcEZ4yCnMCD8DAL1VlbvBJE9/IX4FTIp7BG1xYpf29SLazLSrqUkj4w7Y5y7cCVP6E5tcqqcI0xemPkHug==", + "license": "BSD-3-Clause" + }, + "node_modules/@silvia-odwyer/photon-node": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/@silvia-odwyer/photon-node/-/photon-node-0.3.4.tgz", + "integrity": "sha512-bnly4BKB3KDTFxrUIcgCLbaeVVS8lrAkri1pEzskpmxu9MdfGQTy8b8EgcD83ywD3RPMsIulY8xJH5Awa+t9fA==", + "license": "Apache-2.0" + }, + "node_modules/@smithy/core": { + "version": "3.33.3", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.33.3.tgz", + "integrity": "sha512-CsOeKq/9kA3y6VJHt+/+VTCtBaxJ4OTFpgrjIUhPpDIKxBci1k2bJaQASF2h/ELWrulGp+t97DZ0mevfAD8idg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/credential-provider-imds": { + "version": "4.5.2", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.5.2.tgz", + "integrity": "sha512-A9uSdn72ozbRUSit0eib0TW7nXuNPlaeM0zcGkJ+nE6tFcSDbnmtwoxbTCFBukVQcszDAyvsd7+rTduPTXpygg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.2", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/fetch-http-handler": { + "version": "5.8.0", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.8.0.tgz", + "integrity": "sha512-ycSJu3tFAQ4v04CBB0agqFMVsSQ1iG3yw+SpgxRqKfaURpQD4CZ8Wn0zPMmSnOuTpTh65Vz+EA0rMrw089wvkA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.18.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/is-array-buffer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/is-array-buffer/-/is-array-buffer-2.2.0.tgz", + "integrity": "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/node-http-handler": { + "version": "4.7.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.7.3.tgz", + "integrity": "sha512-/jPhevcTFPMVl6KNjbaI47iOg1zxC7IsnX4PQDGVZKMFceOXtB8IEYaB7a9VvkP/3oC60WzTeKocvSI7vLT0vA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.24.3", + "@smithy/types": "^4.14.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/signature-v4": { + "version": "5.7.3", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.7.3.tgz", + "integrity": "sha512-7ImGm+FkHRLcBaRttIAMZ6bzJZWb2cJGoYjq46F2UjycujWzrL9GEN9h4w7eQyXJYnltrUhxbbieBAIRrdqpow==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/core": "^3.33.3", + "@smithy/types": "^4.17.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/types": { + "version": "4.18.0", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.18.0.tgz", + "integrity": "sha512-CgB6HHWer/vrKps24ulRIbpcpb7K4xAU7SkZ7YHzBPlwHsvsrCJFEXK421s+cJzX+ZrqtA/TuU5w1HzI7k9N8A==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/util-buffer-from": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@smithy/util-buffer-from/-/util-buffer-from-2.2.0.tgz", + "integrity": "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/is-array-buffer": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@smithy/util-utf8": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@smithy/util-utf8/-/util-utf8-2.3.0.tgz", + "integrity": "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/util-buffer-from": "^2.2.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@stablelib/base64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@stablelib/base64/-/base64-1.0.1.tgz", + "integrity": "sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==", + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "22.19.19", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.19.19.tgz", + "integrity": "sha512-dyh/xO2Fh5bYrfWaaqGrRQQGkNdmYw6AmaAUvYeUMNTWQtvb796ikLdmTchRmOlOiIJ1TDXfWgVx1QkUlQ6Hew==", + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/@types/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-wWKOClTTiizcZhXnPY4wikVAwmdYHp8q6DmC+EJUzAMsycb7HB32Kh9RN4+0gExjmPmZSAQjgURXIGATPegAvA==", + "license": "MIT" + }, + "node_modules/agent-base": { + "version": "7.1.4", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-7.1.4.tgz", + "integrity": "sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==", + "license": "MIT", + "engines": { + "node": ">= 14" + } + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/bignumber.js": { + "version": "9.3.1", + "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.3.1.tgz", + "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ==", + "license": "MIT", + "engines": { + "node": "*" + } + }, + "node_modules/bowser": { + "version": "2.14.1", + "resolved": "https://registry.npmjs.org/bowser/-/bowser-2.14.1.tgz", + "integrity": "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg==", + "license": "MIT" + }, + "node_modules/brace-expansion": { + "version": "5.0.9", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", + "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/buffer-equal-constant-time": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/buffer-equal-constant-time/-/buffer-equal-constant-time-1.0.1.tgz", + "integrity": "sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==", + "license": "BSD-3-Clause" + }, + "node_modules/chalk": { + "version": "5.6.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.6.2.tgz", + "integrity": "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA==", + "license": "MIT", + "engines": { + "node": "^12.17.0 || ^14.13 || >=16.0.0" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "license": "MIT", + "engines": { + "node": ">= 12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/diff": { + "version": "8.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-8.0.4.tgz", + "integrity": "sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.3.1" + } + }, + "node_modules/ecdsa-sig-formatter": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/ecdsa-sig-formatter/-/ecdsa-sig-formatter-1.0.11.tgz", + "integrity": "sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==", + "license": "Apache-2.0", + "dependencies": { + "safe-buffer": "^5.0.1" + } + }, + "node_modules/esbuild": { + "version": "0.28.1", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.1.tgz", + "integrity": "sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==", + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=18" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.28.1", + "@esbuild/android-arm": "0.28.1", + "@esbuild/android-arm64": "0.28.1", + "@esbuild/android-x64": "0.28.1", + "@esbuild/darwin-arm64": "0.28.1", + "@esbuild/darwin-x64": "0.28.1", + "@esbuild/freebsd-arm64": "0.28.1", + "@esbuild/freebsd-x64": "0.28.1", + "@esbuild/linux-arm": "0.28.1", + "@esbuild/linux-arm64": "0.28.1", + "@esbuild/linux-ia32": "0.28.1", + "@esbuild/linux-loong64": "0.28.1", + "@esbuild/linux-mips64el": "0.28.1", + "@esbuild/linux-ppc64": "0.28.1", + "@esbuild/linux-riscv64": "0.28.1", + "@esbuild/linux-s390x": "0.28.1", + "@esbuild/linux-x64": "0.28.1", + "@esbuild/netbsd-arm64": "0.28.1", + "@esbuild/netbsd-x64": "0.28.1", + "@esbuild/openbsd-arm64": "0.28.1", + "@esbuild/openbsd-x64": "0.28.1", + "@esbuild/openharmony-arm64": "0.28.1", + "@esbuild/sunos-x64": "0.28.1", + "@esbuild/win32-arm64": "0.28.1", + "@esbuild/win32-ia32": "0.28.1", + "@esbuild/win32-x64": "0.28.1" + } + }, + "node_modules/extend": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", + "integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==", + "license": "MIT" + }, + "node_modules/fast-sha256": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/fast-sha256/-/fast-sha256-1.3.0.tgz", + "integrity": "sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==", + "license": "Unlicense" + }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "license": "MIT", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/gaxios": { + "version": "7.3.1", + "resolved": "https://registry.npmjs.org/gaxios/-/gaxios-7.3.1.tgz", + "integrity": "sha512-kB3rzJV7d9juLZh8/56QTXCwQfxyhdOMdyYk1HdQKFtF8TJTDTZQJtixWIwXdE9Jji91mC41DUNpjleo4L4eAQ==", + "license": "Apache-2.0", + "dependencies": { + "extend": "^3.0.2", + "https-proxy-agent": "^7.0.1", + "node-fetch": "^3.3.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/gcp-metadata": { + "version": "8.1.2", + "resolved": "https://registry.npmjs.org/gcp-metadata/-/gcp-metadata-8.1.2.tgz", + "integrity": "sha512-zV/5HKTfCeKWnxG0Dmrw51hEWFGfcF2xiXqcA3+J90WDuP0SvoiSO5ORvcBsifmx/FoIjgQN3oNOGaQ5PhLFkg==", + "license": "Apache-2.0", + "dependencies": { + "gaxios": "^7.0.0", + "google-logging-utils": "^1.0.0", + "json-bigint": "^1.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/get-east-asian-width": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/get-east-asian-width/-/get-east-asian-width-1.6.0.tgz", + "integrity": "sha512-QRbvDIbx6YklUe6RxeTeleMR0yv3cYH6PsPZHcnVn7xv7zO1BHN8r0XETu8n6Ye3Q+ahtSarc3WgtNWmehIBfA==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/google-auth-library": { + "version": "10.9.1", + "resolved": "https://registry.npmjs.org/google-auth-library/-/google-auth-library-10.9.1.tgz", + "integrity": "sha512-i1ydyHrqcIxXkWh/uBmVkzCvIuq5yiK2ATndIe5XxKholrG/MTYP9xGYka4sQhrbIAgGjL2B6NOE7rFaiF3fXw==", + "license": "Apache-2.0", + "dependencies": { + "base64-js": "^1.3.0", + "ecdsa-sig-formatter": "^1.0.11", + "gaxios": "^7.1.4", + "gcp-metadata": "8.1.2", + "google-logging-utils": "1.1.3", + "jws": "^4.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/google-logging-utils": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/google-logging-utils/-/google-logging-utils-1.1.3.tgz", + "integrity": "sha512-eAmLkjDjAFCVXg7A1unxHsLf961m6y17QFqXqAXGj/gVkKFrEICfStRfwUlGNfeCEjNRa32JEWOUTlYXPyyKvA==", + "license": "Apache-2.0", + "engines": { + "node": ">=14" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "license": "ISC" + }, + "node_modules/grok-mermaid": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/grok-mermaid/-/grok-mermaid-0.2.2.tgz", + "integrity": "sha512-XcJEP5dDC8liHBh52mlLjU18fNvu1ckFsu0QpIG3+APZ270fsj9wxpiA6cOURmbUEuoMVgjbC2+UYgTdCqqgzA==", + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/highlight.js": { + "version": "10.7.3", + "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-10.7.3.tgz", + "integrity": "sha512-tzcUFauisWKNHaRkN4Wjl/ZA07gENAjFl3J/c480dprkGTg5EQstgaNFqBfUqCq54kZRIEcreTsAgF/m2quD7A==", + "license": "BSD-3-Clause", + "engines": { + "node": "*" + } + }, + "node_modules/hosted-git-info": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-9.0.3.tgz", + "integrity": "sha512-Hc+ghLoSt6QaYZUv0WBiIvmMDZuZZ7oaDvdH8MbfOO4lOsxdXLEvuC6ePoGs9H1X9oCLyq6+NVN0MKqD+ydxyg==", + "license": "ISC", + "dependencies": { + "lru-cache": "^11.1.0" + }, + "engines": { + "node": "^20.17.0 || >=22.9.0" + } + }, + "node_modules/http-proxy-agent": { + "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.0", + "debug": "^4.3.4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/https-proxy-agent": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-7.0.6.tgz", + "integrity": "sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==", + "license": "MIT", + "dependencies": { + "agent-base": "^7.1.2", + "debug": "4" + }, + "engines": { + "node": ">= 14" + } + }, + "node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "license": "ISC" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/json-bigint": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/json-bigint/-/json-bigint-1.0.0.tgz", + "integrity": "sha512-SiPv/8VpZuWbvLSMtTDU8hEfrZWg/mH/nV/b4o0CYbSxu1UIQPLdwKOCIyLQX+VIPO5vrLX3i8qtqFyhdPSUSQ==", + "license": "MIT", + "dependencies": { + "bignumber.js": "^9.0.0" + } + }, + "node_modules/json-schema-to-ts": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/json-schema-to-ts/-/json-schema-to-ts-3.1.1.tgz", + "integrity": "sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.18.3", + "ts-algebra": "^2.0.0" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/jwa": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.1.tgz", + "integrity": "sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==", + "license": "MIT", + "dependencies": { + "buffer-equal-constant-time": "^1.0.1", + "ecdsa-sig-formatter": "1.0.11", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/jws": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.1.tgz", + "integrity": "sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==", + "license": "MIT", + "dependencies": { + "jwa": "^2.0.1", + "safe-buffer": "^5.0.1" + } + }, + "node_modules/long": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/long/-/long-5.3.2.tgz", + "integrity": "sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==", + "license": "Apache-2.0" + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.2.tgz", + "integrity": "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/marked": { + "version": "18.0.5", + "resolved": "https://registry.npmjs.org/marked/-/marked-18.0.5.tgz", + "integrity": "sha512-S6GcvALHg6K4ohtu4E7x0a1AqhAjp6cV8KhLSyN9qVapnzJkusVBxZRcIU9AeYsbe6P1hKDusSbEOzGyyuce6w==", + "license": "MIT", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 20" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "deprecated": "Use your platform's native DOMException instead", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "license": "MIT", + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "license": "MIT", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" + }, + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" + } + }, + "node_modules/openai": { + "version": "6.40.0", + "resolved": "https://registry.npmjs.org/openai/-/openai-6.40.0.tgz", + "integrity": "sha512-MWtTjd/gQt4jpbji61NTgFWJLoY/PdRJ6wG9/ZDRMYNMlBKrCrSlkLI+KgHP1vR1qT6LKSAyAqIxno6lcK9JiA==", + "license": "Apache-2.0", + "peerDependencies": { + "ws": "^8.18.0", + "zod": "^3.25 || ^4.0" + }, + "peerDependenciesMeta": { + "ws": { + "optional": true + }, + "zod": { + "optional": true + } + } + }, + "node_modules/p-retry": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/p-retry/-/p-retry-4.6.2.tgz", + "integrity": "sha512-312Id396EbJdvRONlngUx0NydfrIQ5lsYu0znKVUzVvArzEIt08V1qhtyESbGVd1FGX7UKtiFp5uwKZdM8wIuQ==", + "license": "MIT", + "dependencies": { + "@types/retry": "0.12.0", + "retry": "^0.13.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/partial-json": { + "version": "0.1.7", + "resolved": "https://registry.npmjs.org/partial-json/-/partial-json-0.1.7.tgz", + "integrity": "sha512-Njv/59hHaokb/hRUjce3Hdv12wd60MtM9Z5Olmn+nehe0QDAsRtRbJPvJ0Z91TusF0SuZRIvnM+S4l6EIP8leA==", + "license": "MIT" + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/proper-lockfile": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/proper-lockfile/-/proper-lockfile-4.1.2.tgz", + "integrity": "sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==", + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "retry": "^0.12.0", + "signal-exit": "^3.0.2" + } + }, + "node_modules/proper-lockfile/node_modules/retry": { + "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/protobufjs": { + "version": "7.6.6", + "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.6.6.tgz", + "integrity": "sha512-dYDWdjSl5RNb7SgPxGQcRU+GtvP7s2fpkrY0r432PcOIaZ0/rBcxEZnQN67iJhFuQiVw754JDoPruPCNdGsbjg==", + "hasInstallScript": true, + "license": "BSD-3-Clause", + "dependencies": { + "@protobufjs/aspromise": "^1.1.2", + "@protobufjs/base64": "^1.1.2", + "@protobufjs/codegen": "^2.0.5", + "@protobufjs/eventemitter": "^1.1.1", + "@protobufjs/fetch": "^1.1.1", + "@protobufjs/float": "^1.0.2", + "@protobufjs/path": "^1.1.2", + "@protobufjs/pool": "^1.1.0", + "@protobufjs/utf8": "^1.1.1", + "@types/node": ">=13.7.0", + "long": "^5.3.2" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/retry": { + "version": "0.13.1", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.13.1.tgz", + "integrity": "sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==", + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, + "node_modules/standardwebhooks": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/standardwebhooks/-/standardwebhooks-1.1.1.tgz", + "integrity": "sha512-bCbX9ZEyFkWPsRz7Bl3NuQUJohmwGSev/yhr7vhaGPlc4AfIrspIRa6cPTBuI1ItmrTDJ4d/S2hCsfe4+vQGnQ==", + "license": "MIT", + "dependencies": { + "@stablelib/base64": "^1.0.0", + "fast-sha256": "^1.3.0" + } + }, + "node_modules/ts-algebra": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/ts-algebra/-/ts-algebra-2.0.0.tgz", + "integrity": "sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==", + "license": "MIT" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/typebox": { + "version": "1.3.7", + "resolved": "https://registry.npmjs.org/typebox/-/typebox-1.3.7.tgz", + "integrity": "sha512-meKuifc33Pccx0O6PdIzYMq3Og8zvP4TIi/a+Bw3AEMZMxOD0+RHGQvpglEe6Zdy3wZ8nqn/j95h8LUZLk/6Hg==", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "8.9.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-8.9.0.tgz", + "integrity": "sha512-aWZpUj7XoGonMClx4gdDRfgBjqeA+F473aDmROQQbM9n6PRfK/u1q/a0X4wMTgcHfT8H6fpbt98PFuDUwFg2YA==", + "license": "MIT", + "engines": { + "node": ">=22.19.0" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "license": "MIT" + }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/ws": { + "version": "8.21.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz", + "integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/yaml": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.9.0.tgz", + "integrity": "sha512-2AvhNX3mb8zd6Zy7INTtSpl1F15HW6Wnqj0srWlkKLcpYl/gMIMJiyuGq2KeI2YFxUPjdlB+3Lc10seMLtL4cA==", + "license": "ISC", + "bin": { + "yaml": "bin.mjs" + }, + "engines": { + "node": ">= 14.6" + }, + "funding": { + "url": "https://github.com/sponsors/eemeli" + } + } + } +} diff --git a/projects/egress-gate/examples/pi-attested-admission/app/package.json b/projects/egress-gate/examples/pi-attested-admission/app/package.json new file mode 100644 index 00000000..a1fbfd80 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/package.json @@ -0,0 +1,24 @@ +{ + "name": "pi-admission-example", + "private": true, + "type": "module", + "engines": { + "node": ">=22.19.0" + }, + "scripts": { + "build": "tsc", + "check": "tsc --noEmit", + "test": "node dist/test/session.test.js && node dist/test/admission.test.js", + "start": "node dist/src/cli.js" + }, + "dependencies": { + "@earendil-works/pi-agent-core": "0.85.1", + "@earendil-works/pi-ai": "0.85.1", + "@earendil-works/pi-coding-agent": "0.85.1", + "undici": "8.9.0" + }, + "devDependencies": { + "@types/node": "22.19.19", + "typescript": "5.9.3" + } +} diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/admission.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/admission.ts new file mode 100644 index 00000000..a8fd8e16 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/admission.ts @@ -0,0 +1,295 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; +import { isDeepStrictEqual } from "node:util"; +import type { + Context, + Message, + TextContent, + ToolResultMessage, +} from "@earendil-works/pi-ai"; + +export const RECEIPT_HEADER = "x-egress-admission"; +export const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; + +export type TextOrigin = "user" | "system" | "compaction_summary"; +export type AdmissionKind = + | "user_message" + | "system_context" + | "compaction_summary" + | "assistant_message" + | "tool_result" + | "provider_context"; +export type AdmissionResponse = { + decision: "allow" | "replace" | "deny"; + replacement: Record | null; + receipt: string | null; +}; +export type Evaluate = ( + kind: AdmissionKind, + body: Record, + signal?: AbortSignal, +) => Promise; + +export class AdmissionError extends Error { + constructor( + readonly kind: "denied" | "unavailable" | "unsupported" | "invalid", + ) { + super( + { + denied: + "Admission denied this content; the candidate was not added to history.", + unavailable: + "Admission is unavailable; no unchecked content will be added.", + unsupported: + "This example supports text content without reasoning payloads only.", + invalid: + "Admission returned an inconsistent result; the operation was stopped.", + }[kind], + ); + } +} + +export function createHttpEvaluator( + url: string, + credential: string, + sessionId: string, +): Evaluate { + if (new URL(url).protocol !== "https:") + throw new Error("Admission requires HTTPS."); + return async (kind, body, signal) => { + const encoded = JSON.stringify({ + kind, + body, + session_id: sessionId, + submission_id: randomUUID(), + }); + if (Buffer.byteLength(encoded) > MAX_ADMISSION_BYTES) + throw new AdmissionError("unsupported"); + try { + const response = await fetch(url, { + method: "POST", + headers: { + authorization: `Bearer ${credential}`, + "content-type": "application/json", + }, + body: encoded, + signal: AbortSignal.any([ + AbortSignal.timeout(30_000), + ...(signal ? [signal] : []), + ]), + }); + if (!response.ok) throw new AdmissionError("unavailable"); + const encodedResult = await response.text(); + if (Buffer.byteLength(encodedResult) > MAX_ADMISSION_BYTES + 16_384) + throw new AdmissionError("invalid"); + const result: unknown = JSON.parse(encodedResult); + if ( + !isRecord(result) || + !["allow", "replace", "deny"].includes(String(result.decision)) + ) + throw new AdmissionError("invalid"); + if (result.decision === "deny") + return { decision: "deny", replacement: null, receipt: null }; + if (result.decision === "allow" && result.replacement !== null) + throw new AdmissionError("invalid"); + if (result.decision === "replace" && !isRecord(result.replacement)) + throw new AdmissionError("invalid"); + if (kind === "provider_context") { + if ( + typeof result.receipt !== "string" || + !/^[A-Za-z0-9_-]+={0,2}$/.test(result.receipt) || + result.receipt.length > 11_000 + ) + throw new AdmissionError("invalid"); + } else if (result.receipt !== null) throw new AdmissionError("invalid"); + return { + decision: result.decision as "allow" | "replace", + replacement: result.replacement as Record | null, + receipt: result.receipt as string | null, + }; + } catch (error) { + if (error instanceof AdmissionError) throw error; + throw new AdmissionError("unavailable"); + } + }; +} + +export class Admission { + constructor(private readonly evaluate: Evaluate) {} + + async text( + origin: TextOrigin, + text: string, + signal?: AbortSignal, + ): Promise { + const kind = { + user: "user_message", + system: "system_context", + compaction_summary: "compaction_summary", + } as const; + const envelope = { + schema_version: "openshell.pi-message.v1", + origin, + text, + }; + const admitted = await this.apply(kind[origin], envelope, signal); + if ( + admitted.origin !== origin || + admitted.schema_version !== envelope.schema_version || + typeof admitted.text !== "string" + ) + throw new AdmissionError("invalid"); + return admitted.text; + } + + async message(message: Message, signal?: AbortSignal): Promise { + if (message.role === "user") { + return { + role: "user", + content: await this.text("user", textOnly(message.content), signal), + timestamp: message.timestamp, + }; + } + if (message.role === "assistant") { + if ( + message.content.some( + (block) => block.type !== "text" && block.type !== "toolCall", + ) + ) + throw new AdmissionError("unsupported"); + const calls = message.content + .filter((block) => block.type === "toolCall") + .map(({ id, name, arguments: args }) => ({ + id, + name, + arguments: args, + })); + const envelope = { + schema_version: "openshell.pi-assistant-message.v1", + text: message.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("\n"), + tool_calls: calls, + }; + const admitted = await this.apply("assistant_message", envelope, signal); + if ( + typeof admitted.text !== "string" || + !isDeepStrictEqual(admitted.tool_calls, calls) + ) + throw new AdmissionError("invalid"); + return { + role: "assistant", + api: message.api, + provider: message.provider, + model: message.model, + usage: message.usage, + stopReason: message.stopReason, + timestamp: message.timestamp, + content: [ + ...(admitted.text + ? [{ type: "text" as const, text: admitted.text }] + : []), + ...calls.map((call) => ({ type: "toolCall" as const, ...call })), + ], + }; + } + const envelope = { + schema_version: "openshell.pi-tool-result.v1", + tool_call_id: message.toolCallId, + tool_name: message.toolName, + content: [{ type: "text", text: textOnly(message.content) }], + is_error: message.isError, + }; + const admitted = await this.apply("tool_result", envelope, signal); + if ( + admitted.tool_call_id !== message.toolCallId || + admitted.tool_name !== message.toolName || + admitted.is_error !== message.isError || + !Array.isArray(admitted.content) + ) + throw new AdmissionError("invalid"); + const content = admitted.content.map((block: unknown): TextContent => { + if ( + !isRecord(block) || + block.type !== "text" || + typeof block.text !== "string" + ) + throw new AdmissionError("invalid"); + return { type: "text", text: block.text }; + }); + return { + role: "toolResult", + toolCallId: message.toolCallId, + toolName: message.toolName, + content, + isError: message.isError, + timestamp: message.timestamp, + } satisfies ToolResultMessage; + } + + async receipt(context: Context, signal?: AbortSignal): Promise { + const entries = context.messages.flatMap((message) => { + if (message.role === "user") + return [{ role: "user", text: textOnly(message.content) }]; + if (message.role === "toolResult") + return [ + { + role: "tool", + tool_call_id: message.toolCallId.split("|", 1)[0], + text: textOnly(message.content) || "(no tool output)", + }, + ]; + return []; + }); + const result = await this.evaluate( + "provider_context", + { schema_version: "openshell.pi-provider-context.v1", entries }, + signal, + ); + if (result.decision === "deny") throw new AdmissionError("denied"); + // A send-only replacement would leave saved history inconsistent. Fix the + // earlier admission boundary instead of silently diverging at egress. + if (result.decision !== "allow" || !result.receipt) + throw new AdmissionError("invalid"); + return result.receipt; + } + + private async apply( + kind: AdmissionKind, + body: Record, + signal?: AbortSignal, + ): Promise> { + const result = await this.evaluate(kind, body, signal); + if (signal?.aborted) throw new AdmissionError("unavailable"); + if (result.decision === "deny") throw new AdmissionError("denied"); + if (result.decision === "replace") { + if ( + !result.replacement || + result.replacement.schema_version !== body.schema_version + ) + throw new AdmissionError("invalid"); + return result.replacement; + } + return body; + } +} + +export function textOnly( + content: string | readonly { type: string; text?: string }[], +): string { + if (typeof content === "string") return content; + if ( + content.some( + (block) => block.type !== "text" || typeof block.text !== "string", + ) + ) + throw new AdmissionError("unsupported"); + return content.map((block) => block.text).join("\n"); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value); +} diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts new file mode 100644 index 00000000..8396cb1d --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts @@ -0,0 +1,117 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { createInterface } from "node:readline/promises"; +import { parseArgs } from "node:util"; +import type { Model } from "@earendil-works/pi-ai"; +import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; +import { AdmissionSession } from "./session.js"; +import { configureProxy } from "./network.js"; + +async function main(): Promise { + configureProxy(); + const { values } = parseArgs({ + options: { + cwd: { type: "string", default: "/sandbox/project" }, + "session-dir": { type: "string", default: "/sandbox/sessions" }, + model: { type: "string", default: "/app/model.json" }, + admission: { type: "string" }, + prompt: { type: "string" }, + }, + }); + const apiKey = process.env.PI_MODEL_API_KEY; + const admissionKey = process.env.EGRESS_ADMISSION_TOKEN; + // Retain endpoint-bound placeholders only in the application, not in tool + // child environments. This is hygiene, not isolation from same-authority code. + delete process.env.PI_MODEL_API_KEY; + delete process.env.EGRESS_ADMISSION_TOKEN; + if (!apiKey || !admissionKey || !values.admission) + throw new Error("Missing provider or admission configuration."); + const model = JSON.parse( + await readFile(values.model, "utf8"), + ) as Model<"openai-completions">; + const session = await AdmissionSession.create({ + cwd: values.cwd, + sessionDir: values["session-dir"], + agentDir: "/app/agent", + model, + apiKey, + admission: new Admission( + createHttpEvaluator(values.admission, admissionKey, randomUUID()), + ), + }); + console.log( + `Project: ${resolve(values.cwd)}\nSession: ${session.sessionFile}`, + ); + if (values.prompt !== undefined) { + await session.prompt(values.prompt); + console.log(JSON.stringify(session.history, null, 2)); + return; + } + console.log( + "/skill: [instructions] · /compact · /history · /exit. Ctrl-C cancels the current operation.", + ); + const terminal = createInterface({ + input: process.stdin, + output: process.stdout, + }); + let operation: AbortController | undefined; + terminal.on("SIGINT", () => { + if (operation) operation.abort(); + else terminal.close(); + }); + try { + for await (const line of terminal) { + if (line === "/exit") break; + if (!line.trim()) continue; + operation = new AbortController(); + try { + if (line === "/history") + console.log(JSON.stringify(session.history, null, 2)); + else if (line === "/compact") + console.log( + (await session.compact(operation.signal)) + ? "Approved summary saved." + : "No older complete turn to compact.", + ); + else { + await session.prompt(line, operation.signal); + const last = session.history.at(-1); + if (last?.role === "assistant") + console.log( + last.content + .filter((block) => block.type === "text") + .map((block) => block.text) + .join("\n"), + ); + } + } catch (error) { + console.error( + error instanceof AdmissionError + ? error.message + : "Operation stopped; no unchecked candidate was saved.", + ); + if (session.isStopped) { + console.error("An unfinished tool batch requires a new session."); + break; + } + } finally { + operation = undefined; + } + } + } finally { + terminal.close(); + } +} + +main().catch((error) => { + console.error( + error instanceof AdmissionError + ? error.message + : "Example failed; check configuration and service availability.", + ); + process.exitCode = 1; +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/network.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/network.ts new file mode 100644 index 00000000..c45e080c --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/network.ts @@ -0,0 +1,11 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { EnvHttpProxyAgent, setGlobalDispatcher } from "undici"; + +/** Honor the sandbox's proxy after loading Pi and its HTTP dependencies. */ +export function configureProxy(): void { + setGlobalDispatcher( + new EnvHttpProxyAgent({ proxyTunnel: true, allowH2: false }), + ); +} diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts new file mode 100644 index 00000000..e08e6b01 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts @@ -0,0 +1,414 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from "node:fs/promises"; +import { resolve } from "node:path"; +import { + formatSkillInvocation, + type AgentTool, + type StreamFn, +} from "@earendil-works/pi-agent-core"; +import { + isContextOverflow, + validateToolArguments, + type AssistantMessage, + type Context, + type Message, + type Model, + type Usage, +} from "@earendil-works/pi-ai"; +import { streamSimple } from "@earendil-works/pi-ai/compat"; +import { + SessionManager, + DefaultResourceLoader, + SettingsManager, + convertToLlm, + createReadTool, + createBashTool, + createEditTool, + createWriteTool, + createGrepTool, + createFindTool, + createLsTool, + createLocalBashOperations, + formatSkillsForPrompt, + estimateTokens, + shouldCompact, + generateSummaryWithUsage, + sessionEntryToContextMessages, + type Skill, +} from "@earendil-works/pi-coding-agent"; +import { Admission, AdmissionError, RECEIPT_HEADER } from "./admission.js"; + +export interface SessionOptions { + cwd: string; + sessionDir: string; + agentDir: string; + model: Model<"openai-completions">; + apiKey: string; + admission: Admission; + /** Public Pi stream/tool seams also permit deterministic boundary tests. */ + stream?: StreamFn; + tools?: AgentTool[]; + compactAtTokens?: number; +} + +/** The only owner of writable Pi history. Candidates stay local until approved. */ +export class AdmissionSession { + private readonly store: SessionManager; + private readonly tools: AgentTool[]; + private readonly stream: StreamFn; + private systemPrompt = ""; + private skills: Skill[] = []; + private busy = false; + private stopped = false; + private readonly reserveTokens: number; + + private constructor(private readonly options: SessionOptions) { + this.store = SessionManager.create( + resolve(options.cwd), + resolve(options.sessionDir), + ); + this.tools = options.tools ?? projectTools(resolve(options.cwd)); + this.reserveTokens = Math.min(4096, options.model.maxTokens); + this.stream = async (model, context, streamOptions) => { + const receipt = await options.admission.receipt( + context, + streamOptions?.signal, + ); + return (options.stream ?? streamSimple)(model, context, { + ...streamOptions, + apiKey: options.apiKey, + maxRetries: 0, + headers: { ...streamOptions?.headers, [RECEIPT_HEADER]: receipt }, + }); + }; + } + + static async create(options: SessionOptions): Promise { + if ( + options.model.api !== "openai-completions" || + options.model.reasoning || + options.model.input.some((type) => type !== "text") || + new URL(options.model.baseUrl).protocol !== "https:" + ) { + throw new AdmissionError("unsupported"); + } + const session = new AdmissionSession(options); + const resources = new DefaultResourceLoader({ + cwd: resolve(options.cwd), + agentDir: resolve(options.agentDir), + settingsManager: SettingsManager.inMemory({ packages: [] }), + noExtensions: true, + noPromptTemplates: true, + noThemes: true, + }); + await resources.reload(); + const skills = resources.getSkills().skills; + const candidate = [ + "You are a coding assistant. Use the available tools to work in the project directory.", + resources.getSystemPrompt() ?? "", + ...resources.getAppendSystemPrompt(), + ...resources + .getAgentsFiles() + .agentsFiles.map((file) => `${file.path}\n${file.content}`), + formatSkillsForPrompt(skills), + ].join("\n\n"); + session.systemPrompt = await options.admission.text("system", candidate); + session.skills = skills; + return session; + } + + get history(): Message[] { + return structuredClone( + convertToLlm(this.store.buildSessionContext().messages), + ); + } + get entries() { + return structuredClone(this.store.getEntries()); + } + get sessionFile(): string { + return this.store.getSessionFile()!; + } + get isStopped(): boolean { + return this.stopped; + } + + async prompt(input: string, signal?: AbortSignal): Promise { + this.begin(); + try { + let text = input; + const invocation = /^\/skill:([^\s]+)(?:\s+([\s\S]*))?$/.exec(input); + if (invocation) { + const skill = this.skills.find((skill) => skill.name === invocation[1]); + if (!skill) throw new Error("Unknown project skill."); + text = formatSkillInvocation( + { ...skill, content: await readFile(skill.filePath, "utf8") }, + invocation[2], + ); + } + await this.admitAndAppend( + { role: "user", content: text, timestamp: Date.now() }, + signal, + ); + let retriedOverflow = false; + for (;;) { + const response = await ( + await this.stream(this.options.model, this.context(), { + signal, + maxTokens: this.reserveTokens, + }) + ).result(); + if (isContextOverflow(response, this.options.model.contextWindow)) { + if (retriedOverflow || !(await this.compactSession(signal))) + throw new Error("Context is too large; start a new session."); + retriedOverflow = true; + continue; + } + if ( + response.stopReason === "error" || + response.stopReason === "aborted" + ) + throw new Error( + "Model request failed or was cancelled; no response was saved.", + ); + const candidate: AssistantMessage = { + role: "assistant", + content: response.content, + api: this.options.model.api, + model: this.options.model.id, + provider: this.options.model.provider, + usage: retainedUsage(response.usage), + stopReason: response.stopReason, + timestamp: Date.now(), + }; + const assistant = (await this.admitAndAppend( + candidate, + signal, + )) as AssistantMessage; + const calls = assistant.content.filter( + (block) => block.type === "toolCall", + ); + if (!calls.length) break; + for (let index = 0; index < calls.length; index++) { + const call = calls[index]; + try { + if (assistant.stopReason === "length") + throw new Error("Incomplete tool call."); + const tool = this.tools.find((tool) => tool.name === call.name); + let content: + | { type: "text"; text: string }[] + | Awaited>["content"]; + let isError = false; + try { + if (!tool) throw new Error("Requested tool is not available."); + const args = validateToolArguments(tool, call); + content = (await tool.execute(call.id, args, signal)).content; + } catch (error) { + isError = true; + content = [ + { + type: "text", + text: + error instanceof Error + ? error.message + : "Tool execution failed.", + }, + ]; + } + await this.admitAndAppend( + { + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content, + isError, + timestamp: Date.now(), + }, + signal, + ); + } catch (error) { + // No more model calls after a rejected result. Close outstanding + // pairs only with separately admitted, content-free failures. + try { + for (const pending of calls.slice(index)) + await this.admitAndAppend( + { + role: "toolResult", + toolCallId: pending.id, + toolName: pending.name, + content: [ + { + type: "text", + text: "Tool result unavailable; this turn was stopped.", + }, + ], + isError: true, + timestamp: Date.now(), + }, + signal, + ); + } catch { + this.stopped = true; + } + throw error; + } + } + } + const tokens = this.contextTokens(); + if ( + tokens >= (this.options.compactAtTokens ?? Infinity) || + shouldCompact(tokens, this.options.model.contextWindow, { + enabled: true, + reserveTokens: this.reserveTokens, + keepRecentTokens: 0, + }) + ) + await this.compactSession(signal); + } finally { + this.busy = false; + } + } + + async compact(signal?: AbortSignal): Promise { + this.begin(); + try { + return await this.compactSession(signal); + } finally { + this.busy = false; + } + } + + private begin(): void { + if (this.busy || this.stopped) + throw new Error( + "Session is busy or stopped; start a new session if stopped.", + ); + this.busy = true; + } + + private context(): Context { + return { + systemPrompt: this.systemPrompt, + messages: this.history, + tools: this.tools, + }; + } + private contextTokens(): number { + return this.history.reduce( + (sum, message) => sum + estimateTokens(message), + Math.ceil(this.systemPrompt.length / 4), + ); + } + + private async admitAndAppend( + candidate: Message, + signal?: AbortSignal, + ): Promise { + const admitted = await this.options.admission.message(candidate, signal); + this.store.appendMessage(admitted); + return structuredClone(admitted); + } + + private async compactSession(signal?: AbortSignal): Promise { + const entries = this.store.buildContextEntries(); + const keepIndex = entries.findLastIndex( + (entry) => entry.type === "message" && entry.message.role === "user", + ); + if (keepIndex <= 0) return false; + const previous = entries.slice(0, keepIndex); + const messages = previous.flatMap((entry) => + entry.type === "compaction" ? [] : sessionEntryToContextMessages(entry), + ); + if (!messages.length) return false; + const priorSummary = previous.find((entry) => entry.type === "compaction"); + const tokensBefore = this.contextTokens(); + const summary = await generateSummaryWithUsage( + messages, + this.options.model, + this.reserveTokens, + this.options.apiKey, + undefined, + signal, + undefined, + priorSummary?.summary, + "off", + this.stream, + undefined, + { enabled: false, maxRetries: 0, baseDelayMs: 0 }, + ); + const approved = await this.options.admission.text( + "compaction_summary", + summary.text, + signal, + ); + this.store.appendCompaction( + approved, + entries[keepIndex].id, + tokensBefore, + undefined, + undefined, + retainedUsage(summary.usage), + ); + return true; + } +} + +/** Keep bash output below Pi's automatic spill-to-file threshold. */ +export function projectTools(cwd: string): AgentTool[] { + const local = createLocalBashOperations(); + const bash = createBashTool(cwd, { + exposeSessionEnvironment: false, + operations: { + async exec(command, directory, options) { + const limit = new AbortController(); + let bytes = 0; + let lines = 0; + const result = await local.exec(command, directory, { + ...options, + signal: AbortSignal.any([ + limit.signal, + ...(options.signal ? [options.signal] : []), + ]), + onData(data) { + bytes += data.length; + lines += data.toString("utf8").split("\n").length - 1; + if (bytes > 16_000 || lines > 1000) limit.abort(); + else if (!limit.signal.aborted) options.onData(data); + }, + }); + if (limit.signal.aborted) + throw new Error( + "Bash output exceeded the example's in-memory limit.", + ); + return result; + }, + }, + }); + return [ + createReadTool(cwd), + bash, + createEditTool(cwd), + createWriteTool(cwd), + createGrepTool(cwd), + createFindTool(cwd), + createLsTool(cwd), + ]; +} + +function retainedUsage(usage: Usage): Usage { + return { + input: usage.input, + output: usage.output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + totalTokens: usage.totalTokens, + cost: { + input: usage.cost.input, + output: usage.cost.output, + cacheRead: usage.cost.cacheRead, + cacheWrite: usage.cost.cacheWrite, + total: usage.cost.total, + }, + }; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts new file mode 100644 index 00000000..dcd49c1a --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts @@ -0,0 +1,108 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { parseArgs } from "node:util"; +import type { Model } from "@earendil-works/pi-ai"; +import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; +import { AdmissionSession } from "./session.js"; +import { configureProxy } from "./network.js"; + +/** Real service, upstream runtime, real project tools, and the configured model. */ +async function verify(): Promise { + configureProxy(); + const { values } = parseArgs({ options: { admission: { type: "string" } } }); + const apiKey = process.env.PI_MODEL_API_KEY; + const admissionKey = process.env.EGRESS_ADMISSION_TOKEN; + delete process.env.PI_MODEL_API_KEY; + delete process.env.EGRESS_ADMISSION_TOKEN; + assert.ok( + apiKey && admissionKey && values.admission, + "Missing example configuration", + ); + const model = JSON.parse( + await readFile("/app/model.json", "utf8"), + ) as Model<"openai-completions">; + const makeSession = (compactAtTokens?: number) => + AdmissionSession.create({ + cwd: "/sandbox/project", + sessionDir: "/sandbox/sessions", + agentDir: "/app/agent", + model, + apiKey, + compactAtTokens, + admission: new Admission( + createHttpEvaluator(values.admission!, admissionKey, randomUUID()), + ), + }); + const raw = await fetch(`${model.baseUrl}/chat/completions`, { + method: "POST", + headers: { + authorization: `Bearer ${apiKey}`, + "content-type": "application/json", + }, + body: JSON.stringify({ + model: model.id, + messages: [{ role: "user", content: "Harmless bypass check" }], + }), + signal: AbortSignal.timeout(30_000), + }); + assert.equal( + raw.status, + 403, + "A request without an approval receipt must be blocked", + ); + console.log("PASS raw provider request without receipt is blocked"); + const session = await makeSession(); + await assert.rejects( + session.prompt("DENY_THIS"), + (error) => error instanceof AdmissionError && error.kind === "denied", + ); + assert.deepEqual(session.entries, []); + console.log( + "PASS denied user input is absent from live history and Pi entries", + ); + await session.prompt("Reply briefly to this harmless text: REDACT_THIS"); + await session.prompt( + "/skill:review Use the read tool to read notes.txt; do not guess its contents.", + ); + assert.ok( + session.history.some((message) => message.role === "toolResult"), + "The real model must actually use the project tool", + ); + assert.ok( + await session.compact(), + "Manual compaction must summarize an older turn", + ); + const saved = await readFile(session.sessionFile, "utf8"); + for (const snapshot of [ + JSON.stringify(session.history), + JSON.stringify(session.entries), + saved, + ]) { + assert.ok( + !snapshot.includes("DENY_THIS") && !snapshot.includes("REDACT_THIS"), + ); + assert.ok(snapshot.includes("[REDACTED]")); + } + console.log( + "PASS real model, redacted input, rendered skill, tool continuation, manual compaction, and JSONL history", + ); + const automatic = await makeSession(1); + await automatic.prompt("Reply with a brief greeting."); + await automatic.prompt("Reply with a brief farewell."); + assert.ok(automatic.entries.some((entry) => entry.type === "compaction")); + console.log("PASS automatic compaction through the same admission boundary"); + console.log( + `Saved evidence: ${session.sessionFile}\n${automatic.sessionFile}`, + ); +} + +verify().catch(() => { + console.error( + "FAIL end-to-end verification. Check service availability, credentials, model compatibility, and the last PASS line; no checks were skipped.", + ); + process.exitCode = 1; +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/app/test/admission.test.ts b/projects/egress-gate/examples/pi-attested-admission/app/test/admission.test.ts new file mode 100644 index 00000000..d089777c --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/test/admission.test.ts @@ -0,0 +1,112 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { test } from "node:test"; +import type { AssistantMessage } from "@earendil-works/pi-ai"; +import { + Admission, + AdmissionError, + createHttpEvaluator, + textOnly, +} from "../src/admission.js"; + +test("unsupported content and changed executable fields fail closed", async () => { + assert.throws(() => textOnly([{ type: "image" }]), AdmissionError); + const message: AssistantMessage = { + role: "assistant", + api: "openai-completions", + model: "test", + provider: "test", + timestamp: 0, + stopReason: "toolUse", + content: [ + { + type: "toolCall", + id: "call", + name: "bash", + arguments: { command: "original" }, + }, + ], + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; + const admission = new Admission(async (_kind, body) => ({ + decision: "replace", + replacement: { ...body, tool_calls: [] }, + receipt: null, + })); + await assert.rejects(admission.message(message), AdmissionError); +}); + +test("send-only redaction is rejected, and receipt projection preserves user/tool order", async () => { + const admission = new Admission(async (_kind, body) => { + assert.deepEqual(body.entries, [ + { role: "user", text: "hello" }, + { role: "tool", tool_call_id: "call", text: "(no tool output)" }, + ]); + return { decision: "replace", replacement: body, receipt: "receipt" }; + }); + await assert.rejects( + admission.receipt({ + messages: [ + { role: "user", content: "hello", timestamp: 0 }, + { + role: "toolResult", + toolCallId: "call|provider-suffix", + toolName: "read", + content: [], + isError: false, + timestamp: 0, + }, + ], + }), + AdmissionError, + ); +}); + +test("HTTP client rejects insecure configuration and malformed service output", async () => { + assert.throws(() => + createHttpEvaluator("http://service.test", "placeholder", "session"), + ); + const original = globalThis.fetch; + try { + globalThis.fetch = async () => + new Response( + JSON.stringify({ + decision: "allow", + replacement: { text: "unchecked" }, + receipt: null, + }), + ); + await assert.rejects( + createHttpEvaluator( + "https://service.test", + "placeholder", + "session", + )("user_message", {}), + AdmissionError, + ); + globalThis.fetch = async () => { + throw new Error("RAW_SECRET"); + }; + await assert.rejects( + createHttpEvaluator( + "https://service.test", + "placeholder", + "session", + )("user_message", {}), + (error) => + error instanceof AdmissionError && + !error.message.includes("RAW_SECRET"), + ); + } finally { + globalThis.fetch = original; + } +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts b/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts new file mode 100644 index 00000000..c4b83e51 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts @@ -0,0 +1,429 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { mkdtemp, mkdir, readFile, writeFile, readdir } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + createAssistantMessageEventStream, + type AssistantMessage, + type Context, + type Model, +} from "@earendil-works/pi-ai"; +import type { StreamFn } from "@earendil-works/pi-agent-core"; +import { + Admission, + type AdmissionKind, + type AdmissionResponse, + type Evaluate, +} from "../src/admission.js"; +import { AdmissionSession, projectTools } from "../src/session.js"; + +const model: Model<"openai-completions"> = { + id: "test", + name: "Test", + provider: "test", + api: "openai-completions", + baseUrl: "https://provider.test/v1", + reasoning: false, + input: ["text"], + contextWindow: 100000, + maxTokens: 4096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, +}; +const allow: AdmissionResponse = { + decision: "allow", + replacement: null, + receipt: null, +}; +const deny: AdmissionResponse = { + decision: "deny", + replacement: null, + receipt: null, +}; + +function answer( + text: string, + calls: { + id: string; + name: string; + arguments: Record; + }[] = [], +): AssistantMessage { + return { + role: "assistant", + content: [ + { type: "text", text }, + ...calls.map((call) => ({ type: "toolCall" as const, ...call })), + ], + api: model.api, + model: model.id, + provider: model.provider, + timestamp: Date.now(), + stopReason: calls.length ? "toolUse" : "stop", + usage: { + input: 1, + output: 1, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 2, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + }; +} + +async function fixture( + evaluate?: Evaluate, + responses: AssistantMessage[] = [answer("Done")], + compactAtTokens?: number, +) { + const cwd = await mkdtemp(join(tmpdir(), "pi-admission-test-")); + await writeFile(join(cwd, "AGENTS.md"), "Use the project tools."); + await mkdir(join(cwd, ".pi/skills/example"), { recursive: true }); + await writeFile( + join(cwd, ".pi/skills/example/SKILL.md"), + "---\nname: example\ndescription: Example skill\n---\nSKILL_CANDIDATE", + ); + const requests: Context[] = []; + const kinds: AdmissionKind[] = []; + const stream: StreamFn = (_model, context, options) => { + assert.ok(options?.headers?.["x-egress-admission"]); + requests.push(structuredClone({ ...context, tools: undefined })); + const response = responses.shift(); + assert.ok(response, "unexpected additional model call"); + const result = createAssistantMessageEventStream(); + result.push({ + type: "done", + reason: response.stopReason === "toolUse" ? "toolUse" : "stop", + message: response, + }); + return result; + }; + const admission = new Admission(async (kind, body, signal) => { + kinds.push(kind); + return ( + (await evaluate?.(kind, body, signal)) ?? + (kind === "provider_context" ? { ...allow, receipt: "receipt" } : allow) + ); + }); + const session = await AdmissionSession.create({ + cwd, + sessionDir: join(cwd, "sessions"), + agentDir: join(cwd, "agent"), + model, + apiKey: "placeholder", + admission, + stream, + compactAtTokens, + }); + return { session, cwd, requests, kinds }; +} + +async function disk(session: AdmissionSession): Promise { + try { + return await readFile(session.sessionFile, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") return ""; + throw error; + } +} + +for (const [kind, marker, prompt, responses] of [ + ["user_message", "CANDIDATE", "CANDIDATE", [answer("done")]], + ["user_message", "SKILL_CANDIDATE", "/skill:example", [answer("done")]], + ["assistant_message", "CANDIDATE", "hello", [answer("CANDIDATE")]], + [ + "tool_result", + "CANDIDATE", + "read", + [ + answer("", [ + { id: "call", name: "read", arguments: { path: "candidate.txt" } }, + ]), + ], + ], + [ + "tool_result", + "Requested tool is not available", + "read", + [answer("", [{ id: "call", name: "missing", arguments: {} }])], + ], +] as const) { + test(`pending and denied ${kind}: ${marker}`, async () => { + let release!: (result: AdmissionResponse) => void; + let reached!: () => void; + const pending = new Promise((resolve) => { + release = resolve; + }); + const seen = new Promise((resolve) => { + reached = resolve; + }); + const { session, cwd } = await fixture( + async (current, body) => { + if (current === kind && JSON.stringify(body).includes(marker)) { + reached(); + return pending; + } + return current === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [...responses], + ); + await writeFile(join(cwd, "candidate.txt"), "CANDIDATE"); + const run = session.prompt(prompt); + await seen; + assert.ok(!JSON.stringify(session.entries).includes(marker)); + assert.ok(!JSON.stringify(session.history).includes(marker)); + assert.ok(!(await disk(session)).includes(marker)); + release(deny); + await assert.rejects(run); + assert.ok(!JSON.stringify(session.history).includes(marker)); + assert.ok(!(await disk(session)).includes(marker)); + }); +} + +test("redacted real tool output is the only version saved and sent on continuation", async () => { + const { session, cwd, requests } = await fixture( + async (kind, body) => { + if (kind === "tool_result") + return { + decision: "replace", + replacement: { + ...body, + content: [{ type: "text", text: "[REDACTED]" }], + }, + receipt: null, + }; + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [ + answer("", [ + { id: "call", name: "read", arguments: { path: "candidate.txt" } }, + ]), + answer("Finished"), + ], + ); + await writeFile(join(cwd, "candidate.txt"), "RAW_TOOL_CONTENT"); + await session.prompt("Read candidate.txt"); + assert.equal(requests.length, 2); + assert.ok(JSON.stringify(requests[1]).includes("[REDACTED]")); + assert.ok(!JSON.stringify(session.entries).includes("RAW_TOOL_CONTENT")); + assert.ok(!(await disk(session)).includes("RAW_TOOL_CONTENT")); + assert.ok((await disk(session)).includes("[REDACTED]")); +}); + +test("manual compaction waits for approval and preserves the latest whole turn", async () => { + let release!: (result: AdmissionResponse) => void; + let reached!: () => void; + const seen = new Promise((resolve) => { + reached = resolve; + }); + const pending = new Promise((resolve) => { + release = resolve; + }); + let summaryBody: Record = {}; + const { session, requests } = await fixture( + async (kind, body) => { + if (kind === "compaction_summary") { + summaryBody = body; + reached(); + return pending; + } + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [answer("first"), answer("second"), answer("SUMMARY_CANDIDATE")], + ); + await session.prompt("first turn"); + await session.prompt("second turn"); + const before = session.entries; + const fileBefore = await disk(session); + const compact = session.compact(); + await seen; + assert.deepEqual(session.entries, before); + assert.equal(await disk(session), fileBefore); + release({ + decision: "replace", + replacement: { ...summaryBody, text: "Approved summary" }, + receipt: null, + }); + assert.equal(await compact, true); + assert.equal(requests.length, 3); + assert.ok(!JSON.stringify(session.history).includes("SUMMARY_CANDIDATE")); + assert.ok(JSON.stringify(session.history).includes("Approved summary")); + assert.ok(JSON.stringify(session.history).includes("second turn")); + assert.ok(!(await disk(session)).includes("SUMMARY_CANDIDATE")); + assert.ok( + (await disk(session)).includes("first turn"), + "compaction is append-only", + ); +}); + +test("automatic compaction uses the same admitted summary path", async () => { + const { session, kinds } = await fixture( + undefined, + [answer("first"), answer("second"), answer("summary")], + 1, + ); + await session.prompt("one"); + await session.prompt("two"); + assert.equal(kinds.filter((kind) => kind === "compaction_summary").length, 1); + assert.equal( + session.entries.filter((entry) => entry.type === "compaction").length, + 1, + ); +}); + +test("real bash is bounded before Pi can spill an unchecked output log", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-bash-test-")); + const before = (await readdir(tmpdir())).filter((name) => + name.startsWith("pi-bash-"), + ); + const bash = projectTools(cwd).find((tool) => tool.name === "bash")!; + try { + await bash.execute("call", { command: "head -c 100000 /dev/zero" }); + } catch { + /* Pi reports the bounded operation as a tool error. */ + } + const after = (await readdir(tmpdir())).filter((name) => + name.startsWith("pi-bash-"), + ); + assert.deepEqual(after, before); +}); + +test("denied summary leaves both histories unchanged", async () => { + const { session } = await fixture( + async (kind) => + kind === "compaction_summary" + ? deny + : kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow, + [answer("first"), answer("second"), answer("UNAPPROVED_SUMMARY")], + ); + await session.prompt("first turn"); + await session.prompt("second turn"); + const before = session.entries; + const saved = await disk(session); + await assert.rejects(session.compact()); + assert.deepEqual(session.entries, before); + assert.equal(await disk(session), saved); +}); + +test("provider errors and partial responses never become history", async () => { + const error = { + ...answer("PARTIAL_RESPONSE"), + stopReason: "error" as const, + errorMessage: "RAW_PROVIDER_ERROR", + }; + const { session } = await fixture(undefined, [error]); + await assert.rejects(session.prompt("hello")); + assert.equal(session.history.length, 1); + assert.ok(!JSON.stringify(session.entries).includes("PARTIAL_RESPONSE")); + assert.ok(!(await disk(session)).includes("RAW_PROVIDER_ERROR")); +}); + +test("denied tool result stops model continuation and unexecuted calls", async () => { + const { session, cwd, requests } = await fixture( + async (kind, body) => { + if ( + kind === "tool_result" && + JSON.stringify(body).includes("FORBIDDEN_OUTPUT") + ) + return deny; + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [ + answer("", [ + { id: "read", name: "read", arguments: { path: "candidate.txt" } }, + { + id: "write", + name: "write", + arguments: { + path: "must-not-exist", + content: "unchecked continuation", + }, + }, + ]), + ], + ); + await writeFile(join(cwd, "candidate.txt"), "FORBIDDEN_OUTPUT"); + await assert.rejects(session.prompt("Use the tools")); + assert.equal(requests.length, 1); + assert.equal( + session.history.filter((message) => message.role === "toolResult").length, + 2, + ); + await assert.rejects(readFile(join(cwd, "must-not-exist"))); + assert.ok(!JSON.stringify(session.entries).includes("FORBIDDEN_OUTPUT")); +}); + +test("unavailable tool admission leaves an unfinished session stopped", async () => { + const { session, requests } = await fixture( + async (kind) => { + if (kind === "tool_result") throw new Error("SERVICE_UNAVAILABLE"); + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [answer("", [{ id: "call", name: "missing", arguments: {} }])], + ); + await assert.rejects(session.prompt("hello")); + assert.equal(session.isStopped, true); + assert.equal(requests.length, 1); + await assert.rejects(session.prompt("continue")); + assert.equal( + session.history.filter((message) => message.role === "toolResult").length, + 0, + ); +}); + +test("project context is checked before any model request or message write", async () => { + await assert.rejects( + fixture(async (kind, body) => { + assert.equal(kind, "system_context"); + assert.ok(String(body.text).includes("Use the project tools.")); + assert.ok(String(body.text).includes("Example skill")); + return deny; + }), + ); +}); + +test("cancelled admission cannot append even when the service subsequently allows", async () => { + const controller = new AbortController(); + const { session, requests } = await fixture(async (kind) => { + if (kind === "user_message") controller.abort(); + return allow; + }); + await assert.rejects(session.prompt("CANCELLED", controller.signal)); + assert.deepEqual(session.entries, []); + assert.equal(requests.length, 0); +}); + +test("context overflow makes one admitted summary and one retry", async () => { + const overflow = { + ...answer(""), + stopReason: "error" as const, + errorMessage: "exceeds the context window", + }; + const { session, requests, kinds } = await fixture(undefined, [ + answer("first"), + overflow, + answer("summary"), + answer("retry result"), + ]); + await session.prompt("first turn"); + await session.prompt("next turn"); + assert.equal(requests.length, 4); + assert.equal(kinds.filter((kind) => kind === "compaction_summary").length, 1); + assert.ok(JSON.stringify(session.history).includes("retry result")); + assert.ok(!(await disk(session)).includes("exceeds the context window")); +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/tsconfig.json b/projects/egress-gate/examples/pi-attested-admission/app/tsconfig.json similarity index 60% rename from projects/egress-gate/examples/pi-attested-admission/runtime-extension/tsconfig.json rename to projects/egress-gate/examples/pi-attested-admission/app/tsconfig.json index cccd9db4..8969e893 100644 --- a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/tsconfig.json +++ b/projects/egress-gate/examples/pi-attested-admission/app/tsconfig.json @@ -1,13 +1,12 @@ { "compilerOptions": { + "target": "ES2023", "module": "NodeNext", "moduleResolution": "NodeNext", - "noEmitOnError": true, - "outDir": "../integration", - "rootDir": ".", - "skipLibCheck": true, "strict": true, - "target": "ES2022" + "skipLibCheck": true, + "rootDir": ".", + "outDir": "dist" }, - "include": ["*.ts"] + "include": ["src/**/*.ts", "test/**/*.ts"] } diff --git a/projects/egress-gate/examples/pi-attested-admission/bind-sandbox.py b/projects/egress-gate/examples/pi-attested-admission/bind-sandbox.py new file mode 100644 index 00000000..68a66c9e --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/bind-sandbox.py @@ -0,0 +1,25 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Bind the service's demo credential to an operator-observed sandbox ID.""" + +import argparse +import json +import sys +from pathlib import Path + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state", type=Path, required=True) + args = parser.parse_args() + sandbox = json.load(sys.stdin) + identifier = sandbox["id"] + if not isinstance(identifier, str) or not identifier: + raise ValueError("OpenShell did not return a sandbox ID") + (args.state / "sandbox-id").write_text(identifier + "\n") + print("Admission identity bound. Run ./demo.sh launch or ./demo.sh verify.") + + +if __name__ == "__main__": + main() diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 54defffc..cef63653 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -1,852 +1,110 @@ #!/usr/bin/env bash # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 - - set -euo pipefail - +set +x # Never trace populated credential variables. +umask 077 +example=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) +project=$(cd -- "$example/../.." && pwd) +state=$project/.workspaces/pi-no-fork print_only=false -if [[ ${1:-} == "--print" ]]; then - print_only=true - shift -fi - +if [[ ${1:-} == --print ]]; then print_only=true; shift; fi action=${1:-help} -script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) -egress_gate_dir=$(cd -- "$script_dir/../.." && pwd) -env_file=${PI_EGRESS_ENV_FILE-$script_dir/.env} -if [[ -n $env_file && -f $env_file ]]; then - set -a - # shellcheck disable=SC1090 - source "$env_file" - set +a -fi - -forks_dir=${PI_EGRESS_FORKS_DIR:-$egress_gate_dir/.workspaces/pi-attested-admission} -pi_repo=${PI_REPO:-$forks_dir/pi} -openshell_repo=${OPENSHELL_REPO:-$forks_dir/OpenShell} -pi_branch=johnny/before-user-message-commit -openshell_branch=openshell/pi-egress-admission -pi_remote=https://github.com/johnnygreco/pi.git -openshell_remote=https://github.com/johnnygreco/OpenShell.git -host_ip=${EGRESS_GATE_HOST_IP:-YOUR_HOST_IPV4} -models_path_value=${PI_MODELS_PATH:-YOUR_MODELS_PATH} -if [[ $models_path_value == /* || $models_path_value == YOUR_MODELS_PATH ]]; then - models_path=$models_path_value -else - models_path=$script_dir/${models_path_value#./} -fi -workspace_path=${PI_WORKSPACE_PATH:-} -pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} -runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} -openshell_cli=$openshell_repo/scripts/bin/openshell -gateway_name=${PI_EGRESS_GATEWAY_NAME:-pi-egress-demo-gateway} -runtime_policy=$script_dir/policy.yaml -runtime_provider_profile=$script_dir/provider-profile.yaml -runtime_gateway_fragment=$runtime_dir/gateway-middleware.toml -gateway_fragment_template=$script_dir/gateway-middleware.toml.example -pi_settings=$script_dir/settings.json -runtime_extension_source=$script_dir/runtime-extension -runtime_extension_build=$runtime_dir/integration -egress_gate_log=${EGRESS_GATE_LOG:-$runtime_dir/egress-gate.jsonl} -z3_library_path_override=${Z3_LIBRARY_PATH_OVERRIDE:-} - -bold="" -green="" -yellow="" -blue="" -cyan="" -reset="" -if [[ ${NO_COLOR+x} != x && (${FORCE_COLOR:-0} == 1 || (-t 1 && ${TERM:-} != dumb)) ]]; then - bold=$'\033[1m' - green=$'\033[32m' - yellow=$'\033[33m' - blue=$'\033[34m' - cyan=$'\033[36m' - reset=$'\033[0m' +# .env is trusted operator input. Print mode never executes it. +if ! $print_only && [[ -f $example/.env ]]; then + set -a + source "$example/.env" + set +a fi - -print_command() { - local directory=$1 - shift - local argument - local column=2 - local token - printf ' %bworking directory%b: %s\n' "$cyan" "$reset" "$directory" - printf ' %bcommand%b:\n ' "$green" "$reset" - for argument in "$@"; do - printf -v token '%q' "$argument" - if ((column > 2 && column + ${#token} + 1 > 96)); then - printf ' \\\n ' - column=6 - fi - if ((column > 2)); then - printf ' ' - ((column += 1)) - fi - printf '%s' "$token" - ((column += ${#token})) - done - printf '\n' -} - -describe_printed_commands() { - if $print_only; then - printf '\n%b%s%b\n' "$bold$blue" "$1" "$reset" - fi -} - -run_in() { - local directory=$1 - shift - if $print_only; then - print_command "$directory" "$@" - else - (cd -- "$directory" && "$@") - fi -} - -require_file() { - local path=$1 - local description=$2 - if [[ ! -f $path ]]; then - printf 'Missing %s: %s\n' "$description" "$path" >&2 - exit 1 - fi -} - -require_file_contains() { - local path=$1 - local expected_text=$2 - local description=$3 - require_file "$path" "$description" - if ! grep -Fq -- "$expected_text" "$path"; then - printf '%s is missing the required admission hook: %s\n' "$description" "$path" >&2 - printf 'Run `./demo.sh prepare` to rebuild the local Pi runtime.\n' >&2 - exit 1 - fi -} - -require_directory() { - local path=$1 - local description=$2 - if [[ ! -d $path ]]; then - printf 'Missing %s: %s\n' "$description" "$path" >&2 - exit 1 - fi -} - -require_compute_backend() { - local requested_driver=${OPENSHELL_DRIVERS:-} - if [[ -n ${KUBERNETES_SERVICE_HOST:-} ]]; then - return - fi - if [[ -z $requested_driver || $requested_driver == podman ]]; then - if command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then - return - fi - fi - if [[ -z $requested_driver || $requested_driver == docker ]]; then - if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then - return - fi - fi - if [[ -n $requested_driver && $requested_driver != podman && $requested_driver != docker ]]; then - return - fi - - printf 'No running OpenShell compute backend was detected.\n' >&2 - printf 'Start Docker Desktop or Podman, wait until its info command succeeds, then retry:\n' >&2 - printf ' docker info\n' >&2 - printf ' # or: podman info\n' >&2 - printf 'For another supported backend, set OPENSHELL_DRIVERS before running gateway.\n' >&2 - exit 1 -} - -raise_gateway_open_file_limit() { - local target=10240 - local hard_limit - local soft_limit - hard_limit=$(ulimit -Hn) - soft_limit=$(ulimit -Sn) - if [[ $soft_limit == unlimited ]]; then - return - fi - if [[ $hard_limit != unlimited && $hard_limit -lt $target ]]; then - target=$hard_limit - fi - if ((soft_limit >= target)); then - return - fi - if ! ulimit -Sn "$target"; then - printf 'Could not raise the open-file limit from %s to %s for the OpenShell build.\n' \ - "$soft_limit" "$target" >&2 - printf 'Run `ulimit -n %s` in this terminal, then retry.\n' "$target" >&2 - exit 1 - fi -} - -require_gateway_z3() { - local z3_prefix - if [[ -n $z3_library_path_override ]]; then - return - fi - if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists z3; then - return - fi - case $(uname -s) in - Darwin) - if command -v brew >/dev/null 2>&1; then - z3_prefix=$(brew --prefix z3 2>/dev/null || true) - if [[ -f $z3_prefix/lib/libz3.dylib ]]; then - z3_library_path_override=$z3_prefix/lib - return - fi - fi - printf 'The OpenShell gateway build requires Z3. Install it, then retry:\n' >&2 - printf ' brew install z3\n' >&2 - ;; - Linux) - if command -v ldconfig >/dev/null 2>&1 && ldconfig -p 2>/dev/null | grep -q 'libz3\.so'; then - return - fi - printf 'The OpenShell gateway build requires the Z3 development library.\n' >&2 - printf 'On Debian or Ubuntu, install it with: sudo apt-get install libz3-dev\n' >&2 - ;; - *) - printf 'The OpenShell gateway build requires the Z3 native library.\n' >&2 - printf 'Install Z3 or set Z3_LIBRARY_PATH_OVERRIDE to its library directory.\n' >&2 - ;; - esac - exit 1 -} - -require_host_configuration() { - if [[ -n ${EGRESS_GATE_HOST_IP:-} && ${EGRESS_GATE_HOST_IP:-} != YOUR_HOST_IPV4 ]]; then - return - fi - printf 'Set EGRESS_GATE_HOST_IP in %s before starting the gateway.\n' "$env_file" >&2 - exit 1 -} - -require_setup_configuration() { - local missing=() - if [[ -z ${EGRESS_GATE_HOST_IP:-} || ${EGRESS_GATE_HOST_IP:-} == YOUR_HOST_IPV4 ]]; then - missing+=(EGRESS_GATE_HOST_IP) - fi - if [[ -z ${PI_MODELS_PATH:-} || ${PI_MODELS_PATH:-} == YOUR_MODELS_PATH ]]; then - missing+=(PI_MODELS_PATH) - fi - if [[ -z ${PI_MODEL_API_KEY:-} || ${PI_MODEL_API_KEY:-} == your-provider-key ]]; then - missing+=(PI_MODEL_API_KEY) - fi - if ((${#missing[@]} == 0)); then - require_file "$models_path" "Pi model configuration" - if [[ -n $workspace_path ]]; then - require_directory "$workspace_path" "Pi workspace" - fi - return - fi - - printf 'The Pi attested-admission example is not configured.\n' >&2 - printf 'Set these environment variables:\n' >&2 - printf ' %s\n' "${missing[@]}" >&2 - printf '\n' >&2 - printf 'Configure %s:\n' "$env_file" >&2 - printf ' cd %s\n' "$script_dir" >&2 - if [[ ! -f $env_file ]]; then - printf ' cp .env.example .env\n' >&2 - fi - printf ' # Edit .env and replace every example value.\n' >&2 - exit 1 -} - -require_branch() { - local repository=$1 - local expected=$2 - local actual - actual=$(git -C "$repository" branch --show-current) - if [[ $actual != "$expected" ]]; then - printf 'Expected %s to be on branch %s, but found %s.\n' "$repository" "$expected" "${actual:-detached HEAD}" >&2 - exit 1 - fi -} - -ensure_checkout() { - local repository=$1 - local description=$2 - local remote=$3 - local branch=$4 - local parent - parent=$(dirname -- "$repository") - if $print_only; then - describe_printed_commands "$description (only when missing):" - print_command "$parent" git clone --branch "$branch" "$remote" "$repository" - return - fi - if [[ -e $repository && ! -d $repository/.git ]]; then - printf '%s path exists but is not a Git checkout: %s\n' "$description" "$repository" >&2 - exit 1 - fi - if [[ ! -d $repository/.git ]]; then - mkdir -p "$parent" - run_in "$parent" git clone --branch "$branch" "$remote" "$repository" - fi -} - -sync_forks() { - ensure_checkout "$pi_repo" "Pi checkout" "$pi_remote" "$pi_branch" - ensure_checkout "$openshell_repo" "OpenShell checkout" "$openshell_remote" "$openshell_branch" - if ! $print_only; then - require_branch "$pi_repo" "$pi_branch" - require_branch "$openshell_repo" "$openshell_branch" - fi - describe_printed_commands "Update the Pi fork:" - run_in "$pi_repo" git pull --no-rebase --ff-only origin "$pi_branch" - describe_printed_commands "Update the OpenShell fork:" - run_in "$openshell_repo" git pull --no-rebase --ff-only origin "$openshell_branch" -} - -pi_package_tarball() { - local package_directory=$1 - local archive_name=$2 - if $print_only; then - printf '%s/%s-VERSION.tgz' "$pack_dir" "$archive_name" - return - fi - require_file "$package_directory/package.json" "Pi package" - local version - version=$(node -p "require(process.argv[1]).version" "$package_directory/package.json") - printf '%s/%s-%s.tgz' "$pack_dir" "$archive_name" "$version" -} - -prepare_gateway_configuration() { - if $print_only; then - describe_printed_commands "Write the one host-specific gateway setting:" - printf ' %bsource%b: %s\n' "$cyan" "$reset" "$gateway_fragment_template" - printf ' %boutput%b: %s\n' "$green" "$reset" "$runtime_gateway_fragment" - printf ' Replace YOUR_HOST_IPV4 with %s.\n' "$host_ip" - return - fi - require_file "$gateway_fragment_template" "gateway middleware configuration template" - mkdir -p "$runtime_dir" - sed "s/YOUR_HOST_IPV4/$host_ip/" "$gateway_fragment_template" >"$runtime_gateway_fragment" -} - -prepare() { - sync_forks - local agent_tarball - local coding_agent_tarball - agent_tarball=$(pi_package_tarball "$pi_repo/packages/agent" "earendil-works-pi-agent-core") - coding_agent_tarball=$(pi_package_tarball "$pi_repo/packages/coding-agent" "earendil-works-pi-coding-agent") - - describe_printed_commands "Build and package Pi:" - run_in "$pi_repo" npm install --ignore-scripts - run_in "$pi_repo" npm run build:offline - run_in "$pi_repo" mkdir -p "$pack_dir" "$runtime_dir" - run_in "$pi_repo" npm pack --workspace @earendil-works/pi-agent-core --pack-destination "$pack_dir" - run_in "$pi_repo" npm pack --workspace @earendil-works/pi-coding-agent --pack-destination "$pack_dir" - run_in "$pi_repo" npm install --prefix "$runtime_dir" --ignore-scripts "$agent_tarball" "$coding_agent_tarball" - describe_printed_commands "Type-check and compile the trusted runtime extension:" - run_in "$pi_repo" mkdir -p "$runtime_dir/integration-src" "$runtime_extension_build" - run_in "$pi_repo" cp -R "$runtime_extension_source/." "$runtime_dir/integration-src" - run_in "$runtime_dir/integration-src" "$pi_repo/node_modules/.bin/tsgo" -p tsconfig.json - run_in "$runtime_dir/integration-src" cp package.json "$runtime_extension_build/package.json" -} - -serve() { - describe_printed_commands "Run Egress Gate and keep it open:" - run_in "$egress_gate_dir" uv run egress-gate --debug serve \ - --listen 0.0.0.0:50051 --timeout 4s --require-agent-attestation \ - --json-log "$egress_gate_log" -} - -gateway() { - if ! $print_only; then - require_host_configuration - require_compute_backend - raise_gateway_open_file_limit - require_gateway_z3 - require_directory "$openshell_repo" "OpenShell checkout" - require_branch "$openshell_repo" "$openshell_branch" - fi - prepare_gateway_configuration - # A custom checkout may be nested below this uv project. Keep OpenShell's - # mise-pinned uv from inheriting Egress Gate's uv configuration. - describe_printed_commands "Start the matching OpenShell gateway and keep it open:" - run_in "$openshell_repo" env UV_NO_CONFIG=1 mise trust - local gateway_environment=( - env - UV_NO_CONFIG=1 - CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" - OPENSHELL_GATEWAY_NAME="$gateway_name" - OPENSHELL_GATEWAY_CONFIG_FRAGMENT="$runtime_gateway_fragment" - ) - if [[ -n $z3_library_path_override ]]; then - gateway_environment+=(Z3_LIBRARY_PATH_OVERRIDE="$z3_library_path_override") - fi - run_in "$openshell_repo" "${gateway_environment[@]}" mise run gateway -} - -ensure_model_provider() { - if $print_only; then - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider delete pi-model - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider profile delete pi-attested-model - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider profile import --file "$runtime_provider_profile" - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ - --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY - return - fi - if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ - provider get pi-model >/dev/null 2>&1); then - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider delete pi-model - fi - if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ - provider profile export pi-attested-model >/dev/null 2>&1); then - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider profile delete pi-attested-model - fi - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider profile import --file "$runtime_provider_profile" - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ - --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY -} - -delete_demo_sandbox_if_present() { - if $print_only; then - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox delete pi-egress-demo - return - fi - if ! $print_only && (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ - sandbox list --names | grep -Fxq pi-egress-demo); then - printf 'Replacing existing sandbox pi-egress-demo with the current example runtime.\n' - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox delete pi-egress-demo - fi -} - -create_demo_sandbox() { - run_in "$script_dir" "$openshell_cli" --gateway "$gateway_name" sandbox create \ - --name pi-egress-demo \ - --from "$script_dir/sandbox" \ - --provider pi-model \ - --policy "$runtime_policy" \ - --upload "$runtime_dir/node_modules:/sandbox/pi-runtime" \ - --upload "$models_path:/sandbox/.pi/agent/models.json" \ - --upload "$pi_settings:/sandbox/.pi/agent/settings.json" \ - --upload "$runtime_extension_build/openshell-context-admission.js:/sandbox/pi-runtime/integration/openshell-context-admission.js" \ - --upload "$runtime_extension_build/openshell-pi.js:/sandbox/pi-runtime/integration/openshell-pi.js" \ - --upload "$runtime_extension_build/package.json:/sandbox/pi-runtime/integration/package.json" \ - --no-git-ignore \ - --detach - if [[ -n $workspace_path ]]; then - describe_printed_commands "Upload the selected workspace with its .gitignore rules:" - run_in "$workspace_path" "$openshell_cli" --gateway "$gateway_name" sandbox upload \ - pi-egress-demo . /sandbox/workspace - elif $print_only; then - describe_printed_commands "No workspace selected; Pi starts in an empty /sandbox/workspace." - fi -} - -reset_demo() { - if ! $print_only; then - require_setup_configuration - require_file "$openshell_cli" "OpenShell CLI wrapper" - require_file "$(pi_package_tarball "$pi_repo/packages/agent" "earendil-works-pi-agent-core")" \ - "packed Pi agent core" - require_file "$(pi_package_tarball "$pi_repo/packages/coding-agent" "earendil-works-pi-coding-agent")" \ - "packed Pi coding-agent" - require_file_contains \ - "$runtime_dir/node_modules/@earendil-works/pi-agent-core/dist/agent-loop.js" \ - "beforeToolResultAppend" \ - "installed Pi agent core" - require_file_contains \ - "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/bundle/index.js" \ - "runCli" \ - "installed Pi SDK" - require_file "$pi_settings" "Pi settings" - require_file "$runtime_extension_build/openshell-context-admission.js" \ - "compiled OpenShell context-admission adapter" - require_file "$runtime_extension_build/openshell-pi.js" "compiled OpenShell Pi launcher" - require_file "$runtime_extension_build/package.json" "runtime-extension package metadata" - require_file "$runtime_policy" "OpenShell sandbox policy" - require_file "$runtime_provider_profile" "OpenShell provider profile" - fi - - describe_printed_commands "Remove an earlier example sandbox, if present:" - delete_demo_sandbox_if_present - describe_printed_commands "Register the endpoint-scoped model credential in OpenShell:" - ensure_model_provider - describe_printed_commands "Create a fresh sandbox and upload the Pi runtime:" - create_demo_sandbox - printf 'The demo sandbox is ready. Run: ./demo.sh launch\n' -} - -require_demo_sandbox() { - if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ - sandbox list --names | grep -Fxq pi-egress-demo); then - return - fi - printf 'The pi-egress-demo sandbox does not exist. Create it with: ./demo.sh reset\n' >&2 - exit 1 -} - -launch() { - if ! $print_only; then - require_file "$openshell_cli" "OpenShell CLI wrapper" - require_demo_sandbox - fi - describe_printed_commands "Launch Pi interactively in the prepared sandbox:" - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec --tty -n pi-egress-demo --workdir /sandbox/workspace -- \ - env \ - OPENSHELL_AGENT_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation \ - node /sandbox/pi-runtime/integration/openshell-pi.js -} - -run_sandbox_command() { - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec --no-tty -n pi-egress-demo --workdir /sandbox/workspace -- "$@" -} - -capture_sandbox_command() { - local output=$1 - local error=$2 - shift 2 - if $print_only; then - run_sandbox_command "$@" - return - fi - (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec --no-tty -n pi-egress-demo --workdir /sandbox/workspace -- "$@") \ - >"$output" 2>"$error" -} - -copy_session() { - local session_file=$1 - local output=$2 - local error=$3 - capture_sandbox_command "$output" "$error" /bin/cat "$session_file" -} - -log_line_count() { - if [[ -f $egress_gate_log ]]; then - wc -l <"$egress_gate_log" - else - printf '0\n' - fi -} - -assert_logged_reason() { - local first_line=$1 - local reason=$2 - local label=$3 - if ! awk -v first="$first_line" -v reason="\"reason_code\":\"$reason\"" \ - 'NR >= first && index($0, reason) { found = 1 } END { exit !found }' \ - "$egress_gate_log"; then - printf '%s did not produce reason code %s in %s.\n' "$label" "$reason" "$egress_gate_log" >&2 - exit 1 - fi - printf 'PASS %-18s reason_code=%s\n' "$label" "$reason" -} - -verify() { - local verify_id="$(date +%s)-$$" - local verify_dir="/sandbox/pi-admission-verify/$verify_id" - local bridge_url="http://127.0.0.1:8193/v1/agent/conversation" - local temporary_dir - local output - local error - local status - local session - local first_log_line - local raw_request_script='fetch("https://inference-api.nvidia.com/v1/chat/completions", {method:"POST", headers:{"content-type":"application/json", authorization:"Bearer openshell-proxy"}, body:JSON.stringify({model:"nvidia/qwen/qwen3.8-flash-next", messages:[{role:"user",content:"hello"}]})}).then(async response => { console.error(`provider status=${response.status}`); process.exit(response.ok ? 0 : 1); }).catch(error => { console.error(String(error)); process.exit(1); });' - - if $print_only; then - describe_printed_commands "Create fresh real Pi sessions and run every verification case:" - temporary_dir=/tmp/pi-admission-verify - else - require_file "$openshell_cli" "OpenShell CLI wrapper" - require_demo_sandbox - if [[ ! -f $egress_gate_log ]]; then - printf 'Missing Egress Gate JSON log: %s\n' "$egress_gate_log" >&2 - printf 'Start Egress Gate with ./demo.sh serve before running verify.\n' >&2 - exit 1 - fi - temporary_dir=$(mktemp -d) - trap 'rm -rf -- "$temporary_dir"' RETURN - fi - - output=$temporary_dir/deny.out - error=$temporary_dir/deny.err - session=$verify_dir/deny.jsonl - status=0 - capture_sandbox_command "$output" "$error" env \ - OPENSHELL_AGENT_CONVERSATION_URL="$bridge_url" \ - node /sandbox/pi-runtime/integration/openshell-pi.js \ - --session "$session" -p "Reply with exactly: DENY_THIS" || status=$? - if ! $print_only; then - if ((status == 0)) || ! grep -Fq "OpenShell denied this context addition" "$error"; then - printf 'Denied-prompt verification failed. See %s and %s.\n' "$output" "$error" >&2 - exit 1 - fi - if capture_sandbox_command "$output.session" "$error.session" test -e "$session"; then - printf 'Denied prompt unexpectedly created a session: %s\n' "$session" >&2 - exit 1 - fi - printf 'PASS %-18s denied; session not written\n' "denied prompt" - fi - - output=$temporary_dir/redact.out - error=$temporary_dir/redact.err - session=$verify_dir/redact.jsonl - capture_sandbox_command "$output" "$error" env \ - OPENSHELL_AGENT_CONVERSATION_URL="$bridge_url" \ - node /sandbox/pi-runtime/integration/openshell-pi.js \ - --session "$session" -p "Reply with exactly: REDACT_THIS" - copy_session "$session" "$temporary_dir/redact.jsonl" "$temporary_dir/redact-session.err" - if ! $print_only; then - if ! grep -Fq "[REDACTED]" "$temporary_dir/redact.jsonl" || \ - grep -Fq "REDACT_THIS" "$temporary_dir/redact.jsonl"; then - printf 'Redacted-prompt verification failed for %s.\n' "$session" >&2 - exit 1 - fi - if ! grep -Fq '"role":"assistant"' "$temporary_dir/redact.jsonl" || \ - grep -Fq '"stopReason":"error"' "$temporary_dir/redact.jsonl"; then - printf 'Provider-response verification failed for %s.\n' "$session" >&2 - exit 1 - fi - printf 'PASS %-18s provider answered; session contains only [REDACTED]\n' \ - "redacted prompt" - fi - - capture_sandbox_command "$temporary_dir/bridge.out" "$temporary_dir/bridge.err" \ - /usr/bin/curl --silent --show-error \ - --header "content-type: application/json" \ - --data '{}' \ - --write-out $'\n%{http_code}\n' \ - "$bridge_url" - if ! $print_only; then - if ! grep -Fq '"error":"caller_not_authorized"' "$temporary_dir/bridge.out" || \ - ! tail -n 1 "$temporary_dir/bridge.out" | grep -Fxq 401; then - printf 'Unauthenticated bridge request was not rejected. See %s and %s.\n' \ - "$temporary_dir/bridge.out" "$temporary_dir/bridge.err" >&2 - exit 1 - fi - printf 'PASS %-18s caller_not_authorized\n' "raw bridge" - fi - - first_log_line=$(( $(log_line_count) + 1 )) - status=0 - capture_sandbox_command "$temporary_dir/raw.out" "$temporary_dir/raw.err" \ - /usr/bin/node -e "$raw_request_script" || status=$? - if ! $print_only; then - if ((status == 0)); then - printf 'Raw provider request unexpectedly succeeded.\n' >&2 - exit 1 - fi - assert_logged_reason "$first_log_line" attestation_missing "raw provider" - fi - - first_log_line=$(( $(log_line_count) + 1 )) - status=0 - capture_sandbox_command "$temporary_dir/stock.out" "$temporary_dir/stock.err" \ - /usr/bin/pi --session "$verify_dir/stock.jsonl" -p hello || status=$? - if ! $print_only; then - if ((status == 0)); then - printf 'Stock Pi unexpectedly reached the provider.\n' >&2 - exit 1 - fi - assert_logged_reason "$first_log_line" attestation_missing "stock Pi" - fi - - for marker in DENY REDACT; do - local marker_lower=${marker,,} - local expected="[REDACTED]" - if [[ $marker == DENY ]]; then - expected="[Tool result blocked by context admission]" - fi - session=$verify_dir/tool-$marker_lower.jsonl - capture_sandbox_command "$temporary_dir/tool-$marker_lower.out" "$temporary_dir/tool-$marker_lower.err" env \ - OPENSHELL_AGENT_CONVERSATION_URL="$bridge_url" \ - node /sandbox/pi-runtime/integration/openshell-pi.js --session "$session" -p \ - "Use bash to print the concatenation of ${marker}_ and THIS, then tell me the output." - copy_session "$session" "$temporary_dir/tool-$marker_lower.jsonl" \ - "$temporary_dir/tool-$marker_lower-session.err" - if ! $print_only; then - status=0 - awk -v expected="$expected" -v forbidden="${marker}_THIS" ' - index($0, "\"role\":\"toolResult\"") { - found = 1 - if (index($0, expected)) admitted = 1 - if (index($0, forbidden)) leaked = 1 - } - END { - if (!found) exit 2 - if (!admitted || leaked) exit 1 - } - ' "$temporary_dir/tool-$marker_lower.jsonl" || status=$? - if ((status == 0)); then - printf 'PASS %-18s tool result contains %s\n' "tool $marker_lower" "$expected" - elif ((status == 2)); then - printf 'SKIP %-18s model did not call bash\n' "tool $marker_lower" - else - printf 'Tool %s result was not safely admitted.\n' "$marker_lower" >&2 - exit 1 - fi - fi - done - - if $print_only; then - printf ' Assertions inspect each fresh session and %s; request content is never logged.\n' \ - "$egress_gate_log" - fi -} - -cleanup() { - if ! $print_only; then - require_file "$openshell_cli" "OpenShell CLI wrapper" - if ! (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ - status >/dev/null 2>&1); then - cat >&2 <&2 - exit 1 - fi - print_plan - ;; - help | --help | -h) usage ;; - *) - printf 'Unknown action: %s\n\n' "$action" >&2 - usage >&2 - exit 1 - ;; + prepare) + if ! $print_only && [[ $(uname -s) != Linux || $(uname -m) != x86_64 ]]; then + echo "This pinned POC launcher supports Linux x86_64 with Docker." >&2; exit 1 + fi + run uv sync --frozen + run mkdir -p "$state/bin" + for component in openshell openshell-gateway openshell-sandbox; do + target=x86_64-unknown-linux-musl + [[ $component != openshell-gateway ]] || target=x86_64-unknown-linux-gnu + archive=$component-$target.tar.gz + case "$component" in + openshell) checksum=4fb4476d80a1875a0b83547ec3aba999cf0a2e2d75f95f2f709b622e2103520e ;; + openshell-gateway) checksum=59c6da724eae7a00c28826f9191efbdf4fbaa5c768afdc8dea6a80a949ebcc89 ;; + openshell-sandbox) checksum=0bb160f73e5007338b94e3c868f66f50c71cd65c27c932ed9a4fa67c49e6d423 ;; + esac + run curl --fail --location --silent --show-error "https://github.com/NVIDIA/OpenShell/releases/download/v0.0.116/$archive" -o "$state/bin/$archive" + if $print_only; then + printf 'printf "%%s %%s\\n" %q %q | sha256sum --check\n' "$checksum" "$state/bin/$archive" + else + printf '%s %s\n' "$checksum" "$state/bin/$archive" | sha256sum --check + fi + run tar -xzf "$state/bin/$archive" -C "$state/bin" "$component" + run chmod 755 "$state/bin/$component" + done + run uv run --frozen python "$example/prepare.py" --state "$state" --host-ip "$host_ip" + run docker build --tag pi-admission:local "$state/image" + ;; + serve) + run uv run --frozen egress-gate serve --listen 0.0.0.0:50051 --admission-config "$state/admission.json" + ;; + gateway) + run "${runtime_env[@]}" "$state/bin/openshell-gateway" --config "$state/gateway.toml" --port 17672 --bind-address 0.0.0.0 --db-url "sqlite:$state/gateway.db" + ;; + setup) + if ! $print_only; then + : "${PI_MODEL_API_KEY:?Set PI_MODEL_API_KEY in the example .env}" + export PI_MODEL_API_KEY + EGRESS_ADMISSION_TOKEN=$(uv run --frozen python -c 'import json,sys; print(json.load(open(sys.argv[1]))["bearer_token"])' "$state/admission.json") + export EGRESS_ADMISSION_TOKEN + fi + for provider in model admission; do + run "${openshell[@]}" provider profile import --file "$state/$provider-provider.yaml" + variable=PI_MODEL_API_KEY + [[ $provider != admission ]] || variable=EGRESS_ADMISSION_TOKEN + run "${openshell[@]}" provider create --name "pi-admission-$provider" --type "pi-admission-$provider" --credential "$variable" + done + run "${openshell[@]}" sandbox create --name pi-admission --from pi-admission:local --policy "$state/policy.yaml" --provider pi-admission-model --provider pi-admission-admission --detach -- /bin/sleep infinity + if $print_only; then + printf '%q ' "${openshell[@]}" sandbox get pi-admission --output json + printf '| uv run --frozen python %q --state %q\n' "$example/bind-sandbox.py" "$state" + else + "${openshell[@]}" sandbox get pi-admission --output json | uv run --frozen python "$example/bind-sandbox.py" --state "$state" + fi + ;; + launch) + run "${openshell[@]}" sandbox exec --tty --name pi-admission -- /usr/local/bin/node /app/dist/src/cli.js --admission https://host.openshell.internal:5443/v1/admission + ;; + verify) + run "${openshell[@]}" sandbox exec --no-tty --name pi-admission -- /usr/local/bin/node /app/dist/src/verify.js --admission https://host.openshell.internal:5443/v1/admission + ;; + cleanup) + run "${openshell[@]}" sandbox delete pi-admission + for provider in model admission; do + run "${openshell[@]}" provider delete "pi-admission-$provider" + run "${openshell[@]}" provider profile delete "pi-admission-$provider" + done + run uv run --frozen python -c 'import pathlib,sys; pathlib.Path(sys.argv[1]).unlink(missing_ok=True)' "$state/sandbox-id" + printf 'Sandbox and its sessions removed. Stop serve and gateway with Ctrl-C.\n' + printf 'Host configuration and downloaded artifacts remain in %s.\n' "$state" + ;; + help) + printf 'Usage: ./demo.sh [--print] ACTION\n\n' + printf ' prepare Download pinned upstream binaries; generate local TLS/config; build image\n' + printf ' serve Run Egress Gate (keep this terminal open)\n' + printf ' gateway Run isolated OpenShell gateway (keep this terminal open)\n' + printf ' setup Create providers and sandbox; bind admission identity\n' + printf ' launch Start a new interactive Pi-powered session\n' + printf ' verify Run real allow/deny/redact, tools, skill, compaction and bypass checks\n' + printf ' cleanup Delete this sandbox/providers, including saved sessions\n' + printf '\n--print shows commands without executing .env, requiring secrets, or changing state.\n' + ;; + *) echo "Unknown action. Run ./demo.sh help." >&2; exit 2 ;; esac diff --git a/projects/egress-gate/examples/pi-attested-admission/gateway-middleware.toml.example b/projects/egress-gate/examples/pi-attested-admission/gateway-middleware.toml.example deleted file mode 100644 index 66c7d565..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/gateway-middleware.toml.example +++ /dev/null @@ -1,6 +0,0 @@ -[[openshell.supervisor.middleware]] -name = "pi-egress" -grpc_endpoint = "http://YOUR_HOST_IPV4:50051" -allow_insecure_transport = true -max_payload_bytes = 4194304 -timeout = "30s" diff --git a/projects/egress-gate/examples/pi-attested-admission/model.json b/projects/egress-gate/examples/pi-attested-admission/model.json new file mode 100644 index 00000000..febcb1cf --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/model.json @@ -0,0 +1,17 @@ +{ + "id": "azure/anthropic/claude-opus-5", + "name": "Claude Opus 5", + "provider": "pi-egress", + "api": "openai-completions", + "baseUrl": "https://inference-api.nvidia.com/v1", + "reasoning": false, + "input": ["text"], + "contextWindow": 1000000, + "maxTokens": 4096, + "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, + "compat": { + "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsReasoningEffort": false + } +} diff --git a/projects/egress-gate/examples/pi-attested-admission/models.json b/projects/egress-gate/examples/pi-attested-admission/models.json deleted file mode 100644 index 5da24a7f..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/models.json +++ /dev/null @@ -1,68 +0,0 @@ -{ - "providers": { - "attested-provider": { - "baseUrl": "https://inference-api.nvidia.com/v1", - "apiKey": "openshell-proxy", - "models": [ - { - "id": "azure/anthropic/claude-opus-5", - "name": "Claude Opus 5", - "api": "openai-completions", - "reasoning": false, - "input": ["text"], - "contextWindow": 1000000, - "maxTokens": 128000, - "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, - "compat": { - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsReasoningEffort": false - } - }, - { - "id": "azure/openai/gpt-5.6-sol", - "name": "GPT-5.6 Sol", - "api": "openai-responses", - "reasoning": true, - "thinkingLevelMap": { - "off": "none", - "minimal": "low", - "low": "low", - "medium": "medium", - "high": "high", - "xhigh": "xhigh", - "max": "max" - }, - "input": ["text"], - "contextWindow": 1050000, - "maxTokens": 128000, - "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 } - }, - { - "id": "nvidia/qwen/qwen3.8-flash-next", - "name": "Qwen3.8 Flash Next", - "api": "openai-completions", - "reasoning": true, - "thinkingLevelMap": { - "minimal": "low", - "low": "low", - "medium": "medium", - "high": "high", - "xhigh": "high", - "max": "high" - }, - "input": ["text"], - "contextWindow": 262144, - "maxTokens": 32768, - "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, - "compat": { - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsReasoningEffort": true, - "thinkingFormat": "qwen" - } - } - ] - } - } -} diff --git a/projects/egress-gate/examples/pi-attested-admission/policy.yaml b/projects/egress-gate/examples/pi-attested-admission/policy.yaml index 1bdc86b5..3033ebd6 100644 --- a/projects/egress-gate/examples/pi-attested-admission/policy.yaml +++ b/projects/egress-gate/examples/pi-attested-admission/policy.yaml @@ -18,10 +18,29 @@ network_policies: port: 443 protocol: rest enforcement: enforce - access: full + rules: + - allow: + method: POST + path: /v1/chat/completions binaries: - { path: /usr/bin/node } - { path: /usr/local/bin/node } + - { path: /usr/bin/curl } + + admission: + name: Authenticated admission API (not a model endpoint) + endpoints: + - host: host.openshell.internal + port: 5443 + protocol: rest + enforcement: enforce + rules: + - allow: + method: POST + path: /v1/admission + binaries: + - { path: /usr/local/bin/node } + - { path: /usr/bin/curl } network_middlewares: pi_egress_gate: diff --git a/projects/egress-gate/examples/pi-attested-admission/prepare.py b/projects/egress-gate/examples/pi-attested-admission/prepare.py new file mode 100644 index 00000000..a070c1b1 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/prepare.py @@ -0,0 +1,243 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Prepare one host-owned demo configuration. Never run inside the sandbox.""" + +from __future__ import annotations + +import argparse +import ipaddress +import json +import os +import secrets +import shutil +from datetime import UTC, datetime, timedelta +from pathlib import Path +from urllib.parse import urlparse + +import yaml +from cryptography import x509 +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec, ed25519 +from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID + + +def prepare(example: Path, state: Path, host_ip: str) -> None: + """Keep keys outside the image; copy only the public CA and explicit demo files.""" + ipaddress.IPv4Address(host_ip) + os.umask(0o077) + state.mkdir(parents=True, exist_ok=True) + tls = state / "tls" + if not tls.exists(): + _create_certificates(tls, host_ip) + elif (state / "host-ip").read_text() != host_ip: + raise ValueError( + "Host IP changed: clean up and move aside the demo state first" + ) + (state / "host-ip").write_text(host_ip) + model = json.loads((example / "model.json").read_text()) + target = urlparse(model["baseUrl"]) + if target.scheme != "https" or not target.hostname or target.username: + raise ValueError("The model must use an HTTPS endpoint without credentials") + if target.hostname == "host.openshell.internal": + raise ValueError("Model and admission endpoints must be separate") + model_path = target.path.rstrip("/") + "/chat/completions" + policy = yaml.safe_load((example / "policy.yaml").read_text()) + model_endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] + model_endpoint.update(host=target.hostname, port=target.port or 443) + model_endpoint["rules"][0]["allow"]["path"] = model_path + binding = policy["network_middlewares"]["pi_egress_gate"] + binding["endpoints"]["include"] = [target.hostname] + (state / "policy.yaml").write_text(yaml.safe_dump(policy, sort_keys=False)) + for name, host, port, variable in [ + ("model", target.hostname, target.port or 443, "PI_MODEL_API_KEY"), + ("admission", "host.openshell.internal", 5443, "EGRESS_ADMISSION_TOKEN"), + ]: + profile = { + "id": f"pi-admission-{name}", + "display_name": f"Pi example {name}", + "category": "inference" if name == "model" else "other", + "credentials": [ + {"name": "token", "env_vars": [variable], "required": True} + ], + "discovery": {"credentials": ["token"]}, + "endpoints": [ + { + "host": host, + "port": port, + "protocol": "rest", + "access": "read-write", + "enforcement": "enforce", + } + ], + "binaries": ["/usr/local/bin/node", "/usr/bin/curl"], + } + (state / f"{name}-provider.yaml").write_text(yaml.safe_dump(profile)) + config_path = state / "admission.json" + token = ( + json.loads(config_path.read_text())["bearer_token"] + if config_path.exists() + else secrets.token_urlsafe(32) + ) + audience = "urn:openshell:extension:middleware:pi-egress" + config = { + "listen": "0.0.0.0:5443", + "tls_certificate": str(tls / "server/tls.crt"), + "tls_private_key": str(tls / "server/tls.key"), + "gateway_public_key": str(tls / "jwt/public.pem"), + "gateway_issuer": "openshell-gateway:openshell", + "gateway_audience": audience, + "middleware_name": "pi-egress", + "bearer_token": token, + "sandbox_id_file": str(state / "sandbox-id"), + "provider_target": { + "scheme": "https", + "host": target.hostname, + "port": target.port or 443, + "method": "POST", + "path": model_path, + "query": "", + }, + "policy": binding["config"], + } + config_path.write_text(json.dumps(config, indent=2) + "\n") + # JSON string quoting is also valid for these TOML basic string values. + quote = json.dumps + gateway = f"""[openshell] +version = 1 +[openshell.gateway] +name = "pi-admission" +compute_drivers = ["docker"] +[openshell.drivers.docker] +supervisor_bin = {quote(str(state / "bin/openshell-sandbox"))} +[[openshell.supervisor.middleware]] +name = "pi-egress" +grpc_endpoint = "https://{host_ip}:50051" +tls_ca_cert_path = {quote(str(tls / "ca.crt"))} +audience = "{audience}" +max_payload_bytes = 4194304 +timeout = "10s" +""" + (state / "gateway.toml").write_text(gateway) + client = state / "config/openshell/gateways/pi-admission/mtls" + client.mkdir(parents=True, exist_ok=True) + for source, name in [ + (tls / "ca.crt", "ca.crt"), + (tls / "client/tls.crt", "tls.crt"), + (tls / "client/tls.key", "tls.key"), + ]: + shutil.copyfile(source, client / name) + image = state / "image" + # Recreate only this generated build context, so removed source/config files + # cannot survive a subsequent prepare. Host keys and runtime state stay put. + if image.exists(): + shutil.rmtree(image) + image.mkdir() + for directory in ("app/src", "app/test"): + shutil.copytree(example / directory, image / directory, dirs_exist_ok=True) + for name in ("package.json", "package-lock.json", "tsconfig.json"): + shutil.copyfile(example / "app" / name, image / "app" / name) + # This is one explicit project, not a recursive upload of the operator's cwd. + for name in ("AGENTS.md", "notes.txt", ".pi/skills/review/SKILL.md"): + destination = image / "project" / name + destination.parent.mkdir(parents=True, exist_ok=True) + shutil.copyfile(example / "project" / name, destination) + shutil.copyfile(example / "model.json", image / "model.json") + shutil.copyfile(example / "sandbox/Dockerfile", image / "Dockerfile") + shutil.copyfile(tls / "ca.crt", image / "admission-ca.crt") + + +def _create_certificates(tls: Path, host_ip: str) -> None: + now = datetime.now(UTC) + ca_key = ec.generate_private_key(ec.SECP256R1()) + ca_name = x509.Name( + [x509.NameAttribute(NameOID.COMMON_NAME, "Pi admission demo CA")] + ) + ca = ( + x509.CertificateBuilder() + .subject_name(ca_name) + .issuer_name(ca_name) + .public_key(ca_key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + timedelta(days=30)) + .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) + .sign(ca_key, hashes.SHA256()) + ) + tls.mkdir() + (tls / "ca.crt").write_bytes(ca.public_bytes(serialization.Encoding.PEM)) + # The CA key is not needed again; each setup has a 30-day local trust bundle. + for role in ("server", "client"): + key = ec.generate_private_key(ec.SECP256R1()) + certificate = ( + x509.CertificateBuilder() + .subject_name( + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"pi-{role}")]) + ) + .issuer_name(ca_name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + timedelta(days=30)) + .add_extension( + x509.BasicConstraints(ca=False, path_length=None), critical=True + ) + .add_extension( + x509.ExtendedKeyUsage( + [ + ExtendedKeyUsageOID.SERVER_AUTH, + ExtendedKeyUsageOID.CLIENT_AUTH, + ] + ), + critical=False, + ) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("localhost"), + x509.DNSName("host.openshell.internal"), + x509.DNSName("host.docker.internal"), + x509.IPAddress(ipaddress.ip_address("127.0.0.1")), + x509.IPAddress(ipaddress.ip_address(host_ip)), + ] + ), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + directory = tls / role + directory.mkdir() + (directory / "tls.crt").write_bytes( + certificate.public_bytes(serialization.Encoding.PEM) + ) + (directory / "tls.key").write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + jwt_key = ed25519.Ed25519PrivateKey.generate() + (tls / "jwt").mkdir() + (tls / "jwt/signing.pem").write_bytes( + jwt_key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + (tls / "jwt/public.pem").write_bytes( + jwt_key.public_key().public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ) + ) + (tls / "jwt/kid").write_text(secrets.token_hex(16)) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--host-ip", required=True) + args = parser.parse_args() + prepare(Path(__file__).resolve().parent, args.state.resolve(), args.host_ip) diff --git a/projects/egress-gate/examples/pi-attested-admission/project/.pi/skills/review/SKILL.md b/projects/egress-gate/examples/pi-attested-admission/project/.pi/skills/review/SKILL.md new file mode 100644 index 00000000..22d559f8 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/project/.pi/skills/review/SKILL.md @@ -0,0 +1,6 @@ +--- +name: review +description: Review the small demo project using its real files. +--- +Read notes.txt and summarize what you find. This skill itself contains +REDACT_THIS so its expansion demonstrates replacement before history insertion. diff --git a/projects/egress-gate/examples/pi-attested-admission/project/AGENTS.md b/projects/egress-gate/examples/pi-attested-admission/project/AGENTS.md new file mode 100644 index 00000000..0e51298d --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/project/AGENTS.md @@ -0,0 +1,4 @@ +# Demo project + +Use the project tools when asked to inspect files. Report observations accurately. +This project demonstrates admission before conversation-history writes. diff --git a/projects/egress-gate/examples/pi-attested-admission/project/notes.txt b/projects/egress-gate/examples/pi-attested-admission/project/notes.txt new file mode 100644 index 00000000..e72101a0 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/project/notes.txt @@ -0,0 +1,2 @@ +This is a real file in the sandbox project. +REDACT_THIS is an intentionally harmless marker for the example replacement rule. diff --git a/projects/egress-gate/examples/pi-attested-admission/provider-profile.yaml b/projects/egress-gate/examples/pi-attested-admission/provider-profile.yaml deleted file mode 100644 index 8980d48d..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/provider-profile.yaml +++ /dev/null @@ -1,22 +0,0 @@ -id: pi-attested-model -display_name: Pi attested-admission model -description: Endpoint-scoped model credential for the Pi attested-admission example -category: inference -inference_capable: true -credentials: - - name: api_key - description: Model provider API key - env_vars: [PI_MODEL_API_KEY] - required: true - delivery: proxy - auth_style: bearer - header_name: authorization -discovery: - credentials: [api_key] -endpoints: - - host: inference-api.nvidia.com - port: 443 - protocol: rest - access: read-write - enforcement: enforce -binaries: [/usr/bin/node, /usr/local/bin/node] diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts deleted file mode 100644 index 8279c9f0..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts +++ /dev/null @@ -1,464 +0,0 @@ -import { Buffer } from "node:buffer"; -import { createHash } from "node:crypto"; -import type { AgentMessage } from "@earendil-works/pi-agent-core"; -import type { - AssistantMessage, - Context, - ImageContent, - ProviderHeaders, - TextContent, - ToolResultMessage, - UserMessage, -} from "@earendil-works/pi-ai"; -import type { - ContextAdmission, - ContextAdmissionResult, - MessageOrigin, -} from "@earendil-works/pi-coding-agent"; - -const HANDLE_HEADER = "x-openshell-agent-admission-handle"; -const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; -const MAX_BRIDGE_RESPONSE_BYTES = MAX_ADMISSION_BYTES * 4 + 64 * 1024; -const MAX_HANDLE_ENTRIES = 1024; - -type ContentBlock = TextContent | ImageContent; -type MessageEnvelope = { - schema_version: "openshell.pi-message.v1"; - origin: "user" | "compaction_summary" | "branch_summary" | "extension_message"; - text: string; -}; -type ToolResultEnvelope = { - schema_version: "openshell.pi-tool-result.v1"; - tool_call_id: string; - tool_name: string; - content: ContentBlock[]; - is_error: boolean; -}; -type AssistantEnvelope = { - schema_version: "openshell.pi-assistant-message.v1"; - text: string; - tool_calls: { id: string; name: string; arguments: Record }[]; -}; -type BashEnvelope = { - schema_version: "openshell.pi-bash-execution.v1"; - command: string; - output: string; - exit_code: number | null; -}; -type ContextEntry = - | { role: "user"; text: string } - | { role: "tool"; tool_call_id: string; text: string }; -type ProviderContextEnvelope = { - schema_version: "openshell.pi-provider-context.v1"; - entries: ContextEntry[]; -}; -type AdmissionEnvelope = MessageEnvelope | ToolResultEnvelope | AssistantEnvelope | BashEnvelope; -type BridgeEnvelope = AdmissionEnvelope | ProviderContextEnvelope; -type AppendAdmissionHook = - | "user_message" - | "tool_result" - | "assistant_message" - | "compaction_summary" - | "branch_summary" - | "extension_message" - | "bash_execution"; -type AdmissionHook = AppendAdmissionHook | "provider_context"; -type BridgeResult = - | { decision: "deny"; reason_code?: string } - | { decision: "allow"; handle?: string; replacement_body?: Uint8Array }; - -type SummaryMessage = AgentMessage & { summary: string }; -type CustomMessage = AgentMessage & { content: string | ContentBlock[] }; -type BashMessage = AgentMessage & { command: string; output: string; exitCode: number | undefined }; - -export function createOpenShellContextAdmission( - bridgeUrl: string, - getSessionId: () => string, - admissionToken: string, - fetchRequest: typeof fetch = fetch, -): ContextAdmission { - const handles = new Map(); - - async function requestAdmission(hook: AdmissionHook, envelope: BridgeEnvelope): Promise { - const requestBody = new TextEncoder().encode(canonicalJson(envelope)); - if (requestBody.byteLength > MAX_ADMISSION_BYTES) { - throw new Error("OpenShell admission request is too large"); - } - const response = await fetchRequest(bridgeUrl, { - method: "POST", - headers: { - authorization: `Bearer ${admissionToken}`, - "content-type": "application/json", - }, - body: JSON.stringify({ - harness_version: "sdk-v1", - hook, - schema_version: envelope.schema_version, - session_id: getSessionId(), - submission_id: crypto.randomUUID(), - request_body_b64: Buffer.from(requestBody).toString("base64"), - }), - }); - if (!response.ok) throw new Error("OpenShell admission is unavailable"); - const encoded = new Uint8Array(await response.arrayBuffer()); - if (encoded.byteLength > MAX_BRIDGE_RESPONSE_BYTES) { - throw new Error("OpenShell admission response is too large"); - } - return parseBridgeResult(JSON.parse(new TextDecoder().decode(encoded))); - } - - async function admitMessage( - message: T, - meta: { origin: MessageOrigin }, - ): Promise> { - const prepared = envelopeForMessage(message, meta.origin); - if (!prepared) { - return { action: "deny", reason: "Image inputs are not supported by OpenShell admission" }; - } - const result = await requestAdmission(prepared.hook, prepared.envelope); - if (result.decision === "deny") return denied(result.reason_code); - const admittedEnvelope = result.replacement_body - ? parseReplacement(prepared.hook, result.replacement_body) - : prepared.envelope; - const admittedMessage = applyReplacement(message, meta.origin, admittedEnvelope); - return result.replacement_body ? { action: "allow", message: admittedMessage } : { action: "allow" }; - } - - return { - admitMessage, - async admitProviderContext(context) { - const envelope = providerContextEnvelope(context); - if (!envelope) { - return { action: "deny", reason: "Image inputs are not supported by OpenShell admission" }; - } - const result = await requestAdmission("provider_context", envelope); - if (result.decision === "deny") return denied(result.reason_code); - if (!result.handle) throw new Error("OpenShell admission returned no provider-context handle"); - const admittedEnvelope = result.replacement_body - ? parseProviderContextReplacement(result.replacement_body, envelope) - : envelope; - const admittedContext = applyProviderContextReplacement(context, admittedEnvelope.entries); - rememberHandle(handles, contextKey(admittedEnvelope), result.handle); - return result.replacement_body ? { action: "allow", context: admittedContext } : { action: "allow" }; - }, - async transformProviderHeaders(headers: ProviderHeaders, context: Context) { - if (Object.keys(headers).some((name) => name.toLowerCase() === HANDLE_HEADER)) { - throw new Error("OpenShell admission handle header is reserved"); - } - const envelope = providerContextEnvelope(context); - if (!envelope) throw new Error("Image inputs are not supported by OpenShell admission"); - const handle = handles.get(contextKey(envelope)); - if (handle) return { ...headers, [HANDLE_HEADER]: handle }; - throw new Error("OpenShell admission handle is missing for the outbound context"); - }, - }; -} - -function envelopeForMessage( - message: AgentMessage, - origin: MessageOrigin, -): { hook: AppendAdmissionHook; envelope: AdmissionEnvelope } | undefined { - switch (origin) { - case "user": { - if (message.role !== "user") throw new Error("Pi admission origin does not match the message"); - const envelope = textEnvelope("user", message.content); - return envelope && { hook: "user_message", envelope }; - } - case "tool_result": - if (message.role !== "toolResult") throw new Error("Pi admission origin does not match the message"); - return { hook: "tool_result", envelope: toolResultEnvelope(message) }; - case "assistant": - if (message.role !== "assistant") throw new Error("Pi admission origin does not match the message"); - return { hook: "assistant_message", envelope: assistantEnvelope(message) }; - case "compaction_summary": - if (message.role !== "compactionSummary") throw new Error("Pi admission origin does not match the message"); - return { - hook: "compaction_summary", - envelope: messageEnvelope("compaction_summary", (message as SummaryMessage).summary), - }; - case "branch_summary": - if (message.role !== "branchSummary") throw new Error("Pi admission origin does not match the message"); - return { - hook: "branch_summary", - envelope: messageEnvelope("branch_summary", (message as SummaryMessage).summary), - }; - case "extension_message": { - if (message.role !== "custom") throw new Error("Pi admission origin does not match the message"); - const envelope = textEnvelope("extension_message", (message as CustomMessage).content); - return envelope && { hook: "extension_message", envelope }; - } - case "bash_execution": { - if (message.role !== "bashExecution") throw new Error("Pi admission origin does not match the message"); - const bash = message as BashMessage; - return { - hook: "bash_execution", - envelope: { - command: bash.command, - exit_code: bash.exitCode ?? null, - output: bash.output, - schema_version: "openshell.pi-bash-execution.v1", - }, - }; - } - } -} - -function messageEnvelope(origin: MessageEnvelope["origin"], text: string): MessageEnvelope { - return { origin, schema_version: "openshell.pi-message.v1", text }; -} - -function textEnvelope(origin: MessageEnvelope["origin"], content: string | ContentBlock[]): MessageEnvelope | undefined { - if (typeof content === "string") return messageEnvelope(origin, content); - if (content.some((block) => block.type === "image")) return undefined; - return { - origin, - schema_version: "openshell.pi-message.v1", - text: content.map((block) => (block as TextContent).text).join("\n"), - }; -} - -function toolResultEnvelope(message: ToolResultMessage): ToolResultEnvelope { - return { - schema_version: "openshell.pi-tool-result.v1", - tool_call_id: message.toolCallId, - tool_name: message.toolName, - content: message.content, - is_error: message.isError, - }; -} - -function assistantEnvelope(message: AssistantMessage): AssistantEnvelope { - return { - schema_version: "openshell.pi-assistant-message.v1", - text: message.content - .filter((block): block is TextContent => block.type === "text") - .map((block) => block.text) - .join("\n"), - tool_calls: message.content - .filter((block) => block.type === "toolCall") - .map(({ id, name, arguments: args }) => ({ arguments: args, id, name })), - }; -} - -function applyReplacement(message: T, origin: MessageOrigin, envelope: AdmissionEnvelope): T { - switch (origin) { - case "user": - return { ...message, content: replaceTextContent((message as UserMessage).content, (envelope as MessageEnvelope).text) }; - case "tool_result": - return { ...message, content: (envelope as ToolResultEnvelope).content }; - case "assistant": - return replaceAssistantText(message as AssistantMessage, (envelope as AssistantEnvelope).text) as T; - case "compaction_summary": - case "branch_summary": - return { ...message, summary: (envelope as MessageEnvelope).text }; - case "extension_message": - return { ...message, content: replaceTextContent((message as CustomMessage).content, (envelope as MessageEnvelope).text) }; - case "bash_execution": - return { ...message, output: (envelope as BashEnvelope).output }; - } - throw new Error("Pi admission origin is unsupported"); -} - -function replaceAssistantText(message: AssistantMessage, text: string): AssistantMessage { - const content: AssistantMessage["content"] = []; - let replaced = false; - for (const block of message.content) { - if (block.type !== "text") { - content.push(block); - } else if (!replaced) { - if (text) content.push({ type: "text", text }); - replaced = true; - } - } - if (!replaced && text) content.push({ type: "text", text }); - return { ...message, content }; -} - -function replaceTextContent(content: string | ContentBlock[], text: string): string | TextContent[] { - return typeof content === "string" ? text : [{ type: "text", text }]; -} - -function providerContextEnvelope(context: Context): ProviderContextEnvelope | undefined { - const entries: ContextEntry[] = []; - for (const message of context.messages) { - if (message.role === "user") { - const text = textContent(message.content); - if (text === undefined) return undefined; - entries.push({ role: "user", text }); - } else if (message.role === "toolResult") { - const text = textBlocks(message.content); - if (text === undefined) return undefined; - entries.push({ - role: "tool", - tool_call_id: message.toolCallId.split("|", 1)[0], - text: text || "(no tool output)", - }); - } - } - if (entries.length === 0) throw new Error("Provider context has no user message or tool result to admit"); - return { schema_version: "openshell.pi-provider-context.v1", entries }; -} - -function textContent(content: string | ContentBlock[]): string | undefined { - if (typeof content === "string") return content; - return textBlocks(content); -} - -function textBlocks(content: ContentBlock[]): string | undefined { - if (content.some((block) => block.type === "image")) return undefined; - return content.map((block) => (block as TextContent).text).join("\n"); -} - -function applyProviderContextReplacement(context: Context, entries: ContextEntry[]): Context { - let entryIndex = 0; - const messages = context.messages.map((message) => { - if (message.role !== "user" && message.role !== "toolResult") return message; - const entry = entries[entryIndex++]; - if (message.role === "user") { - if (entry.role !== "user") throw new Error("OpenShell admission changed provider-context structure"); - return { ...message, content: replaceTextContent(message.content, entry.text) }; - } - if (entry.role !== "tool" || entry.tool_call_id !== message.toolCallId.split("|", 1)[0]) { - throw new Error("OpenShell admission changed provider-context structure"); - } - return { ...message, content: [{ type: "text" as const, text: entry.text }] }; - }); - if (entryIndex !== entries.length) throw new Error("OpenShell admission changed provider-context structure"); - return { ...context, messages }; -} - -function contextKey(envelope: ProviderContextEnvelope): string { - return createHash("sha256").update(canonicalJson(envelope.entries)).digest("hex"); -} - -function canonicalJson(value: unknown): string { - return JSON.stringify(sortJson(value)); -} - -function sortJson(value: unknown): unknown { - if (Array.isArray(value)) return value.map(sortJson); - if (!isRecord(value)) return value; - return Object.fromEntries(Object.keys(value).sort().map((key) => [key, sortJson(value[key])])); -} - -function rememberHandle(handles: Map, key: string, handle: string): void { - handles.delete(key); - handles.set(key, handle); - if (handles.size > MAX_HANDLE_ENTRIES) { - const oldest = handles.keys().next().value; - if (oldest !== undefined) handles.delete(oldest); - } -} - -function denied(reasonCode?: string): { action: "deny"; reason: string } { - return { - action: "deny", - reason: reasonCode - ? `OpenShell denied this context addition (${reasonCode})` - : "OpenShell denied this context addition", - }; -} - -function parseBridgeResult(value: unknown): BridgeResult { - if (!isRecord(value) || (value.decision !== "allow" && value.decision !== "deny")) { - throw new Error("OpenShell admission returned an invalid response"); - } - if (value.decision === "deny") { - return { decision: "deny", reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined }; - } - if ( - value.handle !== undefined && - (typeof value.handle !== "string" || !value.handle || value.handle.length > 1024) - ) { - throw new Error("OpenShell admission returned an invalid handle"); - } - let replacementBody: Uint8Array | undefined; - if (value.replacement_body_b64 !== undefined) { - if (typeof value.replacement_body_b64 !== "string") { - throw new Error("OpenShell admission returned an invalid replacement"); - } - const decoded = Buffer.from(value.replacement_body_b64, "base64"); - if (decoded.toString("base64") !== value.replacement_body_b64 || decoded.byteLength > MAX_ADMISSION_BYTES) { - throw new Error("OpenShell admission returned an invalid replacement"); - } - replacementBody = decoded; - } - return { decision: "allow", handle: value.handle, replacement_body: replacementBody }; -} - -function parseProviderContextReplacement( - body: Uint8Array, - original: ProviderContextEnvelope, -): ProviderContextEnvelope { - const value: unknown = JSON.parse(new TextDecoder().decode(body)); - if ( - !isRecord(value) || - value.schema_version !== "openshell.pi-provider-context.v1" || - !Array.isArray(value.entries) || - value.entries.length !== original.entries.length - ) { - throw new Error("OpenShell admission returned an invalid provider-context replacement"); - } - const entries = value.entries.map((entry, index): ContextEntry => { - const expected = original.entries[index]; - if (!isRecord(entry) || entry.role !== expected.role || typeof entry.text !== "string") { - throw new Error("OpenShell admission changed provider-context structure"); - } - if (entry.role === "user" && entry.tool_call_id === undefined) return { role: "user", text: entry.text }; - if ( - entry.role === "tool" && - typeof entry.tool_call_id === "string" && - expected.role === "tool" && - entry.tool_call_id === expected.tool_call_id - ) { - return { role: "tool", tool_call_id: entry.tool_call_id, text: entry.text }; - } - throw new Error("OpenShell admission changed provider-context structure"); - }); - return { schema_version: "openshell.pi-provider-context.v1", entries }; -} - -function parseReplacement(hook: AppendAdmissionHook, body: Uint8Array): AdmissionEnvelope { - const value: unknown = JSON.parse(new TextDecoder().decode(body)); - if (!isRecord(value)) throw new Error("OpenShell admission returned an invalid replacement"); - switch (hook) { - case "user_message": - case "compaction_summary": - case "branch_summary": - case "extension_message": - if ( - value.schema_version !== "openshell.pi-message.v1" || - typeof value.origin !== "string" || - typeof value.text !== "string" - ) throw new Error("OpenShell admission returned an invalid message replacement"); - return value as MessageEnvelope; - case "tool_result": - if ( - value.schema_version !== "openshell.pi-tool-result.v1" || - typeof value.tool_call_id !== "string" || - typeof value.tool_name !== "string" || - !Array.isArray(value.content) || - typeof value.is_error !== "boolean" - ) throw new Error("OpenShell admission returned an invalid tool-result replacement"); - return value as ToolResultEnvelope; - case "assistant_message": - if ( - value.schema_version !== "openshell.pi-assistant-message.v1" || - typeof value.text !== "string" || - !Array.isArray(value.tool_calls) - ) throw new Error("OpenShell admission returned an invalid assistant replacement"); - return value as AssistantEnvelope; - case "bash_execution": - if ( - value.schema_version !== "openshell.pi-bash-execution.v1" || - typeof value.command !== "string" || - typeof value.output !== "string" || - (value.exit_code !== null && typeof value.exit_code !== "number") - ) throw new Error("OpenShell admission returned an invalid bash replacement"); - return value as BashEnvelope; - } -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object"; -} diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts deleted file mode 100644 index 5cd15793..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/openshell-pi.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { closeSync, readFileSync } from "node:fs"; -import { runCli, type RuntimeExtension } from "@earendil-works/pi-coding-agent"; - -import { createOpenShellContextAdmission } from "./openshell-context-admission.js"; - -const bridgeUrl = process.env.OPENSHELL_AGENT_CONVERSATION_URL; -if (!bridgeUrl) { - throw new Error("OPENSHELL_AGENT_CONVERSATION_URL is required"); -} - -const runtimeExtension = createRuntimeExtension(bridgeUrl, readAdmissionToken()); - -await runCli(process.argv.slice(2), { runtimeExtension }); - -function createRuntimeExtension(bridgeUrl: string, admissionToken: string): RuntimeExtension { - return { - createContextAdmission: (sessionManager) => - createOpenShellContextAdmission(bridgeUrl, () => sessionManager.getSessionId(), admissionToken), - }; -} - -function readAdmissionToken(): string { - const tokenFdValue = process.env.OPENSHELL_AGENT_ADMISSION_TOKEN_FD; - delete process.env.OPENSHELL_AGENT_ADMISSION_TOKEN_FD; - if (!tokenFdValue || !/^\d+$/.test(tokenFdValue)) { - throw new Error("OPENSHELL_AGENT_ADMISSION_TOKEN_FD must name a readable file descriptor"); - } - - const tokenFd = Number(tokenFdValue); - let admissionToken: string; - try { - admissionToken = readFileSync(tokenFd, "utf8"); - } catch (cause) { - try { - closeSync(tokenFd); - } catch {} - throw new Error("Could not read the OpenShell agent admission token", { cause }); - } - try { - closeSync(tokenFd); - } catch (cause) { - throw new Error("Could not close the OpenShell agent admission token descriptor", { cause }); - } - if (!/^[A-Za-z0-9_-]{43}$/.test(admissionToken)) { - throw new Error("OpenShell supplied an invalid agent admission token"); - } - return admissionToken; -} diff --git a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/package.json b/projects/egress-gate/examples/pi-attested-admission/runtime-extension/package.json deleted file mode 100644 index e986b24b..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/runtime-extension/package.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "private": true, - "type": "module" -} diff --git a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile index d80e70b5..ba1f0a6c 100644 --- a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile +++ b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile @@ -1,13 +1,24 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -FROM ghcr.io/nvidia/openshell-community/sandboxes/pi:latest +FROM node:22.22.2-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e -USER root RUN apt-get update \ - && apt-get install -y --no-install-recommends fd-find ripgrep \ + && apt-get install -y --no-install-recommends ca-certificates curl fd-find ripgrep git iproute2 iptables nftables \ && ln -s /usr/bin/fdfind /usr/local/bin/fd \ - && mkdir -p /sandbox/workspace \ - && chown sandbox:sandbox /sandbox/workspace \ + && useradd --create-home --uid 1001 sandbox \ && rm -rf /var/lib/apt/lists/* +COPY admission-ca.crt /usr/local/share/ca-certificates/admission-ca.crt +RUN update-ca-certificates +WORKDIR /app +COPY app/package.json app/package-lock.json ./ +RUN npm ci --ignore-scripts --no-audit --no-fund +COPY app/ ./ +RUN npm run build && mkdir /app/agent +COPY model.json /app/model.json +COPY --chown=sandbox:sandbox project/ /sandbox/project/ +RUN mkdir /sandbox/sessions && chown sandbox:sandbox /sandbox/sessions \ + && chmod -R a+rX /app /sandbox/project +ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt +WORKDIR /sandbox/project USER sandbox diff --git a/projects/egress-gate/examples/pi-attested-admission/settings.json b/projects/egress-gate/examples/pi-attested-admission/settings.json deleted file mode 100644 index 3b1578da..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/settings.json +++ /dev/null @@ -1,5 +0,0 @@ -{ - "defaultProvider": "attested-provider", - "defaultModel": "nvidia/qwen/qwen3.8-flash-next", - "defaultThinkingLevel": "high" -} diff --git a/projects/egress-gate/scripts/check.sh b/projects/egress-gate/scripts/check.sh index 0a02d5fb..ace57ab6 100755 --- a/projects/egress-gate/scripts/check.sh +++ b/projects/egress-gate/scripts/check.sh @@ -20,6 +20,9 @@ fi "${uv_run[@]}" ruff check . "${uv_run[@]}" ty check "${uv_run[@]}" python -c "import egress_gate" +npm --prefix examples/pi-attested-admission/app ci --ignore-scripts --no-audit --no-fund +npm --prefix examples/pi-attested-admission/app run build +npm --prefix examples/pi-attested-admission/app test "${uv_run[@]}" pip-audit \ --progress-spinner off \ --local diff --git a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs b/projects/egress-gate/tests/js/openshell-context-admission.test.mjs deleted file mode 100644 index 31107c8b..00000000 --- a/projects/egress-gate/tests/js/openshell-context-admission.test.mjs +++ /dev/null @@ -1,321 +0,0 @@ -import assert from "node:assert/strict"; -import { readFileSync } from "node:fs"; -import { describe, it } from "node:test"; - -import { createOpenShellContextAdmission } from "../../examples/pi-attested-admission/runtime-extension/openshell-context-admission.ts"; - -const HANDLE_HEADER = "x-openshell-agent-admission-handle"; -const ADMISSION_TOKEN = "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA"; -const ENTRY_VECTORS = JSON.parse( - readFileSync(new URL("../admission/fixtures/context-entries.json", import.meta.url), "utf8"), -); - -function user(text, timestamp) { - return { role: "user", content: [{ type: "text", text }], timestamp }; -} - -function toolResult(text, isError = false) { - return { - role: "toolResult", - toolCallId: "call-1", - toolName: "bash", - content: [{ type: "text", text }], - isError, - timestamp: 2, - }; -} - -function assistant(text) { - return { - role: "assistant", - content: [ - { type: "thinking", thinking: "keep reasoning" }, - { type: "text", text }, - { type: "toolCall", id: "call-1", name: "read", arguments: { path: "safe" } }, - ], - }; -} - -async function admittedContext(admission, context) { - const result = await admission.admitProviderContext(context); - assert.equal(result.action, "allow"); - return result.context ?? context; -} - -describe("OpenShell context admission adapter", () => { - it("selects the handle for the exact provider context", async () => { - let providerCalls = 0; - const admission = createOpenShellContextAdmission( - "http://bridge.test/admit", - () => "session-123", - ADMISSION_TOKEN, - async (_url, init) => { - assert.equal(new Headers(init?.headers).get("authorization"), `Bearer ${ADMISSION_TOKEN}`); - const request = JSON.parse(String(init?.body)); - const requestBody = Buffer.from(request.request_body_b64, "base64").toString(); - const envelope = JSON.parse(requestBody); - if (request.hook !== "provider_context") { - return new Response(JSON.stringify({ decision: "allow" })); - } - providerCalls += 1; - assert.equal(envelope.schema_version, "openshell.pi-provider-context.v1"); - return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.entries.length}` })); - }, - ); - const current = user("current", 1); - const queued = { role: "user", content: "queued", timestamp: 2 }; - - assert.equal((await admission.admitMessage(current, { origin: "user", source: "interactive" })).action, "allow"); - assert.equal((await admission.admitMessage(queued, { origin: "user", source: "interactive" })).action, "allow"); - - const currentHeaders = await admission.transformProviderHeaders( - {}, - await admittedContext(admission, { messages: [current], tools: [] }), - ); - const queuedHeaders = await admission.transformProviderHeaders( - {}, - await admittedContext(admission, { messages: [current, queued], tools: [] }), - ); - - assert.equal(currentHeaders[HANDLE_HEADER], "handle:1"); - assert.equal(queuedHeaders[HANDLE_HEADER], "handle:2"); - assert.equal(providerCalls, 2); - }); - - it("uses an admitted replacement for the outbound handle", async () => { - const replacement = new TextEncoder().encode( - JSON.stringify({ - schema_version: "openshell.pi-provider-context.v1", - entries: [ - { role: "user", text: "[REDACTED]" }, - { role: "tool", tool_call_id: "call-1", text: "[TOOL REDACTED]" }, - ], - }), - ); - const admission = createOpenShellContextAdmission( - "http://bridge.test/admit", - () => "session-123", - ADMISSION_TOKEN, - async () => - new Response( - JSON.stringify({ - decision: "allow", - handle: "replacement-handle", - replacement_body_b64: Buffer.from(replacement).toString("base64"), - }), - ), - ); - const context = await admittedContext(admission, { - messages: [user("secret", 1), toolResult("tool secret")], - tools: [], - }); - const headers = await admission.transformProviderHeaders({}, context); - - assert.deepEqual(context.messages[0].content, [{ type: "text", text: "[REDACTED]" }]); - assert.deepEqual(context.messages[1].content, [{ type: "text", text: "[TOOL REDACTED]" }]); - assert.equal(headers[HANDLE_HEADER], "replacement-handle"); - }); - - it("attests failed tool results", async () => { - const hooks = []; - const admission = createOpenShellContextAdmission( - "http://bridge.test/admit", - () => "session-123", - ADMISSION_TOKEN, - async (_url, init) => { - const request = JSON.parse(String(init?.body)); - hooks.push(request.hook); - return new Response(JSON.stringify({ - decision: "allow", - ...(request.hook === "provider_context" ? { handle: "context-handle" } : {}), - })); - }, - ); - const prompt = user("run command", 1); - const failed = toolResult("Command exited with code 2", true); - - await admission.admitMessage(prompt, { origin: "user", source: "interactive" }); - await admission.admitMessage(failed, { origin: "tool_result" }); - const headers = await admission.transformProviderHeaders( - {}, - await admittedContext(admission, { messages: [prompt, failed], tools: [] }), - ); - - assert.equal(headers[HANDLE_HEADER], "context-handle"); - assert.deepEqual(hooks, ["user_message", "tool_result", "provider_context"]); - }); - - it("maps every Pi message origin to its exact hook and envelope", async () => { - const cases = [ - { - origin: "user", - message: user("user text", 1), - hook: "user_message", - schema: "openshell.pi-message.v1", - expected: { origin: "user", text: "user text" }, - }, - { - origin: "tool_result", - message: toolResult("tool text"), - hook: "tool_result", - schema: "openshell.pi-tool-result.v1", - expected: { tool_call_id: "call-1", tool_name: "bash", is_error: false }, - }, - { - origin: "assistant", - message: assistant("assistant text"), - hook: "assistant_message", - schema: "openshell.pi-assistant-message.v1", - expected: { text: "assistant text", tool_calls: [{ id: "call-1", name: "read", arguments: { path: "safe" } }] }, - }, - { - origin: "compaction_summary", - message: { role: "compactionSummary", summary: "compact text" }, - hook: "compaction_summary", - schema: "openshell.pi-message.v1", - expected: { origin: "compaction_summary", text: "compact text" }, - }, - { - origin: "branch_summary", - message: { role: "branchSummary", summary: "branch text" }, - hook: "branch_summary", - schema: "openshell.pi-message.v1", - expected: { origin: "branch_summary", text: "branch text" }, - }, - { - origin: "extension_message", - message: { role: "custom", content: "extension text" }, - hook: "extension_message", - schema: "openshell.pi-message.v1", - expected: { origin: "extension_message", text: "extension text" }, - }, - { - origin: "bash_execution", - message: { role: "bashExecution", command: "printf safe", output: "bash text", exitCode: 0 }, - hook: "bash_execution", - schema: "openshell.pi-bash-execution.v1", - expected: { command: "printf safe", output: "bash text", exit_code: 0 }, - }, - ]; - const observed = []; - const admission = createOpenShellContextAdmission( - "http://bridge.test/admit", - () => "session-123", - ADMISSION_TOKEN, - async (_url, init) => { - const request = JSON.parse(String(init?.body)); - const envelope = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); - observed.push({ request, envelope }); - return new Response(JSON.stringify({ decision: "allow" })); - }, - ); - - for (const item of cases) { - assert.equal((await admission.admitMessage(item.message, { origin: item.origin })).action, "allow"); - } - assert.equal(observed.length, cases.length); - for (const [index, item] of cases.entries()) { - assert.equal(observed[index].request.hook, item.hook); - assert.equal(observed[index].request.schema_version, item.schema); - assert.equal(observed[index].envelope.schema_version, item.schema); - for (const [key, value] of Object.entries(item.expected)) { - assert.deepEqual(observed[index].envelope[key], value); - } - } - assert.deepEqual(Object.keys(observed[2].envelope), ["schema_version", "text", "tool_calls"]); - assert.deepEqual(Object.keys(observed[2].envelope.tool_calls[0]), ["arguments", "id", "name"]); - assert.deepEqual(Object.keys(observed[6].envelope), ["command", "exit_code", "output", "schema_version"]); - }); - - it("applies replacements only to the origin's replaceable text", async () => { - const admission = createOpenShellContextAdmission( - "http://bridge.test/admit", - () => "session-123", - ADMISSION_TOKEN, - async (_url, init) => { - const request = JSON.parse(String(init?.body)); - const envelope = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); - if ("text" in envelope) envelope.text = "[REDACTED]"; - if ("output" in envelope) envelope.output = "[REDACTED]"; - return new Response( - JSON.stringify({ - decision: "allow", - replacement_body_b64: Buffer.from(JSON.stringify(envelope)).toString("base64"), - }), - ); - }, - ); - - const summary = await admission.admitMessage( - { role: "compactionSummary", summary: "secret" }, - { origin: "compaction_summary" }, - ); - const bash = await admission.admitMessage( - { role: "bashExecution", command: "printf safe", output: "secret", exitCode: 7 }, - { origin: "bash_execution" }, - ); - const reply = await admission.admitMessage(assistant("secret"), { origin: "assistant" }); - - assert.equal(summary.message.summary, "[REDACTED]"); - assert.deepEqual( - { command: bash.message.command, output: bash.message.output, exitCode: bash.message.exitCode }, - { command: "printf safe", output: "[REDACTED]", exitCode: 7 }, - ); - assert.deepEqual(reply.message.content, [ - { type: "thinking", thinking: "keep reasoning" }, - { type: "text", text: "[REDACTED]" }, - { type: "toolCall", id: "call-1", name: "read", arguments: { path: "safe" } }, - ]); - }); - - it("matches the shared context-entry vectors", async () => { - for (const vector of ENTRY_VECTORS.cases) { - let observed; - const admission = createOpenShellContextAdmission( - "http://bridge.test/admit", - () => "session-123", - ADMISSION_TOKEN, - async (_url, init) => { - const request = JSON.parse(String(init?.body)); - observed = JSON.parse(Buffer.from(request.request_body_b64, "base64").toString()); - return new Response(JSON.stringify({ decision: "allow", handle: "context-handle" })); - }, - ); - - await admission.admitProviderContext(vector.context); - - assert.equal(observed.schema_version, "openshell.pi-provider-context.v1"); - assert.deepEqual(observed.entries, vector.entries, vector.name); - } - }); - - it("denies provider contexts containing images before calling the bridge", async () => { - const admission = createOpenShellContextAdmission( - "http://bridge.test/admit", - () => "session-123", - ADMISSION_TOKEN, - async () => { throw new Error("bridge should not be called"); }, - ); - - const result = await admission.admitProviderContext({ - messages: [{ role: "user", content: [{ type: "image", data: "AA==", mimeType: "image/png" }] }], - tools: [], - }); - - assert.equal(result.action, "deny"); - }); - - it("fails closed when provider-only context is denied", async () => { - const admission = createOpenShellContextAdmission( - "http://bridge.test/admit", - () => "session-123", - ADMISSION_TOKEN, - async () => new Response(JSON.stringify({ decision: "deny", reason_code: "policy_denied" })), - ); - - assert.deepEqual(await admission.admitProviderContext({ messages: [user("summary", 1)], tools: [] }), { - action: "deny", - reason: "OpenShell denied this context addition (policy_denied)", - }); - }); -}); diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 26a56aac..bb9635b3 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -5,515 +5,137 @@ import json import os +import shutil import subprocess +import sys import tomllib from pathlib import Path import yaml - -def test_pi_openshell_context_admission_adapter() -> None: - project_dir = Path(__file__).parents[1] - subprocess.run( - [ - "node", - "--test", - str(project_dir / "tests/js/openshell-context-admission.test.mjs"), - ], - check=True, - cwd=project_dir, - ) - - -def test_pi_example_can_print_each_action_without_running_it( - tmp_path: Path, -) -> None: - project_dir = Path(__file__).parents[1] - script = project_dir / "examples/pi-attested-admission/demo.sh" - pi_repo = tmp_path / "pi" - openshell_repo = tmp_path / "OpenShell" - pack_dir = tmp_path / "pack" - runtime_dir = tmp_path / "runtime" - models_path = project_dir / "examples/pi-attested-admission/models.json" - environment = os.environ | { - "PI_REPO": str(pi_repo), - "OPENSHELL_REPO": str(openshell_repo), - "EGRESS_GATE_HOST_IP": "192.0.2.10", - "PI_MODELS_PATH": str(models_path), - "PI_EGRESS_PACK_DIR": str(pack_dir), - "PI_EGRESS_RUNTIME_DIR": str(runtime_dir), - "PI_EGRESS_ENV_FILE": "", - "PI_WORKSPACE_PATH": str(tmp_path / "workspace"), - } - - results = [ - subprocess.run( +from egress_gate.service.admission import AdmissionServerConfig + +PROJECT = Path(__file__).parents[1] +EXAMPLE = PROJECT / "examples/pi-attested-admission" + + +def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> None: + example = tmp_path / "examples/demo" + example.mkdir(parents=True) + script = example / "demo.sh" + shutil.copyfile(EXAMPLE / "demo.sh", script) + marker = tmp_path / "must-not-exist" + (example / ".env").write_text( + f"touch {marker}\nPI_MODEL_API_KEY=PRIVATE_TEST_VALUE\n" + ) + output = "" + for action in ( + "prepare", + "serve", + "gateway", + "setup", + "launch", + "verify", + "cleanup", + ): + result = subprocess.run( ["bash", str(script), "--print", action], check=True, capture_output=True, - env=environment, text=True, + env=os.environ | {"PI_MODEL_API_KEY": "PRIVATE_TEST_VALUE"}, ) - for action in ( - "prepare", - "serve", - "gateway", - "reset", - "launch", - "verify", - "cleanup", - ) - ] - output = "\n".join(result.stdout for result in results) - normalized_output = " ".join(output.replace("\\\n", " ").split()) - - assert "npm run build:offline" in output - assert "earendil-works-pi-agent-core-VERSION.tgz" in output - assert "earendil-works-pi-coding-agent-VERSION.tgz" in output - assert "npm pack --workspace @earendil-works/pi-agent-core" in output - assert "npm pack --workspace @earendil-works/pi-coding-agent" in output - assert "git clone --branch johnny/before-user-message-commit" in output - assert "git clone --branch openshell/pi-egress-admission" in output - assert ( - "git pull --no-rebase --ff-only origin johnny/before-user-message-commit" - in output - ) - assert ( - "git pull --no-rebase --ff-only origin openshell/pi-egress-admission" in output - ) - assert "gateway-middleware.toml" in output - assert "OPENSHELL_GATEWAY_CONFIG_FRAGMENT=" in output - assert "gateway-middleware.toml.example" in output - assert "render-runtime-config.mjs" not in output - assert str(models_path) in output - assert "egress-gate --debug serve" in output - assert "--json-log" in output - assert "CARGO_BUILD_JOBS=4" in output - assert "OPENSHELL_GATEWAY_NAME=pi-egress-demo-gateway" in output - assert "--gateway pi-egress-demo-gateway" in output + output += result.stdout + assert result.stderr == "" + assert "PRIVATE_TEST_VALUE" not in output + assert not marker.exists() + assert not (tmp_path / ".workspaces").exists() + assert "git clone" not in output + assert "v0.0.116" in output + assert "sha256sum --check" in output + assert "docker build" in output + assert "--admission-config" in output assert "provider create" in output - assert "provider profile import" in output - assert "provider profile delete pi-attested-model" in output - assert "provider delete pi-model" in output - assert "provider profile update" not in output - assert "--type pi-attested-model" in output - assert "PI_MODEL_API_KEY" in output - assert "OPENAI_API_KEY" not in output - assert "api.openai.com" not in output - assert "sandbox create" in output - assert "--from" in output - assert "pi-attested-admission/sandbox" in output - assert "--detach" in output - assert "--no-git-ignore" in output - assert f"{runtime_dir}/node_modules:/sandbox/pi-runtime" in output - assert f"{runtime_dir}:/sandbox/pi-runtime" not in output - assert f"{models_path}:/sandbox/.pi/agent/models.json" in output - assert "settings.json:/sandbox/.pi/agent/settings.json" in output - assert "sandbox upload" in output - assert "/sandbox/workspace" in output - assert "sandbox exec" in output - assert "sandbox exec --tty" in output - assert "sandbox exec --no-tty" in output - assert "DENY_THIS" in output - assert "/usr/bin/pi" in output - assert "PI_OFFLINE=1" not in output - assert "PI_CODING_AGENT_DIR=" not in output - assert "--no-extensions" not in output - assert "node /sandbox/pi-runtime/integration/openshell-pi.js" in normalized_output - assert "PI_OPENSHELL_CONTEXT_ADMISSION" not in output - assert "OPENSHELL_AGENT_CONVERSATION_URL=" in output - assert "/usr/bin/curl --silent --show-error" in normalized_output - assert "managed-pi" not in output - assert "--extension " not in output - assert "integration/openshell-context-admission.js" in output - assert "integration/openshell-pi.js" in output - assert "Type-check and compile the trusted runtime extension" in output - assert "sandbox delete" in output - assert all(result.stderr == "" for result in results) - assert not pi_repo.exists() - assert not openshell_repo.exists() - assert not pack_dir.exists() - assert not runtime_dir.exists() - - reset_output = results[3].stdout - normalized_reset_output = " ".join(reset_output.replace("\\\n", " ").split()) - assert f"working directory: {tmp_path / 'workspace'}" in reset_output - assert ( - "sandbox upload pi-egress-demo . /sandbox/workspace" in normalized_reset_output - ) - assert ( - f"sandbox upload pi-egress-demo {tmp_path / 'workspace'}" - not in normalized_reset_output - ) - - demo_script = (project_dir / "examples/pi-attested-admission/demo.sh").read_text() - launcher = ( - project_dir / "examples/pi-attested-admission/runtime-extension/openshell-pi.ts" - ).read_text() - assert '"beforeToolResultAppend"' in demo_script - assert "caller_not_authorized" in demo_script - assert "exec env -u PI_MODEL_API_KEY node" not in demo_script - assert '3<<<"$PI_MODEL_API_KEY"' not in demo_script - assert "render-runtime-config.mjs" not in demo_script - assert "OPENSHELL_AGENT_ADMISSION_TOKEN_FD" in launcher - assert "delete process.env.OPENSHELL_AGENT_ADMISSION_TOKEN_FD" in launcher - assert 'readFileSync(tokenFd, "utf8")' in launcher - assert "closeSync(tokenFd)" in launcher - assert "/^[A-Za-z0-9_-]{43}$/" in launcher - - -def test_pi_example_print_all_is_a_concise_walkthrough() -> None: - project_dir = Path(__file__).parents[1] - script = project_dir / "examples/pi-attested-admission/demo.sh" - - result = subprocess.run( - ["bash", str(script), "--print", "all"], - check=True, - capture_output=True, - env=os.environ - | { - "EGRESS_GATE_HOST_IP": "192.0.2.10", - "PI_MODELS_PATH": str( - project_dir / "examples/pi-attested-admission/models.json" - ), - "PI_MODEL_API_KEY": "secret-not-printed", - "PI_EGRESS_ENV_FILE": "", - "PI_WORKSPACE_PATH": "/tmp/example-workspace", - }, - text=True, - ) - - assert "Pi attested-admission walkthrough" in result.stdout - assert "Configuration loaded by demo.sh" in result.stdout - assert "Model credential: set (value hidden)" in result.stdout - assert "1. prepare" in result.stdout - assert "7. cleanup" in result.stdout - assert "secret-not-printed" not in result.stdout - assert "working directory:" not in result.stdout - - -def test_pi_example_uses_an_empty_workspace_when_no_path_is_configured() -> None: - project_dir = Path(__file__).parents[1] - script = project_dir / "examples/pi-attested-admission/demo.sh" - environment = { - name: value for name, value in os.environ.items() if name != "PI_WORKSPACE_PATH" - } | { - "EGRESS_GATE_HOST_IP": "192.0.2.10", - "PI_MODELS_PATH": str( - project_dir / "examples/pi-attested-admission/models.json" - ), - "PI_MODEL_API_KEY": "secret-not-printed", - "PI_EGRESS_ENV_FILE": "", - } + assert "--credential PI_MODEL_API_KEY" in output + assert "--credential EGRESS_ADMISSION_TOKEN" in output + assert "sandbox create" in output and "--from pi-admission:local" in output + assert "/app/dist/src/cli.js" in output and "/app/dist/src/verify.js" in output + assert "sandbox delete pi-admission" in output - reset = subprocess.run( - ["bash", str(script), "--print", "reset"], - check=True, - capture_output=True, - env=environment, - text=True, - ) - walkthrough = subprocess.run( - ["bash", str(script), "--print", "all"], - check=True, - capture_output=True, - env=environment, - text=True, - ) - - assert "sandbox upload" not in reset.stdout - assert "empty /sandbox/workspace" in reset.stdout - assert "Status: ready" in walkthrough.stdout - assert "Pi workspace: empty /sandbox/workspace" in walkthrough.stdout - - -def test_pi_example_launch_preserves_the_prepared_sandbox() -> None: - project_dir = Path(__file__).parents[1] - script = project_dir / "examples/pi-attested-admission/demo.sh" - - result = subprocess.run( - ["bash", str(script), "--print", "launch"], - check=True, - capture_output=True, - env=os.environ | {"PI_EGRESS_ENV_FILE": ""}, - text=True, - ) - - normalized_output = " ".join(result.stdout.replace("\\\n", " ").split()) - assert "sandbox exec --tty" in normalized_output - assert "--workdir /sandbox/workspace" in normalized_output - assert "sandbox delete" not in result.stdout - assert "sandbox create" not in result.stdout - assert "provider delete" not in result.stdout - assert "provider create" not in result.stdout - -def test_pi_example_cleanup_explains_when_gateway_is_unavailable( +def test_preparation_uses_upstream_profiles_and_excludes_private_material( tmp_path: Path, ) -> None: - project_dir = Path(__file__).parents[1] - script = project_dir / "examples/pi-attested-admission/demo.sh" - openshell_repo = tmp_path / "OpenShell" - openshell_cli = openshell_repo / "scripts/bin/openshell" - openshell_cli.parent.mkdir(parents=True) - openshell_cli.write_text( - "#!/bin/sh\necho 'transport error: Connection refused' >&2\nexit 1\n" - ) - openshell_cli.chmod(0o755) - - result = subprocess.run( - ["bash", str(script), "cleanup"], - capture_output=True, - env=os.environ - | { - "OPENSHELL_REPO": str(openshell_repo), - "PI_EGRESS_ENV_FILE": "", - }, - text=True, - ) - - assert result.returncode == 1 - assert result.stdout == "" - assert "OpenShell gateway 'pi-egress-demo-gateway' is not reachable" in ( - result.stderr - ) - assert "Terminal 1: ./demo.sh serve" in result.stderr - assert "Terminal 2: ./demo.sh gateway" in result.stderr - assert "Then run: ./demo.sh cleanup" in result.stderr - assert "Do not run ./demo.sh reset" in result.stderr - assert "transport error" not in result.stderr - - -def test_pi_example_uses_terminal_colors_without_leaking_them_to_redirects() -> None: - project_dir = Path(__file__).parents[1] - script = project_dir / "examples/pi-attested-admission/demo.sh" - environment = { - name: value for name, value in os.environ.items() if name != "NO_COLOR" - } | {"FORCE_COLOR": "1", "PI_EGRESS_ENV_FILE": ""} - - colored = subprocess.run( - ["bash", str(script), "--print", "all"], - check=True, - capture_output=True, - env=environment, - text=True, - ) - uncolored = subprocess.run( - ["bash", str(script), "--print", "all"], - check=True, - capture_output=True, - env=environment | {"NO_COLOR": "1"}, - text=True, - ) - assert "\x1b[36m" in colored.stdout - assert "\x1b[" not in uncolored.stdout - - -def test_pi_example_defaults_to_an_ignored_external_workspace() -> None: - project_dir = Path(__file__).parents[1] - script = project_dir / "examples/pi-attested-admission/demo.sh" - - result = subprocess.run( - ["bash", str(script), "--print", "prepare"], - check=True, - capture_output=True, - env={ - name: value - for name, value in os.environ.items() - if name not in {"PI_REPO", "OPENSHELL_REPO", "PI_EGRESS_FORKS_DIR"} - } - | {"PI_EGRESS_ENV_FILE": ""}, - text=True, - ) - - workspace = project_dir / ".workspaces/pi-attested-admission" - assert str(workspace / "pi") in result.stdout - assert str(workspace / "OpenShell") in result.stdout - assert ".workspaces/" in (project_dir / ".gitignore").read_text().splitlines() - - -def test_pi_example_uses_standard_checked_in_configuration() -> None: - project_dir = Path(__file__).parents[1] - example_dir = project_dir / "examples/pi-attested-admission" - models = json.loads((example_dir / "models.json").read_text()) - provider = models["providers"]["attested-provider"] - assert provider["baseUrl"] == "https://inference-api.nvidia.com/v1" - assert provider["apiKey"] == "openshell-proxy" - assert "api" not in provider - configured_models = {model["id"]: model for model in provider["models"]} - assert set(configured_models) == { - "azure/anthropic/claude-opus-5", - "azure/openai/gpt-5.6-sol", - "nvidia/qwen/qwen3.8-flash-next", - } - - opus = configured_models["azure/anthropic/claude-opus-5"] - assert opus["api"] == "openai-completions" - assert opus["reasoning"] is False - assert opus["contextWindow"] == 1_000_000 - assert opus["maxTokens"] == 128_000 - - gpt = configured_models["azure/openai/gpt-5.6-sol"] - assert gpt["api"] == "openai-responses" - assert gpt["reasoning"] is True - assert gpt["contextWindow"] == 1_050_000 - assert gpt["maxTokens"] == 128_000 - - qwen = configured_models["nvidia/qwen/qwen3.8-flash-next"] - assert qwen["api"] == "openai-completions" - assert qwen["reasoning"] is True - assert qwen["contextWindow"] == 262_144 - assert qwen["maxTokens"] == 32_768 - assert qwen["compat"] == { - "maxTokensField": "max_tokens", - "supportsDeveloperRole": False, - "supportsReasoningEffort": True, - "thinkingFormat": "qwen", - } - - settings = json.loads((example_dir / "settings.json").read_text()) - assert settings == { - "defaultProvider": "attested-provider", - "defaultModel": "nvidia/qwen/qwen3.8-flash-next", - "defaultThinkingLevel": "high", - } - - provider_profile = yaml.safe_load( - (example_dir / "provider-profile.yaml").read_text() - ) - assert provider_profile["id"] == "pi-attested-model" - assert provider_profile["credentials"][0]["env_vars"] == ["PI_MODEL_API_KEY"] - assert provider_profile["credentials"][0]["delivery"] == "proxy" - assert provider_profile["endpoints"][0]["host"] == "inference-api.nvidia.com" - assert provider_profile["endpoints"][0]["port"] == 443 - - policy = yaml.safe_load((example_dir / "policy.yaml").read_text()) - endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] - assert endpoint["host"] == "inference-api.nvidia.com" - assert endpoint["port"] == 443 - middleware = policy["network_middlewares"]["pi_egress_gate"] - assert middleware["endpoints"]["include"] == ["inference-api.nvidia.com"] - assert set(policy["network_policies"]) == {"model_provider"} - - sandbox_dockerfile = (example_dir / "sandbox/Dockerfile").read_text() - assert "openshell-community/sandboxes/pi:latest" in sandbox_dockerfile - assert "fd-find ripgrep" in sandbox_dockerfile - - gateway_template = (example_dir / "gateway-middleware.toml.example").read_text() - gateway_fragment = tomllib.loads( - gateway_template.replace("YOUR_HOST_IPV4", "192.0.2.10") - ) - registration = gateway_fragment["openshell"]["supervisor"]["middleware"][0] - assert registration["name"] == "pi-egress" - assert registration["grpc_endpoint"] == "http://192.0.2.10:50051" - assert registration["allow_insecure_transport"] is True - assert registration["max_payload_bytes"] == 4 * 1024 * 1024 - + state = tmp_path / "state" + command = [ + sys.executable, + str(EXAMPLE / "prepare.py"), + "--state", + str(state), + "--host-ip", + "192.0.2.10", + ] + subprocess.run(command, check=True) + config = AdmissionServerConfig.model_validate_json( + (state / "admission.json").read_bytes() + ) + assert config.provider_target.scheme == "https" + assert config.provider_target.path == "/v1/chat/completions" + assert not config.sandbox_id_file.exists() + token = config.bearer_token.get_secret_value() + assert len(token) >= 32 + (state / "image/stale-config.json").write_text("{}") + subprocess.run(command, check=True) + again = AdmissionServerConfig.model_validate_json( + (state / "admission.json").read_bytes() + ) + assert again.bearer_token == config.bearer_token + for name in ("model", "admission"): + profile = yaml.safe_load((state / f"{name}-provider.yaml").read_text()) + credential = profile["credentials"][0] + assert set(credential) == {"name", "env_vars", "required"} + assert token not in json.dumps(profile) + image = state / "image" + assert not (image / "stale-config.json").exists() + assert not list(image.rglob("*.key")) + assert not list(image.rglob("*.pem")) + assert not list(image.rglob(".env")) + assert not (image / "admission.json").exists() + assert not (image / "app/node_modules").exists() + assert (image / "project/.pi/skills/review/SKILL.md").is_file() + gateway = tomllib.loads((state / "gateway.toml").read_text()) + registration = gateway["openshell"]["supervisor"]["middleware"][0] + assert registration["grpc_endpoint"] == "https://192.0.2.10:50051" + assert registration["tls_ca_cert_path"] == str(state / "tls/ca.crt") + policy = yaml.safe_load((state / "policy.yaml").read_text()) + assert policy["network_middlewares"]["pi_egress_gate"]["on_error"] == "fail_closed" + model_endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] + assert model_endpoint["rules"] == [ + {"allow": {"method": "POST", "path": "/v1/chat/completions"}} + ] + assert "access" not in model_endpoint + assert policy["network_policies"]["admission"]["endpoints"][0]["port"] == 5443 -def test_pi_example_loads_its_env_file_automatically(tmp_path: Path) -> None: - project_dir = Path(__file__).parents[1] - script = project_dir / "examples/pi-attested-admission/demo.sh" - models_path = project_dir / "examples/pi-attested-admission/models.json" - env_file = tmp_path / ".env" - env_file.write_text( - "EGRESS_GATE_HOST_IP=192.0.2.10\n" - f"PI_MODELS_PATH={models_path}\n" - "PI_MODEL_API_KEY=loaded-from-env-file\n" - ) - environment = { - name: value - for name, value in os.environ.items() - if name - not in { - "EGRESS_GATE_HOST_IP", - "PI_MODELS_PATH", - "PI_MODEL_API_KEY", - "PI_WORKSPACE_PATH", - } - } | {"PI_EGRESS_ENV_FILE": str(env_file)} +def test_sandbox_binding_accepts_only_operator_cli_output(tmp_path: Path) -> None: result = subprocess.run( - ["bash", str(script), "--print", "all"], - check=True, - capture_output=True, - env=environment, + [sys.executable, str(EXAMPLE / "bind-sandbox.py"), "--state", str(tmp_path)], + input=json.dumps({"id": "actual-sandbox-id"}), text=True, - ) - - assert "Status: ready" in result.stdout - assert "Egress Gate host: 192.0.2.10" in result.stdout - assert f"Pi models file: {models_path}" in result.stdout - assert "Model credential: set (value hidden)" in result.stdout - assert "loaded-from-env-file" not in result.stdout - - -def test_pi_example_reports_all_missing_configuration_before_work( - tmp_path: Path, -) -> None: - project_dir = Path(__file__).parents[1] - script = project_dir / "examples/pi-attested-admission/demo.sh" - environment = { - name: value - for name, value in os.environ.items() - if name - not in { - "EGRESS_GATE_HOST_IP", - "PI_MODELS_PATH", - "PI_MODEL_API_KEY", - "PI_WORKSPACE_PATH", - } - } - environment["PI_EGRESS_ENV_FILE"] = str(tmp_path / "missing.env") - - result = subprocess.run( - ["bash", str(script), "reset"], capture_output=True, - cwd=tmp_path, - env=environment, - text=True, + check=True, ) + assert (tmp_path / "sandbox-id").read_text().strip() == "actual-sandbox-id" + assert "bound" in result.stdout - assert result.returncode == 1 - assert result.stdout == "" - assert "The Pi attested-admission example is not configured." in result.stderr - assert "EGRESS_GATE_HOST_IP" in result.stderr - assert "PI_MODELS_PATH" in result.stderr - assert "PI_MODEL_API_KEY" in result.stderr - assert "PI_WORKSPACE_PATH" not in result.stderr - assert "cp .env.example .env" in result.stderr - assert "git pull" not in result.stderr - - -def test_pi_example_reports_a_missing_compute_backend_before_mise( - tmp_path: Path, -) -> None: - project_dir = Path(__file__).parents[1] - script = project_dir / "examples/pi-attested-admission/demo.sh" - for command in ("docker", "podman"): - stub = tmp_path / command - stub.write_text("#!/bin/sh\nexit 1\n") - stub.chmod(0o755) - - result = subprocess.run( - ["bash", str(script), "gateway"], - capture_output=True, - env=os.environ - | { - "PATH": f"{tmp_path}:{os.environ['PATH']}", - "OPENSHELL_DRIVERS": "", - "PI_EGRESS_ENV_FILE": "", - "EGRESS_GATE_HOST_IP": "192.0.2.10", - "PI_MODELS_PATH": str( - project_dir / "examples/pi-attested-admission/models.json" - ), - "PI_MODEL_API_KEY": "test-key", - }, - text=True, - ) - assert result.returncode == 1 - assert result.stdout == "" - assert "No running OpenShell compute backend was detected." in result.stderr - assert "docker info" in result.stderr - assert "podman info" in result.stderr - assert "mise" not in result.stderr +def test_pi_dependencies_are_exact_upstream_packages() -> None: + package = json.loads((EXAMPLE / "app/package.json").read_text()) + lock = json.loads((EXAMPLE / "app/package-lock.json").read_text()) + for name, version in package["dependencies"].items(): + if name.startswith("@earendil-works/"): + assert version == "0.85.1" + resolved = lock["packages"][f"node_modules/{name}"] + assert resolved["version"] == version + assert resolved["resolved"].startswith("https://registry.npmjs.org/") + assert resolved["integrity"].startswith("sha512-") From 462707fadc900d1ef8b0548dcb4c1a1845deaa0d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 17:56:26 +0000 Subject: [PATCH 52/70] docs(egress-gate): explain no-fork guarantees and verification limits --- projects/egress-gate/README.md | 27 +- .../docs/architecture/admission.md | 257 ++++++++++++------ .../docs/architecture/service-boundary.md | 4 + 3 files changed, 195 insertions(+), 93 deletions(-) diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index f7820a2b..710fc436 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -24,7 +24,7 @@ commands work from any directory and do not depend on repository-only files: egress-gate gates list egress-gate gates schema egress-gate validate --policy /absolute/path/to/your-policy.yaml -egress-gate serve --listen 127.0.0.1:50051 --no-require-agent-attestation +egress-gate serve --listen 127.0.0.1:50051 ``` ## Source-checkout quickstart @@ -39,7 +39,7 @@ uv run egress-gate gates list uv run egress-gate gates schema uv run egress-gate validate \ --policy examples/regex-redaction/egress-gate-config.yaml -uv run egress-gate serve --listen 127.0.0.1:50051 --no-require-agent-attestation +uv run egress-gate serve --listen 127.0.0.1:50051 uv run egress-gate evaluate \ --policy examples/regex-redaction/egress-gate-config.yaml \ --cases examples/regex-redaction/cases.yaml @@ -49,13 +49,12 @@ Use `0.0.0.0` only when the OpenShell supervisor must reach the service across network namespaces. The development server uses plaintext gRPC. Restrict its listen port to trusted networks. -The CLI requires managed Pi context attestations by default, coupling admission -to provider egress verification. The general Gate quickstarts opt out -explicitly. Keep the default, or pass `--require-agent-attestation`, for a -managed harness; use `--no-require-agent-attestation` only for an intentionally -unmanaged deployment. -See the [managed Pi example](examples/pi-attested-admission/README.md) for the -matching Pi and OpenShell fork branches, startup contract, and current limits. +Ordinary HTTP middleware is the default. To require admission receipts, pass +`--admission-config /absolute/path/to/admission.json`. That operator-owned +configuration enables the additional authenticated HTTPS admission API and +TLS/JWT authentication for the middleware listener. See the +[no-fork Pi example](examples/pi-attested-admission/README.md) for a runnable +upstream deployment, trust boundaries, and supported scope. ## Policy shape @@ -95,7 +94,7 @@ need initialization, helper bases, or typed resources use the full class-based ```bash uv run egress-gate --registry my_gates:registry gates list -uv run egress-gate --registry my_gates:registry serve --no-require-agent-attestation +uv run egress-gate --registry my_gates:registry serve ``` OpenShell owns interception, routing, and credential attachment. Egress Gate @@ -111,13 +110,12 @@ from egress_gate.service import EgressGateServer server = EgressGateServer( create_builtin_registry(), timeout_middleware_processing=10, - require_agent_attestation=False, ) server.serve_sync("127.0.0.1:50051") ``` -Make the `require_agent_attestation` choice explicit in programmatic deployments; -set it to `True` for a managed harness. In this unmanaged example, +Pass an `AdmissionServerConfig` as `admission=` to enable the optional +receipt-required deployment. In this ordinary middleware example, `timeout_middleware_processing` gives each evaluation 10 seconds. Omitting it uses the one-second service default. The value is expressed in seconds, must be at least 10 milliseconds, and must resolve to whole @@ -153,6 +151,9 @@ timeout failures must deny. ## Development +Full checks also require Node 22.19+ and npm for the locked upstream Pi example. +The first run installs its JavaScript dependencies. + ```bash make help make test PYTEST_ARGS="tests/gates tests/test_request_processor.py" diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md index 0bd429eb..43d67a14 100644 --- a/projects/egress-gate/docs/architecture/admission.md +++ b/projects/egress-gate/docs/architecture/admission.md @@ -1,84 +1,181 @@ --- -title: Managed harness admission -description: Pi context admission, whole-context attestations, and egress verification. +title: Admission without harness forks +description: Application-owned history admission and standard OpenShell egress verification. agent_markdown: true --- -# Managed harness admission - -Managed Pi sessions use the same Egress Gate policy at three checkpoints. The -append and provider-context checkpoints apply policy to supported message -content before it enters history or is sent. Egress verification supplies the -security boundary: a provider request without a valid attestation is denied -before credentials are attached. - -| Checkpoint | Pi hook | Result | -| --- | --- | --- | -| History append | `user_message`, `tool_result`, `assistant_message`, `compaction_summary`, `branch_summary`, `extension_message`, or `bash_execution` | Allow, deny, or replace supported content before append | -| Provider context | `provider_context` | Allow, deny, or replace the complete ordered user/tool context and issue an attestation | -| Network egress | OpenShell pre-credentials middleware | Verify the attestation against the provider request, then run request policy | - -Assistant append admission covers finalized text and tool calls. Assistant -thinking is not append-admitted or included in the attested user/tool context -hash; request policy scans it at egress. Tool calls are inspectable and -denyable but immutable: a redaction targeting their ID, name, or arguments -fails closed rather than changing them. - -Append-time allows do not carry attestations. Immediately before a provider -request, Pi submits the complete context so retries, continuations, compaction, -queued input, and restored sessions do not depend on the newest entry alone. -OpenShell retains the signed attestation and returns only an opaque handle to -the runtime adapter. - -## Attestation and verification - -An `agent-attestation.v2` claim set binds the canonical context hash and entry -count to the harness and schema versions, middleware binding, policy -fingerprint, sandbox, session and submission identifiers, provider adapter, -provider host and port, signing-key identifier, and issue and expiry times. The -attestation is signed with the Egress Gate instance's ephemeral Ed25519 key and -expires after 300 seconds. - -At egress, Egress Gate: - -1. requires the network enforcement point, rejects the reserved handle header, - and requires an attestation; -2. parses the provider request with the selected OpenAI request adapter and - derives its complete ordered user/tool context; -3. verifies the signature, key, lifetime, trusted context fields, entry count, - and context hash; -4. runs the configured request gate pipeline; and -5. parses the resulting request again and denies if policy mutation changed the - attested semantic context. - -OpenShell attaches proxy-delivered credentials only after this middleware -allows the request. - -## Failures and limits - -Admission payloads and replacements are limited to 4 MiB. The middleware -manifest advertises the registered harness, hook, schema, and limit. Image -inputs are not supported by the Pi adapter and fail closed. Provider-context -admission currently runs before Pi's transport-specific history rewrites, so -switching transports with tool history or sending an orphaned tool call can -also fail closed. - -Stable admission failures include `admission_contract_invalid` and -`admission_unavailable`. Egress verification failures include -`network_context_invalid`, `reserved_header_present`, `attestation_missing`, -`attestation_malformed`, -`attestation_signature_invalid`, `attestation_key_mismatch`, -`attestation_not_yet_valid`, `attestation_expired`, -`attestation_context_mismatch`, `entry_count_mismatch`, -`context_hash_mismatch`, `provider_shape_unsupported`, -`semantic_mutation_denied`, and `egress_verification_failed`. A configured gate -may instead return its own deny reason. - -An Egress Gate started with `--require-agent-attestation` is dedicated to -managed harness traffic: unattested matching provider requests fail closed. -The supervisor-owned loopback bridge requires a per-exec capability delivered -to the launched harness on an inherited file descriptor. The launcher reads and -closes that descriptor and deletes its environment name before Pi starts, so -tool subprocesses do not receive the capability. The token remains in Pi's -memory; a same-user process able to read that memory could copy it, though the -sandbox's process isolation and ptrace restrictions reduce this residual risk. +# Admission without harness forks + +The [runnable Pi example](https://github.com/NVIDIA/OpenShell-Research/tree/johnny/pi-attested-admission/projects/egress-gate/examples/pi-attested-admission) +uses published Pi 0.85.1 packages and OpenShell 0.0.116. No upstream library, +runtime, protobuf, or CLI patches are required. Its smaller surface is a +Pi-powered application, not stock Pi CLI parity. + +## The two boundaries + +Network enforcement cannot undo an earlier local-history write. For example, +a tool may return sensitive text; blocking the next model request leaves that +text in the transcript if it was already appended. Conversely, a cooperative +application check alone does not prevent another client making a raw request. + +```text +Candidate --> admission policy --> approved history --> next model context + | | + deny signed receipt + | | + no history write OpenShell inspects request + | + verify + request policy + | + credential --> model +``` + +There are two distinct properties: + +1. **Local insertion:** the controlled application asks before writing to live + conversation state or Pi's SessionManager. Only approved/replacement content + enters history. Transient candidate buffers necessarily exist. +2. **Egress:** an external verifier checks a service-signed receipt against the + actual intercepted request before OpenShell attaches provider credentials. + +A receipt proves service approval of covered content, **not** that a particular +extension ran or that every historical append was checked. A compromised +application or same-authority code can violate local storage integrity. + +## One history owner + +The application uses Pi's public model stream, resource loader, built-in tools, +summary generator, and session storage. It does not run a second autonomous +AgentSession or depend on late message notifications. + +| Candidate | What is admitted before writing | +| --- | --- | +| User / explicit skill | Final text after supported skill rendering | +| Project context | Loaded project instructions and model-visible skill metadata | +| Assistant | Finalized text and tool-call IDs, names, arguments | +| Tool | Final output, including invalid-argument, missing-tool and execution errors | +| Compaction | Complete summary, for both manual and automatic triggers | + +Tool-call fields are inspectable but immutable: attempted executable-argument +redaction fails closed. Unsupported images/reasoning/provider state is rejected, +not stored as unchecked sidecars. Tool details and progress are not transcripts. +Bash uses a bounded public operations wrapper to avoid Pi's output-log spill. + +On a denied tool result, the application stops model calls. It submits fixed, +content-free failures for outstanding calls through the same boundary. If those +cannot be admitted, the session stops without claiming crash recovery. +Tool side effects themselves are not reversible by result admission. + +Compaction keeps the latest whole user turn. Its summary-generation request +needs a fresh receipt; its finished summary needs fresh insertion approval. +Denial leaves the preceding context and file unchanged. Auto compaction runs +between completed turns; overflow gets at most one compact/retry. Old approved +entries remain in the append-only JSONL file. + +One cwd scopes resources, tools and storage. It is not confinement; OpenShell +filesystem policy is. The application is installed outside the writable project +and does not load third-party extensions or implicitly resume saved transcripts. + +## Service, identity, and receipts + +The service adds one bounded `POST /v1/admission` HTTPS endpoint alongside +ordinary OpenShell middleware gRPC. It reuses the transport-neutral admission +models, shape adapters, policy pipeline and receipt authority. + +The host setup provisions one admission bearer credential, provider destination, +policy and actual sandbox ID. The sandbox cannot select its authoritative +identity or submit a policy. The single host-owned identity file is populated +after sandbox creation; until then admission is unavailable. There is no +registration API or new credential broker. + +Upstream OpenShell delivers endpoint-bound credential placeholders. Real secrets +stay outside the sandbox. A placeholder is still an application-accessible +capability, not process attestation. Removing it from tool child environments +is hygiene, not isolation from malicious same-authority code. + +The gRPC boundary verifies the existing EdDSA extension JWT against the operator's +pinned gateway public key, issuer, audience and token type. HTTP evaluations also +require a supervisor caller whose sandbox ID matches request context. +Both listeners use verified TLS. The gateway advertises only the standard HTTP +middleware contract, not fork-specific harness RPCs. + +Before every model call, including tool continuation and summarization, the +application asks approval for the ordered user/tool text projection and sends +the resulting base64url receipt in one `x-egress-admission` header. + +At egress the verifier: + +1. requires exactly one well-formed receipt; +2. parses the supported provider body and derives the ordered user/tool text; +3. checks signature, key, expiry, sandbox, destination, policy and content hash; +4. runs the configured request gates; +5. rechecks that mutations did not change receipt-covered content; and +6. removes the receipt header before forwarding. + +The existing `agent-attestation.v2` wire claim format is retained internally. +Its ephemeral service signing key and five-minute lifetime permit identical +retries, not one-time delivery. Restarting the service invalidates old receipts. + +The receipt does **not** sign every byte, system/assistant messages, model +parameters or tool schemas. Those remain subject to the normal request policy. +Final-context replacement is rejected by the application so outbound text cannot +silently diverge from its approved history. There is one final middleware +attachment; adding a later content-mutating middleware breaks that assumption. + +## Deliberate POC limits + +One text-only OpenAI-compatible Chat Completions model, sequential tools, fresh +sessions and explicit skills. No TUI/RPC parity, arbitrary extensions, reasoning, +images, WebSockets, transport switching, branching, or crash resume. +Network policy allows only the chosen POST model path and separately scopes the +admission endpoint. Unknown shapes fail closed; admission requests do not +recursively require model receipts. + +Ordinary HTTP-only Egress Gate remains available with `egress-gate serve`. +Only `--admission-config` selects the receipt-required deployment. + +## Evidence and Dev Note narrative + +The implementation's deterministic tests cover pending/denied candidates before +both live and durable writes, accepted replacements, real tool continuations, +and the shared manual/auto compaction path. Service tests cover authenticated +caller binding, upstream RPCs, receipts, policy decisions and header removal. +The example's `demo.sh verify` is a separate real-model end-to-end acceptance +command, not a simulated demonstration. Its success must be observed, not inferred +from unit tests. See the PR validation record for the latest executed checks. + +Implementation validation on **2026-09-09** used: + +| Component | Tested pin | +| --- | --- | +| Pi public npm packages | `0.85.1`, exact dependencies and integrity hashes in the example lockfile | +| OpenShell CLI, gateway and supervisor | `0.0.116`, release commit `d1155aa70042d3e2ee49dbfa15346b108b7c1d92`; archive checksums in `demo.sh` | +| Node image | `22.22.2-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e` | +| HTTP client | Undici `8.9.0`; explicit public proxy configuration after loading Pi | + +The isolated upstream sandbox successfully exercised TLS/JWT bootstrap, +endpoint-bound admission credentials, allow/deny/replacement, a real Pi session +denial before history, and rejection of a raw provider request without a receipt. +Pi's actual tool-capable serialized request with a receipt passed the gate and +received HTTP 401 from the real endpoint when deliberately given an invalid test +credential. This establishes the transport seam, **not** successful model output. + +**Real-model acceptance remains pending a valid provider key.** The checked-in +verification command passed its bypass/denial checks and then failed at the model +call with that invalid credential; it did not skip ahead. Tool continuations, +skills and compaction have deterministic application coverage but must also pass +that real-model command before describing the whole example as e2e-verified. + +A useful Dev Note, **“Gating at the network layer is not enough,”** can follow: + +1. A network-denied tool result can still contaminate local history. +2. Move the local decision before the write; show deny and replacement in JSONL. +3. Keep a real agent: tools, skills and compaction all use that one boundary. +4. Demonstrate a raw provider request bypassing the application but being denied + by OpenShell because it has no approval receipt. +5. Explain the complementary guarantees and honestly show their limits. + +The takeaway is not “the network boundary is insufficient security.” It is that +local-history integrity and outbound-request authorization happen at different +times and require different enforcement points. No fork makes the composition +easier to reproduce; it does not make the guarantees stronger by itself. diff --git a/projects/egress-gate/docs/architecture/service-boundary.md b/projects/egress-gate/docs/architecture/service-boundary.md index 686cd493..7c8c7eb6 100644 --- a/projects/egress-gate/docs/architecture/service-boundary.md +++ b/projects/egress-gate/docs/architecture/service-boundary.md @@ -10,6 +10,10 @@ The `service/` package is the only handwritten package that imports OpenShell protobuf/gRPC bindings. It owns exact encoded wire limits and transport status mapping. Domain models own protobuf-free invariants. +The optional admission deployment adds a bounded HTTPS candidate endpoint and +authenticates standard gRPC calls using OpenShell's existing extension JWTs. +It does not add a protobuf RPC. See [Admission without forks](admission.md). + The OpenShell supervisor owns the intercepted request. Egress Gate receives its request data over gRPC and works with local immutable `HttpRequest` snapshots. The Egress Gate service adapter returns a decision and final mutations; the From fc906927374172b05d5c8ae1b30e63d9890f9d0d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 17:59:52 +0000 Subject: [PATCH 53/70] style(egress-gate): match repository license header spacing --- projects/egress-gate/examples/pi-attested-admission/demo.sh | 1 + projects/egress-gate/scripts/generate-bindings.sh | 1 + 2 files changed, 2 insertions(+) diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index cef63653..f6f9fcd3 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 + set -euo pipefail set +x # Never trace populated credential variables. umask 077 diff --git a/projects/egress-gate/scripts/generate-bindings.sh b/projects/egress-gate/scripts/generate-bindings.sh index 191c2f3d..de664006 100755 --- a/projects/egress-gate/scripts/generate-bindings.sh +++ b/projects/egress-gate/scripts/generate-bindings.sh @@ -1,6 +1,7 @@ #!/usr/bin/env bash # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 + set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." From d378b7d512351f55ffd85c48a970386a48a7ce58 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 18:03:39 +0000 Subject: [PATCH 54/70] style(egress-gate): follow public declaration order in admission tests --- .../tests/service/test_http_admission.py | 214 +++++++++--------- 1 file changed, 107 insertions(+), 107 deletions(-) diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py index c8697715..b2380f38 100644 --- a/projects/egress-gate/tests/service/test_http_admission.py +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -38,6 +38,113 @@ AUTHORIZATION = {"Authorization": "Bearer test-admission-credential"} +@pytest.mark.asyncio +async def test_http_admission_allow_deny_replace_and_authentication( + tmp_path: Path, +) -> None: + async with _clients(tmp_path) as (client, _, config, _): + denied_auth = await client.post("/v1/admission", json=_call("safe")) + assert denied_auth.status == 401 + for text, expected in ( + ("safe", "allow"), + ("DENY_THIS", "deny"), + ("REDACT_THIS", "replace"), + ): + response = await client.post( + "/v1/admission", json=_call(text), headers=AUTHORIZATION + ) + assert response.status == 200 + result = await response.json() + assert result["decision"] == expected, result["reason_code"] + assert result["receipt"] is None + if expected == "replace": + assert result["replacement"]["text"] == "[REDACTED]" + forged = {**_call("safe"), "sandbox_id": "somebody-else"} + response = await client.post( + "/v1/admission", json=forged, headers=AUTHORIZATION + ) + assert response.status == 400 + config.sandbox_id_file.unlink() + response = await client.post( + "/v1/admission", json=_call("safe"), headers=AUTHORIZATION + ) + assert response.status == 503 + + +@pytest.mark.asyncio +async def test_http_receipt_is_verified_and_stripped_by_standard_authenticated_rpc( + tmp_path: Path, +) -> None: + async with _clients(tmp_path) as (client, stub, config, token): + response = await client.post( + "/v1/admission", + json=_call("safe", kind="provider_context"), + headers=AUTHORIZATION, + ) + result = await response.json() + assert result["decision"] == "allow", result["reason_code"] + assert result["receipt"] + request = _network(config, result["receipt"]) + metadata = (("authorization", f"Bearer {token}"),) + allowed = await stub.EvaluateHttpRequest(request, metadata=metadata) + assert allowed.decision == pb.DECISION_ALLOW + assert allowed.header_mutations[-1].remove.name == RECEIPT_HEADER + with pytest.raises(grpc.aio.AioRpcError) as failure: + await stub.EvaluateHttpRequest(request) + assert failure.value.code() == grpc.StatusCode.UNAUTHENTICATED + request.context.sandbox_id = "forged" + with pytest.raises(grpc.aio.AioRpcError) as failure: + await stub.EvaluateHttpRequest(request, metadata=metadata) + assert failure.value.code() == grpc.StatusCode.UNAUTHENTICATED + request.context.sandbox_id = "sandbox" + request.headers.pop() + denied = await stub.EvaluateHttpRequest(request, metadata=metadata) + assert denied.reason_code == "attestation_missing" + request.headers.add(name=RECEIPT_HEADER, value=result["receipt"]) + request.headers.add(name=RECEIPT_HEADER, value=result["receipt"]) + denied = await stub.EvaluateHttpRequest(request, metadata=metadata) + assert denied.reason_code == "attestation_malformed" + empty = message_factory.GetMessageClass( + empty_pb2.DESCRIPTOR.message_types_by_name["Empty"] + )() + manifest = await stub.Describe(empty, metadata=metadata) + assert manifest.expected_audience == AUDIENCE + assert len(manifest.bindings) == 1 + + +@pytest.mark.parametrize("change", ["issuer", "audience", "expired", "type", "key"]) +def test_gateway_authentication_rejects_invalid_trust_claims(change: str) -> None: + key = Ed25519PrivateKey.generate() + public = key.public_key().public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ) + claims = { + "iss": "trusted-gateway", + "aud": AUDIENCE, + "iat": int(time.time()) - 10, + "exp": int(time.time()) + 60, + "caller_kind": "supervisor", + "sandbox_id": "sandbox", + } + if change == "issuer": + claims["iss"] = "some-other-gateway" + elif change == "audience": + claims["aud"] = "another-service" + elif change == "expired": + claims["exp"] = int(time.time()) - 1 + token = jwt.encode( + claims, + Ed25519PrivateKey.generate() if change == "key" else key, + algorithm="EdDSA", + headers={"typ": "JWT" if change == "type" else "openshell-ext+jwt"}, + ) + request = pb.HttpRequestEvaluation(context=pb.RequestContext(sandbox_id="sandbox")) + with pytest.raises((ValueError, jwt.PyJWTError)): + GatewayAuthentication(public, "trusted-gateway", AUDIENCE).verify( + (("authorization", f"Bearer {token}"),), request + ) + + @asynccontextmanager async def _clients( directory: Path, @@ -153,110 +260,3 @@ def _network(config: AdmissionServerConfig, receipt: str) -> pb.HttpRequestEvalu ) json_format.ParseDict(config.policy, request.config) return request - - -@pytest.mark.asyncio -async def test_http_admission_allow_deny_replace_and_authentication( - tmp_path: Path, -) -> None: - async with _clients(tmp_path) as (client, _, config, _): - denied_auth = await client.post("/v1/admission", json=_call("safe")) - assert denied_auth.status == 401 - for text, expected in ( - ("safe", "allow"), - ("DENY_THIS", "deny"), - ("REDACT_THIS", "replace"), - ): - response = await client.post( - "/v1/admission", json=_call(text), headers=AUTHORIZATION - ) - assert response.status == 200 - result = await response.json() - assert result["decision"] == expected, result["reason_code"] - assert result["receipt"] is None - if expected == "replace": - assert result["replacement"]["text"] == "[REDACTED]" - forged = {**_call("safe"), "sandbox_id": "somebody-else"} - response = await client.post( - "/v1/admission", json=forged, headers=AUTHORIZATION - ) - assert response.status == 400 - config.sandbox_id_file.unlink() - response = await client.post( - "/v1/admission", json=_call("safe"), headers=AUTHORIZATION - ) - assert response.status == 503 - - -@pytest.mark.asyncio -async def test_http_receipt_is_verified_and_stripped_by_standard_authenticated_rpc( - tmp_path: Path, -) -> None: - async with _clients(tmp_path) as (client, stub, config, token): - response = await client.post( - "/v1/admission", - json=_call("safe", kind="provider_context"), - headers=AUTHORIZATION, - ) - result = await response.json() - assert result["decision"] == "allow", result["reason_code"] - assert result["receipt"] - request = _network(config, result["receipt"]) - metadata = (("authorization", f"Bearer {token}"),) - allowed = await stub.EvaluateHttpRequest(request, metadata=metadata) - assert allowed.decision == pb.DECISION_ALLOW - assert allowed.header_mutations[-1].remove.name == RECEIPT_HEADER - with pytest.raises(grpc.aio.AioRpcError) as failure: - await stub.EvaluateHttpRequest(request) - assert failure.value.code() == grpc.StatusCode.UNAUTHENTICATED - request.context.sandbox_id = "forged" - with pytest.raises(grpc.aio.AioRpcError) as failure: - await stub.EvaluateHttpRequest(request, metadata=metadata) - assert failure.value.code() == grpc.StatusCode.UNAUTHENTICATED - request.context.sandbox_id = "sandbox" - request.headers.pop() - denied = await stub.EvaluateHttpRequest(request, metadata=metadata) - assert denied.reason_code == "attestation_missing" - request.headers.add(name=RECEIPT_HEADER, value=result["receipt"]) - request.headers.add(name=RECEIPT_HEADER, value=result["receipt"]) - denied = await stub.EvaluateHttpRequest(request, metadata=metadata) - assert denied.reason_code == "attestation_malformed" - empty = message_factory.GetMessageClass( - empty_pb2.DESCRIPTOR.message_types_by_name["Empty"] - )() - manifest = await stub.Describe(empty, metadata=metadata) - assert manifest.expected_audience == AUDIENCE - assert len(manifest.bindings) == 1 - - -@pytest.mark.parametrize("change", ["issuer", "audience", "expired", "type", "key"]) -def test_gateway_authentication_rejects_invalid_trust_claims(change: str) -> None: - key = Ed25519PrivateKey.generate() - public = key.public_key().public_bytes( - serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo - ) - claims = { - "iss": "trusted-gateway", - "aud": AUDIENCE, - "iat": int(time.time()) - 10, - "exp": int(time.time()) + 60, - "caller_kind": "supervisor", - "sandbox_id": "sandbox", - } - if change == "issuer": - claims["iss"] = "some-other-gateway" - elif change == "audience": - claims["aud"] = "another-service" - elif change == "expired": - claims["exp"] = int(time.time()) - 1 - token = jwt.encode( - claims, - Ed25519PrivateKey.generate() if change == "key" else key, - algorithm="EdDSA", - headers={"typ": "JWT" if change == "type" else "openshell-ext+jwt"}, - ) - request = pb.HttpRequestEvaluation(context=pb.RequestContext(sandbox_id="sandbox")) - with pytest.raises((ValueError, jwt.PyJWTError)): - GatewayAuthentication(public, "trusted-gateway", AUDIENCE).verify( - (("authorization", f"Bearer {token}"),), request - ) From 4aa62a7d6cdc083fcb2df0aa03f8a2e083e11842 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 19:18:24 +0000 Subject: [PATCH 55/70] refactor(egress-gate): narrow admission to the no-fork Pi POC --- projects/egress-gate/README.md | 6 +- .../analysis/qa-reports/2026-09-02.html | 161 ----- .../docs/architecture/admission.md | 11 +- .../app/test/service-integration.ts | 88 +++ projects/egress-gate/scripts/check.sh | 4 +- .../src/egress_gate/admission/__init__.py | 34 +- .../src/egress_gate/admission/adapters.py | 589 ++---------------- .../src/egress_gate/admission/canonical.py | 137 +--- .../src/egress_gate/admission/models.py | 3 - .../src/egress_gate/admission/processor.py | 9 +- .../src/egress_gate/service/servicer.py | 2 - .../tests/admission/fixtures/README.md | 19 +- .../admission/fixtures/context-entries.json | 55 -- .../fixtures/pi-openai-responses.json | 73 --- .../tests/admission/test_admission.py | 249 +------- .../tests/service/test_http_admission.py | 137 +++- 16 files changed, 328 insertions(+), 1249 deletions(-) delete mode 100644 projects/egress-gate/analysis/qa-reports/2026-09-02.html create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts delete mode 100644 projects/egress-gate/tests/admission/fixtures/context-entries.json delete mode 100644 projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 710fc436..07a77f81 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -152,7 +152,11 @@ timeout failures must deny. ## Development Full checks also require Node 22.19+ and npm for the locked upstream Pi example. -The first run installs its JavaScript dependencies. +The first run installs its JavaScript dependencies. `make check` builds the Pi +application before running Python tests, including the local cross-language +integration test. To run that test directly, first run +`npm --prefix examples/pi-attested-admission/app ci --ignore-scripts` and +`npm --prefix examples/pi-attested-admission/app run build`. ```bash make help diff --git a/projects/egress-gate/analysis/qa-reports/2026-09-02.html b/projects/egress-gate/analysis/qa-reports/2026-09-02.html deleted file mode 100644 index 6492be42..00000000 --- a/projects/egress-gate/analysis/qa-reports/2026-09-02.html +++ /dev/null @@ -1,161 +0,0 @@ - - - - - - - Pi attested-admission integration QA — 2026-09-02 - - - -

-

Pi attested-admission integration QA

-

Date: 2026-09-02 UTC

-

Live-session update: 2026-09-03 UTC, through 02:40:14

-

- This report records the final proof-of-concept integration state across - Pi, OpenShell, and OpenShell Research. It contains only sanitized command - summaries: no credentials, request bodies, model output, environment - values, or local workspace paths. -

- -

Reviewed revisions

- - - - - - - -
RepositoryIntegration headUpstream base
Pi177b42723d072b7954a5b1690ccf62c97f075b37e266507b606b9552fa277252644054afd4384b11
OpenShell4d7194dc8166bfafc7236e0212d2e88aee4f7231a6b757d35f983fe4415427484ad06533d32e9e4b
OpenShell Researchff8319a69255ce0f858691095c6ea9f24d9b603f743839dae47621c13a3bc339bad2a2c8d0167591
- -

Validation

- - - - - - - - -
RepositoryResult
PiFocused suite passed: 38/38 after the assistant atomic-deny fix. npm run check passed its preliminary gates and reported only unchanged upstream packages/ai catalog TypeScript drift.
OpenShellPassed: pre-commit, the full repository test and CI lanes, and mise run go:ci. The merged proxy-delivery end-to-end case passed; the broader Docker lane later failed a separate policy-reload case because its sandbox emitted no JSON result.
OpenShell ResearchPassed: make check and make check-py311, 377/377 tests in each environment after the upstream merge, plus formatting, lint, typing, dependency audit, 11/11 documentation-renderer tests, the clean strict documentation build, and an HTTP 200 artifact preview.
Independent reviewPreviously reviewed Pi and OpenShell work clean: no blocker, high, or medium findings. The later proxy-delivery branch integration was validated but not independently re-reviewed when this report was updated.
- -

Live end-to-end result

-

- The configured machine completed the real ./demo.sh verify - workflow and an extended interactive Pi session. The required verifier - cases passed: denied input was not written, redacted input was persisted - only as [REDACTED], unauthorized bridge and unattested - provider calls were rejected, stock Pi could not bypass attestation, and - an admitted tool-result replacement persisted correctly. The - model-dependent tool-denial case skipped because the model did not choose - to call Bash; this is the verifier's documented non-failing outcome. -

- -

Exploratory session review

-

- The reviewed session is the persistent Pi JSONL session created at - 2026-09-03T01:45:53Z, ID - 01a064f1-a65a-7fe8-ab58-e20a67f868d7. The review used the - session structure and sanitized Egress Gate decision log; it did not copy - credentials or provider request bodies into this report. -

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
AreaObserved behaviorFinding
Standard Pi workflowOne 158-entry JSONL session preserved 14 user messages, 76 assistant messages, 63 tool results, model and thinking-level changes, and one compaction event. Pi created and edited six chapter files and resumed work across repeated turns.Working. Persistent sessions, the normal tools, workspace writes, long-running interaction, and the standard TUI path were active.
Credential isolationWhen Pi inspected every PI_* environment variable, it saw model, provider, reasoning, and session metadata but no model API key or OpenShell resolver value.Working. Proxy-delivered provider authentication kept the credential out of the agent environment.
Reasoning controlsThe session began at high, changed to xhigh, and persisted 72 assistant thinking blocks. Provider usage separately reported reasoning tokens.Working as a model feature. The model produced reasoning and Pi retained it as ordinary session state.
CompactionPi compacted after a 69,090-token turn. The next provider request used 26,727 input tokens, then read the on-disk progress ledger and returned an accurate current story summary.Working. The compacted summary describes only the discarded prefix and looks stale in isolation, but firstKeptEntryId retains the Chapter 4 completion and the retained tail contains Chapters 5 and 6.
Provider failureImmediately after switching to xhigh, one continue submission produced four retries containing Connection error., empty content, and zero token usage.Failure, not denial. The session does not contain an HTTP status or lower-level cause, so it cannot distinguish the provider, proxy, or network source. The later request succeeded without a configuration change; correlation does not prove that xhigh caused it.
Reasoning-only completionThe next continue request returned 810 output tokens, of which 807 were reported as reasoning. It stopped successfully with one thinking block, no visible text, and no tool call. The user had to submit write it before work continued.Usability failure. This was a provider completion accepted by Pi, not an admission denial. It should first be reproduced against the same endpoint with stock Pi before changing the integration.
Denied-message auditAll 14 user entries visible in the reviewed JSONL were persisted, but an input denied before append is intentionally absent. The content-safe Egress Gate log has request IDs but no session ID, submission ID, or timestamp that can correlate a denial to this session.Observability gap. Pi can prove what was appended; it cannot prove from its own history whether another submitted message was denied. The model's claim that no denial occurred was therefore stronger than its evidence.
Thinking admissionAssistant thinking is persisted, can be sent on later provider turns, and consumes context. The current append envelope and attested context omit it; only request-time policy scanning covers it when it appears on the wire.Security-model gap. The current proof of concept does not guarantee that all persisted or provider-visible reasoning was admitted and bound into the context attestation.
Sandbox utilitiesThe model attempted to use bc and file, which are absent from the image. Both Bash tool results were recorded as successful because a later command in each shell invocation exited successfully.Minor environment/diagnostic issue. It did not stop the workflow, but demonstrates that a successful tool result does not imply every command in a compound shell command succeeded.
- -

Decision-log evidence and limits

-

- Since the latest Egress Gate server-start record, the content-safe log - contains 80 allows and two denials with - reason_code=attestation_missing. It contains no middleware - errors and no regex-denial reason. This supports the conclusion that the - visible interactive failures were not middleware denials. It cannot prove - which session or submission produced a record because the current log - deliberately omits the correlation fields needed for that join. -

- -

Recommended follow-up

-
    -
  1. Add content-free denial audit metadata that can be correlated by sandbox, session, and submission without retaining the denied text or adding it to model context.
  2. -
  3. Bring assistant thinking inside the same append-admission and whole-context attestation contract as other persisted provider-visible content.
  4. -
  5. Reproduce the reasoning-only completion and the four connection retries with stock Pi against the same endpoint. Preserve standard Pi behavior unless the integration is shown to be responsible.
  6. -
  7. Expose enough transport error detail to distinguish an upstream/provider failure from a middleware denial without logging request content or credentials.
  8. -
- -

Verified design boundary

-

- One policy fingerprint connects append-time admission, provider-context - admission, and provider egress. The attestation binds one hash of the - complete ordered user/tool context. System/developer and assistant content - is scanned by the request policy at egress but is not included in that - hash. Denied additions do not enter Pi's live or persisted history. -

- -

Known limits

-
    -
  • Image inputs are unsupported by this example and fail closed.
  • -
  • Provider-context admission precedes transport-specific history rewrites; switching transports with existing tool history or sending orphaned tool calls may fail closed.
  • -
  • Admission payloads are limited to 4 MiB and attestations expire after 300 seconds.
  • -
  • The per-exec bridge token remains in Pi process memory; a same-user process able to read that memory could copy it.
  • -
  • This is a proof of concept. Phase 2b provenance-ledger work, additional message hashing, and production deployment automation remain out of scope.
  • -
-
- - diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md index 43d67a14..9c59d897 100644 --- a/projects/egress-gate/docs/architecture/admission.md +++ b/projects/egress-gate/docs/architecture/admission.md @@ -80,7 +80,11 @@ and does not load third-party extensions or implicitly resume saved transcripts. The service adds one bounded `POST /v1/admission` HTTPS endpoint alongside ordinary OpenShell middleware gRPC. It reuses the transport-neutral admission -models, shape adapters, policy pipeline and receipt authority. +models, shape adapters, policy pipeline and receipt authority. Provider validation +supports Chat Completions only and extracts ordered user/tool entries directly; +there is no second normalized model-request representation. Branching, extension +messages and standalone bash-execution envelopes are not admission APIs in this +POC. Bash tool output uses the same tool-result boundary as other tools. The host setup provisions one admission bearer credential, provider destination, policy and actual sandbox ID. The sandbox cannot select its authoritative @@ -140,6 +144,11 @@ The implementation's deterministic tests cover pending/denied candidates before both live and durable writes, accepted replacements, real tool continuations, and the shared manual/auto compaction path. Service tests cover authenticated caller binding, upstream RPCs, receipts, policy decisions and header removal. +A cross-language integration test also runs the actual Pi serializer and HTTP +admission client against local HTTPS admission and provider endpoints. It checks +redaction, skills, a real read-tool continuation, both compaction paths and +receipt verification over authenticated gRPC. Only provider responses are +controlled test data; it does not substitute for live OpenShell acceptance. The example's `demo.sh verify` is a separate real-model end-to-end acceptance command, not a simulated demonstration. Its success must be observed, not inferred from unit tests. See the PR validation record for the latest executed checks. diff --git a/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts b/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts new file mode 100644 index 00000000..9e8600a7 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts @@ -0,0 +1,88 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +// Invoked by pytest with real local admission/provider endpoints. No stream or +// evaluator is replaced: Pi serializes requests and consumes the provider SSE. +import assert from "node:assert/strict"; +import { randomUUID } from "node:crypto"; +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import type { Model } from "@earendil-works/pi-ai"; +import { + Admission, + AdmissionError, + createHttpEvaluator, +} from "../src/admission.js"; +import { AdmissionSession } from "../src/session.js"; + +const [endpoint, directory] = process.argv.slice(2); +const model: Model<"openai-completions"> = { + id: "test", + name: "Local integration provider", + provider: "test", + api: "openai-completions", + baseUrl: `${endpoint}/v1`, + reasoning: false, + input: ["text"], + contextWindow: 100000, + maxTokens: 4096, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + compat: { maxTokensField: "max_tokens", supportsDeveloperRole: false }, +}; +const create = (compactAtTokens?: number) => + AdmissionSession.create({ + cwd: join(directory, "image/project"), + sessionDir: join(directory, "sessions"), + agentDir: join(directory, "agent"), + model, + apiKey: "local-test-credential", + admission: new Admission( + createHttpEvaluator( + `${endpoint}/v1/admission`, + "test-admission-credential", + randomUUID(), + ), + ), + compactAtTokens, + }); + +const session = await create(); +await assert.rejects(session.prompt("DENY_THIS"), AdmissionError); +assert.equal(session.history.length, 0); +assert.equal(session.entries.length, 0); +await session.prompt("Please repeat REDACT_THIS and café."); +await session.prompt("/skill:review"); +const readResult = session.history.find( + (message) => message.role === "toolResult" && message.toolName === "read", +); +assert.ok(readResult?.role === "toolResult"); +assert.equal(readResult.isError, false); +assert.match( + JSON.stringify(readResult.content), + /This is a real file in the sandbox project\./, +); +assert.match(JSON.stringify(readResult.content), /\[REDACTED\]/); +for (const snapshot of [ + JSON.stringify(session.history), + await readFile(session.sessionFile, "utf8"), +]) { + assert.ok(snapshot.includes("[REDACTED]")); + assert.ok(!snapshot.includes("REDACT_THIS") && !snapshot.includes("DENY_THIS")); +} +assert.ok(await session.compact()); +assert.ok(session.entries.some((entry) => entry.type === "compaction")); +assert.match(JSON.stringify(session.history), /Approved summary/); + +const automatic = await create(1); +await automatic.prompt("Hello"); +await automatic.prompt("One more turn"); +assert.ok(automatic.entries.some((entry) => entry.type === "compaction")); +for (const current of [session, automatic]) { + for (const snapshot of [ + JSON.stringify(current.history), + JSON.stringify(current.entries), + await readFile(current.sessionFile, "utf8"), + ]) { + assert.ok(!snapshot.includes("REDACT_THIS") && !snapshot.includes("DENY_THIS")); + } +} diff --git a/projects/egress-gate/scripts/check.sh b/projects/egress-gate/scripts/check.sh index ace57ab6..1c34563e 100755 --- a/projects/egress-gate/scripts/check.sh +++ b/projects/egress-gate/scripts/check.sh @@ -15,13 +15,13 @@ if [[ $# -gt 0 ]]; then uv_run+=(--python "$2") fi +npm --prefix examples/pi-attested-admission/app ci --ignore-scripts --no-audit --no-fund +npm --prefix examples/pi-attested-admission/app run build "${uv_run[@]}" pytest -q "${uv_run[@]}" ruff format --check . "${uv_run[@]}" ruff check . "${uv_run[@]}" ty check "${uv_run[@]}" python -c "import egress_gate" -npm --prefix examples/pi-attested-admission/app ci --ignore-scripts --no-audit --no-fund -npm --prefix examples/pi-attested-admission/app run build npm --prefix examples/pi-attested-admission/app test "${uv_run[@]}" pip-audit \ --progress-spinner off \ diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py index c453ed7c..72662631 100644 --- a/projects/egress-gate/src/egress_gate/admission/__init__.py +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -8,13 +8,9 @@ ContextEntryV1, HarnessAdapter, HarnessAdapterRegistry, - OpenAIChatCompletionsV1Adapter, - OpenAIResponsesV1Adapter, PiAssistantMessageV1, PiAssistantMessageV1Adapter, PiAssistantToolCallV1, - PiBashExecutionV1, - PiBashExecutionV1Adapter, PiImageContentV1, PiMessageV1, PiMessageV1Adapter, @@ -24,24 +20,13 @@ PiToolResultV1, PiToolResultV1Adapter, PreparedHarnessRequest, - ProviderAdapterRegistry, - ProviderRequestAdapter, ToolContextEntryV1, UserContextEntryV1, context_entries_subject, create_pi_adapter_registry, - create_provider_adapter_registry, -) -from egress_gate.admission.canonical import ( - CanonicalFunctionCallV1, - CanonicalGenerationV1, - CanonicalMessageV1, - CanonicalRole, - CanonicalToolChoiceV1, - CanonicalToolV1, - ModelRequestV1, - canonical_json_bytes, + extract_provider_entries, ) +from egress_gate.admission.canonical import canonical_json_bytes from egress_gate.admission.models import ( MAX_ADMISSION_BODY_BYTES, PI_HARNESS_VERSION, @@ -70,12 +55,6 @@ "AgentAttestationClaimsV2", "AttestedEntries", "AttestedEgressProcessor", - "CanonicalFunctionCallV1", - "CanonicalGenerationV1", - "CanonicalMessageV1", - "CanonicalRole", - "CanonicalToolChoiceV1", - "CanonicalToolV1", "ContextEntryV1", "HarnessAdapter", "HarnessAdapterRegistry", @@ -85,14 +64,9 @@ "HarnessAdmissionResult", "MAX_ADMISSION_BODY_BYTES", "PI_HARNESS_VERSION", - "ModelRequestV1", - "OpenAIChatCompletionsV1Adapter", - "OpenAIResponsesV1Adapter", "PiAssistantMessageV1", "PiAssistantMessageV1Adapter", "PiAssistantToolCallV1", - "PiBashExecutionV1", - "PiBashExecutionV1Adapter", "PiMessageV1", "PiImageContentV1", "PiTextContentV1", @@ -102,8 +76,6 @@ "PiProviderContextV1", "PiProviderContextV1Adapter", "PreparedHarnessRequest", - "ProviderAdapterRegistry", - "ProviderRequestAdapter", "RECEIPT_HEADER", "ReceiptAuthority", "ReceiptVerificationError", @@ -111,6 +83,6 @@ "UserContextEntryV1", "canonical_json_bytes", "context_entries_subject", + "extract_provider_entries", "create_pi_adapter_registry", - "create_provider_adapter_registry", ] diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index 920aa3d7..1311d8f1 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -7,6 +7,7 @@ import hashlib import json +import math from typing import Literal, Protocol, TypeAlias from pydantic import ( @@ -17,16 +18,7 @@ model_validator, ) -from egress_gate.admission.canonical import ( - CanonicalFunctionCallV1, - CanonicalGenerationV1, - CanonicalMessageV1, - CanonicalRole, - CanonicalToolChoiceV1, - CanonicalToolV1, - ModelRequestV1, - canonical_json_bytes, -) +from egress_gate.admission.canonical import canonical_json_bytes from egress_gate.admission.models import ( AdmissionHook, HarnessAdmissionContext, @@ -52,9 +44,7 @@ class ProviderShapeError(ValueError): """A content-safe signal that a provider request is unsupported.""" -PiMessageOrigin: TypeAlias = Literal[ - "user", "system", "compaction_summary", "branch_summary", "extension_message" -] +PiMessageOrigin: TypeAlias = Literal["user", "system", "compaction_summary"] class PiMessageV1(StrictDomainModel): @@ -116,15 +106,6 @@ def _tool_calls_are_a_tuple(cls, value: object) -> object: return tuple(value) if isinstance(value, list) else value -class PiBashExecutionV1(StrictDomainModel): - """Replaceable bash output and immutable execution metadata.""" - - schema_version: Literal["openshell.pi-bash-execution.v1"] - command: ScalarString - output: ScalarString - exit_code: int | None - - class UserContextEntryV1(StrictDomainModel): """One ordered user entry sent to a provider.""" @@ -156,11 +137,7 @@ def _entries_are_a_tuple(cls, value: object) -> object: HarnessNative: TypeAlias = ( - PiMessageV1 - | PiToolResultV1 - | PiAssistantMessageV1 - | PiBashExecutionV1 - | PiProviderContextV1 + PiMessageV1 | PiToolResultV1 | PiAssistantMessageV1 | PiProviderContextV1 ) AttestedEntries: TypeAlias = tuple[ContextEntryV1, ...] @@ -173,11 +150,9 @@ def __init__( *, native: HarnessNative, projected_body: bytes, - original_body: bytes, ) -> None: self.native = native self.projected_body = projected_body - self.original_body = original_body class HarnessAdapter(Protocol): @@ -232,7 +207,6 @@ def prepare( return PreparedHarnessRequest( native=native, projected_body=canonical_json_bytes(native), - original_body=request.request_body, ) def validate_result( @@ -267,7 +241,6 @@ def prepare( return PreparedHarnessRequest( native=native, projected_body=canonical_json_bytes(native), - original_body=request.request_body, ) def validate_result( @@ -289,43 +262,6 @@ def validate_result( return replacement, updated -class PiBashExecutionV1Adapter(_AppendHarnessAdapter): - """Strict adapter for Pi bash output.""" - - def prepare( - self, - request: HarnessAdmissionRequest, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> PreparedHarnessRequest: - native = _parse_pi_bash_execution(request.request_body, timeout) - return PreparedHarnessRequest( - native=native, - projected_body=canonical_json_bytes(native), - original_body=request.request_body, - ) - - def validate_result( - self, - prepared: PreparedHarnessRequest, - projected_body: bytes, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> tuple[bytes | None, PiBashExecutionV1]: - updated = _parse_pi_bash_execution(projected_body, timeout) - if not isinstance(prepared.native, PiBashExecutionV1): - raise AdmissionMutationError("bash admission state is invalid") - immutable_before = (prepared.native.command, prepared.native.exit_code) - immutable_after = (updated.command, updated.exit_code) - if immutable_after != immutable_before: - raise AdmissionMutationError("admission changed bash execution metadata") - encoded = canonical_json_bytes(updated) - replacement = ( - None if encoded == canonical_json_bytes(prepared.native) else encoded - ) - return replacement, updated - - class PiToolResultV1Adapter(_AppendHarnessAdapter): """Strict adapter for Pi tool-result content blocks.""" @@ -340,7 +276,6 @@ def prepare( return PreparedHarnessRequest( native=native, projected_body=canonical_json_bytes(native), - original_body=request.request_body, ) def validate_result( @@ -387,7 +322,6 @@ def prepare( return PreparedHarnessRequest( native=native, projected_body=canonical_json_bytes(native), - original_body=request.request_body, ) def validate_result( @@ -453,11 +387,6 @@ def resolve(self, context: HarnessAdmissionContext) -> HarnessAdapter: "harness admission shape is unsupported" ) from None - @property - def bindings(self) -> tuple[tuple[str, str, str], ...]: - """Return registered harness, hook, and schema bindings.""" - return tuple(self._adapters) - class _ProviderTextBlock(StrictDomainModel): type: Literal["text"] @@ -488,6 +417,19 @@ class _ProviderMessage(StrictDomainModel): def _provider_sequences_are_tuples(cls, value: object) -> object: return tuple(value) if isinstance(value, list) else value + @model_validator(mode="after") + def _role_fields_are_consistent(self) -> _ProviderMessage: + if self.role == "tool": + if self.content is None or self.tool_call_id is None or self.tool_calls: + raise ValueError("tool messages require content and tool_call_id") + elif self.tool_call_id is not None: + raise ValueError("only tool messages may carry tool_call_id") + if self.tool_calls and self.role != "assistant": + raise ValueError("only assistant messages may carry tool calls") + if self.content is None and not self.tool_calls: + raise ValueError("messages require content or tool calls") + return self + @model_validator(mode="after") def _optional_fields_have_one_representation(self) -> _ProviderMessage: if "content" not in self.model_fields_set: @@ -570,382 +512,37 @@ def _compatibility_fields_have_one_representation(self) -> _ProviderRequest: raise ValueError(f"provider request {field_name} cannot be null") return self - @property - def output_token_limit(self) -> int: - value = self.max_completion_tokens or self.max_tokens - if value is None: - raise ValueError("provider request has no max-token field") - return value - - -class _ResponsesInputText(StrictDomainModel): - type: Literal["input_text"] - text: ScalarString - - -class _ResponsesOutputText(StrictDomainModel): - type: Literal["output_text"] - text: ScalarString - annotations: tuple[object, ...] - - @field_validator("annotations", mode="before") - @classmethod - def _annotations_are_a_tuple(cls, value: object) -> object: - return tuple(value) if isinstance(value, list) else value - - -class _ResponsesInputMessage(StrictDomainModel): - role: Literal["system", "developer", "user"] - content: ScalarString | tuple[_ResponsesInputText, ...] - type: Literal["message"] | None = None - - @field_validator("content", mode="before") - @classmethod - def _content_is_a_tuple(cls, value: object) -> object: - return tuple(value) if isinstance(value, list) else value - - @model_validator(mode="after") - def _optional_type_is_not_null(self) -> _ResponsesInputMessage: - if "type" in self.model_fields_set and self.type is None: - raise ValueError("Responses input message type cannot be null") - return self - - -class _ResponsesAssistantMessage(StrictDomainModel): - type: Literal["message"] - role: Literal["assistant"] - content: tuple[_ResponsesOutputText, ...] - status: Literal["completed"] - id: ScalarString - phase: Literal["commentary", "final_answer"] | None = None - - @field_validator("content", mode="before") - @classmethod - def _content_is_a_tuple(cls, value: object) -> object: - return tuple(value) if isinstance(value, list) else value - - -class _ResponsesFunctionCall(StrictDomainModel): - type: Literal["function_call"] - call_id: ScalarString - name: ScalarString - arguments: ScalarString - id: ScalarString | None = None - namespace: ScalarString | None = None - - -class _ResponsesFunctionCallOutput(StrictDomainModel): - type: Literal["function_call_output"] - call_id: ScalarString - output: ScalarString | tuple[_ResponsesInputText, ...] - - @field_validator("output", mode="before") - @classmethod - def _output_is_a_tuple(cls, value: object) -> object: - return tuple(value) if isinstance(value, list) else value - - -class _ResponsesReasoningSummary(StrictDomainModel): - type: Literal["summary_text"] - text: ScalarString - - -class _ResponsesReasoningContent(StrictDomainModel): - type: Literal["reasoning_text"] - text: ScalarString - - -class _ResponsesReasoning(StrictDomainModel): - type: Literal["reasoning"] - id: ScalarString - summary: tuple[_ResponsesReasoningSummary, ...] - content: tuple[_ResponsesReasoningContent, ...] | None = None - encrypted_content: ScalarString | None = None - status: Literal["in_progress", "completed", "incomplete"] | None = None - - @field_validator("summary", "content", mode="before") - @classmethod - def _sequences_are_tuples(cls, value: object) -> object: - return tuple(value) if isinstance(value, list) else value - -_ResponsesInputItem: TypeAlias = ( - _ResponsesInputMessage - | _ResponsesAssistantMessage - | _ResponsesFunctionCall - | _ResponsesFunctionCallOutput - | _ResponsesReasoning -) - - -class _ResponsesTool(StrictDomainModel): - type: Literal["function"] - name: ScalarString - description: ScalarString - parameters: dict[str, object] - strict: bool | None = None - - -class _ResponsesNamedToolChoice(StrictDomainModel): - type: Literal["function"] - name: ScalarString - - -class _ResponsesReasoningOptions(StrictDomainModel): - effort: ScalarString - summary: Literal["auto", "detailed", "concise"] | None = None - - -class _ResponsesPromptCacheOptions(StrictDomainModel): - mode: Literal["explicit"] - - -class _ResponsesRequest(StrictDomainModel): - model: ScalarString - input: tuple[_ResponsesInputItem, ...] - stream: Literal[True] - store: Literal[False] - max_output_tokens: int = Field(ge=1) - tools: tuple[_ResponsesTool, ...] = () - tool_choice: Literal["auto", "none", "required"] | _ResponsesNamedToolChoice = ( - "auto" - ) - temperature: int | float | None = Field(default=None, allow_inf_nan=False) - prompt_cache_key: ScalarString | None = None - prompt_cache_retention: Literal["24h"] | None = None - prompt_cache_options: _ResponsesPromptCacheOptions | None = None - reasoning: _ResponsesReasoningOptions | None = None - include: tuple[Literal["reasoning.encrypted_content"], ...] = () - service_tier: Literal["auto", "default", "flex", "scale", "priority"] | None = None - - @field_validator("input", "tools", "include", mode="before") - @classmethod - def _collections_are_tuples(cls, value: object) -> object: - return tuple(value) if isinstance(value, list | tuple) else value - - -class ProviderRequestAdapter(Protocol): - """Validate and project a provider request for rendered-prompt extraction.""" - - schema_version: str - - def canonicalize( - self, request: HttpRequest, timeout: Timeout - ) -> ModelRequestV1: ... - - def attested_entries( - self, request: HttpRequest, timeout: Timeout - ) -> AttestedEntries: ... - - -class OpenAIChatCompletionsV1Adapter: - """Pinned OpenAI-compatible Chat Completions request adapter.""" - - schema_version = "openai.chat-completions.v1" - - def canonicalize(self, request: HttpRequest, timeout: Timeout) -> ModelRequestV1: - if request.target.method.upper() != "POST": - raise ProviderShapeError("provider request method is unsupported") - content_types = [ - header.value.strip().lower() - for header in request.headers - if header.name.lower() == "content-type" - ] - if content_types != ["application/json"]: - raise ProviderShapeError("provider request requires one JSON content type") - if any(header.name.lower() == "content-encoding" for header in request.headers): - raise ProviderShapeError("provider request content encoding is unsupported") - value = _load_json(request.body, ProviderShapeError, timeout) - try: - provider = _PROVIDER_ADAPTER.validate_python(value, strict=True) - except ValidationError: - raise ProviderShapeError("provider request body is unsupported") from None - if not isinstance(provider, _ProviderRequest): - raise ProviderShapeError("provider request body is unsupported") - messages = tuple( - _provider_message_to_canonical(item) for item in provider.messages - ) - tools = tuple( - CanonicalToolV1( - name=item.function.name, - description=item.function.description, - input_schema=item.function.parameters, - ) - for item in provider.tools - ) - if isinstance(provider.tool_choice, str): - tool_choice = CanonicalToolChoiceV1(mode=provider.tool_choice) - else: - tool_choice = CanonicalToolChoiceV1( - mode="function", - function_name=provider.tool_choice.function.name, - ) - return ModelRequestV1( - model=provider.model, - messages=messages, - tools=tools, - tool_choice=tool_choice, - generation=CanonicalGenerationV1( - temperature=provider.temperature, - max_tokens=provider.output_token_limit, - ), +def extract_provider_entries(request: HttpRequest, timeout: Timeout) -> AttestedEntries: + """Validate Chat Completions and extract only the receipt-covered context.""" + _validate_json_request(request) + value = _load_json(request.body, ProviderShapeError, timeout) + try: + provider = _PROVIDER_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise ProviderShapeError("provider request body is unsupported") from None + entries: list[ContextEntryV1] = [] + for message in provider.messages: + content = ( + "\n".join(block.text for block in message.content) + if isinstance(message.content, tuple) + else message.content ) - - def attested_entries( - self, request: HttpRequest, timeout: Timeout - ) -> AttestedEntries: - """Extract every user and tool entry in provider order.""" - canonical = self.canonicalize(request, timeout) - entries: list[ContextEntryV1] = [] - for message in canonical.messages: - if message.role is CanonicalRole.USER and message.content is not None: - entries.append(UserContextEntryV1(role="user", text=message.content)) - if message.role is CanonicalRole.TOOL and message.content is not None: - if message.tool_call_id is None: - raise ProviderShapeError("provider tool result has no call ID") - entries.append( - ToolContextEntryV1( - role="tool", - tool_call_id=message.tool_call_id, - text=message.content, - ) - ) - if not entries: - raise ProviderShapeError("provider request has no attested context entries") - return tuple(entries) - - -class OpenAIResponsesV1Adapter: - """Pinned OpenAI-compatible Responses request adapter.""" - - schema_version = "openai.responses.v1" - - def canonicalize(self, request: HttpRequest, timeout: Timeout) -> ModelRequestV1: - provider = self._parse(request, timeout) - messages: list[CanonicalMessageV1] = [] - for item in provider.input: - if isinstance(item, _ResponsesInputMessage): - messages.append( - CanonicalMessageV1( - role=CanonicalRole(item.role), - content=_responses_text(item.content), - ) + if message.role == "user" and content is not None: + entries.append(UserContextEntryV1(role="user", text=content)) + elif message.role == "tool" and content is not None: + # The role validator requires this ID for every tool message. + assert message.tool_call_id is not None + entries.append( + ToolContextEntryV1( + role="tool", + tool_call_id=_provider_tool_call_id(message.tool_call_id), + text=content, ) - elif isinstance(item, _ResponsesAssistantMessage): - messages.append( - CanonicalMessageV1( - role=CanonicalRole.ASSISTANT, - content="\n".join(block.text for block in item.content), - ) - ) - elif isinstance(item, _ResponsesFunctionCall): - messages.append( - CanonicalMessageV1( - role=CanonicalRole.ASSISTANT, - content=None, - tool_calls=( - CanonicalFunctionCallV1( - id=item.call_id, - name=item.name, - arguments=item.arguments, - ), - ), - ) - ) - elif isinstance(item, _ResponsesFunctionCallOutput): - messages.append(_responses_tool_result(item)) - tools = tuple( - CanonicalToolV1( - name=item.name, - description=item.description, - input_schema=item.parameters, ) - for item in provider.tools - ) - if isinstance(provider.tool_choice, str): - tool_choice = CanonicalToolChoiceV1(mode=provider.tool_choice) - else: - tool_choice = CanonicalToolChoiceV1( - mode="function", function_name=provider.tool_choice.name - ) - return ModelRequestV1( - model=provider.model, - messages=tuple(messages), - tools=tools, - tool_choice=tool_choice, - generation=CanonicalGenerationV1( - temperature=provider.temperature, - max_tokens=provider.max_output_tokens, - ), - ) - - def attested_entries( - self, request: HttpRequest, timeout: Timeout - ) -> AttestedEntries: - """Extract every user and function-call output entry in provider order.""" - provider = self._parse(request, timeout) - entries: list[ContextEntryV1] = [] - for item in provider.input: - if isinstance(item, _ResponsesInputMessage) and item.role == "user": - entries.append( - UserContextEntryV1(role="user", text=_responses_text(item.content)) - ) - if isinstance(item, _ResponsesFunctionCallOutput): - message = _responses_tool_result(item) - if message.tool_call_id is None or message.content is None: - raise ProviderShapeError("provider tool result is incomplete") - entries.append( - ToolContextEntryV1( - role="tool", - tool_call_id=message.tool_call_id, - text=message.content, - ) - ) - if not entries: - raise ProviderShapeError("provider request has no attested context entries") - return tuple(entries) - - def _parse(self, request: HttpRequest, timeout: Timeout) -> _ResponsesRequest: - _validate_json_request(request) - value = _load_json(request.body, ProviderShapeError, timeout) - try: - provider = _RESPONSES_PROVIDER_ADAPTER.validate_python(value, strict=True) - except ValidationError: - raise ProviderShapeError("provider request body is unsupported") from None - if not isinstance(provider, _ResponsesRequest): - raise ProviderShapeError("provider request body is unsupported") - return provider - - -class ProviderAdapterRegistry: - """Explicit versioned provider-adapter registry.""" - - def __init__(self) -> None: - self._adapters: dict[str, ProviderRequestAdapter] = {} - - def register(self, adapter: ProviderRequestAdapter) -> None: - if adapter.schema_version in self._adapters: - raise ValueError("provider adapter is already registered") - self._adapters[adapter.schema_version] = adapter - - def resolve(self, schema_version: str) -> ProviderRequestAdapter: - try: - return self._adapters[schema_version] - except KeyError: - raise ProviderShapeError("provider adapter is unsupported") from None - - def resolve_request( - self, request: HttpRequest, timeout: Timeout - ) -> ProviderRequestAdapter: - """Select the adapter from the mutually exclusive top-level request shape.""" - value = _load_json(request.body, ProviderShapeError, timeout) - if not isinstance(value, dict): - raise ProviderShapeError("provider request body is unsupported") - if "messages" in value and "input" not in value: - return self.resolve(OpenAIChatCompletionsV1Adapter.schema_version) - if "input" in value and "messages" not in value: - return self.resolve(OpenAIResponsesV1Adapter.schema_version) - raise ProviderShapeError("provider request body is unsupported") + if not entries: + raise ProviderShapeError("provider request has no attested context entries") + return tuple(entries) def create_pi_adapter_registry() -> HarnessAdapterRegistry: @@ -955,8 +552,6 @@ def create_pi_adapter_registry() -> HarnessAdapterRegistry: (AdmissionHook.USER_MESSAGE, "user"), (AdmissionHook.SYSTEM_CONTEXT, "system"), (AdmissionHook.COMPACTION_SUMMARY, "compaction_summary"), - (AdmissionHook.BRANCH_SUMMARY, "branch_summary"), - (AdmissionHook.EXTENSION_MESSAGE, "extension_message"), ): registry.register( "pi", @@ -976,12 +571,6 @@ def create_pi_adapter_registry() -> HarnessAdapterRegistry: "openshell.pi-assistant-message.v1", PiAssistantMessageV1Adapter(), ) - registry.register( - "pi", - AdmissionHook.BASH_EXECUTION, - "openshell.pi-bash-execution.v1", - PiBashExecutionV1Adapter(), - ) registry.register( "pi", AdmissionHook.PROVIDER_CONTEXT, @@ -991,14 +580,6 @@ def create_pi_adapter_registry() -> HarnessAdapterRegistry: return registry -def create_provider_adapter_registry() -> ProviderAdapterRegistry: - """Return the built-in OpenAI provider-request registry.""" - registry = ProviderAdapterRegistry() - registry.register(OpenAIChatCompletionsV1Adapter()) - registry.register(OpenAIResponsesV1Adapter()) - return registry - - def _parse_pi_body( body: bytes, timeout: Timeout, *, accepted_origin: PiMessageOrigin = "user" ) -> PiMessageV1: @@ -1007,8 +588,6 @@ def _parse_pi_body( parsed = _PI_ADAPTER.validate_python(value, strict=True) except ValidationError: raise AdmissionShapeError("Pi request body is unsupported") from None - if not isinstance(parsed, PiMessageV1): - raise AdmissionShapeError("Pi request body is unsupported") if parsed.origin != accepted_origin: raise AdmissionShapeError("Pi message origin is unsupported") if canonical_json_bytes(parsed) != body: @@ -1025,25 +604,12 @@ def _parse_pi_assistant_message(body: bytes, timeout: Timeout) -> PiAssistantMes return parsed -def _parse_pi_bash_execution(body: bytes, timeout: Timeout) -> PiBashExecutionV1: - value = _load_json(body, AdmissionShapeError, timeout) - try: - parsed = _PI_BASH_EXECUTION_ADAPTER.validate_python(value, strict=True) - except ValidationError: - raise AdmissionShapeError("Pi bash-execution body is unsupported") from None - if canonical_json_bytes(parsed) != body: - raise AdmissionShapeError("Pi bash-execution body is not canonical JSON") - return parsed - - def _parse_pi_tool_result(body: bytes, timeout: Timeout) -> PiToolResultV1: value = _load_json(body, AdmissionShapeError, timeout) try: parsed = _PI_TOOL_RESULT_ADAPTER.validate_python(value, strict=True) except ValidationError: raise AdmissionShapeError("Pi tool-result body is unsupported") from None - if not isinstance(parsed, PiToolResultV1): - raise AdmissionShapeError("Pi tool-result body is unsupported") return parsed @@ -1097,24 +663,6 @@ def _validate_json_request(request: HttpRequest) -> None: raise ProviderShapeError("provider request content encoding is unsupported") -def _responses_text(value: ScalarString | tuple[_ResponsesInputText, ...]) -> str: - if isinstance(value, str): - return value - if not value: - raise ProviderShapeError("provider message content cannot be empty") - return "\n".join(block.text for block in value) - - -def _responses_tool_result( - item: _ResponsesFunctionCallOutput, -) -> CanonicalMessageV1: - return CanonicalMessageV1( - role=CanonicalRole.TOOL, - content=_responses_text(item.output), - tool_call_id=_provider_tool_call_id(item.call_id), - ) - - def _provider_tool_call_id(value: str) -> str: return value.split("|", 1)[0] @@ -1126,52 +674,23 @@ def _load_json(body: bytes, error_type: type[ValueError], timeout: Timeout) -> o raise error_type("request body is not canonical JSON") from None try: text = body.decode("utf-8", errors="strict") - return json.loads(text, object_pairs_hook=_unique_object) + return json.loads(text, parse_float=_finite_json_float) except (UnicodeDecodeError, json.JSONDecodeError, RecursionError, ValueError): raise error_type("request body is not canonical JSON") from None -def _unique_object(pairs: list[tuple[str, object]]) -> dict[str, object]: - output: dict[str, object] = {} - for key, value in pairs: - if key in output: - raise ValueError("duplicate JSON object key") - output[key] = value - return output - - -def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1: - if isinstance(item.content, tuple): - content = "\n".join(block.text for block in item.content) - else: - content = item.content - return CanonicalMessageV1( - role=CanonicalRole(item.role), - content=content, - name=item.name, - tool_call_id=( - _provider_tool_call_id(item.tool_call_id) - if item.tool_call_id is not None - else None - ), - tool_calls=tuple( - CanonicalFunctionCallV1( - id=call.id, - name=call.function.name, - arguments=call.function.arguments, - ) - for call in item.tool_calls - ), - ) +def _finite_json_float(value: str) -> float: + number = float(value) + if not math.isfinite(number): + raise ValueError("JSON numbers must be finite") + return number _PI_ADAPTER = TypeAdapter(PiMessageV1) _PI_TOOL_RESULT_ADAPTER = TypeAdapter(PiToolResultV1) _PI_ASSISTANT_MESSAGE_ADAPTER = TypeAdapter(PiAssistantMessageV1) -_PI_BASH_EXECUTION_ADAPTER = TypeAdapter(PiBashExecutionV1) _PI_PROVIDER_CONTEXT_ADAPTER = TypeAdapter(PiProviderContextV1) _PROVIDER_ADAPTER = TypeAdapter(_ProviderRequest) -_RESPONSES_PROVIDER_ADAPTER = TypeAdapter(_ResponsesRequest) __all__ = [ @@ -1181,15 +700,11 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 "ContextEntryV1", "HarnessAdapter", "HarnessAdapterRegistry", - "OpenAIChatCompletionsV1Adapter", - "OpenAIResponsesV1Adapter", "PiMessageV1", "PiImageContentV1", "PiAssistantMessageV1", "PiAssistantMessageV1Adapter", "PiAssistantToolCallV1", - "PiBashExecutionV1", - "PiBashExecutionV1Adapter", "PiTextContentV1", "PiToolResultV1", "PiToolResultV1Adapter", @@ -1197,12 +712,10 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 "PiProviderContextV1", "PiProviderContextV1Adapter", "PreparedHarnessRequest", - "ProviderAdapterRegistry", - "ProviderRequestAdapter", "ProviderShapeError", "ToolContextEntryV1", "UserContextEntryV1", + "extract_provider_entries", "context_entries_subject", "create_pi_adapter_registry", - "create_provider_adapter_registry", ] diff --git a/projects/egress-gate/src/egress_gate/admission/canonical.py b/projects/egress-gate/src/egress_gate/admission/canonical.py index cd5d08f0..60ce0fc9 100644 --- a/projects/egress-gate/src/egress_gate/admission/canonical.py +++ b/projects/egress-gate/src/egress_gate/admission/canonical.py @@ -1,115 +1,13 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Strict canonical model-request schema and encoding.""" +"""Stable JSON encoding for admission messages and receipt claims.""" from __future__ import annotations import json -import math -from enum import StrEnum -from typing import Literal - -from pydantic import Field, field_validator, model_validator from egress_gate.base import StrictDomainModel -from egress_gate.string_validators import ScalarString - - -class CanonicalRole(StrEnum): - """Roles supported by the pinned provider schema.""" - - SYSTEM = "system" - DEVELOPER = "developer" - USER = "user" - ASSISTANT = "assistant" - TOOL = "tool" - - -class CanonicalFunctionCallV1(StrictDomainModel): - """One model-produced function call without lossy argument parsing.""" - - id: ScalarString - name: ScalarString - arguments: ScalarString - - -class CanonicalMessageV1(StrictDomainModel): - """One ordered, provider-visible message.""" - - role: CanonicalRole - content: ScalarString | None - name: ScalarString | None = None - tool_call_id: ScalarString | None = None - tool_calls: tuple[CanonicalFunctionCallV1, ...] = () - - @model_validator(mode="after") - def _role_fields_are_consistent(self) -> CanonicalMessageV1: - if self.role is CanonicalRole.TOOL: - if self.content is None or self.tool_call_id is None or self.tool_calls: - raise ValueError("tool messages require content and tool_call_id") - elif self.tool_call_id is not None: - raise ValueError("only tool messages may carry tool_call_id") - if self.tool_calls and self.role is not CanonicalRole.ASSISTANT: - raise ValueError("only assistant messages may carry tool calls") - if self.content is None and not self.tool_calls: - raise ValueError("messages require content or tool calls") - return self - - -class CanonicalToolV1(StrictDomainModel): - """One complete function-tool definition.""" - - name: ScalarString - description: ScalarString - input_schema: dict[str, object] - - @field_validator("input_schema") - @classmethod - def _schema_is_canonical_json(cls, value: dict[str, object]) -> dict[str, object]: - _validate_json_value(value) - return value - - -class CanonicalToolChoiceV1(StrictDomainModel): - """Pinned OpenAI tool-selection semantics.""" - - mode: Literal["auto", "none", "required", "function"] - function_name: ScalarString | None = None - - @model_validator(mode="after") - def _function_name_matches_mode(self) -> CanonicalToolChoiceV1: - if (self.mode == "function") != (self.function_name is not None): - raise ValueError("function tool choice requires exactly one name") - return self - - -class CanonicalGenerationV1(StrictDomainModel): - """Semantic generation fields accepted from the pinned Pi serializer.""" - - temperature: float | None = Field(default=None, allow_inf_nan=False) - max_tokens: int = Field(ge=1) - - @field_validator("temperature", mode="before") - @classmethod - def _normalize_temperature(cls, value: object) -> float | None: - if value is None: - return value - if isinstance(value, bool) or not isinstance(value, int | float): - raise ValueError("temperature must be numeric") - normalized = float(value) - return 0.0 if normalized == 0 else normalized - - -class ModelRequestV1(StrictDomainModel): - """Validated semantic view of one supported provider request.""" - - schema_version: Literal["model-request.v1"] = "model-request.v1" - model: ScalarString - messages: tuple[CanonicalMessageV1, ...] - tools: tuple[CanonicalToolV1, ...] - tool_choice: CanonicalToolChoiceV1 - generation: CanonicalGenerationV1 def canonical_json_bytes(value: StrictDomainModel) -> bytes: @@ -121,36 +19,3 @@ def canonical_json_bytes(value: StrictDomainModel) -> bytes: separators=(",", ":"), sort_keys=True, ).encode("utf-8") - - -def _validate_json_value(value: object) -> None: - if value is None or isinstance(value, str | bool | int): - return - if isinstance(value, float): - if not math.isfinite(value): - raise ValueError("JSON numbers must be finite") - return - if isinstance(value, list): - for item in value: - _validate_json_value(item) - return - if isinstance(value, dict): - for key, item in value.items(): - if not isinstance(key, str): - raise ValueError("JSON object keys must be strings") - key.encode("utf-8", errors="strict") - _validate_json_value(item) - return - raise ValueError("value is not canonical JSON") - - -__all__ = [ - "CanonicalFunctionCallV1", - "CanonicalGenerationV1", - "CanonicalMessageV1", - "CanonicalRole", - "CanonicalToolChoiceV1", - "CanonicalToolV1", - "ModelRequestV1", - "canonical_json_bytes", -] diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py index 3e55f4e7..4a7ad6ba 100644 --- a/projects/egress-gate/src/egress_gate/admission/models.py +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -28,9 +28,6 @@ class AdmissionHook(StrEnum): TOOL_RESULT = "tool_result" ASSISTANT_MESSAGE = "assistant_message" COMPACTION_SUMMARY = "compaction_summary" - BRANCH_SUMMARY = "branch_summary" - EXTENSION_MESSAGE = "extension_message" - BASH_EXECUTION = "bash_execution" PROVIDER_CONTEXT = "provider_context" diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py index 29c62759..561fa812 100644 --- a/projects/egress-gate/src/egress_gate/admission/processor.py +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -15,9 +15,9 @@ AdmissionMutationError, AdmissionShapeError, HarnessAdapterRegistry, - ProviderAdapterRegistry, ProviderShapeError, context_entries_subject, + extract_provider_entries, ) from egress_gate.admission.models import ( MAX_ADMISSION_BODY_BYTES, @@ -161,7 +161,6 @@ class AttestedEgressProcessor: def __init__( self, request_processor: RequestProcessor, - provider_adapters: ProviderAdapterRegistry, receipt_authority: ReceiptAuthority, *, middleware_name: str, @@ -171,7 +170,6 @@ def __init__( if not fingerprint: raise ValueError("attested egress requires a policy fingerprint") self._request_processor = request_processor - self._provider_adapters = provider_adapters self._receipt_authority = receipt_authority self._middleware_name = middleware_name self._harness_version = harness_version @@ -215,8 +213,7 @@ def process( } ) try: - adapter = self._provider_adapters.resolve_request(request, timeout) - entries = adapter.attested_entries(request, timeout) + entries = extract_provider_entries(request, timeout) subject_hash, entry_count = context_entries_subject(entries) timeout.raise_if_expired() context = HarnessAdmissionContext( @@ -245,7 +242,7 @@ def process( final_request = apply_request_mutations( request, gate_result.request_mutations ) - final_entries = adapter.attested_entries(final_request, timeout) + final_entries = extract_provider_entries(final_request, timeout) if final_entries != entries: return self._deny("semantic_mutation_denied") timeout.raise_if_expired() diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index bf6679c5..bf43a066 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -28,7 +28,6 @@ HarnessAdmissionResult, ReceiptAuthority, create_pi_adapter_registry, - create_provider_adapter_registry, ) from egress_gate.bindings import supervisor_middleware_pb2 as pb2 from egress_gate.bindings import supervisor_middleware_pb2_grpc as pb2_grpc @@ -301,7 +300,6 @@ def _prepare_and_process( if self._require_agent_attestation: return AttestedEgressProcessor( processor, - create_provider_adapter_registry(), self._receipt_authority, middleware_name=request.middleware_name, harness_version=PI_HARNESS_VERSION, diff --git a/projects/egress-gate/tests/admission/fixtures/README.md b/projects/egress-gate/tests/admission/fixtures/README.md index f490dba4..2bfa46a7 100644 --- a/projects/egress-gate/tests/admission/fixtures/README.md +++ b/projects/egress-gate/tests/admission/fixtures/README.md @@ -1,10 +1,10 @@ # Pi provider fixture provenance -These payloads were captured on 2026-09-02 from Pi commit +The Chat Completions payloads were captured on 2026-09-02 from Pi commit `61500e60394060f2f56a76c61a0067c33988c9f8` through the native stream -adapters' `onPayload` fake-fetch boundary. The capture used the three models -and compatibility settings checked into the attested-admission example. No -provider request was sent. +adapter's `onPayload` fake-fetch boundary. They preserve historical serializer +shapes as regression cases, not the current example's model configuration. +No provider request was sent during capture. The strict adapter decisions are deliberate: @@ -12,9 +12,10 @@ The strict adapter decisions are deliberate: `reasoning_content` string emitted when Pi replays Qwen reasoning. That reasoning field is preserved for validation but is not projected as message text. -- Responses accepts replayed `reasoning`, assistant `message`, - `function_call`, and `function_call_output` items, including the optional - reasoning fields present in the captured payload. - Unknown fields, explicit nulls for optional compatibility fields, image - inputs, and mixed top-level Chat Completions/Responses shapes remain - unsupported and fail closed. + inputs, and Responses requests are unsupported and fail closed. + +The current pinned Pi serializer is exercised directly by +`tests/service/test_http_admission.py`, which runs the real application client +against local admission and provider endpoints. Provider responses are controlled +test data; the runnable example's real-model verification is separate. diff --git a/projects/egress-gate/tests/admission/fixtures/context-entries.json b/projects/egress-gate/tests/admission/fixtures/context-entries.json deleted file mode 100644 index 75c4e279..00000000 --- a/projects/egress-gate/tests/admission/fixtures/context-entries.json +++ /dev/null @@ -1,55 +0,0 @@ -{ - "cases": [ - { - "name": "converted-history-origins", - "context": { - "messages": [ - { - "role": "user", - "content": [ - { - "type": "text", - "text": "The conversation history before this point was compacted into the following summary:\n\n\ncompact text\n" - } - ] - }, - { - "role": "assistant", - "content": [{"type": "text", "text": "assistant text"}] - }, - { - "role": "user", - "content": "Ran `printf safe`\n```\nbash text\n```" - }, - { - "role": "user", - "content": [ - {"type": "text", "text": "extension text"}, - {"type": "text", "text": "continued"} - ] - }, - { - "role": "toolResult", - "toolCallId": "call-1|provider-id", - "toolName": "read", - "content": [{"type": "text", "text": "tool text"}], - "isError": false - } - ], - "tools": [] - }, - "entries": [ - { - "role": "user", - "text": "The conversation history before this point was compacted into the following summary:\n\n\ncompact text\n" - }, - { - "role": "user", - "text": "Ran `printf safe`\n```\nbash text\n```" - }, - {"role": "user", "text": "extension text\ncontinued"}, - {"role": "tool", "tool_call_id": "call-1", "text": "tool text"} - ] - } - ] -} diff --git a/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json b/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json deleted file mode 100644 index 315061b9..00000000 --- a/projects/egress-gate/tests/admission/fixtures/pi-openai-responses.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "user_request": { - "model": "azure/openai/gpt-5.6-sol", - "input": [ - {"role": "developer", "content": "fixture system prompt"}, - {"role": "user", "content": [{"type": "input_text", "text": "safe"}]} - ], - "stream": true, - "store": false, - "max_output_tokens": 128000, - "tools": [ - { - "type": "function", - "name": "read", - "description": "Read a file", - "parameters": {"type": "object", "properties": {}} - } - ], - "reasoning": {"effort": "high", "summary": "auto"}, - "include": ["reasoning.encrypted_content"] - }, - "tool_result_request": { - "model": "azure/openai/gpt-5.6-sol", - "input": [ - {"role": "developer", "content": "fixture system prompt"}, - { - "role": "user", - "content": [{"type": "input_text", "text": "use the tool"}] - }, - { - "type": "reasoning", - "id": "rs-1", - "summary": [{"type": "summary_text", "text": "summary"}], - "content": [{"type": "reasoning_text", "text": "reasoning"}], - "encrypted_content": "encrypted", - "status": "completed" - }, - { - "type": "message", - "role": "assistant", - "content": [ - {"type": "output_text", "text": "I will read it.", "annotations": []} - ], - "status": "completed", - "id": "msg_pi_1" - }, - { - "type": "function_call", - "call_id": "call-1", - "name": "read", - "arguments": "{}" - }, - { - "type": "function_call_output", - "call_id": "call-1", - "output": "safe tool output" - } - ], - "stream": true, - "store": false, - "max_output_tokens": 128000, - "tools": [ - { - "type": "function", - "name": "read", - "description": "Read a file", - "parameters": {"type": "object", "properties": {}} - } - ], - "reasoning": {"effort": "high", "summary": "auto"}, - "include": ["reasoning.encrypted_content"] - } -} diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index 5dd661e5..e6950788 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -24,7 +24,6 @@ HarnessAdmissionRequest, PiAssistantMessageV1, PiAssistantToolCallV1, - PiBashExecutionV1, PiMessageV1, PiProviderContextV1, PiTextContentV1, @@ -32,7 +31,7 @@ ReceiptAuthority, canonical_json_bytes, create_pi_adapter_registry, - create_provider_adapter_registry, + extract_provider_entries, ) from egress_gate.gates import create_builtin_registry from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext @@ -40,18 +39,11 @@ DENY_TEXT = "DENY_THIS" REDACT_TEXT = "REDACT_THIS" -_PI_RESPONSES_FIXTURES = json.loads( - (Path(__file__).parent / "fixtures/pi-openai-responses.json").read_text() -) +# Captured serializer payloads remain useful regression fixtures; live serializer +# coverage is exercised by the cross-language service test. _PI_CHAT_FIXTURES = json.loads( (Path(__file__).parent / "fixtures/pi-openai-completions.json").read_text() ) -_CONTEXT_ENTRY_VECTORS = json.loads( - (Path(__file__).parent / "fixtures/context-entries.json").read_text() -) -# These payloads were captured at Pi's fake-fetch boundary from its native -# openai-responses and openai-completions stream functions. They intentionally -# preserve the serializer output rather than restating it through test builders. def _processors( @@ -119,7 +111,6 @@ def _processors( ), AttestedEgressProcessor( request_processor, - create_provider_adapter_registry(), authority, middleware_name="pi-egress", harness_version="sdk-v1", @@ -145,13 +136,11 @@ def _context( target: HttpTarget | None = None, ) -> HarnessAdmissionContext: schema = { + AdmissionHook.SYSTEM_CONTEXT: "openshell.pi-message.v1", AdmissionHook.USER_MESSAGE: "openshell.pi-message.v1", AdmissionHook.COMPACTION_SUMMARY: "openshell.pi-message.v1", - AdmissionHook.BRANCH_SUMMARY: "openshell.pi-message.v1", - AdmissionHook.EXTENSION_MESSAGE: "openshell.pi-message.v1", AdmissionHook.TOOL_RESULT: "openshell.pi-tool-result.v1", AdmissionHook.ASSISTANT_MESSAGE: "openshell.pi-assistant-message.v1", - AdmissionHook.BASH_EXECUTION: "openshell.pi-bash-execution.v1", AdmissionHook.PROVIDER_CONTEXT: "openshell.pi-provider-context.v1", }[hook] return HarnessAdmissionContext( @@ -169,13 +158,7 @@ def _context( def _admit( processor: HarnessAdmissionProcessor, - value: ( - PiMessageV1 - | PiToolResultV1 - | PiAssistantMessageV1 - | PiBashExecutionV1 - | PiProviderContextV1 - ), + value: (PiMessageV1 | PiToolResultV1 | PiAssistantMessageV1 | PiProviderContextV1), *, target: HttpTarget | None = None, timeout: Timeout | None = None, @@ -183,18 +166,15 @@ def _admit( if isinstance(value, PiMessageV1): hook = { "user": AdmissionHook.USER_MESSAGE, + "system": AdmissionHook.SYSTEM_CONTEXT, "compaction_summary": AdmissionHook.COMPACTION_SUMMARY, - "branch_summary": AdmissionHook.BRANCH_SUMMARY, - "extension_message": AdmissionHook.EXTENSION_MESSAGE, }[value.origin] elif isinstance(value, PiToolResultV1): hook = AdmissionHook.TOOL_RESULT elif isinstance(value, PiAssistantMessageV1): hook = AdmissionHook.ASSISTANT_MESSAGE - elif isinstance(value, PiProviderContextV1): - hook = AdmissionHook.PROVIDER_CONTEXT else: - hook = AdmissionHook.BASH_EXECUTION + hook = AdmissionHook.PROVIDER_CONTEXT return processor.process( HarnessAdmissionRequest( request_body=canonical_json_bytes(value), @@ -210,9 +190,7 @@ def _admit( def _message( text: str, *, - origin: Literal[ - "user", "compaction_summary", "branch_summary", "extension_message" - ] = "user", + origin: Literal["user", "system", "compaction_summary"] = "user", ) -> PiMessageV1: return PiMessageV1( schema_version="openshell.pi-message.v1", origin=origin, text=text @@ -237,15 +215,6 @@ def _assistant( ) -def _bash(output: str, *, command: str = "printf safe") -> PiBashExecutionV1: - return PiBashExecutionV1( - schema_version="openshell.pi-bash-execution.v1", - command=command, - output=output, - exit_code=0, - ) - - def _tool_result( text: str, *, image: bool = False, tool_call_id: str = "call-1" ) -> PiToolResultV1: @@ -310,30 +279,6 @@ def _provider_request( ) -def _responses_request( - prompt: str, - *, - tool_result: str | None = None, -) -> HttpRequest: - fixture_name = "tool_result_request" if tool_result is not None else "user_request" - provider_body = json.loads(json.dumps(_PI_RESPONSES_FIXTURES[fixture_name])) - provider_body["input"][1]["content"][0]["text"] = prompt - if tool_result is not None: - provider_body["input"][-1]["output"] = tool_result - body = json.dumps( - provider_body, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ).encode() - return HttpRequest( - context=RequestContext(request_id="network-1", sandbox_id="sandbox-1"), - target=_target().model_copy(update={"path": "/v1/responses"}), - headers=(HttpHeader(name="content-type", value="application/json"),), - body=body, - ) - - def _egress( processor: AttestedEgressProcessor, request: HttpRequest, @@ -358,13 +303,11 @@ def _admit_provider_request( admission: HarnessAdmissionProcessor, request: HttpRequest, ): - registry = create_provider_adapter_registry() - adapter = registry.resolve_request(request, Timeout.from_seconds(1)) return _admit( admission, PiProviderContextV1( schema_version="openshell.pi-provider-context.v1", - entries=adapter.attested_entries(request, Timeout.from_seconds(1)), + entries=extract_provider_entries(request, Timeout.from_seconds(1)), ), target=request.target, ) @@ -390,92 +333,7 @@ def test_complete_pi_chat_context_is_attested(fixture_name: str) -> None: assert admitted.attestation is not None assert admitted.attestation.startswith(b"ag2.") assert result.decision.value == "allow" - - -def test_provider_adapters_match_shared_context_entry_vectors() -> None: - expected = PiProviderContextV1.model_validate( - { - "schema_version": "openshell.pi-provider-context.v1", - "entries": _CONTEXT_ENTRY_VECTORS["cases"][0]["entries"], - }, - strict=True, - ).entries - source_messages = [ - message - for message in _CONTEXT_ENTRY_VECTORS["cases"][0]["context"]["messages"] - if message["role"] in {"user", "toolResult"} - ] - messages = [{"role": "system", "content": "system"}] - responses_input = [{"role": "developer", "content": "system"}] - for entry, source in zip(expected, source_messages, strict=True): - if entry.role == "user": - messages.append({"role": "user", "content": source["content"]}) - content = source["content"] - responses_input.append( - { - "role": "user", - "content": ( - content - if isinstance(content, str) - else [ - {"type": "input_text", "text": block["text"]} - for block in content - ] - ), - } - ) - else: - messages.append( - { - "role": "tool", - "content": entry.text, - "tool_call_id": entry.tool_call_id, - } - ) - responses_input.append( - { - "type": "function_call_output", - "call_id": entry.tool_call_id, - "output": entry.text, - } - ) - chat_body = json.loads(json.dumps(_PI_CHAT_FIXTURES["user_request"])) - chat_body["messages"] = messages - chat = _provider_request("unused").model_copy( - update={"body": json.dumps(chat_body, separators=(",", ":")).encode()} - ) - responses_body = json.loads(json.dumps(_PI_RESPONSES_FIXTURES["user_request"])) - responses_body["input"] = responses_input - responses = _responses_request("unused").model_copy( - update={"body": json.dumps(responses_body, separators=(",", ":")).encode()} - ) - registry = create_provider_adapter_registry() - - assert ( - registry.resolve_request(chat, Timeout.from_seconds(1)).attested_entries( - chat, Timeout.from_seconds(1) - ) - == expected - ) - assert ( - registry.resolve_request(responses, Timeout.from_seconds(1)).attested_entries( - responses, Timeout.from_seconds(1) - ) - == expected - ) - - -def test_complete_responses_context_authorizes_retries() -> None: - admission, egress, _ = _processors() - request = _responses_request("use the tool", tool_result="safe tool output") - admitted = _admit_provider_request(admission, request) - - first = _egress(egress, request, admitted.attestation) - retry = _egress(egress, request, admitted.attestation) - - assert admitted.attestation is not None - assert first.decision.value == "allow" - assert retry.decision.value == "allow" + assert _egress(egress, request, admitted.attestation).decision.value == "allow" @pytest.mark.parametrize( @@ -514,20 +372,6 @@ def test_chat_context_tampering_is_denied(mutation, reason_code: str) -> None: assert result.reason_code == reason_code -def test_responses_earlier_entry_tampering_is_denied() -> None: - admission, egress, _ = _processors() - request = _responses_request("use the tool", tool_result="safe tool output") - admitted = _admit_provider_request(admission, request) - - result = _egress( - egress, - _responses_request("changed prompt", tool_result="safe tool output"), - admitted.attestation, - ) - - assert result.reason_code == "context_hash_mismatch" - - def test_provider_context_redaction_binds_only_the_replacement() -> None: admission, egress, _ = _processors() original = _provider_request(f"hide {REDACT_TEXT} please") @@ -554,6 +398,7 @@ def test_restored_context_with_denied_text_is_blocked_at_send_time() -> None: denied = _admit_provider_request(admission, _provider_request(DENY_TEXT)) assert denied.decision is AdmissionDecision.DENY + assert denied.replacement_body is None assert denied.reason_code == "egress_gate_regex_denied" @@ -583,15 +428,6 @@ def test_attestation_uses_stable_destination_across_tls_proxy_normalization() -> assert wrong_host.reason_code == "attestation_context_mismatch" -def test_append_time_allow_returns_no_attestation() -> None: - admission, _, _ = _processors() - - admitted = _admit(admission, _user("safe")) - - assert admitted.decision is AdmissionDecision.ALLOW - assert admitted.attestation is None - - def test_tool_result_denial_redaction_and_images_fail_closed() -> None: admission, _, _ = _processors() @@ -614,7 +450,7 @@ def test_tool_result_denial_redaction_and_images_fail_closed() -> None: @pytest.mark.parametrize( "origin", - ["user", "compaction_summary", "branch_summary", "extension_message"], + ["user", "system", "compaction_summary"], ) def test_text_message_origins_allow_replace_and_deny(origin) -> None: admission, _, _ = _processors() @@ -624,6 +460,7 @@ def test_text_message_origins_allow_replace_and_deny(origin) -> None: denied = _admit(admission, _message(DENY_TEXT, origin=origin)) assert allowed.decision is AdmissionDecision.ALLOW + assert allowed.attestation is None assert redacted.decision is AdmissionDecision.REPLACE assert redacted.replacement_body is not None replacement = PiMessageV1.model_validate_json( @@ -636,7 +473,7 @@ def test_text_message_origins_allow_replace_and_deny(origin) -> None: def test_text_message_binding_rejects_a_different_origin() -> None: admission, _, _ = _processors() - value = _message("safe", origin="branch_summary") + value = _message("safe", origin="user") result = admission.process( HarnessAdmissionRequest( @@ -701,43 +538,6 @@ def test_assistant_message_rejects_tool_call_mutation() -> None: assert result.reason_code == "admission_contract_invalid" -def test_bash_execution_allows_output_replacement_and_denial() -> None: - admission, _, _ = _processors() - - allowed = _admit(admission, _bash("safe")) - redacted = _admit(admission, _bash(REDACT_TEXT)) - denied = _admit(admission, _bash(DENY_TEXT)) - - assert allowed.decision is AdmissionDecision.ALLOW - assert redacted.decision is AdmissionDecision.REPLACE - assert redacted.replacement_body is not None - replacement = PiBashExecutionV1.model_validate_json( - redacted.replacement_body, strict=True - ) - assert replacement.output == "[REDACTED]" - assert (replacement.command, replacement.exit_code) == ("printf safe", 0) - assert denied.decision is AdmissionDecision.DENY - - -def test_bash_execution_rejects_command_mutation() -> None: - admission, _, _ = _processors() - - result = _admit(admission, _bash("safe", command=f"printf {REDACT_TEXT}")) - - assert result.decision is AdmissionDecision.DENY - assert result.reason_code == "admission_contract_invalid" - - -def test_denial_returns_no_attestation_or_replacement() -> None: - admission, _, _ = _processors() - - denied = _admit(admission, _user(f"do not persist {DENY_TEXT}")) - - assert denied.decision is AdmissionDecision.DENY - assert denied.attestation is None - assert denied.replacement_body is None - - @pytest.mark.parametrize( "context_update", [{"harness": "unknown"}, {"schema_version": "openshell.unknown.v1"}], @@ -833,9 +633,15 @@ def test_provider_shape_validation_and_optional_reasoning_field_are_preserved() lambda body: body.update({"max_completion_tokens": 128}), lambda body: body.update({"store": None}), lambda body: body["tools"][0]["function"].update({"strict": None}), + lambda body: body.update({"input": []}), + lambda body: body["messages"][1].update({"tool_call_id": "wrong-role"}), + lambda body: body["messages"][1].update({"role": "tool"}), + lambda body: body["tools"][0]["function"].update( + {"parameters": {"limit": float("inf")}} + ), ], ) -def test_mixed_or_null_chat_compatibility_fields_fail_closed(mutation) -> None: +def test_unsupported_chat_shapes_fail_closed(mutation) -> None: admission, egress, _ = _processors() request = _provider_request("safe") admitted = _admit_provider_request(admission, request) @@ -879,16 +685,3 @@ def test_qwen_replay_fields_fail_closed_unless_explicitly_supported(mutation) -> result = _egress(egress, request, admitted.attestation) assert result.reason_code == "provider_shape_unsupported" - - -def test_duplicate_receipt_header_is_denied_in_managed_flow() -> None: - admission, egress, _ = _processors() - request = _provider_request( - "safe", - headers=(HttpHeader(name=RECEIPT_HEADER, value="eg1.untrusted"),), - ) - admitted = _admit_provider_request(admission, request) - - result = _egress(egress, request, admitted.attestation) - - assert result.reason_code == "attestation_malformed" diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py index b2380f38..e78db8c2 100644 --- a/projects/egress-gate/tests/service/test_http_admission.py +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -5,7 +5,11 @@ from __future__ import annotations +import asyncio import json +import os +import subprocess +import sys import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -27,6 +31,7 @@ from egress_gate.gates import create_builtin_registry from egress_gate.service.admission import ( AdmissionServerConfig, + admission_tls_context, create_admission_application, ) from egress_gate.service.authentication import GatewayAuthentication @@ -42,7 +47,7 @@ async def test_http_admission_allow_deny_replace_and_authentication( tmp_path: Path, ) -> None: - async with _clients(tmp_path) as (client, _, config, _): + async with _clients(tmp_path) as (client, _, config, _, _): denied_auth = await client.post("/v1/admission", json=_call("safe")) assert denied_auth.status == 401 for text, expected in ( @@ -75,7 +80,7 @@ async def test_http_admission_allow_deny_replace_and_authentication( async def test_http_receipt_is_verified_and_stripped_by_standard_authenticated_rpc( tmp_path: Path, ) -> None: - async with _clients(tmp_path) as (client, stub, config, token): + async with _clients(tmp_path) as (client, stub, config, token, _): response = await client.post( "/v1/admission", json=_call("safe", kind="provider_context"), @@ -145,6 +150,125 @@ def test_gateway_authentication_rejects_invalid_trust_claims(change: str) -> Non ) +@pytest.mark.asyncio +async def test_pi_session_through_admission_and_authenticated_egress( + tmp_path: Path, + unused_tcp_port: int, +) -> None: + example = PROJECT / "examples/pi-attested-admission" + subprocess.run( + [ + sys.executable, + str(example / "prepare.py"), + "--state", + str(tmp_path), + "--host-ip", + "127.0.0.1", + ], + check=True, + capture_output=True, + ) + async with _clients(tmp_path) as (_, stub, config, token, middleware): + config = config.model_copy( + update={ + "tls_certificate": tmp_path / "tls/server/tls.crt", + "tls_private_key": tmp_path / "tls/server/tls.key", + "provider_target": config.provider_target.model_copy( + update={"host": "127.0.0.1", "port": unused_tcp_port} + ), + } + ) + calls: list[bytes] = [] + + async def provider(request: web.Request) -> web.Response: + body = await request.read() + evaluation = pb.HttpRequestEvaluation( + phase=pb.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, + context=pb.RequestContext(sandbox_id="sandbox", request_id="pi"), + target=pb.HttpRequestTarget(**config.provider_target.model_dump()), + middleware_name=config.middleware_name, + body=body, + headers=[ + pb.HttpHeader(name=k, value=v) for k, v in request.headers.items() + ], + ) + json_format.ParseDict(config.policy, evaluation.config) + metadata = (("authorization", f"Bearer {token}"),) + result = await stub.EvaluateHttpRequest(evaluation, metadata=metadata) + assert result.decision == pb.DECISION_ALLOW, result.reason_code + assert result.header_mutations[-1].remove.name == RECEIPT_HEADER + assert ( + "REDACT_THIS" not in body.decode() and "DENY_THIS" not in body.decode() + ) + calls.append(body) + if len(calls) == 1: + changed = json.loads(body) + next(m for m in changed["messages"] if m["role"] == "user")[ + "content" + ] = "tampered" + evaluation.body = json.dumps(changed).encode() + denied = await stub.EvaluateHttpRequest(evaluation, metadata=metadata) + assert denied.reason_code == "context_hash_mismatch" + assert len(calls) <= 7, "unexpected model call" + delta: dict[str, object] = {"role": "assistant", "content": "REDACT_THIS"} + finish = "stop" + if len(calls) == 2: + delta = { + "role": "assistant", + "tool_calls": [ + { + "index": 0, + "id": "call-1", + "type": "function", + "function": { + "name": "read", + "arguments": '{"path":"notes.txt"}', + }, + } + ], + } + finish = "tool_calls" + elif len(calls) in (4, 7): + delta["content"] = "Approved summary" + chunk = { + "id": "local", + "object": "chat.completion.chunk", + "created": 0, + "model": "test", + "choices": [{"index": 0, "delta": delta, "finish_reason": finish}], + } + return web.Response( + text=f"data: {json.dumps(chunk)}\n\ndata: [DONE]\n\n", + content_type="text/event-stream", + ) + + application = create_admission_application(middleware, config) + application.router.add_post("/v1/chat/completions", provider) + server = TestServer(application, scheme="https", port=unused_tcp_port) + await server.start_server(ssl=admission_tls_context(config)) + try: + process = await asyncio.create_subprocess_exec( + "node", + str(example / "app/dist/test/service-integration.js"), + str(server.make_url("/")).rstrip("/"), + str(tmp_path), + env=os.environ | {"NODE_EXTRA_CA_CERTS": str(tmp_path / "tls/ca.crt")}, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE, + ) + try: + stdout, stderr = await asyncio.wait_for(process.communicate(), 30) + finally: + if process.returncode is None: + process.kill() + await process.wait() + assert process.returncode == 0, (stdout + stderr).decode() + assert len(calls) == 7 + assert any(m["role"] == "tool" for m in json.loads(calls[2])["messages"]) + finally: + await server.close() + + @asynccontextmanager async def _clients( directory: Path, @@ -154,6 +278,7 @@ async def _clients( rpc.SupervisorMiddlewareStub, AdmissionServerConfig, str, + EgressGateMiddleware, ] ]: key = Ed25519PrivateKey.generate() @@ -215,7 +340,13 @@ async def _clients( TestServer(create_admission_application(middleware, config)) ) as client: try: - yield client, rpc.SupervisorMiddlewareStub(channel), config, token + yield ( + client, + rpc.SupervisorMiddlewareStub(channel), + config, + token, + middleware, + ) finally: await channel.close() await server.stop(0) From 8bd6ab0a304240bda136148b218a0a47d3b722c4 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 19:55:55 +0000 Subject: [PATCH 56/70] Use an existing OpenShell gateway for the Pi admission demo --- .../docs/architecture/admission.md | 23 ++- .../pi-attested-admission/.env.example | 11 +- .../examples/pi-attested-admission/README.md | 98 ++++++---- .../examples/pi-attested-admission/demo.sh | 57 +++--- .../pi-attested-admission/policy.yaml | 2 +- .../examples/pi-attested-admission/prepare.py | 171 ++++++++---------- .../tests/service/test_http_admission.py | 28 +-- .../tests/test_pi_example_commands.py | 68 ++++++- 8 files changed, 264 insertions(+), 194 deletions(-) diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md index 9c59d897..6d487b26 100644 --- a/projects/egress-gate/docs/architecture/admission.md +++ b/projects/egress-gate/docs/architecture/admission.md @@ -7,7 +7,8 @@ agent_markdown: true # Admission without harness forks The [runnable Pi example](https://github.com/NVIDIA/OpenShell-Research/tree/johnny/pi-attested-admission/projects/egress-gate/examples/pi-attested-admission) -uses published Pi 0.85.1 packages and OpenShell 0.0.116. No upstream library, +uses published Pi 0.85.1 packages with an existing OpenShell gateway (0.0.116 +is the tested protocol baseline). No upstream library, runtime, protobuf, or CLI patches are required. Its smaller surface is a Pi-powered application, not stock Pi CLI parity. @@ -86,8 +87,12 @@ there is no second normalized model-request representation. Branching, extension messages and standalone bash-execution envelopes are not admission APIs in this POC. Bash tool output uses the same tool-result boundary as other tools. -The host setup provisions one admission bearer credential, provider destination, -policy and actual sandbox ID. The sandbox cannot select its authoritative +The operator supplies the existing gateway's public Ed25519 signing key and +issuer. Host setup generates only service TLS, one admission bearer credential, +provider destination and policy; setup reads the actual sandbox ID. It prints +a middleware registration for the operator to install and does not generate +gateway credentials, download OpenShell binaries, or restart the gateway. +The sandbox cannot select its authoritative identity or submit a policy. The single host-owned identity file is populated after sandbox creation; until then admission is unavailable. There is no registration API or new credential broker. @@ -153,23 +158,27 @@ The example's `demo.sh verify` is a separate real-model end-to-end acceptance command, not a simulated demonstration. Its success must be observed, not inferred from unit tests. See the PR validation record for the latest executed checks. -Implementation validation on **2026-09-09** used: +Protocol and application validation on **2026-09-09** used: | Component | Tested pin | | --- | --- | | Pi public npm packages | `0.85.1`, exact dependencies and integrity hashes in the example lockfile | -| OpenShell CLI, gateway and supervisor | `0.0.116`, release commit `d1155aa70042d3e2ee49dbfa15346b108b7c1d92`; archive checksums in `demo.sh` | +| OpenShell CLI, gateway and supervisor | `0.0.116`, release commit `d1155aa70042d3e2ee49dbfa15346b108b7c1d92`; the launcher now uses the operator's installed runtime | | Node image | `22.22.2-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e` | | HTTP client | Undici `8.9.0`; explicit public proxy configuration after loading Pi | -The isolated upstream sandbox successfully exercised TLS/JWT bootstrap, +Earlier validation using the now-removed isolated launcher exercised TLS/JWT bootstrap, endpoint-bound admission credentials, allow/deny/replacement, a real Pi session denial before history, and rejection of a raw provider request without a receipt. Pi's actual tool-capable serialized request with a receipt passed the gate and received HTTP 401 from the real endpoint when deliberately given an invalid test credential. This establishes the transport seam, **not** successful model output. -**Real-model acceptance remains pending a valid provider key.** The checked-in +**Existing-gateway deployment and real-model acceptance remain unverified.** +Preparation is tested with both DNS and IPv4 service addresses, and local +cross-language tests exercise service TLS and gateway public-key verification. +The host launcher contains no Linux-specific binary bootstrap; Linux tests do +not establish macOS deployment support. The checked-in verification command passed its bypass/denial checks and then failed at the model call with that invalid credential; it did not skip ahead. Tool continuations, skills and compaction have deterministic application coverage but must also pass diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example index 388732b5..d7d8dec8 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.env.example +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -1,4 +1,11 @@ -# Host address reachable from Docker sandboxes (Linux Docker bridge default). -EGRESS_GATE_HOST_IP=172.17.0.1 +# Existing gateway name, already configured in your installed OpenShell CLI. +OPENSHELL_GATEWAY=your-gateway +# Ask your gateway operator for its public Ed25519 signing key and exact issuer. +# Never use the private signing key or the gateway TLS certificate here. +OPENSHELL_GATEWAY_PUBLIC_KEY=/absolute/path/to/gateway-public.pem +OPENSHELL_GATEWAY_ISSUER=openshell-gateway:openshell +# Egress Gate hostname or IPv4 address reachable from gateway AND sandbox. +# Docker Desktop commonly uses host.docker.internal; Linux can use the bridge IP. +EGRESS_GATE_HOST=host.docker.internal # Real key for the HTTPS endpoint/model in model.json. Never copied into the image. PI_MODEL_API_KEY=your-provider-key diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 16977017..4296de27 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -11,53 +11,71 @@ and manual/automatic compaction. Neither Pi nor OpenShell needs a patch. ## Try it -Prerequisites: **Linux x86_64**, running Docker, Python 3.11+, uv 0.11+, -curl, and a real key for a text-only OpenAI-compatible Chat Completions model. -The checked-in model uses NVIDIA's inference endpoint and requires access to it. -Edit [model.json](model.json) to use another compatible HTTPS endpoint/model. +You need an **existing, authenticated OpenShell gateway** and the `openshell` +CLI configured with its name. Use OpenShell **0.0.116** (the tested protocol +baseline) or a compatible newer release with middleware authentication and +proxy credential delivery. This example does not install or start OpenShell. + +Also needed: Bash, Python 3.11+, uv 0.11+, Docker, and a real key for a text-only +Chat Completions model. The simple image workflow assumes your gateway's Docker +driver uses the same Docker daemon as `docker build`. Remote drivers/image +distribution are outside this example. The host no longer has to be Linux; +Linux has been tested, while macOS execution remains unverified. From this directory: ```sh cp .env.example .env -# Edit .env: set PI_MODEL_API_KEY and the host address reachable from Docker. +# Fill in the existing gateway name, public signing key path, issuer, +# reachable Egress Gate host, and model API key. # Edit model.json if using a different endpoint/model. ./demo.sh prepare +./demo.sh registration ``` -Preparation downloads checksum-verified OpenShell **0.0.116** binaries, installs -locked Pi **0.85.1** packages in a pinned Node image, and creates local TLS and -configuration under the project's ignored `.workspaces/pi-no-fork/` directory. -No fork clones, Rust build, global Pi install, or existing gateway are needed. -The current launcher is deliberately Linux-only; other platforms are not tested. +The checked-in model uses NVIDIA's inference endpoint and requires access to it. +Ask your gateway operator for the **public Ed25519 signing key (PEM)** and exact +JWT issuer. These are not the gateway TLS certificate or its private signing +key. Keep the public-key file at the absolute path set in `.env`; the service +reads it on startup. + +`EGRESS_GATE_HOST` must resolve to this service from **both gateway and sandbox**: +use a reachable DNS name or IPv4 address, without a scheme or port. +Docker Desktop commonly provides `host.docker.internal`; Linux Docker may need +its bridge address. Do not use `localhost` when callers are in containers. + +Preparation builds the pinned Pi **0.85.1** image and writes service TLS, +policy and provider profiles under `../../.workspaces/pi-admission/`. +No fork clones, gateway binaries, gateway keys or gateway configuration are +created. `registration` prints only the middleware entry to add. -Keep these two terminals open: +Keep Egress Gate running in one terminal: ```sh -# Terminal 1 ./demo.sh serve ``` -```sh -# Terminal 2 -./demo.sh gateway -``` +Have the operator merge the printed entry into the existing gateway's +configuration. Make `tls/ca.crt` readable to the gateway (copy or mount the +public certificate if needed) and adjust `tls_ca_cert_path` in the entry to +that gateway-visible path. Restart the gateway through its usual service +manager to load the registration; coordinate this on a shared gateway. +The demo never edits or restarts it. -Then create the sandbox and start a session: +In a second terminal, from this directory: ```sh -# Terminal 3 ./demo.sh setup ./demo.sh launch ``` -The gateway is isolated on port **17672**. Egress Gate listens on **50051** -(authenticated middleware gRPC) and **5443** (authenticated admission HTTPS). -Allow access only from this host/sandbox network. TLS certificates last 30 days. -If these ports are occupied, stop the conflicting demo before starting this one. +`setup` displays the selected gateway and creates the `pi-admission` sandbox +and its two provider profiles/instances. Reserve those names for this demo. +Egress Gate listens on **50051** (authenticated middleware gRPC) and **5443** +(authenticated admission HTTPS). Restrict access to the gateway/sandbox network. -Every action is inspectable without executing it, loading `.env`, or printing -secrets: +Every action can print its commands without executing them, loading `.env`, +or printing secrets: ```sh ./demo.sh --print prepare @@ -65,9 +83,14 @@ secrets: ./demo.sh --print launch ``` -Printed commands name credential environment variables; OpenShell reads their -values on the host. The generated admission configuration contains a private -bearer token and must remain outside the image and repository. +Print mode uses exported configuration or placeholders because it does not load +`.env`. Real commands use `.env`; credentials are passed by environment-variable +name. The generated `admission.json` contains a private admission token and must +remain outside the image and repository. + +**Validation status:** local cross-language integration passes, but the complete +existing-gateway workflow with a valid model key remains to be verified. +`./demo.sh verify` below is that separate real-model acceptance check. ## What to try @@ -122,11 +145,20 @@ pending-admission tests live in [app/test/](app/test/). Cleanup deletes only this demo sandbox and its provider instances/profiles. **Sandbox files and sessions are deleted and are not recoverable by this script.** -Copy out anything wanted first. Stop the two foreground services with Ctrl-C. -Downloaded artifacts and private host configuration remain in the ignored state -directory for inspection/reuse. Run setup again to create a fresh sandbox. -Changes to model/policy/project files require prepare, a service restart, and a -fresh sandbox (cleanup then setup). +Copy out anything wanted first, then stop `serve` with Ctrl-C. The gateway and +its middleware registration are left untouched; the operator can remove the +registration when the demo is no longer needed. Host configuration and the local +Docker image remain for reuse. + +For source/model/policy changes, clean up the old demo sandbox, run `prepare`, +restart `serve`, then run `setup`. Valid service certificates are reused for +the same host. After 30 days or a host change, `prepare` generates new service +TLS: install the new CA in the gateway and restart it before setup. Refresh the +public signing-key file if the gateway rotates its key. + +The earlier isolated launcher's `.workspaces/pi-no-fork/` directory is no longer +used. Any old isolated gateway must be stopped separately; this launcher does +not manage it. ## How the pieces fit @@ -159,7 +191,7 @@ Egress Gate schemas. A provider-context replacement is rejected: silently redacting only the outbound request would leave saved history inconsistent. [prepare.py](prepare.py) is **trusted host-side operator code**, not the -in-sandbox harness. It provisions policy, TLS, and endpoint-bound provider +in-sandbox harness. It provisions policy, service TLS, and endpoint-bound provider profiles. Setup reads the actual sandbox ID from OpenShell and binds the admission credential to it. The application cannot supply an authoritative sandbox ID or choose a policy. Upstream credential delivery gives it placeholders, diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index f6f9fcd3..1b82d3e5 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -7,7 +7,7 @@ set +x # Never trace populated credential variables. umask 077 example=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) project=$(cd -- "$example/../.." && pwd) -state=$project/.workspaces/pi-no-fork +state=$project/.workspaces/pi-admission print_only=false if [[ ${1:-} == --print ]]; then print_only=true; shift; fi action=${1:-help} @@ -17,55 +17,39 @@ if ! $print_only && [[ -f $example/.env ]]; then source "$example/.env" set +a fi -host_ip=${EGRESS_GATE_HOST_IP:-172.17.0.1} -cli=$state/bin/openshell -openshell=(env XDG_CONFIG_HOME="$state/config" "$cli" --gateway pi-admission --gateway-endpoint https://127.0.0.1:17672) -runtime_env=(env OPENSHELL_LOCAL_TLS_DIR="$state/tls" XDG_CONFIG_HOME="$state/config" XDG_STATE_HOME="$state/state" XDG_DATA_HOME="$state/data") +service_host=${EGRESS_GATE_HOST:-YOUR_SERVICE_HOST} +gateway=${OPENSHELL_GATEWAY:-YOUR_GATEWAY} +openshell=(openshell --gateway "$gateway") run() { if $print_only; then printf '%q ' "$@"; printf '\n'; else "$@"; fi } cd "$project" case "$action" in prepare) - if ! $print_only && [[ $(uname -s) != Linux || $(uname -m) != x86_64 ]]; then - echo "This pinned POC launcher supports Linux x86_64 with Docker." >&2; exit 1 + if ! $print_only; then + : "${EGRESS_GATE_HOST:?Set the service hostname or IPv4 address in .env}" + : "${OPENSHELL_GATEWAY_PUBLIC_KEY:?Set the gateway public PEM path in .env}" + : "${OPENSHELL_GATEWAY_ISSUER:?Set the gateway JWT issuer in .env}" fi run uv sync --frozen - run mkdir -p "$state/bin" - for component in openshell openshell-gateway openshell-sandbox; do - target=x86_64-unknown-linux-musl - [[ $component != openshell-gateway ]] || target=x86_64-unknown-linux-gnu - archive=$component-$target.tar.gz - case "$component" in - openshell) checksum=4fb4476d80a1875a0b83547ec3aba999cf0a2e2d75f95f2f709b622e2103520e ;; - openshell-gateway) checksum=59c6da724eae7a00c28826f9191efbdf4fbaa5c768afdc8dea6a80a949ebcc89 ;; - openshell-sandbox) checksum=0bb160f73e5007338b94e3c868f66f50c71cd65c27c932ed9a4fa67c49e6d423 ;; - esac - run curl --fail --location --silent --show-error "https://github.com/NVIDIA/OpenShell/releases/download/v0.0.116/$archive" -o "$state/bin/$archive" - if $print_only; then - printf 'printf "%%s %%s\\n" %q %q | sha256sum --check\n' "$checksum" "$state/bin/$archive" - else - printf '%s %s\n' "$checksum" "$state/bin/$archive" | sha256sum --check - fi - run tar -xzf "$state/bin/$archive" -C "$state/bin" "$component" - run chmod 755 "$state/bin/$component" - done - run uv run --frozen python "$example/prepare.py" --state "$state" --host-ip "$host_ip" + run uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway-public-key "${OPENSHELL_GATEWAY_PUBLIC_KEY:-/path/to/gateway-public.pem}" --gateway-issuer "${OPENSHELL_GATEWAY_ISSUER:-YOUR_GATEWAY_ISSUER}" run docker build --tag pi-admission:local "$state/image" ;; serve) run uv run --frozen egress-gate serve --listen 0.0.0.0:50051 --admission-config "$state/admission.json" ;; - gateway) - run "${runtime_env[@]}" "$state/bin/openshell-gateway" --config "$state/gateway.toml" --port 17672 --bind-address 0.0.0.0 --db-url "sqlite:$state/gateway.db" + registration) + run cat "$state/middleware.toml" ;; setup) if ! $print_only; then + : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" : "${PI_MODEL_API_KEY:?Set PI_MODEL_API_KEY in the example .env}" export PI_MODEL_API_KEY EGRESS_ADMISSION_TOKEN=$(uv run --frozen python -c 'import json,sys; print(json.load(open(sys.argv[1]))["bearer_token"])' "$state/admission.json") export EGRESS_ADMISSION_TOKEN fi + run "${openshell[@]}" gateway info for provider in model admission; do run "${openshell[@]}" provider profile import --file "$state/$provider-provider.yaml" variable=PI_MODEL_API_KEY @@ -81,26 +65,29 @@ case "$action" in fi ;; launch) - run "${openshell[@]}" sandbox exec --tty --name pi-admission -- /usr/local/bin/node /app/dist/src/cli.js --admission https://host.openshell.internal:5443/v1/admission + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${EGRESS_GATE_HOST:?Set the service host in .env}"; fi + run "${openshell[@]}" sandbox exec --tty --name pi-admission -- /usr/local/bin/node /app/dist/src/cli.js --admission "https://$service_host:5443/v1/admission" ;; verify) - run "${openshell[@]}" sandbox exec --no-tty --name pi-admission -- /usr/local/bin/node /app/dist/src/verify.js --admission https://host.openshell.internal:5443/v1/admission + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${EGRESS_GATE_HOST:?Set the service host in .env}"; fi + run "${openshell[@]}" sandbox exec --no-tty --name pi-admission -- /usr/local/bin/node /app/dist/src/verify.js --admission "https://$service_host:5443/v1/admission" ;; cleanup) + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}"; fi run "${openshell[@]}" sandbox delete pi-admission for provider in model admission; do run "${openshell[@]}" provider delete "pi-admission-$provider" run "${openshell[@]}" provider profile delete "pi-admission-$provider" done run uv run --frozen python -c 'import pathlib,sys; pathlib.Path(sys.argv[1]).unlink(missing_ok=True)' "$state/sandbox-id" - printf 'Sandbox and its sessions removed. Stop serve and gateway with Ctrl-C.\n' - printf 'Host configuration and downloaded artifacts remain in %s.\n' "$state" + printf 'Sandbox and its sessions removed. Stop serve with Ctrl-C; the gateway is unchanged.\n' + printf 'Host configuration remains in %s; the local Docker image is retained.\n' "$state" ;; help) printf 'Usage: ./demo.sh [--print] ACTION\n\n' - printf ' prepare Download pinned upstream binaries; generate local TLS/config; build image\n' + printf ' prepare Generate service TLS/config; build the Pi image\n' printf ' serve Run Egress Gate (keep this terminal open)\n' - printf ' gateway Run isolated OpenShell gateway (keep this terminal open)\n' + printf ' registration Print middleware config for your gateway operator\n' printf ' setup Create providers and sandbox; bind admission identity\n' printf ' launch Start a new interactive Pi-powered session\n' printf ' verify Run real allow/deny/redact, tools, skill, compaction and bypass checks\n' diff --git a/projects/egress-gate/examples/pi-attested-admission/policy.yaml b/projects/egress-gate/examples/pi-attested-admission/policy.yaml index 3033ebd6..1aa323db 100644 --- a/projects/egress-gate/examples/pi-attested-admission/policy.yaml +++ b/projects/egress-gate/examples/pi-attested-admission/policy.yaml @@ -30,7 +30,7 @@ network_policies: admission: name: Authenticated admission API (not a model endpoint) endpoints: - - host: host.openshell.internal + - host: host.docker.internal # prepare replaces this with EGRESS_GATE_HOST. port: 5443 protocol: rest enforcement: enforce diff --git a/projects/egress-gate/examples/pi-attested-admission/prepare.py b/projects/egress-gate/examples/pi-attested-admission/prepare.py index a070c1b1..e9e289d1 100644 --- a/projects/egress-gate/examples/pi-attested-admission/prepare.py +++ b/projects/egress-gate/examples/pi-attested-admission/prepare.py @@ -22,36 +22,47 @@ from cryptography.x509.oid import ExtendedKeyUsageOID, NameOID -def prepare(example: Path, state: Path, host_ip: str) -> None: +def prepare( + example: Path, state: Path, host: str, gateway_public_key: Path, gateway_issuer: str +) -> None: """Keep keys outside the image; copy only the public CA and explicit demo files.""" - ipaddress.IPv4Address(host_ip) + endpoint = urlparse(f"https://{host}:5443") + if endpoint.hostname != host or endpoint.port != 5443 or endpoint.path: + raise ValueError("Use a DNS hostname or IPv4 address, without a URL or port") + public_key = serialization.load_pem_public_key(gateway_public_key.read_bytes()) + if not isinstance(public_key, ed25519.Ed25519PublicKey): + raise ValueError("Provide the gateway's Ed25519 public signing key") os.umask(0o077) state.mkdir(parents=True, exist_ok=True) tls = state / "tls" - if not tls.exists(): - _create_certificates(tls, host_ip) - elif (state / "host-ip").read_text() != host_ip: - raise ValueError( - "Host IP changed: clean up and move aside the demo state first" - ) - (state / "host-ip").write_text(host_ip) + certificate = tls / "server/tls.crt" + if ( + not certificate.exists() + or (state / "service-host").read_text() != host + or x509.load_pem_x509_certificate(certificate.read_bytes()).not_valid_after_utc + <= datetime.now(UTC) + ): + _create_certificates(tls, host) + print("Service TLS created: install tls/ca.crt in the gateway's trust config.") + (state / "service-host").write_text(host) model = json.loads((example / "model.json").read_text()) target = urlparse(model["baseUrl"]) if target.scheme != "https" or not target.hostname or target.username: raise ValueError("The model must use an HTTPS endpoint without credentials") - if target.hostname == "host.openshell.internal": + if target.hostname == host: raise ValueError("Model and admission endpoints must be separate") model_path = target.path.rstrip("/") + "/chat/completions" policy = yaml.safe_load((example / "policy.yaml").read_text()) model_endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] model_endpoint.update(host=target.hostname, port=target.port or 443) model_endpoint["rules"][0]["allow"]["path"] = model_path + policy["network_policies"]["admission"]["endpoints"][0]["host"] = host binding = policy["network_middlewares"]["pi_egress_gate"] binding["endpoints"]["include"] = [target.hostname] (state / "policy.yaml").write_text(yaml.safe_dump(policy, sort_keys=False)) - for name, host, port, variable in [ + for name, provider_host, port, variable in [ ("model", target.hostname, target.port or 443, "PI_MODEL_API_KEY"), - ("admission", "host.openshell.internal", 5443, "EGRESS_ADMISSION_TOKEN"), + ("admission", host, 5443, "EGRESS_ADMISSION_TOKEN"), ]: profile = { "id": f"pi-admission-{name}", @@ -63,7 +74,7 @@ def prepare(example: Path, state: Path, host_ip: str) -> None: "discovery": {"credentials": ["token"]}, "endpoints": [ { - "host": host, + "host": provider_host, "port": port, "protocol": "rest", "access": "read-write", @@ -84,8 +95,8 @@ def prepare(example: Path, state: Path, host_ip: str) -> None: "listen": "0.0.0.0:5443", "tls_certificate": str(tls / "server/tls.crt"), "tls_private_key": str(tls / "server/tls.key"), - "gateway_public_key": str(tls / "jwt/public.pem"), - "gateway_issuer": "openshell-gateway:openshell", + "gateway_public_key": str(gateway_public_key.resolve()), + "gateway_issuer": gateway_issuer, "gateway_audience": audience, "middleware_name": "pi-egress", "bearer_token": token, @@ -103,30 +114,15 @@ def prepare(example: Path, state: Path, host_ip: str) -> None: config_path.write_text(json.dumps(config, indent=2) + "\n") # JSON string quoting is also valid for these TOML basic string values. quote = json.dumps - gateway = f"""[openshell] -version = 1 -[openshell.gateway] -name = "pi-admission" -compute_drivers = ["docker"] -[openshell.drivers.docker] -supervisor_bin = {quote(str(state / "bin/openshell-sandbox"))} -[[openshell.supervisor.middleware]] + registration = f"""[[openshell.supervisor.middleware]] name = "pi-egress" -grpc_endpoint = "https://{host_ip}:50051" +grpc_endpoint = "https://{host}:50051" tls_ca_cert_path = {quote(str(tls / "ca.crt"))} audience = "{audience}" max_payload_bytes = 4194304 timeout = "10s" """ - (state / "gateway.toml").write_text(gateway) - client = state / "config/openshell/gateways/pi-admission/mtls" - client.mkdir(parents=True, exist_ok=True) - for source, name in [ - (tls / "ca.crt", "ca.crt"), - (tls / "client/tls.crt", "tls.crt"), - (tls / "client/tls.key", "tls.key"), - ]: - shutil.copyfile(source, client / name) + (state / "middleware.toml").write_text(registration) image = state / "image" # Recreate only this generated build context, so removed source/config files # cannot survive a subsequent prepare. Host keys and runtime state stay put. @@ -147,7 +143,7 @@ def prepare(example: Path, state: Path, host_ip: str) -> None: shutil.copyfile(tls / "ca.crt", image / "admission-ca.crt") -def _create_certificates(tls: Path, host_ip: str) -> None: +def _create_certificates(tls: Path, host: str) -> None: now = datetime.now(UTC) ca_key = ec.generate_private_key(ec.SECP256R1()) ca_name = x509.Name( @@ -164,80 +160,65 @@ def _create_certificates(tls: Path, host_ip: str) -> None: .add_extension(x509.BasicConstraints(ca=True, path_length=0), critical=True) .sign(ca_key, hashes.SHA256()) ) - tls.mkdir() + tls.mkdir(exist_ok=True) (tls / "ca.crt").write_bytes(ca.public_bytes(serialization.Encoding.PEM)) # The CA key is not needed again; each setup has a 30-day local trust bundle. - for role in ("server", "client"): - key = ec.generate_private_key(ec.SECP256R1()) - certificate = ( - x509.CertificateBuilder() - .subject_name( - x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, f"pi-{role}")]) - ) - .issuer_name(ca_name) - .public_key(key.public_key()) - .serial_number(x509.random_serial_number()) - .not_valid_before(now - timedelta(minutes=5)) - .not_valid_after(now + timedelta(days=30)) - .add_extension( - x509.BasicConstraints(ca=False, path_length=None), critical=True - ) - .add_extension( - x509.ExtendedKeyUsage( - [ - ExtendedKeyUsageOID.SERVER_AUTH, - ExtendedKeyUsageOID.CLIENT_AUTH, - ] - ), - critical=False, - ) - .add_extension( - x509.SubjectAlternativeName( - [ - x509.DNSName("localhost"), - x509.DNSName("host.openshell.internal"), - x509.DNSName("host.docker.internal"), - x509.IPAddress(ipaddress.ip_address("127.0.0.1")), - x509.IPAddress(ipaddress.ip_address(host_ip)), - ] - ), - critical=False, - ) - .sign(ca_key, hashes.SHA256()) + key = ec.generate_private_key(ec.SECP256R1()) + certificate = ( + x509.CertificateBuilder() + .subject_name( + x509.Name([x509.NameAttribute(NameOID.COMMON_NAME, "pi-admission-service")]) ) - directory = tls / role - directory.mkdir() - (directory / "tls.crt").write_bytes( - certificate.public_bytes(serialization.Encoding.PEM) + .issuer_name(ca_name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=5)) + .not_valid_after(now + timedelta(days=30)) + .add_extension(x509.BasicConstraints(ca=False, path_length=None), critical=True) + .add_extension( + x509.ExtendedKeyUsage([ExtendedKeyUsageOID.SERVER_AUTH]), + critical=False, ) - (directory / "tls.key").write_bytes( - key.private_bytes( - serialization.Encoding.PEM, - serialization.PrivateFormat.PKCS8, - serialization.NoEncryption(), - ) + .add_extension( + x509.SubjectAlternativeName( + [x509.DNSName("localhost"), _service_name(host)] + ), + critical=False, ) - jwt_key = ed25519.Ed25519PrivateKey.generate() - (tls / "jwt").mkdir() - (tls / "jwt/signing.pem").write_bytes( - jwt_key.private_bytes( + .sign(ca_key, hashes.SHA256()) + ) + directory = tls / "server" + directory.mkdir(exist_ok=True) + (directory / "tls.crt").write_bytes( + certificate.public_bytes(serialization.Encoding.PEM) + ) + (directory / "tls.key").write_bytes( + key.private_bytes( serialization.Encoding.PEM, serialization.PrivateFormat.PKCS8, serialization.NoEncryption(), ) ) - (tls / "jwt/public.pem").write_bytes( - jwt_key.public_key().public_bytes( - serialization.Encoding.PEM, - serialization.PublicFormat.SubjectPublicKeyInfo, - ) - ) - (tls / "jwt/kid").write_text(secrets.token_hex(16)) + + +def _service_name(host: str) -> x509.GeneralName: + try: + return x509.IPAddress(ipaddress.ip_address(host)) + except ValueError: + return x509.DNSName(host) if __name__ == "__main__": parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--state", type=Path, required=True) - parser.add_argument("--host-ip", required=True) + parser.add_argument("--host", required=True) + parser.add_argument("--gateway-public-key", type=Path, required=True) + parser.add_argument("--gateway-issuer", required=True) args = parser.parse_args() - prepare(Path(__file__).resolve().parent, args.state.resolve(), args.host_ip) + prepare( + Path(__file__).resolve().parent, + args.state.resolve(), + args.host, + args.gateway_public_key.resolve(), + args.gateway_issuer, + ) diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py index e78db8c2..6a152f68 100644 --- a/projects/egress-gate/tests/service/test_http_admission.py +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -156,19 +156,23 @@ async def test_pi_session_through_admission_and_authenticated_egress( unused_tcp_port: int, ) -> None: example = PROJECT / "examples/pi-attested-admission" - subprocess.run( - [ - sys.executable, - str(example / "prepare.py"), - "--state", - str(tmp_path), - "--host-ip", - "127.0.0.1", - ], - check=True, - capture_output=True, - ) async with _clients(tmp_path) as (_, stub, config, token, middleware): + subprocess.run( + [ + sys.executable, + str(example / "prepare.py"), + "--state", + str(tmp_path), + "--host", + "127.0.0.1", + "--gateway-public-key", + str(config.gateway_public_key), + "--gateway-issuer", + config.gateway_issuer, + ], + check=True, + capture_output=True, + ) config = config.model_copy( update={ "tls_certificate": tmp_path / "tls/server/tls.crt", diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index bb9635b3..999f354d 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -11,7 +11,11 @@ import tomllib from pathlib import Path +import pytest import yaml +from cryptography import x509 +from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey from egress_gate.service.admission import AdmissionServerConfig @@ -32,7 +36,7 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> for action in ( "prepare", "serve", - "gateway", + "registration", "setup", "launch", "verify", @@ -43,7 +47,12 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> check=True, capture_output=True, text=True, - env=os.environ | {"PI_MODEL_API_KEY": "PRIVATE_TEST_VALUE"}, + env=os.environ + | { + "PI_MODEL_API_KEY": "PRIVATE_TEST_VALUE", + "OPENSHELL_GATEWAY": "test-gateway", + "EGRESS_GATE_HOST": "service.example", + }, ) output += result.stdout assert result.stderr == "" @@ -51,8 +60,12 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> assert not marker.exists() assert not (tmp_path / ".workspaces").exists() assert "git clone" not in output - assert "v0.0.116" in output - assert "sha256sum --check" in output + assert "openshell-gateway" not in output + assert "sha256sum" not in output and "curl" not in output + assert "--gateway test-gateway" in output + assert "https://service.example:5443/v1/admission" in output + assert "middleware.toml" in output + assert "17672" not in output and "XDG_CONFIG_HOME" not in output assert "docker build" in output assert "--admission-config" in output assert "provider create" in output @@ -63,23 +76,50 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> assert "sandbox delete pi-admission" in output -def test_preparation_uses_upstream_profiles_and_excludes_private_material( +@pytest.mark.parametrize("host", ["192.0.2.10", "host.docker.internal"]) +def test_preparation_uses_existing_gateway_and_excludes_private_material( tmp_path: Path, + host: str, ) -> None: state = tmp_path / "state" + public = ( + Ed25519PrivateKey.generate() + .public_key() + .public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ) + ) + public_path = tmp_path / "gateway-public.pem" + public_path.write_bytes(public) command = [ sys.executable, str(EXAMPLE / "prepare.py"), "--state", str(state), - "--host-ip", - "192.0.2.10", + "--host", + host, + "--gateway-public-key", + str(public_path), + "--gateway-issuer", + "existing-gateway-issuer", ] subprocess.run(command, check=True) config = AdmissionServerConfig.model_validate_json( (state / "admission.json").read_bytes() ) assert config.provider_target.scheme == "https" + assert config.gateway_public_key == public_path + assert config.gateway_issuer == "existing-gateway-issuer" + assert public_path.read_bytes() == public + assert not (state / "tls/jwt").exists() + assert not (state / "tls/client").exists() + assert not (state / "gateway.toml").exists() + certificate_bytes = (state / "tls/server/tls.crt").read_bytes() + certificate = x509.load_pem_x509_certificate(certificate_bytes) + names = certificate.extensions.get_extension_for_class( + x509.SubjectAlternativeName + ).value + assert host in [str(name.value) for name in names] assert config.provider_target.path == "/v1/chat/completions" assert not config.sandbox_id_file.exists() token = config.bearer_token.get_secret_value() @@ -90,6 +130,7 @@ def test_preparation_uses_upstream_profiles_and_excludes_private_material( (state / "admission.json").read_bytes() ) assert again.bearer_token == config.bearer_token + assert (state / "tls/server/tls.crt").read_bytes() == certificate_bytes for name in ("model", "admission"): profile = yaml.safe_load((state / f"{name}-provider.yaml").read_text()) credential = profile["credentials"][0] @@ -103,10 +144,12 @@ def test_preparation_uses_upstream_profiles_and_excludes_private_material( assert not (image / "admission.json").exists() assert not (image / "app/node_modules").exists() assert (image / "project/.pi/skills/review/SKILL.md").is_file() - gateway = tomllib.loads((state / "gateway.toml").read_text()) + gateway = tomllib.loads((state / "middleware.toml").read_text()) registration = gateway["openshell"]["supervisor"]["middleware"][0] - assert registration["grpc_endpoint"] == "https://192.0.2.10:50051" + assert registration["grpc_endpoint"] == f"https://{host}:50051" assert registration["tls_ca_cert_path"] == str(state / "tls/ca.crt") + assert "gateway" not in gateway["openshell"] + assert "drivers" not in gateway["openshell"] policy = yaml.safe_load((state / "policy.yaml").read_text()) assert policy["network_middlewares"]["pi_egress_gate"]["on_error"] == "fail_closed" model_endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] @@ -115,6 +158,13 @@ def test_preparation_uses_upstream_profiles_and_excludes_private_material( ] assert "access" not in model_endpoint assert policy["network_policies"]["admission"]["endpoints"][0]["port"] == 5443 + assert policy["network_policies"]["admission"]["endpoints"][0]["host"] == host + admission_profile = yaml.safe_load((state / "admission-provider.yaml").read_text()) + assert admission_profile["endpoints"][0]["host"] == host + command[command.index("--host") + 1] = "new-service.example" + subprocess.run(command, check=True) + assert (state / "tls/server/tls.crt").read_bytes() != certificate_bytes + assert public_path.read_bytes() == public def test_sandbox_binding_accepts_only_operator_cli_output(tmp_path: Path) -> None: From 65e71edca8b6e1f05c68ad31bfbe217e8dda782f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 20:23:01 +0000 Subject: [PATCH 57/70] Discover Pi admission gateway identity using existing mTLS credentials --- .../docs/architecture/admission.md | 15 +- .../pi-attested-admission/.env.example | 6 +- .../examples/pi-attested-admission/README.md | 24 +- .../examples/pi-attested-admission/demo.sh | 11 +- .../examples/pi-attested-admission/prepare.py | 69 +++++- .../tests/service/test_http_admission.py | 24 +- .../tests/test_pi_example_commands.py | 216 ++++++++++++++++-- 7 files changed, 300 insertions(+), 65 deletions(-) diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md index 6d487b26..1e6ead24 100644 --- a/projects/egress-gate/docs/architecture/admission.md +++ b/projects/egress-gate/docs/architecture/admission.md @@ -87,9 +87,13 @@ there is no second normalized model-request representation. Branching, extension messages and standalone bash-execution envelopes are not admission APIs in this POC. Bash tool output uses the same tool-result boundary as other tools. -The operator supplies the existing gateway's public Ed25519 signing key and -issuer. Host setup generates only service TLS, one admission bearer credential, -provider destination and policy; setup reads the actual sandbox ID. It prints +Preparation discovers the existing gateway's public Ed25519 signing key and +issuer using its HTTPS discovery endpoints and the CLI's saved mTLS credentials. +Only the gateway name, reachable service host and model key are supplied by the +operator. Discovery requires one published signing key and refuses plaintext, +cross-origin key URLs and untrusted TLS; browser/edge-login gateways are outside +this POC helper's scope. Host setup generates service TLS, one admission bearer +credential, provider destination and policy; setup reads the actual sandbox ID. It prints a middleware registration for the operator to install and does not generate gateway credentials, download OpenShell binaries, or restart the gateway. The sandbox cannot select its authoritative @@ -175,8 +179,9 @@ received HTTP 401 from the real endpoint when deliberately given an invalid test credential. This establishes the transport seam, **not** successful model output. **Existing-gateway deployment and real-model acceptance remain unverified.** -Preparation is tested with both DNS and IPv4 service addresses, and local -cross-language tests exercise service TLS and gateway public-key verification. +Preparation is tested with both DNS and IPv4 service addresses against a local +mTLS discovery server. Local cross-language tests exercise service TLS and +gateway public-key verification. The host launcher contains no Linux-specific binary bootstrap; Linux tests do not establish macOS deployment support. The checked-in verification command passed its bypass/denial checks and then failed at the model diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example index d7d8dec8..ea0f4f0d 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.env.example +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -1,9 +1,5 @@ -# Existing gateway name, already configured in your installed OpenShell CLI. +# Existing HTTPS/mTLS gateway registered in your OpenShell CLI (gateway list). OPENSHELL_GATEWAY=your-gateway -# Ask your gateway operator for its public Ed25519 signing key and exact issuer. -# Never use the private signing key or the gateway TLS certificate here. -OPENSHELL_GATEWAY_PUBLIC_KEY=/absolute/path/to/gateway-public.pem -OPENSHELL_GATEWAY_ISSUER=openshell-gateway:openshell # Egress Gate hostname or IPv4 address reachable from gateway AND sandbox. # Docker Desktop commonly uses host.docker.internal; Linux can use the bridge IP. EGRESS_GATE_HOST=host.docker.internal diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 4296de27..f6e4f2f3 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -11,7 +11,7 @@ and manual/automatic compaction. Neither Pi nor OpenShell needs a patch. ## Try it -You need an **existing, authenticated OpenShell gateway** and the `openshell` +You need an **existing HTTPS/mTLS OpenShell gateway** and the `openshell` CLI configured with its name. Use OpenShell **0.0.116** (the tested protocol baseline) or a compatible newer release with middleware authentication and proxy credential delivery. This example does not install or start OpenShell. @@ -26,18 +26,20 @@ From this directory: ```sh cp .env.example .env -# Fill in the existing gateway name, public signing key path, issuer, -# reachable Egress Gate host, and model API key. +# Fill in the gateway name, reachable Egress Gate host, and model API key. # Edit model.json if using a different endpoint/model. ./demo.sh prepare ./demo.sh registration ``` The checked-in model uses NVIDIA's inference endpoint and requires access to it. -Ask your gateway operator for the **public Ed25519 signing key (PEM)** and exact -JWT issuer. These are not the gateway TLS certificate or its private signing -key. Keep the public-key file at the absolute path set in `.env`; the service -reads it on startup. +`prepare` reads the selected endpoint from `openshell gateway list --output json` +and discovers its issuer and public signing key over verified HTTPS. It reuses +the CLI's existing client certificates under +`${XDG_CONFIG_HOME:-$HOME/.config}/openshell/gateways//mtls/`. +You do not supply signing keys, an issuer, or certificate paths. This POC supports +registered mTLS gateways; plaintext and browser/edge-login gateways are not +supported by this discovery helper. It never disables TLS verification. `EGRESS_GATE_HOST` must resolve to this service from **both gateway and sandbox**: use a reachable DNS name or IPv4 address, without a scheme or port. @@ -46,8 +48,9 @@ its bridge address. Do not use `localhost` when callers are in containers. Preparation builds the pinned Pi **0.85.1** image and writes service TLS, policy and provider profiles under `../../.workspaces/pi-admission/`. -No fork clones, gateway binaries, gateway keys or gateway configuration are -created. `registration` prints only the middleware entry to add. +The discovered public key is saved there as `gateway-public.pem`. No fork clones, +gateway binaries, gateway private keys or full gateway configuration are created. +`registration` prints only the middleware entry to add. Keep Egress Gate running in one terminal: @@ -154,7 +157,8 @@ For source/model/policy changes, clean up the old demo sandbox, run `prepare`, restart `serve`, then run `setup`. Valid service certificates are reused for the same host. After 30 days or a host change, `prepare` generates new service TLS: install the new CA in the gateway and restart it before setup. Refresh the -public signing-key file if the gateway rotates its key. +gateway identity by rerunning `prepare` and restarting `serve` if the gateway +rotates its signing key. Discovery currently expects one published signing key. The earlier isolated launcher's `.workspaces/pi-no-fork/` directory is no longer used. Any old isolated gateway must be stopped separately; this launcher does diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 1b82d3e5..a11b3b60 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -28,11 +28,16 @@ case "$action" in prepare) if ! $print_only; then : "${EGRESS_GATE_HOST:?Set the service hostname or IPv4 address in .env}" - : "${OPENSHELL_GATEWAY_PUBLIC_KEY:?Set the gateway public PEM path in .env}" - : "${OPENSHELL_GATEWAY_ISSUER:?Set the gateway JWT issuer in .env}" + : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" fi run uv sync --frozen - run uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway-public-key "${OPENSHELL_GATEWAY_PUBLIC_KEY:-/path/to/gateway-public.pem}" --gateway-issuer "${OPENSHELL_GATEWAY_ISSUER:-YOUR_GATEWAY_ISSUER}" + if $print_only; then + printf '%q ' "${openshell[@]}" gateway list --output json + printf '| ' + run uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" + else + "${openshell[@]}" gateway list --output json | uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" + fi run docker build --tag pi-admission:local "$state/image" ;; serve) diff --git a/projects/egress-gate/examples/pi-attested-admission/prepare.py b/projects/egress-gate/examples/pi-attested-admission/prepare.py index e9e289d1..f84250a5 100644 --- a/projects/egress-gate/examples/pi-attested-admission/prepare.py +++ b/projects/egress-gate/examples/pi-attested-admission/prepare.py @@ -11,10 +11,14 @@ import os import secrets import shutil +import ssl +import sys from datetime import UTC, datetime, timedelta +from http.client import HTTPSConnection from pathlib import Path from urllib.parse import urlparse +import jwt import yaml from cryptography import x509 from cryptography.hazmat.primitives import hashes, serialization @@ -143,6 +147,55 @@ def prepare( shutil.copyfile(tls / "ca.crt", image / "admission-ca.crt") +def _discover_gateway(gateway: dict[str, str]) -> tuple[bytes, str]: + """Use the CLI's registered endpoint and existing client TLS, never new keys.""" + endpoint = urlparse(gateway["endpoint"]) + name = gateway["name"] + if endpoint.scheme != "https" or not endpoint.hostname or gateway["auth"] != "mtls": + raise ValueError("This demo requires a registered HTTPS/mTLS gateway") + if not name or Path(name).name != name or name in (".", ".."): + raise ValueError("Invalid gateway name") + config = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) + tls = config / "openshell/gateways" / name / "mtls" + context = ssl.create_default_context(cafile=str(tls / "ca.crt")) + context.load_cert_chain(tls / "tls.crt", tls / "tls.key") + connection = HTTPSConnection( + endpoint.hostname, endpoint.port, context=context, timeout=10 + ) + try: + print(f"Discovering gateway identity from {gateway['endpoint']}") + connection.request("GET", "/.well-known/openid-configuration") + response = connection.getresponse() + if response.status != 200: + raise ValueError(f"Gateway discovery returned HTTP {response.status}") + discovery = json.load(response) + issuer = discovery["issuer"] + if not isinstance(issuer, str) or not issuer: + raise ValueError("Gateway discovery must provide a nonempty issuer") + jwks = urlparse(discovery["jwks_uri"]) + if (jwks.scheme, jwks.netloc) != (endpoint.scheme, endpoint.netloc): + raise ValueError( + "Gateway signing keys must come from the same HTTPS origin" + ) + connection.request("GET", jwks.path + (f"?{jwks.query}" if jwks.query else "")) + response = connection.getresponse() + if response.status != 200: + raise ValueError( + f"Gateway signing-key discovery returned HTTP {response.status}" + ) + keys = json.load(response)["keys"] + if len(keys) != 1: + raise ValueError("This demo expects one gateway signing key") + key = jwt.PyJWK.from_dict(keys[0]).key + if not isinstance(key, ed25519.Ed25519PublicKey): + raise ValueError("Gateway must publish an Ed25519 public signing key") + return key.public_bytes( + serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo + ), issuer + finally: + connection.close() + + def _create_certificates(tls: Path, host: str) -> None: now = datetime.now(UTC) ca_key = ec.generate_private_key(ec.SECP256R1()) @@ -212,13 +265,21 @@ def _service_name(host: str) -> x509.GeneralName: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument("--state", type=Path, required=True) parser.add_argument("--host", required=True) - parser.add_argument("--gateway-public-key", type=Path, required=True) - parser.add_argument("--gateway-issuer", required=True) + parser.add_argument("--gateway", required=True) args = parser.parse_args() + gateways = json.load(sys.stdin) + gateway = next((item for item in gateways if item["name"] == args.gateway), None) + if gateway is None: + parser.error("Gateway is not registered; use openshell gateway add first") + public_key, issuer = _discover_gateway(gateway) + os.umask(0o077) + args.state.mkdir(parents=True, exist_ok=True) + public_path = args.state.resolve() / "gateway-public.pem" + public_path.write_bytes(public_key) prepare( Path(__file__).resolve().parent, args.state.resolve(), args.host, - args.gateway_public_key.resolve(), - args.gateway_issuer, + public_path, + issuer, ) diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py index 6a152f68..6161af9f 100644 --- a/projects/egress-gate/tests/service/test_http_admission.py +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -8,8 +8,7 @@ import asyncio import json import os -import subprocess -import sys +import runpy import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -157,21 +156,12 @@ async def test_pi_session_through_admission_and_authenticated_egress( ) -> None: example = PROJECT / "examples/pi-attested-admission" async with _clients(tmp_path) as (_, stub, config, token, middleware): - subprocess.run( - [ - sys.executable, - str(example / "prepare.py"), - "--state", - str(tmp_path), - "--host", - "127.0.0.1", - "--gateway-public-key", - str(config.gateway_public_key), - "--gateway-issuer", - config.gateway_issuer, - ], - check=True, - capture_output=True, + runpy.run_path(str(example / "prepare.py"))["prepare"]( + example, + tmp_path, + "127.0.0.1", + config.gateway_public_key, + config.gateway_issuer, ) config = config.model_copy( update={ diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 999f354d..fea166f1 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -3,19 +3,27 @@ from __future__ import annotations +import ipaddress import json import os import shutil +import ssl import subprocess import sys +import threading import tomllib +from collections.abc import Iterator +from datetime import UTC, datetime, timedelta +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from pathlib import Path import pytest import yaml from cryptography import x509 -from cryptography.hazmat.primitives import serialization +from cryptography.hazmat.primitives import hashes, serialization +from cryptography.hazmat.primitives.asymmetric import ec from cryptography.hazmat.primitives.asymmetric.ed25519 import Ed25519PrivateKey +from jwt.algorithms import OKPAlgorithm from egress_gate.service.admission import AdmissionServerConfig @@ -23,6 +31,125 @@ EXAMPLE = PROJECT / "examples/pi-attested-admission" +@pytest.fixture +def gateway_discovery( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> Iterator[tuple[dict[str, str], bytes]]: + """A real mTLS discovery server using the CLI's on-disk client layout.""" + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + tls = tmp_path / "config/openshell/gateways/test-gateway/mtls" + tls.mkdir(parents=True) + ca_key = ec.generate_private_key(ec.SECP256R1()) + ca_name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "Test CA")]) + now = datetime.now(UTC) + for name in ("ca", "tls"): + key = ca_key if name == "ca" else ec.generate_private_key(ec.SECP256R1()) + certificate = ( + x509.CertificateBuilder() + .subject_name( + ca_name + if name == "ca" + else x509.Name( + [x509.NameAttribute(x509.NameOID.COMMON_NAME, "localhost")] + ) + ) + .issuer_name(ca_name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(now - timedelta(minutes=1)) + .not_valid_after(now + timedelta(days=1)) + .add_extension( + x509.BasicConstraints(ca=name == "ca", path_length=None), critical=True + ) + .add_extension( + x509.KeyUsage( + digital_signature=True, + content_commitment=False, + key_encipherment=False, + data_encipherment=False, + key_agreement=False, + key_cert_sign=name == "ca", + crl_sign=name == "ca", + encipher_only=False, + decipher_only=False, + ), + critical=True, + ) + .add_extension( + x509.SubjectAlternativeName( + [ + x509.DNSName("localhost"), + x509.IPAddress(ipaddress.ip_address("127.0.0.1")), + ] + ), + critical=False, + ) + .add_extension( + x509.SubjectKeyIdentifier.from_public_key(key.public_key()), + critical=False, + ) + .add_extension( + x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), + critical=False, + ) + .sign(ca_key, hashes.SHA256()) + ) + (tls / f"{name}.crt").write_bytes( + certificate.public_bytes(serialization.Encoding.PEM) + ) + if name == "tls": + (tls / "tls.key").write_bytes( + key.private_bytes( + serialization.Encoding.PEM, + serialization.PrivateFormat.PKCS8, + serialization.NoEncryption(), + ) + ) + signing_key = Ed25519PrivateKey.generate().public_key() + gateway = {"name": "test-gateway", "auth": "mtls"} + + class Handler(BaseHTTPRequestHandler): + def do_GET(self) -> None: + if self.path == "/.well-known/openid-configuration": + body = { + "issuer": "existing-gateway-issuer", + "jwks_uri": gateway.get( + "jwks_uri", gateway["endpoint"] + "/.well-known/jwks.json" + ), + } + else: + assert self.path == "/.well-known/jwks.json" + body = {"keys": [json.loads(OKPAlgorithm.to_jwk(signing_key))]} + self.send_response(200) + self.end_headers() + self.wfile.write(json.dumps(body).encode()) + + def log_message(self, format: str, *args: object) -> None: + pass + + server = ThreadingHTTPServer(("127.0.0.1", 0), Handler) + context = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER) + context.load_cert_chain(tls / "tls.crt", tls / "tls.key") + context.load_verify_locations(tls / "ca.crt") + context.verify_mode = ssl.CERT_REQUIRED + server.socket = context.wrap_socket(server.socket, server_side=True) + gateway["endpoint"] = f"https://127.0.0.1:{server.server_port}" + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + try: + yield ( + gateway, + signing_key.public_bytes( + serialization.Encoding.PEM, + serialization.PublicFormat.SubjectPublicKeyInfo, + ), + ) + finally: + server.shutdown() + server.server_close() + thread.join() + + def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> None: example = tmp_path / "examples/demo" example.mkdir(parents=True) @@ -65,6 +192,8 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> assert "--gateway test-gateway" in output assert "https://service.example:5443/v1/admission" in output assert "middleware.toml" in output + assert "gateway list --output json" in output + assert "--gateway-public-key" not in output and "--gateway-issuer" not in output assert "17672" not in output and "XDG_CONFIG_HOME" not in output assert "docker build" in output assert "--admission-config" in output @@ -80,17 +209,11 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> def test_preparation_uses_existing_gateway_and_excludes_private_material( tmp_path: Path, host: str, + gateway_discovery: tuple[dict[str, str], bytes], ) -> None: state = tmp_path / "state" - public = ( - Ed25519PrivateKey.generate() - .public_key() - .public_bytes( - serialization.Encoding.PEM, serialization.PublicFormat.SubjectPublicKeyInfo - ) - ) - public_path = tmp_path / "gateway-public.pem" - public_path.write_bytes(public) + gateway, public = gateway_discovery + public_path = state / "gateway-public.pem" command = [ sys.executable, str(EXAMPLE / "prepare.py"), @@ -98,12 +221,10 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( str(state), "--host", host, - "--gateway-public-key", - str(public_path), - "--gateway-issuer", - "existing-gateway-issuer", + "--gateway", + gateway["name"], ] - subprocess.run(command, check=True) + subprocess.run(command, input=json.dumps([gateway]), text=True, check=True) config = AdmissionServerConfig.model_validate_json( (state / "admission.json").read_bytes() ) @@ -125,7 +246,7 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( token = config.bearer_token.get_secret_value() assert len(token) >= 32 (state / "image/stale-config.json").write_text("{}") - subprocess.run(command, check=True) + subprocess.run(command, input=json.dumps([gateway]), text=True, check=True) again = AdmissionServerConfig.model_validate_json( (state / "admission.json").read_bytes() ) @@ -144,12 +265,12 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( assert not (image / "admission.json").exists() assert not (image / "app/node_modules").exists() assert (image / "project/.pi/skills/review/SKILL.md").is_file() - gateway = tomllib.loads((state / "middleware.toml").read_text()) - registration = gateway["openshell"]["supervisor"]["middleware"][0] + middleware = tomllib.loads((state / "middleware.toml").read_text()) + registration = middleware["openshell"]["supervisor"]["middleware"][0] assert registration["grpc_endpoint"] == f"https://{host}:50051" assert registration["tls_ca_cert_path"] == str(state / "tls/ca.crt") - assert "gateway" not in gateway["openshell"] - assert "drivers" not in gateway["openshell"] + assert "gateway" not in middleware["openshell"] + assert "drivers" not in middleware["openshell"] policy = yaml.safe_load((state / "policy.yaml").read_text()) assert policy["network_middlewares"]["pi_egress_gate"]["on_error"] == "fail_closed" model_endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] @@ -162,11 +283,64 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( admission_profile = yaml.safe_load((state / "admission-provider.yaml").read_text()) assert admission_profile["endpoints"][0]["host"] == host command[command.index("--host") + 1] = "new-service.example" - subprocess.run(command, check=True) + subprocess.run(command, input=json.dumps([gateway]), text=True, check=True) assert (state / "tls/server/tls.crt").read_bytes() != certificate_bytes assert public_path.read_bytes() == public +@pytest.mark.parametrize("failure", ["plaintext", "foreign-key-url", "untrusted-ca"]) +def test_discovery_rejects_untrusted_gateway_before_preparation( + tmp_path: Path, gateway_discovery: tuple[dict[str, str], bytes], failure: str +) -> None: + gateway, _ = gateway_discovery + if failure == "plaintext": + gateway["endpoint"] = gateway["endpoint"].replace("https:", "http:") + elif failure == "foreign-key-url": + gateway["jwks_uri"] = "https://untrusted.example/keys" + else: + # Keep the client identity, but remove its trust in the server's CA. + key = Ed25519PrivateKey.generate() + name = x509.Name([x509.NameAttribute(x509.NameOID.COMMON_NAME, "Wrong CA")]) + certificate = ( + x509.CertificateBuilder() + .subject_name(name) + .issuer_name(name) + .public_key(key.public_key()) + .serial_number(x509.random_serial_number()) + .not_valid_before(datetime.now(UTC) - timedelta(minutes=1)) + .not_valid_after(datetime.now(UTC) + timedelta(days=1)) + .add_extension( + x509.BasicConstraints(ca=True, path_length=None), critical=True + ) + .sign(key, None) + ) + (tmp_path / "config/openshell/gateways/test-gateway/mtls/ca.crt").write_bytes( + certificate.public_bytes(serialization.Encoding.PEM) + ) + result = subprocess.run( + [ + sys.executable, + str(EXAMPLE / "prepare.py"), + "--state", + str(tmp_path / "state"), + "--host", + "127.0.0.1", + "--gateway", + gateway["name"], + ], + input=json.dumps([gateway]), + text=True, + capture_output=True, + ) + assert result.returncode != 0 + assert { + "plaintext": "registered HTTPS/mTLS gateway", + "foreign-key-url": "same HTTPS origin", + "untrusted-ca": "CERTIFICATE_VERIFY_FAILED", + }[failure] in result.stderr + assert not (tmp_path / "state").exists() + + def test_sandbox_binding_accepts_only_operator_cli_output(tmp_path: Path) -> None: result = subprocess.run( [sys.executable, str(EXAMPLE / "bind-sandbox.py"), "--state", str(tmp_path)], From 8144726405ebd050deb326793a7f1fe2ed67314f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 20:27:08 +0000 Subject: [PATCH 58/70] Include admission architecture in documentation navigation --- zensical.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/zensical.toml b/zensical.toml index 7e16c712..bb422fb8 100644 --- a/zensical.toml +++ b/zensical.toml @@ -44,6 +44,7 @@ nav = [ {"Architecture" = [ "documentation/egress-gate/architecture/index.md", {"Request lifecycle" = "documentation/egress-gate/architecture/request-lifecycle.md"}, + {"Admission without harness forks" = "documentation/egress-gate/architecture/admission.md"}, {"Service boundary" = "documentation/egress-gate/architecture/service-boundary.md"} ]}, {"Reference" = [ From 377cc50577cc78d3c39f0eefc1fbed15a76622f0 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 20:42:52 +0000 Subject: [PATCH 59/70] Fix gateway TLS compatibility and make demo model configuration operator-owned --- .../examples/pi-attested-admission/.gitignore | 2 + .../examples/pi-attested-admission/README.md | 24 +++++++- .../examples/pi-attested-admission/demo.sh | 4 ++ .../{model.json => model.json.example} | 8 +-- .../pi-attested-admission/policy.yaml | 4 +- .../examples/pi-attested-admission/prepare.py | 3 + .../tests/service/test_http_admission.py | 12 +++- .../tests/test_pi_example_commands.py | 55 +++++++++++-------- 8 files changed, 79 insertions(+), 33 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/.gitignore rename projects/egress-gate/examples/pi-attested-admission/{model.json => model.json.example} (68%) diff --git a/projects/egress-gate/examples/pi-attested-admission/.gitignore b/projects/egress-gate/examples/pi-attested-admission/.gitignore new file mode 100644 index 00000000..3d23b0c5 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/.gitignore @@ -0,0 +1,2 @@ +# Operator-owned model configuration, like the already ignored .env. +/model.json diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index f6e4f2f3..2d666f09 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -26,13 +26,33 @@ From this directory: ```sh cp .env.example .env +cp model.json.example model.json # Fill in the gateway name, reachable Egress Gate host, and model API key. -# Edit model.json if using a different endpoint/model. +# Edit model.json for your endpoint, model ID, and token limits (see below). ./demo.sh prepare ./demo.sh registration ``` -The checked-in model uses NVIDIA's inference endpoint and requires access to it. +Create your own `model.json` from [model.json.example](model.json.example). +No working provider configuration is shipped. Use a text-only, tool-capable +OpenAI-compatible Chat Completions endpoint; NVIDIA inference is one option if +you have access, not a requirement. Set: + +- `id` and `name`: your provider's model ID and a display name. +- `baseUrl`: the HTTPS API base, such as `https://your-provider.example/v1`; + the application appends `/chat/completions`. +- `contextWindow` and `maxTokens`: the model's context limit and your desired + response limit, in tokens. The template's numbers are examples. +- `compat.maxTokensField`: the field your provider accepts (`max_tokens` or + `max_completion_tokens`). The other compatibility settings are conservative + defaults; adjust them if your endpoint requires it. + +Keep `api`, `provider`, `reasoning`, and `input` as shown for this demo. +The zero `cost` values disable cost estimates; provider usage is not free. +Put the API key only in `.env`, never in `model.json`. +Both files are ignored by Git. Preparation copies your model configuration into +the local sandbox image, but not `.env` or the API key. + `prepare` reads the selected endpoint from `openshell gateway list --output json` and discovers its issuer and public signing key over verified HTTPS. It reuses the CLI's existing client certificates under diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index a11b3b60..0094a337 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -29,6 +29,10 @@ case "$action" in if ! $print_only; then : "${EGRESS_GATE_HOST:?Set the service hostname or IPv4 address in .env}" : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" + if [[ ! -f $example/model.json ]]; then + echo 'Create model.json from model.json.example and configure your model first.' >&2 + exit 1 + fi fi run uv sync --frozen if $print_only; then diff --git a/projects/egress-gate/examples/pi-attested-admission/model.json b/projects/egress-gate/examples/pi-attested-admission/model.json.example similarity index 68% rename from projects/egress-gate/examples/pi-attested-admission/model.json rename to projects/egress-gate/examples/pi-attested-admission/model.json.example index febcb1cf..61be28b7 100644 --- a/projects/egress-gate/examples/pi-attested-admission/model.json +++ b/projects/egress-gate/examples/pi-attested-admission/model.json.example @@ -1,12 +1,12 @@ { - "id": "azure/anthropic/claude-opus-5", - "name": "Claude Opus 5", + "id": "YOUR_MODEL_ID", + "name": "Your model", "provider": "pi-egress", "api": "openai-completions", - "baseUrl": "https://inference-api.nvidia.com/v1", + "baseUrl": "https://api.example.com/v1", "reasoning": false, "input": ["text"], - "contextWindow": 1000000, + "contextWindow": 128000, "maxTokens": 4096, "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, "compat": { diff --git a/projects/egress-gate/examples/pi-attested-admission/policy.yaml b/projects/egress-gate/examples/pi-attested-admission/policy.yaml index 1aa323db..7530e679 100644 --- a/projects/egress-gate/examples/pi-attested-admission/policy.yaml +++ b/projects/egress-gate/examples/pi-attested-admission/policy.yaml @@ -14,7 +14,7 @@ network_policies: model_provider: name: Configured model endpoint endpoints: - - host: inference-api.nvidia.com + - host: api.example.com # prepare replaces this with the model.json endpoint. port: 443 protocol: rest enforcement: enforce @@ -80,4 +80,4 @@ network_middlewares: on_error: fail_closed endpoints: include: - - inference-api.nvidia.com + - api.example.com # prepare replaces this with the model.json endpoint. diff --git a/projects/egress-gate/examples/pi-attested-admission/prepare.py b/projects/egress-gate/examples/pi-attested-admission/prepare.py index f84250a5..c08ae322 100644 --- a/projects/egress-gate/examples/pi-attested-admission/prepare.py +++ b/projects/egress-gate/examples/pi-attested-admission/prepare.py @@ -158,6 +158,9 @@ def _discover_gateway(gateway: dict[str, str]) -> tuple[bytes, str]: config = Path(os.environ.get("XDG_CONFIG_HOME", Path.home() / ".config")) tls = config / "openshell/gateways" / name / "mtls" context = ssl.create_default_context(cafile=str(tls / "ca.crt")) + # OpenShell's generated certificates omit extensions required by Python 3.13's + # strict X.509 mode. Retain CA/signature, expiry and hostname verification. + context.verify_flags &= ~ssl.VERIFY_X509_STRICT context.load_cert_chain(tls / "tls.crt", tls / "tls.key") connection = HTTPSConnection( endpoint.hostname, endpoint.port, context=context, timeout=10 diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py index 6161af9f..2bb1fc61 100644 --- a/projects/egress-gate/tests/service/test_http_admission.py +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -9,6 +9,7 @@ import json import os import runpy +import shutil import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -154,7 +155,14 @@ async def test_pi_session_through_admission_and_authenticated_egress( tmp_path: Path, unused_tcp_port: int, ) -> None: - example = PROJECT / "examples/pi-attested-admission" + source = PROJECT / "examples/pi-attested-admission" + example = tmp_path / "example" + shutil.copytree( + source, + example, + ignore=shutil.ignore_patterns(".env", "model.json", "node_modules", "dist"), + ) + shutil.copyfile(example / "model.json.example", example / "model.json") async with _clients(tmp_path) as (_, stub, config, token, middleware): runpy.run_path(str(example / "prepare.py"))["prepare"]( example, @@ -243,7 +251,7 @@ async def provider(request: web.Request) -> web.Response: try: process = await asyncio.create_subprocess_exec( "node", - str(example / "app/dist/test/service-integration.js"), + str(source / "app/dist/test/service-integration.js"), str(server.make_url("/")).rstrip("/"), str(tmp_path), env=os.environ | {"NODE_EXTRA_CA_CERTS": str(tmp_path / "tls/ca.crt")}, diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index fea166f1..51da965f 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -35,7 +35,7 @@ def gateway_discovery( tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> Iterator[tuple[dict[str, str], bytes]]: - """A real mTLS discovery server using the CLI's on-disk client layout.""" + """Real mTLS with OpenShell-style certs (no AKI or CA Key Usage extension).""" monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) tls = tmp_path / "config/openshell/gateways/test-gateway/mtls" tls.mkdir(parents=True) @@ -50,7 +50,7 @@ def gateway_discovery( ca_name if name == "ca" else x509.Name( - [x509.NameAttribute(x509.NameOID.COMMON_NAME, "localhost")] + [x509.NameAttribute(x509.NameOID.COMMON_NAME, "test-gateway")] ) ) .issuer_name(ca_name) @@ -61,24 +61,9 @@ def gateway_discovery( .add_extension( x509.BasicConstraints(ca=name == "ca", path_length=None), critical=True ) - .add_extension( - x509.KeyUsage( - digital_signature=True, - content_commitment=False, - key_encipherment=False, - data_encipherment=False, - key_agreement=False, - key_cert_sign=name == "ca", - crl_sign=name == "ca", - encipher_only=False, - decipher_only=False, - ), - critical=True, - ) .add_extension( x509.SubjectAlternativeName( [ - x509.DNSName("localhost"), x509.IPAddress(ipaddress.ip_address("127.0.0.1")), ] ), @@ -88,10 +73,6 @@ def gateway_discovery( x509.SubjectKeyIdentifier.from_public_key(key.public_key()), critical=False, ) - .add_extension( - x509.AuthorityKeyIdentifier.from_issuer_public_key(ca_key.public_key()), - critical=False, - ) .sign(ca_key, hashes.SHA256()) ) (tls / f"{name}.crt").write_bytes( @@ -205,6 +186,20 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> assert "sandbox delete pi-admission" in output +def test_prepare_requires_operator_model_configuration(tmp_path: Path) -> None: + script = tmp_path / "demo.sh" + shutil.copyfile(EXAMPLE / "demo.sh", script) + result = subprocess.run( + ["bash", str(script), "prepare"], + env=os.environ | {"OPENSHELL_GATEWAY": "test", "EGRESS_GATE_HOST": "localhost"}, + capture_output=True, + text=True, + ) + assert result.returncode == 1 + assert "Create model.json from model.json.example" in result.stderr + assert not (tmp_path / "model.json").exists() + + @pytest.mark.parametrize("host", ["192.0.2.10", "host.docker.internal"]) def test_preparation_uses_existing_gateway_and_excludes_private_material( tmp_path: Path, @@ -212,11 +207,18 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( gateway_discovery: tuple[dict[str, str], bytes], ) -> None: state = tmp_path / "state" + example = tmp_path / "example" + shutil.copytree( + EXAMPLE, + example, + ignore=shutil.ignore_patterns(".env", "model.json", "node_modules", "dist"), + ) + shutil.copyfile(example / "model.json.example", example / "model.json") gateway, public = gateway_discovery public_path = state / "gateway-public.pem" command = [ sys.executable, - str(EXAMPLE / "prepare.py"), + str(example / "prepare.py"), "--state", str(state), "--host", @@ -229,6 +231,7 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( (state / "admission.json").read_bytes() ) assert config.provider_target.scheme == "https" + assert config.provider_target.host == "api.example.com" assert config.gateway_public_key == public_path assert config.gateway_issuer == "existing-gateway-issuer" assert public_path.read_bytes() == public @@ -265,6 +268,7 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( assert not (image / "admission.json").exists() assert not (image / "app/node_modules").exists() assert (image / "project/.pi/skills/review/SKILL.md").is_file() + assert json.loads((image / "model.json").read_text())["id"] == "YOUR_MODEL_ID" middleware = tomllib.loads((state / "middleware.toml").read_text()) registration = middleware["openshell"]["supervisor"]["middleware"][0] assert registration["grpc_endpoint"] == f"https://{host}:50051" @@ -288,7 +292,9 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( assert public_path.read_bytes() == public -@pytest.mark.parametrize("failure", ["plaintext", "foreign-key-url", "untrusted-ca"]) +@pytest.mark.parametrize( + "failure", ["plaintext", "foreign-key-url", "untrusted-ca", "wrong-hostname"] +) def test_discovery_rejects_untrusted_gateway_before_preparation( tmp_path: Path, gateway_discovery: tuple[dict[str, str], bytes], failure: str ) -> None: @@ -297,6 +303,8 @@ def test_discovery_rejects_untrusted_gateway_before_preparation( gateway["endpoint"] = gateway["endpoint"].replace("https:", "http:") elif failure == "foreign-key-url": gateway["jwks_uri"] = "https://untrusted.example/keys" + elif failure == "wrong-hostname": + gateway["endpoint"] = gateway["endpoint"].replace("127.0.0.1", "localhost") else: # Keep the client identity, but remove its trust in the server's CA. key = Ed25519PrivateKey.generate() @@ -337,6 +345,7 @@ def test_discovery_rejects_untrusted_gateway_before_preparation( "plaintext": "registered HTTPS/mTLS gateway", "foreign-key-url": "same HTTPS origin", "untrusted-ca": "CERTIFICATE_VERIFY_FAILED", + "wrong-hostname": "Hostname mismatch", }[failure] in result.stderr assert not (tmp_path / "state").exists() From 560925e3cbb436455426751573cf6b6a23553279 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 22:33:21 +0000 Subject: [PATCH 60/70] Automate demo gateway registration and cleanup across installer platforms --- .../docs/architecture/admission.md | 10 +- .../examples/pi-attested-admission/README.md | 59 ++++-- .../examples/pi-attested-admission/demo.sh | 24 ++- .../gateway-registration.py | 178 ++++++++++++++++++ .../tests/test_pi_example_commands.py | 138 +++++++++++++- 5 files changed, 382 insertions(+), 27 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/gateway-registration.py diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md index 1e6ead24..68ed07a8 100644 --- a/projects/egress-gate/docs/architecture/admission.md +++ b/projects/egress-gate/docs/architecture/admission.md @@ -93,9 +93,13 @@ Only the gateway name, reachable service host and model key are supplied by the operator. Discovery requires one published signing key and refuses plaintext, cross-origin key URLs and untrusted TLS; browser/edge-login gateways are outside this POC helper's scope. Host setup generates service TLS, one admission bearer -credential, provider destination and policy; setup reads the actual sandbox ID. It prints -a middleware registration for the operator to install and does not generate -gateway credentials, download OpenShell binaries, or restart the gateway. +credential, provider destination and policy; setup reads the actual sandbox ID. +For local installer-managed gateways, the same `register` command selects the +config and service manager (Homebrew or the DEB/RPM user service), adds the demo's +middleware entry, and restarts the gateway; `cleanup` removes that entry and +restarts it again. Other deployments use the printed +registration with their own gateway operator. The demo does not generate +gateway credentials or download OpenShell binaries. The sandbox cannot select its authoritative identity or submit a policy. The single host-owned identity file is populated after sandbox creation; until then admission is unavailable. There is no diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 2d666f09..7b3b0434 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -19,8 +19,10 @@ proxy credential delivery. This example does not install or start OpenShell. Also needed: Bash, Python 3.11+, uv 0.11+, Docker, and a real key for a text-only Chat Completions model. The simple image workflow assumes your gateway's Docker driver uses the same Docker daemon as `docker build`. Remote drivers/image -distribution are outside this example. The host no longer has to be Linux; -Linux has been tested, while macOS execution remains unverified. +distribution are outside this example. The same commands below work with a local, +installer-managed gateway on macOS or Linux; the helper selects its config and +service manager automatically. Remote and custom gateway deployments require +operator-managed registration (see below). From this directory: @@ -30,7 +32,6 @@ cp model.json.example model.json # Fill in the gateway name, reachable Egress Gate host, and model API key. # Edit model.json for your endpoint, model ID, and token limits (see below). ./demo.sh prepare -./demo.sh registration ``` Create your own `model.json` from [model.json.example](model.json.example). @@ -69,8 +70,7 @@ its bridge address. Do not use `localhost` when callers are in containers. Preparation builds the pinned Pi **0.85.1** image and writes service TLS, policy and provider profiles under `../../.workspaces/pi-admission/`. The discovered public key is saved there as `gateway-public.pem`. No fork clones, -gateway binaries, gateway private keys or full gateway configuration are created. -`registration` prints only the middleware entry to add. +gateway binaries or gateway private keys are created. Keep Egress Gate running in one terminal: @@ -78,20 +78,33 @@ Keep Egress Gate running in one terminal: ./demo.sh serve ``` -Have the operator merge the printed entry into the existing gateway's -configuration. Make `tls/ca.crt` readable to the gateway (copy or mount the -public certificate if needed) and adjust `tls_ca_cert_path` in the entry to -that gateway-visible path. Restart the gateway through its usual service -manager to load the registration; coordinate this on a shared gateway. -The demo never edits or restarts it. - In a second terminal, from this directory: ```sh +./demo.sh register ./demo.sh setup ./demo.sh launch ``` +`register` finds the local gateway's config, adds only `pi-egress`, restarts the +gateway through its service manager, and waits for gateway health. It preserves +unrelated settings and refuses to overwrite a registration it did not create. +If the installation uses built-in defaults without a config file, it creates a +minimal one for this registration. You do not need to choose a config path or +run service-manager commands yourself. +**Registration and cleanup briefly interrupt this gateway.** Coordinate this +if anyone else uses it. No OpenShell changes or additional `.env` settings are +needed for the standard installation. + +Automatic service handling supports Homebrew and the DEB/RPM user service. +Other service layouts (including Snap and custom config overrides) are +operator-managed; the helper does not guess their config or request root access. + +For other deployments, `./demo.sh registration` only **prints** the TOML entry; +it does not install it. The gateway operator must merge it into the active config, +make the public `tls/ca.crt` accessible at `tls_ca_cert_path`, and restart the +gateway. Those operator-managed registrations must also be removed manually. + `setup` displays the selected gateway and creates the `pi-admission` sandbox and its two provider profiles/instances. Reserve those names for this demo. Egress Gate listens on **50051** (authenticated middleware gRPC) and **5443** @@ -102,8 +115,10 @@ or printing secrets: ```sh ./demo.sh --print prepare +./demo.sh --print register ./demo.sh --print setup ./demo.sh --print launch +./demo.sh --print cleanup ``` Print mode uses exported configuration or placeholders because it does not load @@ -113,6 +128,8 @@ remain outside the image and repository. **Validation status:** local cross-language integration passes, but the complete existing-gateway workflow with a valid model key remains to be verified. +Registration lifecycle tests cover both supported service managers; live +service-manager execution remains unverified. `./demo.sh verify` below is that separate real-model acceptance check. ## What to try @@ -166,17 +183,19 @@ compaction. It exits unsuccessfully on any missing capability or failed check; it does not skip checks or substitute a mock model. Deterministic failure and pending-admission tests live in [app/test/](app/test/). -Cleanup deletes only this demo sandbox and its provider instances/profiles. +Cleanup deletes only this demo sandbox and its provider instances/profiles, +then removes the registration created by `register` and restarts the gateway. **Sandbox files and sessions are deleted and are not recoverable by this script.** -Copy out anything wanted first, then stop `serve` with Ctrl-C. The gateway and -its middleware registration are left untouched; the operator can remove the -registration when the demo is no longer needed. Host configuration and the local -Docker image remain for reuse. +Copy out anything wanted first, then stop `serve` with Ctrl-C. Host configuration +and the local Docker image remain for reuse. To remove only the registration +(without deleting the sandbox), use `./demo.sh unregister`. This also works +after a failed registration restart; fix the service problem and retry. For source/model/policy changes, clean up the old demo sandbox, run `prepare`, -restart `serve`, then run `setup`. Valid service certificates are reused for -the same host. After 30 days or a host change, `prepare` generates new service -TLS: install the new CA in the gateway and restart it before setup. Refresh the +restart `serve`, then run `register` and `setup`. Valid service certificates are +reused for the same host. After 30 days or a host change, `prepare` generates new service +TLS: rerun `register` to reload the new CA before setup (other deployments must +update their gateway-visible CA and restart manually). Refresh the gateway identity by rerunning `prepare` and restarting `serve` if the gateway rotates its signing key. Discovery currently expects one published signing key. diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 0094a337..65edf293 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -23,6 +23,17 @@ openshell=(openshell --gateway "$gateway") run() { if $print_only; then printf '%q ' "$@"; printf '\n'; else "$@"; fi } +registration() { + run uv run --frozen python "$example/gateway-registration.py" "$1" --state "$state" --gateway "$gateway" + if $print_only; then + printf '# Helper edits only pi-egress, waits for health, and internally runs: ' + case "$OSTYPE" in + darwin*) run brew services restart openshell ;; + linux*) run systemctl --user restart openshell-gateway ;; + *) printf 'no supported service manager\n' ;; + esac + fi +} cd "$project" case "$action" in prepare) @@ -50,6 +61,10 @@ case "$action" in registration) run cat "$state/middleware.toml" ;; + register|unregister) + if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}"; fi + registration "$action" + ;; setup) if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" @@ -89,18 +104,21 @@ case "$action" in run "${openshell[@]}" provider profile delete "pi-admission-$provider" done run uv run --frozen python -c 'import pathlib,sys; pathlib.Path(sys.argv[1]).unlink(missing_ok=True)' "$state/sandbox-id" - printf 'Sandbox and its sessions removed. Stop serve with Ctrl-C; the gateway is unchanged.\n' + registration unregister + printf 'Sandbox and its sessions removed. Stop serve with Ctrl-C.\n' printf 'Host configuration remains in %s; the local Docker image is retained.\n' "$state" ;; help) printf 'Usage: ./demo.sh [--print] ACTION\n\n' printf ' prepare Generate service TLS/config; build the Pi image\n' printf ' serve Run Egress Gate (keep this terminal open)\n' - printf ' registration Print middleware config for your gateway operator\n' + printf ' register Add middleware to the local gateway and restart it\n' + printf ' unregister Remove that registration and restart the gateway\n' + printf ' registration Show the TOML entry (manual deployments only; does not register)\n' printf ' setup Create providers and sandbox; bind admission identity\n' printf ' launch Start a new interactive Pi-powered session\n' printf ' verify Run real allow/deny/redact, tools, skill, compaction and bypass checks\n' - printf ' cleanup Delete this sandbox/providers, including saved sessions\n' + printf ' cleanup Delete sandbox/providers/sessions; unregister middleware\n' printf '\n--print shows commands without executing .env, requiring secrets, or changing state.\n' ;; *) echo "Unknown action. Run ./demo.sh help." >&2; exit 2 ;; diff --git a/projects/egress-gate/examples/pi-attested-admission/gateway-registration.py b/projects/egress-gate/examples/pi-attested-admission/gateway-registration.py new file mode 100644 index 00000000..f390454d --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/gateway-registration.py @@ -0,0 +1,178 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +"""Register this demo with a local, installer-managed OpenShell gateway.""" + +from __future__ import annotations + +import argparse +import json +import os +import shlex +import subprocess +import sys +import tempfile +import time +import tomllib +from pathlib import Path +from urllib.parse import urlsplit + +from egress_gate.gateway_config import ( + default_gateway_config_path, + list_gateway_registrations, + remove_gateway_config, +) + + +def configure(action: str, state: Path, gateway: str) -> None: + record = state / "gateway-registration.json" + if action == "unregister" and not record.exists(): + print("No demo-managed gateway registration to remove.") + return + gateways = json.loads(_output("openshell", "gateway", "list", "--output", "json")) + selected = next((item for item in gateways if item["name"] == gateway), None) + endpoint = urlsplit(selected["endpoint"] if selected else "") + if ( + endpoint.scheme != "https" + or endpoint.hostname not in {"localhost", "127.0.0.1", "::1"} + or endpoint.port != 17670 + ): + raise ValueError( + "Select the local installer-managed gateway on HTTPS port 17670." + ) + + config, restart = _local_service() + registration = {"gateway": gateway, "config": str(config)} + if record.exists() and json.loads(record.read_text()) != registration: + raise ValueError( + "Gateway/config changed; restore the previous selection to clean up first." + ) + + if action == "register": + fragment = (state / "middleware.toml").read_text() + desired = tomllib.loads(fragment)["openshell"]["supervisor"]["middleware"][0] + original = ( + config.read_text() if config.exists() else "[openshell]\nversion = 1\n" + ) + # The shared reader validates the middleware table before we edit anything. + matches = [ + r for r in list_gateway_registrations(config) if r.name == "pi-egress" + ] + if matches: + entries = tomllib.loads(original)["openshell"]["supervisor"]["middleware"] + existing = [entry for entry in entries if entry.get("name") == "pi-egress"] + if existing != [desired] or not record.exists(): + raise ValueError( + "pi-egress is already registered; refusing to overwrite it." + ) + else: + updated = original.rstrip() + "\n\n" + fragment + tomllib.loads(updated) + # Remember ownership so cleanup also works after a failed restart. + record.write_text(json.dumps(registration) + "\n") + config.parent.mkdir(parents=True, exist_ok=True) + with tempfile.NamedTemporaryFile( + mode="w", dir=config.parent, delete=False + ) as temporary: + try: + temporary.write(updated) + temporary.close() + if config.exists(): + os.chmod(temporary.name, config.stat().st_mode & 0o777) + Path(temporary.name).replace(config) + finally: + Path(temporary.name).unlink(missing_ok=True) + print(f"Registered pi-egress in {config}", flush=True) + else: + remove_gateway_config(config, middleware_name="pi-egress") + print(f"Removed pi-egress from {config}", flush=True) + + # Restart even on a retry: the previous write may have succeeded but reload failed. + print( + f"+ {shlex.join(restart)} (briefly interrupts this gateway)", + flush=True, + ) + subprocess.run(restart, check=True) + _wait_for_gateway(gateway) + if action == "unregister": + record.unlink() + + +def _output(*command: str) -> str: + return subprocess.check_output(command, text=True, timeout=10) + + +def _local_service() -> tuple[Path, list[str]]: + config = default_gateway_config_path() + service_env = config.with_name("gateway.env") + if sys.platform == "darwin": + # Match the Homebrew wrapper: user config, then prefix config. + prefix = Path(_output("brew", "--prefix").strip()) / "var/openshell" + if not service_env.is_file(): + service_env = prefix / "gateway.env" + if not config.is_file(): + config = prefix / "gateway.toml" + config = config.resolve(strict=True) + restart = ["brew", "services", "restart", "openshell"] + elif sys.platform == "linux": + # DEB/RPM installations use defaults until a user config is created. + if ( + _output( + "systemctl", + "--user", + "show", + "openshell-gateway", + "--property=LoadState", + "--value", + ).strip() + != "loaded" + ): + raise ValueError("No installer-managed OpenShell user service found.") + restart = ["systemctl", "--user", "restart", "openshell-gateway"] + else: + raise ValueError("No supported local gateway service manager found.") + # Custom service environments are operator-managed, not inferred from our shell. + if os.environ.get("OPENSHELL_GATEWAY_CONFIG") or ( + service_env.is_file() + and any( + "OPENSHELL_GATEWAY_CONFIG" in line + for line in service_env.read_text().splitlines() + if line.strip() and not line.lstrip().startswith("#") + ) + ): + raise ValueError( + "Custom gateway config override: use operator-managed registration." + ) + return config.resolve(), restart + + +def _wait_for_gateway(gateway: str) -> None: + command = ["openshell", "--gateway", gateway, "gateway", "info", "--output", "json"] + print(f"Waiting for healthy gateway: {shlex.join(command)}", flush=True) + deadline = time.monotonic() + 30 + while time.monotonic() < deadline: + try: + result = subprocess.run(command, capture_output=True, text=True, timeout=5) + if ( + result.returncode == 0 + and json.loads(result.stdout)["status"] == "healthy" + ): + return + except subprocess.TimeoutExpired: + pass + time.sleep(1) + raise ValueError( + "Gateway did not become healthy. Check the gateway service logs, then retry." + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("action", choices=("register", "unregister")) + parser.add_argument("--state", type=Path, required=True) + parser.add_argument("--gateway", required=True) + args = parser.parse_args() + try: + configure(args.action, args.state, args.gateway) + except (OSError, ValueError, subprocess.SubprocessError) as error: + parser.exit(1, f"{error}\n") diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 51da965f..ec6ffc94 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -6,6 +6,7 @@ import ipaddress import json import os +import runpy import shutil import ssl import subprocess @@ -145,6 +146,8 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> "prepare", "serve", "registration", + "register", + "unregister", "setup", "launch", "verify", @@ -168,7 +171,6 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> assert not marker.exists() assert not (tmp_path / ".workspaces").exists() assert "git clone" not in output - assert "openshell-gateway" not in output assert "sha256sum" not in output and "curl" not in output assert "--gateway test-gateway" in output assert "https://service.example:5443/v1/admission" in output @@ -184,6 +186,140 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> assert "sandbox create" in output and "--from pi-admission:local" in output assert "/app/dist/src/cli.js" in output and "/app/dist/src/verify.js" in output assert "sandbox delete pi-admission" in output + assert "gateway-registration.py" in output + assert ( + "brew services restart openshell" + if sys.platform == "darwin" + else "systemctl --user restart openshell-gateway" + ) in output + + +@pytest.mark.parametrize( + "installation", ["homebrew-prefix", "homebrew-user", "systemd", "systemd-defaults"] +) +def test_installer_registration_round_trip( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch, installation: str +) -> None: + configure = runpy.run_path(str(EXAMPLE / "gateway-registration.py"))["configure"] + homebrew = installation.startswith("homebrew") + monkeypatch.setattr(sys, "platform", "darwin" if homebrew else "linux") + monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "config")) + monkeypatch.delenv("OPENSHELL_GATEWAY_CONFIG", raising=False) + prefix_config = tmp_path / "brew/var/openshell/gateway.toml" + prefix_config.parent.mkdir(parents=True) + original = ( + "# Keep my comments\n[openshell]\nversion = 1\n" + '[openshell.gateway]\nbind_address = "127.0.0.1:17670"\n' + '[[openshell.supervisor.middleware]]\nname = "other"\n' + 'grpc_endpoint = "http://localhost:1234"\n' + ) + prefix_config.write_text(original) + config = prefix_config + if installation != "homebrew-prefix": + config = tmp_path / "config/openshell/gateway.toml" + config.parent.mkdir(parents=True) + config.write_text(original) + if installation == "systemd-defaults": + config.unlink() + original = "[openshell]\nversion = 1\n" + state = tmp_path / "state" + state.mkdir() + fragment = ( + '[[openshell.supervisor.middleware]]\nname = "pi-egress"\n' + 'grpc_endpoint = "https://service.example:50051"\n' + 'tls_ca_cert_path = "/demo/tls/ca.crt"\n' + 'audience = "urn:openshell:extension:middleware:pi-egress"\n' + 'max_payload_bytes = 4194304\ntimeout = "10s"\n' + ) + (state / "middleware.toml").write_text(fragment) + commands: list[tuple[str, ...]] = [] + endpoint = "https://localhost:17670" + fail_restart = False + restart = ( + ["brew", "services", "restart", "openshell"] + if homebrew + else ["systemctl", "--user", "restart", "openshell-gateway"] + ) + + def output(command: tuple[str, ...], **_kwargs: object) -> str: + commands.append(command) + if command == ("brew", "--prefix"): + assert homebrew + return str(tmp_path / "brew") + if command == ( + "systemctl", + "--user", + "show", + "openshell-gateway", + "--property=LoadState", + "--value", + ): + assert not homebrew + return "loaded\n" + assert command == ("openshell", "gateway", "list", "--output", "json") + return json.dumps([{"name": "openshell", "endpoint": endpoint}]) + + def run(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[str]: + commands.append(tuple(command)) + if command == restart: + if fail_restart: + raise subprocess.CalledProcessError(1, command) + return subprocess.CompletedProcess(command, 0) + assert command == [ + "openshell", + "--gateway", + "openshell", + "gateway", + "info", + "--output", + "json", + ] + return subprocess.CompletedProcess(command, 0, '{"status":"healthy"}') + + monkeypatch.setattr(subprocess, "check_output", output) + monkeypatch.setattr(subprocess, "run", run) + # Never modify a local service when the selected gateway is remote. + endpoint = "https://remote.example:17670" + with pytest.raises(ValueError, match="local installer-managed gateway"): + configure("register", state, "openshell") + if installation == "systemd-defaults": + assert not config.exists() + else: + assert config.read_text() == original + endpoint = "https://localhost:17670" + # Refuse to take over an operator's pre-existing registration, even if identical. + config.write_text(original + fragment) + with pytest.raises(ValueError, match="refusing to overwrite"): + configure("register", state, "openshell") + config.write_text(original) + if installation == "systemd-defaults": + config.unlink() + fail_restart = True + with pytest.raises(subprocess.CalledProcessError): + configure("register", state, "openshell") + assert (state / "gateway-registration.json").exists() + fail_restart = False + configure("register", state, "openshell") + registered = config.read_text() + assert registered.startswith(original) + assert registered.count('name = "pi-egress"') == 1 + assert ( + tomllib.loads(registered)["openshell"]["supervisor"]["middleware"][-1] + == (tomllib.loads(fragment)["openshell"]["supervisor"]["middleware"][0]) + ) + fail_restart = True + with pytest.raises(subprocess.CalledProcessError): + configure("unregister", state, "openshell") + assert (state / "gateway-registration.json").exists() + fail_restart = False + configure("unregister", state, "openshell") + assert config.read_text().strip() == original.strip() + assert not (state / "gateway-registration.json").exists() + before = len(commands) + configure("unregister", state, "openshell") + assert len(commands) == before + if installation == "homebrew-user": + assert prefix_config.read_text() == original def test_prepare_requires_operator_model_configuration(tmp_path: Path) -> None: From 57501110ca2fd668190e7f19f76fdcb0dac94fbd Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 9 Sep 2026 22:48:41 +0000 Subject: [PATCH 61/70] Handle missing demo resources during cleanup --- .../examples/pi-attested-admission/demo.sh | 24 ++++++- .../tests/test_pi_example_commands.py | 69 +++++++++++++++++++ 2 files changed, 90 insertions(+), 3 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 65edf293..d3a55e02 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -23,6 +23,24 @@ openshell=(openshell --gateway "$gateway") run() { if $print_only; then printf '%q ' "$@"; printf '\n'; else "$@"; fi } +delete_if_present() { + local resource=$1 output status + shift + if $print_only; then run "$@"; return; fi + if output=$("$@" 2>&1); then + printf '%s\n' "$output" + else + status=$? + # Older OpenShell releases return gRPC NotFound for an absent resource. + if [[ $output == *"code: 'Some requested entity was not found'"* && + $output == *"message: \"$resource not found\""* ]]; then + printf '%s already absent; continuing cleanup.\n' "$resource" + else + printf '%s\n' "$output" >&2 + return "$status" + fi + fi +} registration() { run uv run --frozen python "$example/gateway-registration.py" "$1" --state "$state" --gateway "$gateway" if $print_only; then @@ -98,10 +116,10 @@ case "$action" in ;; cleanup) if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}"; fi - run "${openshell[@]}" sandbox delete pi-admission + delete_if_present sandbox "${openshell[@]}" sandbox delete pi-admission for provider in model admission; do - run "${openshell[@]}" provider delete "pi-admission-$provider" - run "${openshell[@]}" provider profile delete "pi-admission-$provider" + delete_if_present provider "${openshell[@]}" provider delete "pi-admission-$provider" + delete_if_present 'provider profile' "${openshell[@]}" provider profile delete "pi-admission-$provider" done run uv run --frozen python -c 'import pathlib,sys; pathlib.Path(sys.argv[1]).unlink(missing_ok=True)' "$state/sandbox-id" registration unregister diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index ec6ffc94..f50c8120 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -194,6 +194,75 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> ) in output +@pytest.mark.parametrize( + ("failure", "expected_status"), + [ + ("", 0), + ( + "code: 'Some requested entity was not found', " + 'message: "sandbox not found"', + 0, + ), + ("code: 'Permission denied', message: \"sandbox not found\"", 7), + ( + "code: 'Some requested entity was not found', " + 'message: "workspace not found"', + 7, + ), + ("Connection refused", 7), + ], +) +def test_cleanup_after_partial_setup( + tmp_path: Path, failure: str, expected_status: int +) -> None: + example = tmp_path / "examples/demo" + example.mkdir(parents=True) + script = example / "demo.sh" + shutil.copyfile(EXAMPLE / "demo.sh", script) + commands = tmp_path / "commands" + binaries = tmp_path / "bin" + binaries.mkdir() + # Exercise the real shell flow without deleting a live sandbox or registration. + stub = f"""#!{sys.executable} +import os, pathlib, sys +with open(os.environ["COMMAND_LOG"], "a") as log: + log.write(pathlib.Path(sys.argv[0]).name + " " + " ".join(sys.argv[1:]) + "\\n") +if "sandbox" in sys.argv and os.environ["SANDBOX_FAILURE"]: + print(os.environ["SANDBOX_FAILURE"], file=sys.stderr) + sys.exit(7) +""" + for name in ("openshell", "uv"): + executable = binaries / name + executable.write_text(stub) + executable.chmod(0o755) + result = subprocess.run( + ["bash", str(script), "cleanup"], + capture_output=True, + text=True, + env=os.environ + | { + "PATH": f"{binaries}{os.pathsep}{os.environ['PATH']}", + "OPENSHELL_GATEWAY": "test-gateway", + "COMMAND_LOG": str(commands), + "SANDBOX_FAILURE": failure, + }, + ) + assert result.returncode == expected_status + recorded = commands.read_text().splitlines() + assert recorded[0] == "openshell --gateway test-gateway sandbox delete pi-admission" + if expected_status: + assert len(recorded) == 1 + assert failure in result.stderr + assert "sessions removed" not in result.stdout + else: + assert len(recorded) == 7 + assert "provider delete pi-admission-model" in recorded[1] + assert "provider profile delete pi-admission-admission" in recorded[4] + assert "gateway-registration.py unregister" in recorded[-1] + if failure: + assert "already absent; continuing cleanup" in result.stdout + + @pytest.mark.parametrize( "installation", ["homebrew-prefix", "homebrew-user", "systemd", "systemd-defaults"] ) From 39d74d0b3f2a892d2977a70452092fc792cb8342 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 10 Sep 2026 01:28:35 +0000 Subject: [PATCH 62/70] Run admission-controlled sessions in Pi's native TUI --- .../docs/architecture/admission.md | 46 +- .../pi-attested-admission/.env.example | 4 +- .../examples/pi-attested-admission/README.md | 76 ++- .../pi-attested-admission/app/src/agent.ts | 419 ++++++++++++ .../pi-attested-admission/app/src/cli.ts | 76 +-- .../pi-attested-admission/app/src/session.ts | 596 +++++++----------- .../pi-attested-admission/app/src/tools.ts | 56 ++ .../app/test/service-integration.ts | 52 +- .../app/test/session.test.ts | 222 ++++++- .../tests/service/test_http_admission.py | 68 +- 10 files changed, 1125 insertions(+), 490 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/src/tools.ts diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md index 68ed07a8..7507743b 100644 --- a/projects/egress-gate/docs/architecture/admission.md +++ b/projects/egress-gate/docs/architecture/admission.md @@ -10,7 +10,7 @@ The [runnable Pi example](https://github.com/NVIDIA/OpenShell-Research/tree/john uses published Pi 0.85.1 packages with an existing OpenShell gateway (0.0.116 is the tested protocol baseline). No upstream library, runtime, protobuf, or CLI patches are required. Its smaller surface is a -Pi-powered application, not stock Pi CLI parity. +Pi application using the native TUI, not full stock Pi CLI parity. ## The two boundaries @@ -45,9 +45,12 @@ application or same-authority code can violate local storage integrity. ## One history owner -The application uses Pi's public model stream, resource loader, built-in tools, -summary generator, and session storage. It does not run a second autonomous -AgentSession or depend on late message notifications. +The application supplies an admission-controlled `Agent` through the public +`AgentSessionConfig.agent` SDK seam. Pi's native `InteractiveMode` and session +runtime use that agent. It checks candidates before changing live state or +emitting message events; the native `AgentSession` alone persists those approved +events. It does not depend on late message notifications or remove content +after insertion. | Candidate | What is admitted before writing | | --- | --- | @@ -59,7 +62,10 @@ AgentSession or depend on late message notifications. Tool-call fields are inspectable but immutable: attempted executable-argument redaction fails closed. Unsupported images/reasoning/provider state is rejected, -not stored as unchecked sidecars. Tool details and progress are not transcripts. +not stored as unchecked sidecars. Tool details and progress are not published; +the TUI receives only admitted final results, with activity indicators while +waiting. Editor drafts and pending input queues are distinct from admitted +conversation history. Bash uses a bounded public operations wrapper to avoid Pi's output-log spill. On a denied tool result, the application stops model calls. It submits fixed, @@ -69,9 +75,11 @@ Tool side effects themselves are not reversible by result admission. Compaction keeps the latest whole user turn. Its summary-generation request needs a fresh receipt; its finished summary needs fresh insertion approval. -Denial leaves the preceding context and file unchanged. Auto compaction runs -between completed turns; overflow gets at most one compact/retry. Old approved -entries remain in the append-only JSONL file. +The trusted `session_before_compact` extension supplies an admitted summary or +explicitly cancels, including on failure; it never falls through to an unchecked +default summary. Denial leaves the preceding context and file unchanged. +Native automatic compaction also runs between tool turns; overflow gets at most +one compact/retry. Old approved entries remain in the append-only JSONL file. One cwd scopes resources, tools and storage. It is not confinement; OpenShell filesystem policy is. The application is installed outside the writable project @@ -142,8 +150,13 @@ attachment; adding a later content-mutating middleware breaks that assumption. ## Deliberate POC limits One text-only OpenAI-compatible Chat Completions model, sequential tools, fresh -sessions and explicit skills. No TUI/RPC parity, arbitrary extensions, reasoning, -images, WebSockets, transport switching, branching, or crash resume. +sessions and explicit skills. The native TUI supports admitted chat, tool cards, +steering/follow-ups, compaction and `/new`. Direct `!`/`!!` shell execution, +custom extension messages, import/resume, branching, renaming, model switching, +and resource reload are blocked at their public session/runtime entry points. +Shell work through the model's bash tool remains supported. +No RPC mode, arbitrary extensions, reasoning, images, WebSockets, transport +switching, or crash resume. Network policy allows only the chosen POST model path and separately scopes the admission endpoint. Unknown shapes fail closed; admission requests do not recursively require model receipts. @@ -160,11 +173,18 @@ caller binding, upstream RPCs, receipts, policy decisions and header removal. A cross-language integration test also runs the actual Pi serializer and HTTP admission client against local HTTPS admission and provider endpoints. It checks redaction, skills, a real read-tool continuation, both compaction paths and -receipt verification over authenticated gRPC. Only provider responses are +receipt verification over authenticated gRPC. A pseudo-terminal variant drives +the actual Pi TUI, including tool expansion, manual compaction, denial and +`/new`, then inspects the saved JSONL. Only provider responses are controlled test data; it does not substitute for live OpenShell acceptance. The example's `demo.sh verify` is a separate real-model end-to-end acceptance command, not a simulated demonstration. Its success must be observed, not inferred -from unit tests. See the PR validation record for the latest executed checks. +from unit tests. + +The native-TUI update was validated locally on **2026-09-10**: 387 Python tests, +24 Node tests, lint/type checks, dependency audit and the documentation build +passed. This includes the terminal-driven integration above, not a live +OpenShell/real-model acceptance run. Protocol and application validation on **2026-09-09** used: @@ -182,7 +202,7 @@ Pi's actual tool-capable serialized request with a receipt passed the gate and received HTTP 401 from the real endpoint when deliberately given an invalid test credential. This establishes the transport seam, **not** successful model output. -**Existing-gateway deployment and real-model acceptance remain unverified.** +**The updated native-TUI workflow still needs live OpenShell/real-model acceptance.** Preparation is tested with both DNS and IPv4 service addresses against a local mTLS discovery server. Local cross-language tests exercise service TLS and gateway public-key verification. diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example index ea0f4f0d..9a587928 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.env.example +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -1,7 +1,7 @@ # Existing HTTPS/mTLS gateway registered in your OpenShell CLI (gateway list). OPENSHELL_GATEWAY=your-gateway # Egress Gate hostname or IPv4 address reachable from gateway AND sandbox. -# Docker Desktop commonly uses host.docker.internal; Linux can use the bridge IP. -EGRESS_GATE_HOST=host.docker.internal +# Use reachable DNS or a LAN IPv4 address; Docker-only names may fail on the host. +EGRESS_GATE_HOST=your-service-host # Real key for the HTTPS endpoint/model in model.json. Never copied into the image. PI_MODEL_API_KEY=your-provider-key diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 7b3b0434..0126480a 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -5,9 +5,11 @@ Egress Gate approves content **before** it enters the assistant's live history or Pi's saved session. It can deny text or replace it; a second check at the network boundary prevents sending an unapproved user/tool context. -This is an application built from Pi's public APIs, not the stock Pi CLI. -It keeps real tools, tool continuations, project instructions, explicit skills, -and manual/automatic compaction. Neither Pi nor OpenShell needs a patch. +This launches **Pi's native TUI** through its public SDK: the normal editor, +chat, tool cards, shortcuts, and compaction UI. An application-owned agent +checks content before publishing it to Pi's session. It keeps real tools, +tool continuations, project instructions, explicit skills, and manual/automatic +compaction. Neither Pi nor OpenShell needs a patch. ## Try it @@ -43,7 +45,8 @@ you have access, not a requirement. Set: - `baseUrl`: the HTTPS API base, such as `https://your-provider.example/v1`; the application appends `/chat/completions`. - `contextWindow` and `maxTokens`: the model's context limit and your desired - response limit, in tokens. The template's numbers are examples. + response limit, in tokens. The POC caps each response at the smaller of + `maxTokens` and 4,096 tokens. The template's numbers are examples. - `compat.maxTokensField`: the field your provider accepts (`max_tokens` or `max_completion_tokens`). The other compatibility settings are conservative defaults; adjust them if your endpoint requires it. @@ -64,8 +67,11 @@ supported by this discovery helper. It never disables TLS verification. `EGRESS_GATE_HOST` must resolve to this service from **both gateway and sandbox**: use a reachable DNS name or IPv4 address, without a scheme or port. -Docker Desktop commonly provides `host.docker.internal`; Linux Docker may need -its bridge address. Do not use `localhost` when callers are in containers. +`host.docker.internal` may work inside Docker Desktop containers but fail to +resolve for a gateway running directly on the host. Use the service machine's +reachable LAN IPv4 address or a DNS name that works from both places. Do not +use `localhost` when callers are in containers. The gateway connects to the +middleware during startup, so an unreachable address can prevent it starting. Preparation builds the pinned Pi **0.85.1** image and writes service TLS, policy and provider profiles under `../../.workspaces/pi-admission/`. @@ -126,10 +132,11 @@ Print mode uses exported configuration or placeholders because it does not load name. The generated `admission.json` contains a private admission token and must remain outside the image and repository. -**Validation status:** local cross-language integration passes, but the complete -existing-gateway workflow with a valid model key remains to be verified. -Registration lifecycle tests cover both supported service managers; live -service-manager execution remains unverified. +**Validation status:** local SDK and native-TUI integration tests pass, including +real admission HTTP/RPC traffic, tool output, compaction, denial and saved JSONL. +Provider responses in those tests are controlled fixtures. The updated native-TUI +workflow still needs acceptance testing with a running OpenShell gateway and a +real model. Registration lifecycle tests cover both supported service managers. `./demo.sh verify` below is that separate real-model acceptance check. ## What to try @@ -139,13 +146,11 @@ Type these into the running application: ```text Hello. Briefly describe what you can do. Please repeat REDACT_THIS. -/history DENY_THIS -/history /skill:review /compact -/history -/exit +/new +/quit ``` `DENY_THIS` and `REDACT_THIS` are harmless, literal demonstration markers defined @@ -161,13 +166,24 @@ The skill asks the model to read the real `notes.txt`; that result is admitted before the next model call. `cwd` is convenient scoping, not an access-control boundary: OpenShell's filesystem policy supplies that boundary. -Responses and tool progress are buffered, not streamed into the transcript. -`/history` shows only admitted active context. The startup message names the -Pi JSONL file under `/sandbox/sessions`. Compaction retains the latest whole turn; -older **approved** entries remain in the append-only file. Automatic compaction -uses the same summary path at a completed-turn context threshold. Ctrl-C cancels -the current operation. An unfinished tool batch that cannot be safely closed -requires a new session. +Responses and tool output are buffered until approved, rather than streamed +unchecked into the transcript. Pi still shows activity while waiting. +Use Ctrl+O to expand tool output and `/session` to inspect session information; +Pi saves JSONL under `/sandbox/sessions`. The former custom `/history` and +`/exit` commands are gone; use Pi's chat view and `/quit`. +Compaction retains the latest whole turn; older **approved** entries remain in +the append-only file. Automatic compaction +uses the same summary path at Pi's context thresholds, including between tool +turns. Esc cancels the current operation. Steering and follow-up inputs are +admitted after skill expansion, before joining the transcript. Drafts and pending +input queues are not approved history. An unfinished tool batch that cannot be +safely closed requires `/new`. + +This POC deliberately blocks `!`/`!!`, resume/import, branching, renaming, +model switching, and resource reload: these need additional handling before +they can be safely enabled. Ask the model to use the **bash tool** for shell +work. Arbitrary extensions are not loaded. Tool cards display admitted results, +not unchecked progress or extra tool metadata such as edit diffs. ## Verify and clean up @@ -192,7 +208,9 @@ and the local Docker image remain for reuse. To remove only the registration after a failed registration restart; fix the service problem and retry. For source/model/policy changes, clean up the old demo sandbox, run `prepare`, -restart `serve`, then run `register` and `setup`. Valid service certificates are +restart `serve`, then run `register`, `setup`, and `launch`. This rebuild is +also required when upgrading from the earlier line-based interface to the TUI; +launching an existing sandbox continues using its old image. Valid service certificates are reused for the same host. After 30 days or a host change, `prepare` generates new service TLS: rerun `register` to reload the new CA before setup (other deployments must update their gateway-visible CA and restart manually). Refresh the @@ -222,10 +240,13 @@ OpenShell supervisor ----------> verify actual request + policy attach real provider key --> model ``` -[session.ts](app/src/session.ts) is the only owner of writable history. It uses -Pi's model calls, tool implementations, resource loader, summarizer, and -`SessionManager`; it does not instantiate an autonomous `AgentSession` with -unchecked insertion paths. Finalized assistant text and tool calls are admitted +[agent.ts](app/src/agent.ts) supplies Pi's public `AgentSessionConfig.agent` +with an admission-controlled execution loop. It approves each candidate before +updating live state or emitting message events. Pi's native `AgentSession` +is the **only persistence owner**; it saves those approved events. +[session.ts](app/src/session.ts) wires the runtime and the +`session_before_compact` extension, and blocks alternate unchecked write paths. +Finalized assistant text and tool calls are admitted before execution. Tool output, missing-tool/argument/execution errors, rendered skills, and completed summaries all pass the same boundary. @@ -258,7 +279,8 @@ custom OpenShell protobuf field, or second model proxy. the intercepted body. Receipts are reusable for identical content for up to five minutes; service restarts invalidate them. - One text-only Chat Completions model, sequential tools, and new sessions. - No TUI/RPC parity, third-party extensions, resume/branching, images, reasoning + The real TUI is used, but not every stock CLI feature is supported. + No RPC mode, third-party extensions, resume/branching, images, reasoning payloads, WebSockets, or model switching. Unsupported content fails closed. - Redaction can change ordinary text, not executable tool arguments or call identifiers. Admitting a tool result cannot reverse tool side effects. diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts new file mode 100644 index 00000000..be1be8ec --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts @@ -0,0 +1,419 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { + Agent, + type AgentEvent, + type AgentMessage, + type AgentContext, + type StreamFn, +} from "@earendil-works/pi-agent-core"; +import { + isContextOverflow, + validateToolArguments, + type AssistantMessage, + type ImageContent, + type Message, + type Model, + type ToolResultMessage, + type Usage, +} from "@earendil-works/pi-ai"; +import { convertToLlm } from "@earendil-works/pi-coding-agent"; +import { Admission, AdmissionError } from "./admission.js"; + +export class ContextOverflowError extends Error {} + +/** Own the execution loop so even pending content never enters Pi's reducer. + * AgentSession alone persists the approved message_end events. + */ +export class AdmissionAgent extends Agent { + private readonly live; + private readonly subscribers = new Set< + (event: AgentEvent, signal: AbortSignal) => Promise | void + >(); + private readonly steering: AgentMessage[] = []; + private readonly followUps: AgentMessage[] = []; + private controller?: AbortController; + private settled: Promise = Promise.resolve(); + stopped = false; + + constructor( + model: Model<"openai-completions">, + streamFn: StreamFn, + private readonly admission: Admission, + ) { + super({ initialState: { model, thinkingLevel: "off" }, streamFn }); + // Pi's base lifecycle fields are readonly. This engine owns its own public + // state and lifecycle; it never invokes the base execution/state reducer. + this.live = { ...super.state, pendingToolCalls: new Set() }; + } + + override get state() { + return this.live; + } + override get signal() { + return this.controller?.signal; + } + override subscribe( + listener: (event: AgentEvent, signal: AbortSignal) => Promise | void, + ) { + this.subscribers.add(listener); + return () => { + this.subscribers.delete(listener); + }; + } + override abort() { + this.controller?.abort(); + } + override waitForIdle() { + return this.settled; + } + override steer(message: AgentMessage) { + this.steering.push(message); + } + override followUp(message: AgentMessage) { + this.followUps.push(message); + } + override clearSteeringQueue() { + this.steering.length = 0; + } + override clearFollowUpQueue() { + this.followUps.length = 0; + } + override clearAllQueues() { + this.clearSteeringQueue(); + this.clearFollowUpQueue(); + } + override hasQueuedMessages() { + return this.steering.length + this.followUps.length > 0; + } + override reset() { + if (this.live.isStreaming) + throw new Error("Cancel the current operation first."); + this.live.messages = []; + this.live.errorMessage = undefined; + this.stopped = false; + this.clearAllQueues(); + } + override prompt( + input: string | AgentMessage | AgentMessage[], + images?: ImageContent[], + ): Promise { + if (images?.length) + return Promise.reject(new AdmissionError("unsupported")); + const messages: AgentMessage[] = + typeof input === "string" + ? [{ role: "user", content: input, timestamp: Date.now() }] + : Array.isArray(input) + ? input + : [input]; + return this.run(messages); + } + override continue(): Promise { + const last = this.live.messages.at(-1); + if ( + !this.hasQueuedMessages() && + last?.role !== "user" && + last?.role !== "toolResult" + ) + return Promise.reject( + new Error("There is no unfinished turn to continue."), + ); + return this.run([]); + } + + private async run(candidates: AgentMessage[]): Promise { + if (this.live.isStreaming || this.stopped) + throw new Error("Session is busy or stopped; use /new if stopped."); + this.controller = new AbortController(); + this.live.isStreaming = true; + this.live.errorMessage = undefined; + let settle!: () => void; + this.settled = new Promise((resolve) => { + settle = resolve; + }); + const published: AgentMessage[] = []; + try { + await this.emit({ type: "agent_start" }); + await this.admitBatch(candidates, published); + const steered = await this.drain( + this.steering, + this.steeringMode, + published, + ); + if (!candidates.length && !steered) + await this.drain(this.followUps, this.followUpMode, published); + for (;;) { + this.signal!.throwIfAborted(); + await this.emit({ type: "turn_start" }); + // AgentSession rebuilds system context when tools/settings change. + // Approve that snapshot before every provider call. + const systemPrompt = await this.admission.text( + "system", + this.live.systemPrompt, + this.signal, + ); + const response = await ( + await this.streamFunction( + this.live.model, + { + systemPrompt, + messages: convertToLlm(this.live.messages), + tools: this.live.tools, + }, + { + signal: this.signal, + maxTokens: Math.min(4096, this.live.model.maxTokens), + }, + ) + ).result(); + this.signal!.throwIfAborted(); + if (isContextOverflow(response, this.live.model.contextWindow)) + throw new ContextOverflowError("Context is too large."); + if ( + response.stopReason === "error" || + response.stopReason === "aborted" + ) + throw new Error( + "Model request failed or was cancelled; no response was saved.", + ); + const assistant = (await this.admit({ + role: "assistant", + content: response.content, + api: this.live.model.api, + provider: this.live.model.provider, + model: this.live.model.id, + usage: retainedUsage(response.usage), + stopReason: response.stopReason, + timestamp: Date.now(), + })) as AssistantMessage; + await this.publish(assistant, published); + const calls = assistant.content.filter( + (block) => block.type === "toolCall", + ); + const toolResults: ToolResultMessage[] = []; + for (let index = 0; index < calls.length; index++) { + const call = calls[index]; + try { + if (assistant.stopReason === "length") + throw new Error("Incomplete tool call."); + this.signal!.throwIfAborted(); + const tool = this.live.tools.find( + (tool) => tool.name === call.name, + ); + let args: unknown = call.arguments; + let result; + let isError = false; + this.live.pendingToolCalls.add(call.id); + await this.emit({ + type: "tool_execution_start", + toolCallId: call.id, + toolName: call.name, + args, + }); + try { + if (!tool) throw new Error("Requested tool is not available."); + args = validateToolArguments(tool, call); + const before = await this.beforeToolCall?.( + { + assistantMessage: assistant, + toolCall: call, + args, + context: this.context(), + }, + this.signal, + ); + if (before?.block) + throw new Error(before.reason ?? "Tool execution blocked."); + // No onUpdate callback: partial tool output is not approved yet. + result = await tool.execute(call.id, args, this.signal); + } catch (error) { + isError = true; + result = { + content: [ + { + type: "text" as const, + text: + error instanceof Error + ? error.message + : "Tool execution failed.", + }, + ], + details: undefined, + }; + } + const after = await this.afterToolCall?.( + { + assistantMessage: assistant, + toolCall: call, + args, + result, + isError, + context: this.context(), + }, + this.signal, + ); + const approved = (await this.admit({ + role: "toolResult", + toolCallId: call.id, + toolName: call.name, + content: after?.content ?? result.content, + isError: after?.isError ?? isError, + timestamp: Date.now(), + })) as ToolResultMessage; + await this.publishTool(approved, published); + toolResults.push(approved); + } catch (error) { + // Close outstanding pairs with separately admitted, content-free + // failures. If admission is unavailable, require a new session. + try { + for (const pending of calls.slice(index)) { + const approved = (await this.admit({ + role: "toolResult", + toolCallId: pending.id, + toolName: pending.name, + content: [ + { + type: "text", + text: "Tool result unavailable; this turn was stopped.", + }, + ], + isError: true, + timestamp: Date.now(), + })) as ToolResultMessage; + await this.publishTool(approved, published); + } + } catch { + this.stopped = true; + } + throw error; + } + } + await this.emit({ type: "turn_end", message: assistant, toolResults }); + const steered = await this.drain( + this.steering, + this.steeringMode, + published, + ); + const followedUp = + !calls.length && + !steered && + (await this.drain(this.followUps, this.followUpMode, published)); + if (!calls.length && !steered && !followedUp) break; + // Native automatic compaction between tool turns uses the same + // session_before_compact admission hook as manual compaction. + await this.prepareNextTurnWithContext?.( + { + message: assistant, + toolResults, + context: this.context(), + newMessages: published, + }, + this.signal, + ); + } + } catch (error) { + this.clearAllQueues(); + // Never turn an unchecked exception or partial provider response into a + // persisted assistant error message. + this.live.errorMessage = + error instanceof AdmissionError + ? error.message + : "Operation stopped; no unchecked content was saved."; + if ( + error instanceof AdmissionError || + error instanceof ContextOverflowError + ) + throw error; + throw new Error(this.live.errorMessage); + } finally { + try { + await this.emit({ type: "agent_end", messages: published }); + } finally { + this.live.pendingToolCalls.clear(); + this.live.isStreaming = false; + this.controller = undefined; + settle(); + } + } + } + + private context(): AgentContext { + return { + systemPrompt: this.live.systemPrompt, + messages: this.live.messages.slice(), + tools: this.live.tools, + }; + } + private async admit(candidate: AgentMessage): Promise { + if ( + candidate.role !== "user" && + candidate.role !== "assistant" && + candidate.role !== "toolResult" + ) + throw new AdmissionError("unsupported"); + return this.admission.message(candidate, this.signal); + } + private async admitBatch( + candidates: AgentMessage[], + published: AgentMessage[], + ) { + const approved = []; + for (const candidate of candidates) + approved.push(await this.admit(candidate)); + this.signal!.throwIfAborted(); + for (const message of approved) await this.publish(message, published); + } + private async drain( + queue: AgentMessage[], + mode: string, + published: AgentMessage[], + ): Promise { + if (!queue.length) return false; + const candidates = queue.splice(0, mode === "all" ? queue.length : 1); + await this.admitBatch(candidates, published); + return true; + } + private async publish(message: Message, published: AgentMessage[]) { + this.live.messages = [...this.live.messages, message]; + published.push(message); + await this.emit({ type: "message_start", message }); + await this.emit({ type: "message_end", message }); + } + private async publishTool( + message: ToolResultMessage, + published: AgentMessage[], + ) { + this.live.pendingToolCalls.delete(message.toolCallId); + // Drop unchecked details (including edit diffs) and usage metadata. + await this.emit({ + type: "tool_execution_end", + toolCallId: message.toolCallId, + toolName: message.toolName, + result: { content: message.content }, + isError: message.isError, + }); + await this.publish(message, published); + } + private async emit(event: AgentEvent) { + for (const subscriber of this.subscribers) + await subscriber(event, this.signal!); + } +} + +export function retainedUsage(usage: Usage): Usage { + return { + input: usage.input, + output: usage.output, + cacheRead: usage.cacheRead, + cacheWrite: usage.cacheWrite, + totalTokens: usage.totalTokens, + cost: { + input: usage.cost.input, + output: usage.cost.output, + cacheRead: usage.cost.cacheRead, + cacheWrite: usage.cost.cacheWrite, + total: usage.cost.total, + }, + }; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts index 8396cb1d..5c81045f 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts @@ -3,12 +3,11 @@ import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; -import { resolve } from "node:path"; -import { createInterface } from "node:readline/promises"; import { parseArgs } from "node:util"; import type { Model } from "@earendil-works/pi-ai"; +import { InteractiveMode, convertToLlm } from "@earendil-works/pi-coding-agent"; import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; -import { AdmissionSession } from "./session.js"; +import { createAdmissionRuntime } from "./session.js"; import { configureProxy } from "./network.js"; async function main(): Promise { @@ -33,7 +32,7 @@ async function main(): Promise { const model = JSON.parse( await readFile(values.model, "utf8"), ) as Model<"openai-completions">; - const session = await AdmissionSession.create({ + const runtime = await createAdmissionRuntime({ cwd: values.cwd, sessionDir: values["session-dir"], agentDir: "/app/agent", @@ -43,68 +42,19 @@ async function main(): Promise { createHttpEvaluator(values.admission, admissionKey, randomUUID()), ), }); - console.log( - `Project: ${resolve(values.cwd)}\nSession: ${session.sessionFile}`, - ); if (values.prompt !== undefined) { - await session.prompt(values.prompt); - console.log(JSON.stringify(session.history, null, 2)); - return; - } - console.log( - "/skill: [instructions] · /compact · /history · /exit. Ctrl-C cancels the current operation.", - ); - const terminal = createInterface({ - input: process.stdin, - output: process.stdout, - }); - let operation: AbortController | undefined; - terminal.on("SIGINT", () => { - if (operation) operation.abort(); - else terminal.close(); - }); - try { - for await (const line of terminal) { - if (line === "/exit") break; - if (!line.trim()) continue; - operation = new AbortController(); - try { - if (line === "/history") - console.log(JSON.stringify(session.history, null, 2)); - else if (line === "/compact") - console.log( - (await session.compact(operation.signal)) - ? "Approved summary saved." - : "No older complete turn to compact.", - ); - else { - await session.prompt(line, operation.signal); - const last = session.history.at(-1); - if (last?.role === "assistant") - console.log( - last.content - .filter((block) => block.type === "text") - .map((block) => block.text) - .join("\n"), - ); - } - } catch (error) { - console.error( - error instanceof AdmissionError - ? error.message - : "Operation stopped; no unchecked candidate was saved.", - ); - if (session.isStopped) { - console.error("An unfinished tool batch requires a new session."); - break; - } - } finally { - operation = undefined; - } + try { + await runtime.session.bindExtensions({}); + await runtime.session.prompt(values.prompt); + console.log( + JSON.stringify(convertToLlm(runtime.session.messages), null, 2), + ); + } finally { + await runtime.dispose(); } - } finally { - terminal.close(); + return; } + await new InteractiveMode(runtime).run(); } main().catch((error) => { diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts index e08e6b01..a83cc8eb 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts @@ -1,44 +1,31 @@ // SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. // SPDX-License-Identifier: Apache-2.0 -import { readFile } from "node:fs/promises"; import { resolve } from "node:path"; -import { - formatSkillInvocation, - type AgentTool, - type StreamFn, -} from "@earendil-works/pi-agent-core"; -import { - isContextOverflow, - validateToolArguments, - type AssistantMessage, - type Context, - type Message, - type Model, - type Usage, -} from "@earendil-works/pi-ai"; +import type { AgentTool, StreamFn } from "@earendil-works/pi-agent-core"; +import type { Model } from "@earendil-works/pi-ai"; import { streamSimple } from "@earendil-works/pi-ai/compat"; import { + AgentSession, + AgentSessionRuntime, SessionManager, - DefaultResourceLoader, SettingsManager, + createAgentSessionServices, convertToLlm, - createReadTool, - createBashTool, - createEditTool, - createWriteTool, - createGrepTool, - createFindTool, - createLsTool, - createLocalBashOperations, - formatSkillsForPrompt, - estimateTokens, - shouldCompact, generateSummaryWithUsage, sessionEntryToContextMessages, - type Skill, + type CreateAgentSessionRuntimeFactory, + type PromptOptions, } from "@earendil-works/pi-coding-agent"; import { Admission, AdmissionError, RECEIPT_HEADER } from "./admission.js"; +import { + AdmissionAgent, + ContextOverflowError, + retainedUsage, +} from "./agent.js"; +import { projectTools } from "./tools.js"; + +export { projectTools } from "./tools.js"; export interface SessionOptions { cwd: string; @@ -47,368 +34,263 @@ export interface SessionOptions { model: Model<"openai-completions">; apiKey: string; admission: Admission; - /** Public Pi stream/tool seams also permit deterministic boundary tests. */ + /** Deterministic integration tests use Pi's public stream/tool seams. */ stream?: StreamFn; tools?: AgentTool[]; compactAtTokens?: number; } -/** The only owner of writable Pi history. Candidates stay local until approved. */ -export class AdmissionSession { - private readonly store: SessionManager; - private readonly tools: AgentTool[]; - private readonly stream: StreamFn; - private systemPrompt = ""; - private skills: Skill[] = []; - private busy = false; - private stopped = false; - private readonly reserveTokens: number; - - private constructor(private readonly options: SessionOptions) { - this.store = SessionManager.create( - resolve(options.cwd), - resolve(options.sessionDir), - ); - this.tools = options.tools ?? projectTools(resolve(options.cwd)); - this.reserveTokens = Math.min(4096, options.model.maxTokens); - this.stream = async (model, context, streamOptions) => { - const receipt = await options.admission.receipt( - context, - streamOptions?.signal, - ); - return (options.stream ?? streamSimple)(model, context, { - ...streamOptions, - apiKey: options.apiKey, - maxRetries: 0, - headers: { ...streamOptions?.headers, [RECEIPT_HEADER]: receipt }, - }); - }; - } - +/** Native Pi session/persistence, with explicit guards for unsupported writes. */ +export class AdmissionSession extends AgentSession { static async create(options: SessionOptions): Promise { - if ( - options.model.api !== "openai-completions" || - options.model.reasoning || - options.model.input.some((type) => type !== "text") || - new URL(options.model.baseUrl).protocol !== "https:" - ) { - throw new AdmissionError("unsupported"); - } - const session = new AdmissionSession(options); - const resources = new DefaultResourceLoader({ + const result = await sessionFactory(options)({ cwd: resolve(options.cwd), agentDir: resolve(options.agentDir), - settingsManager: SettingsManager.inMemory({ packages: [] }), - noExtensions: true, - noPromptTemplates: true, - noThemes: true, + sessionManager: SessionManager.create( + resolve(options.cwd), + resolve(options.sessionDir), + ), }); - await resources.reload(); - const skills = resources.getSkills().skills; - const candidate = [ - "You are a coding assistant. Use the available tools to work in the project directory.", - resources.getSystemPrompt() ?? "", - ...resources.getAppendSystemPrompt(), - ...resources - .getAgentsFiles() - .agentsFiles.map((file) => `${file.path}\n${file.content}`), - formatSkillsForPrompt(skills), - ].join("\n\n"); - session.systemPrompt = await options.admission.text("system", candidate); - session.skills = skills; - return session; + await result.session.bindExtensions({}); + return result.session; } - get history(): Message[] { - return structuredClone( - convertToLlm(this.store.buildSessionContext().messages), - ); + get history() { + return structuredClone(convertToLlm(this.messages)); } get entries() { - return structuredClone(this.store.getEntries()); + return structuredClone(this.sessionManager.getEntries()); } - get sessionFile(): string { - return this.store.getSessionFile()!; + override get sessionFile(): string { + return this.sessionManager.getSessionFile()!; } - get isStopped(): boolean { - return this.stopped; + get isStopped() { + return (this.agent as AdmissionAgent).stopped; } - async prompt(input: string, signal?: AbortSignal): Promise { - this.begin(); + override async prompt(text: string, options?: PromptOptions): Promise { + if (this.isStopped) + throw new Error("An unfinished tool batch requires /new."); try { - let text = input; - const invocation = /^\/skill:([^\s]+)(?:\s+([\s\S]*))?$/.exec(input); - if (invocation) { - const skill = this.skills.find((skill) => skill.name === invocation[1]); - if (!skill) throw new Error("Unknown project skill."); - text = formatSkillInvocation( - { ...skill, content: await readFile(skill.filePath, "utf8") }, - invocation[2], - ); - } - await this.admitAndAppend( - { role: "user", content: text, timestamp: Date.now() }, - signal, - ); - let retriedOverflow = false; - for (;;) { - const response = await ( - await this.stream(this.options.model, this.context(), { - signal, - maxTokens: this.reserveTokens, - }) - ).result(); - if (isContextOverflow(response, this.options.model.contextWindow)) { - if (retriedOverflow || !(await this.compactSession(signal))) - throw new Error("Context is too large; start a new session."); - retriedOverflow = true; - continue; - } - if ( - response.stopReason === "error" || - response.stopReason === "aborted" - ) - throw new Error( - "Model request failed or was cancelled; no response was saved.", - ); - const candidate: AssistantMessage = { - role: "assistant", - content: response.content, - api: this.options.model.api, - model: this.options.model.id, - provider: this.options.model.provider, - usage: retainedUsage(response.usage), - stopReason: response.stopReason, - timestamp: Date.now(), - }; - const assistant = (await this.admitAndAppend( - candidate, - signal, - )) as AssistantMessage; - const calls = assistant.content.filter( - (block) => block.type === "toolCall", - ); - if (!calls.length) break; - for (let index = 0; index < calls.length; index++) { - const call = calls[index]; - try { - if (assistant.stopReason === "length") - throw new Error("Incomplete tool call."); - const tool = this.tools.find((tool) => tool.name === call.name); - let content: - | { type: "text"; text: string }[] - | Awaited>["content"]; - let isError = false; - try { - if (!tool) throw new Error("Requested tool is not available."); - const args = validateToolArguments(tool, call); - content = (await tool.execute(call.id, args, signal)).content; - } catch (error) { - isError = true; - content = [ - { - type: "text", - text: - error instanceof Error - ? error.message - : "Tool execution failed.", - }, - ]; - } - await this.admitAndAppend( - { - role: "toolResult", - toolCallId: call.id, - toolName: call.name, - content, - isError, - timestamp: Date.now(), - }, - signal, - ); - } catch (error) { - // No more model calls after a rejected result. Close outstanding - // pairs only with separately admitted, content-free failures. - try { - for (const pending of calls.slice(index)) - await this.admitAndAppend( - { - role: "toolResult", - toolCallId: pending.id, - toolName: pending.name, - content: [ - { - type: "text", - text: "Tool result unavailable; this turn was stopped.", - }, - ], - isError: true, - timestamp: Date.now(), - }, - signal, - ); - } catch { - this.stopped = true; - } - throw error; - } - } - } - const tokens = this.contextTokens(); - if ( - tokens >= (this.options.compactAtTokens ?? Infinity) || - shouldCompact(tokens, this.options.model.contextWindow, { - enabled: true, - reserveTokens: this.reserveTokens, - keepRecentTokens: 0, - }) - ) - await this.compactSession(signal); + await super.prompt(text, options); + } catch (error) { + if (!(error instanceof ContextOverflowError)) throw error; + // The failed provider response was never published. Compact only approved + // history, then retry that unfinished turn once. + await this.compact(); + await this.agent.continue(); } finally { - this.busy = false; + if (!this.isStreaming) this.clearQueue(); } } - async compact(signal?: AbortSignal): Promise { - this.begin(); - try { - return await this.compactSession(signal); - } finally { - this.busy = false; - } + // These native entry points write outside the agent's message event path. + // Keep them unavailable until each has its own pre-write admission boundary. + override async executeBash(): Promise { + return unsupported("Direct ! commands; ask the model to use the bash tool"); } - - private begin(): void { - if (this.busy || this.stopped) - throw new Error( - "Session is busy or stopped; start a new session if stopped.", - ); - this.busy = true; + override recordBashResult(): never { + return unsupported("Direct shell results"); } - - private context(): Context { - return { - systemPrompt: this.systemPrompt, - messages: this.history, - tools: this.tools, - }; + override async sendCustomMessage(): Promise { + return unsupported("Custom extension messages"); } - private contextTokens(): number { - return this.history.reduce( - (sum, message) => sum + estimateTokens(message), - Math.ceil(this.systemPrompt.length / 4), - ); + override async navigateTree(): Promise { + return unsupported("Session branching"); } - - private async admitAndAppend( - candidate: Message, - signal?: AbortSignal, - ): Promise { - const admitted = await this.options.admission.message(candidate, signal); - this.store.appendMessage(admitted); - return structuredClone(admitted); + override async reload(): Promise { + return unsupported("Resource reload; use /new"); + } + override async setModel(): Promise { + return unsupported("Model switching"); } + override async cycleModel(): Promise { + return unsupported("Model switching"); + } + override setSessionName(): never { + return unsupported("Session renaming"); + } +} - private async compactSession(signal?: AbortSignal): Promise { - const entries = this.store.buildContextEntries(); - const keepIndex = entries.findLastIndex( - (entry) => entry.type === "message" && entry.message.role === "user", - ); - if (keepIndex <= 0) return false; - const previous = entries.slice(0, keepIndex); - const messages = previous.flatMap((entry) => - entry.type === "compaction" ? [] : sessionEntryToContextMessages(entry), - ); - if (!messages.length) return false; - const priorSummary = previous.find((entry) => entry.type === "compaction"); - const tokensBefore = this.contextTokens(); - const summary = await generateSummaryWithUsage( - messages, - this.options.model, - this.reserveTokens, - this.options.apiKey, - undefined, - signal, - undefined, - priorSummary?.summary, - "off", - this.stream, - undefined, - { enabled: false, maxRetries: 0, baseDelayMs: 0 }, +/** Use Pi's real TUI runtime; /new is safe, importing unchecked history is not. */ +export async function createAdmissionRuntime( + options: SessionOptions, +): Promise { + const factory = sessionFactory(options); + const result = await factory({ + cwd: resolve(options.cwd), + agentDir: resolve(options.agentDir), + sessionManager: SessionManager.create( + resolve(options.cwd), + resolve(options.sessionDir), + ), + }); + return new AdmissionRuntime( + result.session, + result.services, + factory, + result.diagnostics, + ); +} + +function sessionFactory(options: SessionOptions) { + if ( + options.model.api !== "openai-completions" || + options.model.reasoning || + options.model.input.some((type) => type !== "text") || + new URL(options.model.baseUrl).protocol !== "https:" + ) + throw new AdmissionError("unsupported"); + const stream: StreamFn = async (model, context, streamOptions) => { + const receipt = await options.admission.receipt( + context, + streamOptions?.signal, ); - const approved = await this.options.admission.text( - "compaction_summary", - summary.text, - signal, + return (options.stream ?? streamSimple)(model, context, { + ...streamOptions, + apiKey: options.apiKey, + maxRetries: 0, + headers: { ...streamOptions?.headers, [RECEIPT_HEADER]: receipt }, + }); + }; + return async ({ + cwd, + agentDir, + sessionManager, + sessionStartEvent, + }: Parameters[0]) => { + if (sessionManager.getEntries().length) + return unsupported("Restoring existing history"); + const reserveTokens = Math.min(4096, options.model.maxTokens); + const services = await createAgentSessionServices({ + cwd, + agentDir, + settingsManager: SettingsManager.inMemory({ + packages: [], + enableInstallTelemetry: false, + compaction: { + enabled: true, + keepRecentTokens: 0, + reserveTokens: + options.compactAtTokens === undefined + ? reserveTokens + : options.model.contextWindow - options.compactAtTokens, + }, + retry: { enabled: false }, + }), + resourceLoaderOptions: { + noExtensions: true, + noPromptTemplates: true, + noThemes: true, + extensionFactories: [ + { + name: "admission", + factory: (pi) => { + pi.on("session_before_compact", async (event) => { + // Supplying a summary or explicitly cancelling is mandatory: + // throwing from an extension handler could fall back to Pi's + // unchecked default summarizer. + try { + const entries = sessionManager.buildContextEntries(); + const keepIndex = entries.findLastIndex( + (entry) => + entry.type === "message" && entry.message.role === "user", + ); + if (keepIndex <= 0) return { cancel: true }; + const previous = entries.slice(0, keepIndex); + const messages = previous.flatMap((entry) => + entry.type === "compaction" + ? [] + : sessionEntryToContextMessages(entry), + ); + if (!messages.length) return { cancel: true }; + const summary = await generateSummaryWithUsage( + messages, + options.model, + reserveTokens, + options.apiKey, + undefined, + event.signal, + event.customInstructions, + previous.find((entry) => entry.type === "compaction") + ?.summary, + "off", + stream, + undefined, + { enabled: false, maxRetries: 0, baseDelayMs: 0 }, + ); + const approved = await options.admission.text( + "compaction_summary", + summary.text, + event.signal, + ); + return { + compaction: { + summary: approved, + firstKeptEntryId: entries[keepIndex].id, + tokensBefore: event.preparation.tokensBefore, + usage: retainedUsage(summary.usage), + }, + }; + } catch { + return { cancel: true }; + } + }); + }, + }, + ], + }, + }); + services.modelRuntime.registerProvider(options.model.provider, { + api: options.model.api, + baseUrl: options.model.baseUrl, + models: [options.model], + }); + await services.modelRuntime.setRuntimeApiKey( + options.model.provider, + options.apiKey, ); - this.store.appendCompaction( - approved, - entries[keepIndex].id, - tokensBefore, - undefined, - undefined, - retainedUsage(summary.usage), + const tools = options.tools ?? projectTools(cwd); + const session = new AdmissionSession({ + agent: new AdmissionAgent(options.model, stream, options.admission), + cwd, + sessionManager, + sessionStartEvent, + settingsManager: services.settingsManager, + resourceLoader: services.resourceLoader, + modelRuntime: services.modelRuntime, + baseToolsOverride: Object.fromEntries( + tools.map((tool) => [tool.name, tool]), + ), + initialActiveToolNames: tools.map((tool) => tool.name), + allowedToolNames: tools.map((tool) => tool.name), + }); + // Check project instructions and skill metadata before exposing the session. + session.agent.state.systemPrompt = await options.admission.text( + "system", + session.systemPrompt, ); - return true; - } + return { + session, + services, + diagnostics: services.diagnostics, + extensionsResult: services.resourceLoader.getExtensions(), + }; + }; } -/** Keep bash output below Pi's automatic spill-to-file threshold. */ -export function projectTools(cwd: string): AgentTool[] { - const local = createLocalBashOperations(); - const bash = createBashTool(cwd, { - exposeSessionEnvironment: false, - operations: { - async exec(command, directory, options) { - const limit = new AbortController(); - let bytes = 0; - let lines = 0; - const result = await local.exec(command, directory, { - ...options, - signal: AbortSignal.any([ - limit.signal, - ...(options.signal ? [options.signal] : []), - ]), - onData(data) { - bytes += data.length; - lines += data.toString("utf8").split("\n").length - 1; - if (bytes > 16_000 || lines > 1000) limit.abort(); - else if (!limit.signal.aborted) options.onData(data); - }, - }); - if (limit.signal.aborted) - throw new Error( - "Bash output exceeded the example's in-memory limit.", - ); - return result; - }, - }, - }); - return [ - createReadTool(cwd), - bash, - createEditTool(cwd), - createWriteTool(cwd), - createGrepTool(cwd), - createFindTool(cwd), - createLsTool(cwd), - ]; +class AdmissionRuntime extends AgentSessionRuntime { + override async switchSession(): Promise { + return unsupported("Resume"); + } + override async importFromJsonl(): Promise { + return unsupported("Import"); + } + override async fork(): Promise { + return unsupported("Fork"); + } } -function retainedUsage(usage: Usage): Usage { - return { - input: usage.input, - output: usage.output, - cacheRead: usage.cacheRead, - cacheWrite: usage.cacheWrite, - totalTokens: usage.totalTokens, - cost: { - input: usage.cost.input, - output: usage.cost.output, - cacheRead: usage.cost.cacheRead, - cacheWrite: usage.cost.cacheWrite, - total: usage.cost.total, - }, - }; +function unsupported(feature: string): never { + throw new Error(`${feature} is not supported by this admission example.`); } diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/tools.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/tools.ts new file mode 100644 index 00000000..86224b8d --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/tools.ts @@ -0,0 +1,56 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import type { AgentTool } from "@earendil-works/pi-agent-core"; +import { + createReadTool, + createBashTool, + createEditTool, + createWriteTool, + createGrepTool, + createFindTool, + createLsTool, + createLocalBashOperations, +} from "@earendil-works/pi-coding-agent"; + +/** Keep bash output below Pi's automatic spill-to-file threshold. */ +export function projectTools(cwd: string): AgentTool[] { + const local = createLocalBashOperations(); + const bash = createBashTool(cwd, { + exposeSessionEnvironment: false, + operations: { + async exec(command, directory, options) { + const limit = new AbortController(); + let bytes = 0; + let lines = 0; + const result = await local.exec(command, directory, { + ...options, + signal: AbortSignal.any([ + limit.signal, + ...(options.signal ? [options.signal] : []), + ]), + onData(data) { + bytes += data.length; + lines += data.toString("utf8").split("\n").length - 1; + if (bytes > 16_000 || lines > 1000) limit.abort(); + else if (!limit.signal.aborted) options.onData(data); + }, + }); + if (limit.signal.aborted) + throw new Error( + "Bash output exceeded the example's in-memory limit.", + ); + return result; + }, + }, + }); + return [ + createReadTool(cwd), + bash, + createEditTool(cwd), + createWriteTool(cwd), + createGrepTool(cwd), + createFindTool(cwd), + createLsTool(cwd), + ]; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts b/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts index 9e8600a7..12b2a90c 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts @@ -8,12 +8,13 @@ import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; import type { Model } from "@earendil-works/pi-ai"; +import { InteractiveMode } from "@earendil-works/pi-coding-agent"; import { Admission, AdmissionError, createHttpEvaluator, } from "../src/admission.js"; -import { AdmissionSession } from "../src/session.js"; +import { AdmissionSession, createAdmissionRuntime } from "../src/session.js"; const [endpoint, directory] = process.argv.slice(2); const model: Model<"openai-completions"> = { @@ -29,22 +30,33 @@ const model: Model<"openai-completions"> = { cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, compat: { maxTokensField: "max_tokens", supportsDeveloperRole: false }, }; -const create = (compactAtTokens?: number) => - AdmissionSession.create({ - cwd: join(directory, "image/project"), - sessionDir: join(directory, "sessions"), - agentDir: join(directory, "agent"), - model, - apiKey: "local-test-credential", - admission: new Admission( - createHttpEvaluator( - `${endpoint}/v1/admission`, - "test-admission-credential", - randomUUID(), - ), +const options = (compactAtTokens?: number) => ({ + cwd: join(directory, "image/project"), + sessionDir: join(directory, "sessions"), + agentDir: join(directory, "agent"), + model, + apiKey: "local-test-credential", + admission: new Admission( + createHttpEvaluator( + `${endpoint}/v1/admission`, + "test-admission-credential", + randomUUID(), ), - compactAtTokens, - }); + ), + compactAtTokens, +}); + +if (process.argv.includes("--tui")) { + const runtime = await createAdmissionRuntime(options()); + await new InteractiveMode(runtime, { + initialMessage: "Please repeat REDACT_THIS and café.", + initialMessages: ["/skill:review"], + }).run(); + process.exit(0); +} + +const create = (compactAtTokens?: number) => + AdmissionSession.create(options(compactAtTokens)); const session = await create(); await assert.rejects(session.prompt("DENY_THIS"), AdmissionError); @@ -67,7 +79,9 @@ for (const snapshot of [ await readFile(session.sessionFile, "utf8"), ]) { assert.ok(snapshot.includes("[REDACTED]")); - assert.ok(!snapshot.includes("REDACT_THIS") && !snapshot.includes("DENY_THIS")); + assert.ok( + !snapshot.includes("REDACT_THIS") && !snapshot.includes("DENY_THIS"), + ); } assert.ok(await session.compact()); assert.ok(session.entries.some((entry) => entry.type === "compaction")); @@ -83,6 +97,8 @@ for (const current of [session, automatic]) { JSON.stringify(current.entries), await readFile(current.sessionFile, "utf8"), ]) { - assert.ok(!snapshot.includes("REDACT_THIS") && !snapshot.includes("DENY_THIS")); + assert.ok( + !snapshot.includes("REDACT_THIS") && !snapshot.includes("DENY_THIS"), + ); } } diff --git a/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts b/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts index c4b83e51..b2814269 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts @@ -19,7 +19,11 @@ import { type AdmissionResponse, type Evaluate, } from "../src/admission.js"; -import { AdmissionSession, projectTools } from "../src/session.js"; +import { + AdmissionSession, + createAdmissionRuntime, + projectTools, +} from "../src/session.js"; const model: Model<"openai-completions"> = { id: "test", @@ -172,14 +176,21 @@ for (const [kind, marker, prompt, responses] of [ }, [...responses], ); + const events: unknown[] = []; + session.subscribe((event) => { + events.push(structuredClone(event)); + }); await writeFile(join(cwd, "candidate.txt"), "CANDIDATE"); const run = session.prompt(prompt); await seen; assert.ok(!JSON.stringify(session.entries).includes(marker)); assert.ok(!JSON.stringify(session.history).includes(marker)); + assert.ok(!JSON.stringify(session.messages).includes(marker)); + assert.ok(!JSON.stringify(events).includes(marker)); assert.ok(!(await disk(session)).includes(marker)); release(deny); await assert.rejects(run); + assert.ok(!JSON.stringify(events).includes(marker)); assert.ok(!JSON.stringify(session.history).includes(marker)); assert.ok(!(await disk(session)).includes(marker)); }); @@ -253,7 +264,7 @@ test("manual compaction waits for approval and preserves the latest whole turn", replacement: { ...summaryBody, text: "Approved summary" }, receipt: null, }); - assert.equal(await compact, true); + assert.equal((await compact).summary, "Approved summary"); assert.equal(requests.length, 3); assert.ok(!JSON.stringify(session.history).includes("SUMMARY_CANDIDATE")); assert.ok(JSON.stringify(session.history).includes("Approved summary")); @@ -398,12 +409,11 @@ test("project context is checked before any model request or message write", asy }); test("cancelled admission cannot append even when the service subsequently allows", async () => { - const controller = new AbortController(); const { session, requests } = await fixture(async (kind) => { - if (kind === "user_message") controller.abort(); + if (kind === "user_message") session.agent.abort(); return allow; }); - await assert.rejects(session.prompt("CANCELLED", controller.signal)); + await assert.rejects(session.prompt("CANCELLED")); assert.deepEqual(session.entries, []); assert.equal(requests.length, 0); }); @@ -427,3 +437,205 @@ test("context overflow makes one admitted summary and one retry", async () => { assert.ok(JSON.stringify(session.history).includes("retry result")); assert.ok(!(await disk(session)).includes("exceeds the context window")); }); + +test("native session persists each approved message once and never renders tool details", async () => { + const { session, cwd } = await fixture(undefined, [ + answer("", [ + { id: "read", name: "read", arguments: { path: "notes.txt" } }, + ]), + answer("Done"), + ]); + await writeFile(join(cwd, "notes.txt"), "Approved file"); + const tool = session.agent.state.tools.find((tool) => tool.name === "read")!; + const execute = tool.execute; + tool.execute = async (id, args, signal, onUpdate) => { + onUpdate?.({ + content: [{ type: "text", text: "UNCHECKED_PROGRESS" }], + details: undefined, + }); + const result = await execute(id, args, signal); + return { ...result, details: { diff: "UNCHECKED_DETAILS" } }; + }; + const events: unknown[] = []; + session.subscribe((event) => { + events.push(structuredClone(event)); + }); + await session.prompt("Read notes.txt"); + assert.equal(session.messages.length, 4); + assert.equal( + session.entries.filter((entry) => entry.type === "message").length, + 4, + ); + const saved = (await disk(session)) + .trim() + .split("\n") + .map((line) => JSON.parse(line)); + assert.equal(saved.filter((entry) => entry.type === "message").length, 4); + assert.ok(JSON.stringify(events).includes("Approved file")); + for (const snapshot of [ + JSON.stringify(events), + JSON.stringify(session.messages), + await disk(session), + ]) + assert.ok(!snapshot.includes("UNCHECKED")); +}); + +for (const mode of ["steer", "followUp"] as const) { + test(`native ${mode} queue admits expanded input before transcript insertion`, async () => { + let release!: () => void; + let reached!: () => void; + const seen = new Promise((resolve) => { + reached = resolve; + }); + const hold = new Promise((resolve) => { + release = resolve; + }); + const { session, requests } = await fixture( + async (kind, body) => { + if (kind === "assistant_message" && body.text === "First") { + reached(); + await hold; + } + if ( + kind === "user_message" && + String(body.text).includes("SKILL_CANDIDATE") + ) + return { + ...allow, + decision: "replace", + replacement: { + ...body, + text: String(body.text).replace( + "SKILL_CANDIDATE", + "APPROVED_SKILL", + ), + }, + }; + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [answer("First"), answer("Second")], + ); + const running = session.prompt("hello"); + await seen; + await session[mode]("/skill:example"); + assert.ok(!JSON.stringify(session.messages).includes("SKILL_CANDIDATE")); + assert.ok(!(await disk(session)).includes("SKILL_CANDIDATE")); + release(); + await running; + assert.equal(requests.length, 2); + assert.ok(JSON.stringify(requests[1]).includes("APPROVED_SKILL")); + assert.ok(!JSON.stringify(session.entries).includes("SKILL_CANDIDATE")); + assert.equal(session.agent.hasQueuedMessages(), false); + assert.equal( + session.getSteeringMessages().length + + session.getFollowUpMessages().length, + 0, + ); + }); +} + +test("abort settles the native lifecycle without saving late content and permits the next prompt", async () => { + let release!: () => void; + let reached!: () => void; + const seen = new Promise((resolve) => { + reached = resolve; + }); + const hold = new Promise((resolve) => { + release = resolve; + }); + const { session } = await fixture( + async (kind, body) => { + if (kind === "assistant_message" && body.text === "LATE_RESPONSE") { + reached(); + await hold; + } + return kind === "provider_context" + ? { ...allow, receipt: "receipt" } + : allow; + }, + [answer("LATE_RESPONSE"), answer("Next reply")], + ); + const running = session.prompt("first"); + await seen; + const rejected = assert.rejects(running); + const abort = session.abort(); + assert.equal(session.agent.state.isStreaming, true); + release(); + await Promise.all([rejected, abort]); + await session.agent.waitForIdle(); + assert.equal(session.isStreaming, false); + assert.ok(!JSON.stringify(session.messages).includes("LATE_RESPONSE")); + assert.ok(!(await disk(session)).includes("LATE_RESPONSE")); + await session.prompt("next"); + assert.ok(JSON.stringify(session.history).includes("Next reply")); +}); + +test("native alternate writes fail closed; /new reuses the admission factory", async () => { + const cwd = await mkdtemp(join(tmpdir(), "pi-runtime-test-")); + const runtime = await createAdmissionRuntime({ + cwd, + agentDir: join(cwd, "agent"), + sessionDir: join(cwd, "sessions"), + model, + apiKey: "placeholder", + admission: new Admission(async (kind) => + kind === "user_message" ? deny : allow, + ), + }); + await runtime.session.bindExtensions({}); + const before = JSON.stringify(runtime.session.messages); + const entries = runtime.session.sessionManager.getEntries(); + await assert.rejects( + runtime.session.executeBash("touch must-not-exist"), + /not supported/, + ); + assert.throws( + () => + runtime.session.recordBashResult("bad", { + output: "UNCHECKED", + exitCode: 0, + cancelled: false, + truncated: false, + }), + /not supported/, + ); + await assert.rejects( + runtime.session.sendCustomMessage({ + customType: "test", + content: "UNCHECKED", + display: true, + }), + /not supported/, + ); + await assert.rejects( + runtime.session.navigateTree("missing"), + /not supported/, + ); + await assert.rejects(runtime.session.setModel(model), /not supported/); + assert.throws( + () => runtime.session.setSessionName("UNCHECKED"), + /not supported/, + ); + await assert.rejects( + runtime.switchSession("/does/not/exist"), + /not supported/, + ); + await assert.rejects( + runtime.importFromJsonl("/does/not/exist"), + /not supported/, + ); + await assert.rejects(runtime.fork("missing"), /not supported/); + assert.equal(JSON.stringify(runtime.session.messages), before); + assert.deepEqual(runtime.session.sessionManager.getEntries(), entries); + await assert.rejects(readFile(join(cwd, "must-not-exist"))); + const previous = runtime.session; + await runtime.newSession(); + assert.notEqual(runtime.session, previous); + await runtime.session.bindExtensions({}); + await assert.rejects(runtime.session.prompt("DENIED")); + assert.equal(runtime.session.messages.length, 0); + assert.equal(runtime.session.sessionManager.getEntries().length, 0); + await runtime.dispose(); +}); diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py index 2bb1fc61..214a455e 100644 --- a/projects/egress-gate/tests/service/test_http_admission.py +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -8,8 +8,10 @@ import asyncio import json import os +import pty import runpy import shutil +import termios import time from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -151,9 +153,11 @@ def test_gateway_authentication_rejects_invalid_trust_claims(change: str) -> Non @pytest.mark.asyncio +@pytest.mark.parametrize("tui", [False, True], ids=["sdk", "native-tui"]) async def test_pi_session_through_admission_and_authenticated_egress( tmp_path: Path, unused_tcp_port: int, + tui: bool, ) -> None: source = PROJECT / "examples/pi-attested-admission" example = tmp_path / "example" @@ -248,29 +252,83 @@ async def provider(request: web.Request) -> web.Response: application.router.add_post("/v1/chat/completions", provider) server = TestServer(application, scheme="https", port=unused_tcp_port) await server.start_server(ssl=admission_tls_context(config)) + terminal: tuple[int, int] | None = None try: + if tui: + terminal = pty.openpty() + termios.tcsetwinsize(terminal[1], (35, 110)) + os.set_blocking(terminal[0], False) process = await asyncio.create_subprocess_exec( "node", str(source / "app/dist/test/service-integration.js"), str(server.make_url("/")).rstrip("/"), str(tmp_path), - env=os.environ | {"NODE_EXTRA_CA_CERTS": str(tmp_path / "tls/ca.crt")}, - stdout=asyncio.subprocess.PIPE, - stderr=asyncio.subprocess.PIPE, + *(["--tui"] if tui else []), + env=os.environ + | { + "NODE_EXTRA_CA_CERTS": str(tmp_path / "tls/ca.crt"), + "PI_OFFLINE": "1", + "PI_CODING_AGENT_DIR": str(tmp_path / "agent"), + "TERM": "xterm-256color", + }, + stdin=terminal[1] if terminal else None, + stdout=terminal[1] if terminal else asyncio.subprocess.PIPE, + stderr=terminal[1] if terminal else asyncio.subprocess.PIPE, ) try: - stdout, stderr = await asyncio.wait_for(process.communicate(), 30) + if terminal: + await _terminal_until(terminal[0], b"notes.txt") + os.write(terminal[0], b"\x0f") # Pi's expand-tool-output shortcut. + await _terminal_until(terminal[0], b"This is a real file") + os.write(terminal[0], b"/compact\r") + await _terminal_until(terminal[0], b"Approved summary") + os.write(terminal[0], b"DENY_THIS\r") + await _terminal_until(terminal[0], b"Admission denied") + os.write(terminal[0], b"/new\r") + await _terminal_until(terminal[0], b"New session started") + os.write(terminal[0], b"DENY_THIS\r") + await _terminal_until(terminal[0], b"Admission denied") + os.write(terminal[0], b"/quit\r") + await asyncio.wait_for(process.wait(), 10) + saved = "\n".join( + path.read_text() + for path in (tmp_path / "sessions").glob("*.jsonl") + ) + assert "[REDACTED]" in saved and "Approved summary" in saved + assert "DENY_THIS" not in saved and "REDACT_THIS" not in saved + stdout = stderr = b"" + else: + stdout, stderr = await asyncio.wait_for(process.communicate(), 30) finally: if process.returncode is None: process.kill() await process.wait() assert process.returncode == 0, (stdout + stderr).decode() - assert len(calls) == 7 + assert len(calls) == (4 if tui else 7) assert any(m["role"] == "tool" for m in json.loads(calls[2])["messages"]) finally: + if terminal: + os.close(terminal[0]) + os.close(terminal[1]) await server.close() +async def _terminal_until(fd: int, expected: bytes) -> None: + output = b"" + try: + async with asyncio.timeout(20): + while expected not in output: + try: + output += os.read(fd, 65536) + except BlockingIOError: + pass + await asyncio.sleep(0.02) + except TimeoutError: + pytest.fail( + f"Terminal did not show {expected!r}: {output.decode(errors='replace')}" + ) + + @asynccontextmanager async def _clients( directory: Path, From 3094f62c17fd9cd46d3106f365dd51e00b40ccaa Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 10 Sep 2026 02:09:55 +0000 Subject: [PATCH 63/70] Use Pi's native model catalog in the admission demo --- .../docs/architecture/admission.md | 7 ++ .../pi-attested-admission/.env.example | 4 +- .../examples/pi-attested-admission/.gitignore | 2 + .../examples/pi-attested-admission/README.md | 37 ++++++-- .../pi-attested-admission/app/src/cli.ts | 8 +- .../pi-attested-admission/app/src/model.ts | 31 +++++++ .../pi-attested-admission/app/src/session.ts | 8 +- .../pi-attested-admission/app/src/verify.ts | 6 +- .../app/test/service-integration.ts | 22 ++--- .../examples/pi-attested-admission/demo.sh | 8 +- .../pi-attested-admission/model.json.example | 17 ---- .../pi-attested-admission/models.json.example | 31 +++++++ .../pi-attested-admission/policy.yaml | 4 +- .../examples/pi-attested-admission/prepare.py | 80 ++++++++++++++-- .../pi-attested-admission/sandbox/Dockerfile | 5 +- .../tests/service/test_http_admission.py | 12 ++- .../tests/test_pi_example_commands.py | 92 ++++++++++++++++++- 17 files changed, 300 insertions(+), 74 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/app/src/model.ts delete mode 100644 projects/egress-gate/examples/pi-attested-admission/model.json.example create mode 100644 projects/egress-gate/examples/pi-attested-admission/models.json.example diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md index 7507743b..7c1d57d3 100644 --- a/projects/egress-gate/docs/architecture/admission.md +++ b/projects/egress-gate/docs/architecture/admission.md @@ -102,6 +102,13 @@ operator. Discovery requires one published signing key and refuses plaintext, cross-origin key URLs and untrusted TLS; browser/edge-login gateways are outside this POC helper's scope. Host setup generates service TLS, one admission bearer credential, provider destination and policy; setup reads the actual sandbox ID. +The operator supplies Pi's native `models.json` catalog. Preparation selects one +declared model (using `PI_MODEL=provider/model` when there are several), stages +only that model and its provider settings without provider API-key configuration, +and derives the endpoint policy from it. Pi's own parser resolves model defaults +and compatibility settings. Credential and model-cache stores are in memory; +the image does not need a writable `auth.json`. Changing the selection requires +repreparing and recreating the demo, not live switching. For local installer-managed gateways, the same `register` command selects the config and service manager (Homebrew or the DEB/RPM user service), adds the demo's middleware entry, and restarts the gateway; `cleanup` removes that entry and diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example index 9a587928..6f53970c 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.env.example +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -3,5 +3,7 @@ OPENSHELL_GATEWAY=your-gateway # Egress Gate hostname or IPv4 address reachable from gateway AND sandbox. # Use reachable DNS or a LAN IPv4 address; Docker-only names may fail on the host. EGRESS_GATE_HOST=your-service-host -# Real key for the HTTPS endpoint/model in model.json. Never copied into the image. +# Required only when models.json declares more than one model. +# PI_MODEL=example/YOUR_MODEL_ID +# Real key for the HTTPS endpoint/model in models.json. Never copied into the image. PI_MODEL_API_KEY=your-provider-key diff --git a/projects/egress-gate/examples/pi-attested-admission/.gitignore b/projects/egress-gate/examples/pi-attested-admission/.gitignore index 3d23b0c5..16fb84a6 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.gitignore +++ b/projects/egress-gate/examples/pi-attested-admission/.gitignore @@ -1,2 +1,4 @@ # Operator-owned model configuration, like the already ignored .env. +# Keep the previous operator filename ignored during migration. /model.json +/models.json diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 0126480a..dbf157ff 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -30,19 +30,23 @@ From this directory: ```sh cp .env.example .env -cp model.json.example model.json +cp models.json.example models.json # Fill in the gateway name, reachable Egress Gate host, and model API key. -# Edit model.json for your endpoint, model ID, and token limits (see below). +# Edit models.json for your endpoint, model ID, and token limits (see below). ./demo.sh prepare ``` -Create your own `model.json` from [model.json.example](model.json.example). +Create your own `models.json` from [models.json.example](models.json.example). +It uses **Pi's native `{"providers": {...}}` catalog format**. Add as many providers +and models as you like. One declared model is selected automatically; with more +than one, set `PI_MODEL=provider/model` in `.env` (for example, +`PI_MODEL=example/YOUR_MODEL_ID`). Model IDs can contain slashes. No working provider configuration is shipped. Use a text-only, tool-capable OpenAI-compatible Chat Completions endpoint; NVIDIA inference is one option if you have access, not a requirement. Set: -- `id` and `name`: your provider's model ID and a display name. -- `baseUrl`: the HTTPS API base, such as `https://your-provider.example/v1`; +- `providers..models`: your models, each with an `id` and optional `name`. +- `providers..baseUrl`: the HTTPS API base, such as `https://your-provider.example/v1`; the application appends `/chat/completions`. - `contextWindow` and `maxTokens`: the model's context limit and your desired response limit, in tokens. The POC caps each response at the smaller of @@ -51,11 +55,24 @@ you have access, not a requirement. Set: `max_completion_tokens`). The other compatibility settings are conservative defaults; adjust them if your endpoint requires it. -Keep `api`, `provider`, `reasoning`, and `input` as shown for this demo. +Keep `api`, `reasoning`, and `input` as shown for this demo. The zero `cost` values disable cost estimates; provider usage is not free. -Put the API key only in `.env`, never in `model.json`. -Both files are ignored by Git. Preparation copies your model configuration into -the local sandbox image, but not `.env` or the API key. +Put the API key only in `.env`, never in `models.json`. +Both files are ignored by Git. Preparation copies **only the selected model** and +its provider settings into the image, removing provider `apiKey` configuration. +Pi resolves its defaults and compatibility settings; a model-level `baseUrl` or +`api` takes precedence over the provider setting. Declare both values explicitly +at one of those levels. This POC does not support OAuth or custom headers. +Credentials and model caches stay in memory; no writable `/app/agent/auth.json` +is needed. + +If you used the earlier single-object `model.json`, start from the new template +and transfer your endpoint and model settings; renaming the file alone is not enough. +The catalog can contain many models, but each prepared demo uses **one**. To change +the selection, run `./demo.sh cleanup`, stop `serve`, update `PI_MODEL` and its key, +then repeat `prepare`, `serve`, `register`, and `setup`. Live model switching is +disabled because OpenShell's policy and admission receipts are bound to the +prepared endpoint. `prepare` reads the selected endpoint from `openshell gateway list --output json` and discovers its issuer and public signing key over verified HTTPS. It reuses @@ -168,6 +185,8 @@ boundary: OpenShell's filesystem policy supplies that boundary. Responses and tool output are buffered until approved, rather than streamed unchecked into the transcript. Pi still shows activity while waiting. +The image suppresses only Node warning `UNDICI-EHPA` (the experimental +`EnvHttpProxyAgent` notice); other warnings and errors remain visible. Use Ctrl+O to expand tool output and `/session` to inspect session information; Pi saves JSONL under `/sandbox/sessions`. The former custom `/history` and `/exit` commands are gone; use Pi's chat view and `/quit`. diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts index 5c81045f..006d53e7 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts @@ -2,13 +2,12 @@ // SPDX-License-Identifier: Apache-2.0 import { randomUUID } from "node:crypto"; -import { readFile } from "node:fs/promises"; import { parseArgs } from "node:util"; -import type { Model } from "@earendil-works/pi-ai"; import { InteractiveMode, convertToLlm } from "@earendil-works/pi-coding-agent"; import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; import { createAdmissionRuntime } from "./session.js"; import { configureProxy } from "./network.js"; +import { loadSelectedModel } from "./model.js"; async function main(): Promise { configureProxy(); @@ -16,7 +15,6 @@ async function main(): Promise { options: { cwd: { type: "string", default: "/sandbox/project" }, "session-dir": { type: "string", default: "/sandbox/sessions" }, - model: { type: "string", default: "/app/model.json" }, admission: { type: "string" }, prompt: { type: "string" }, }, @@ -29,9 +27,7 @@ async function main(): Promise { delete process.env.EGRESS_ADMISSION_TOKEN; if (!apiKey || !admissionKey || !values.admission) throw new Error("Missing provider or admission configuration."); - const model = JSON.parse( - await readFile(values.model, "utf8"), - ) as Model<"openai-completions">; + const model = await loadSelectedModel(); const runtime = await createAdmissionRuntime({ cwd: values.cwd, sessionDir: values["session-dir"], diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/model.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/model.ts new file mode 100644 index 00000000..4d942d19 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/model.ts @@ -0,0 +1,31 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFile } from "node:fs/promises"; +import { join } from "node:path"; +import { + InMemoryCredentialStore, + InMemoryModelsStore, + type Model, +} from "@earendil-works/pi-ai"; +import { ModelRuntime } from "@earendil-works/pi-coding-agent"; + +/** Let Pi resolve its native catalog, without writing into the read-only image. */ +export async function loadSelectedModel( + directory = "/app", +): Promise> { + const { provider, id } = JSON.parse( + await readFile(join(directory, "model-selection.json"), "utf8"), + ) as { provider: string; id: string }; + const runtime = await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsStore: new InMemoryModelsStore(), + modelsPath: join(directory, "models.json"), + }); + const error = runtime.getError(); + if (error) throw new Error(error); + const model = runtime.getModel(provider, id); + if (!model || model.api !== "openai-completions") + throw new Error("The prepared model must use openai-completions."); + return model as Model<"openai-completions">; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts index a83cc8eb..a3884aa3 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts @@ -3,13 +3,14 @@ import { resolve } from "node:path"; import type { AgentTool, StreamFn } from "@earendil-works/pi-agent-core"; -import type { Model } from "@earendil-works/pi-ai"; +import { InMemoryCredentialStore, type Model } from "@earendil-works/pi-ai"; import { streamSimple } from "@earendil-works/pi-ai/compat"; import { AgentSession, AgentSessionRuntime, SessionManager, SettingsManager, + ModelRuntime, createAgentSessionServices, convertToLlm, generateSummaryWithUsage, @@ -165,6 +166,11 @@ function sessionFactory(options: SessionOptions) { const services = await createAgentSessionServices({ cwd, agentDir, + // OpenShell supplies runtime credentials; /app remains read-only. + modelRuntime: await ModelRuntime.create({ + credentials: new InMemoryCredentialStore(), + modelsPath: null, + }), settingsManager: SettingsManager.inMemory({ packages: [], enableInstallTelemetry: false, diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts index dcd49c1a..e579dc45 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts @@ -5,10 +5,10 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { parseArgs } from "node:util"; -import type { Model } from "@earendil-works/pi-ai"; import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; import { AdmissionSession } from "./session.js"; import { configureProxy } from "./network.js"; +import { loadSelectedModel } from "./model.js"; /** Real service, upstream runtime, real project tools, and the configured model. */ async function verify(): Promise { @@ -22,9 +22,7 @@ async function verify(): Promise { apiKey && admissionKey && values.admission, "Missing example configuration", ); - const model = JSON.parse( - await readFile("/app/model.json", "utf8"), - ) as Model<"openai-completions">; + const model = await loadSelectedModel(); const makeSession = (compactAtTokens?: number) => AdmissionSession.create({ cwd: "/sandbox/project", diff --git a/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts b/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts index 12b2a90c..32a0d9b5 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts @@ -7,7 +7,6 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { join } from "node:path"; -import type { Model } from "@earendil-works/pi-ai"; import { InteractiveMode } from "@earendil-works/pi-coding-agent"; import { Admission, @@ -15,21 +14,14 @@ import { createHttpEvaluator, } from "../src/admission.js"; import { AdmissionSession, createAdmissionRuntime } from "../src/session.js"; +import { loadSelectedModel } from "../src/model.js"; const [endpoint, directory] = process.argv.slice(2); -const model: Model<"openai-completions"> = { - id: "test", - name: "Local integration provider", - provider: "test", - api: "openai-completions", - baseUrl: `${endpoint}/v1`, - reasoning: false, - input: ["text"], - contextWindow: 100000, - maxTokens: 4096, - cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, - compat: { maxTokensField: "max_tokens", supportsDeveloperRole: false }, -}; +const model = await loadSelectedModel(join(directory, "image")); +assert.equal(model.id, "YOUR_MODEL_ID"); +assert.equal(model.compat?.supportsDeveloperRole, false); +// Only the endpoint changes: exercise the prepared catalog through Pi's parser. +model.baseUrl = `${endpoint}/v1`; const options = (compactAtTokens?: number) => ({ cwd: join(directory, "image/project"), sessionDir: join(directory, "sessions"), @@ -48,6 +40,7 @@ const options = (compactAtTokens?: number) => ({ if (process.argv.includes("--tui")) { const runtime = await createAdmissionRuntime(options()); + assert.equal(runtime.services.modelRuntime.getError(), undefined); await new InteractiveMode(runtime, { initialMessage: "Please repeat REDACT_THIS and café.", initialMessages: ["/skill:review"], @@ -59,6 +52,7 @@ const create = (compactAtTokens?: number) => AdmissionSession.create(options(compactAtTokens)); const session = await create(); +assert.equal(session.modelRuntime.getError(), undefined); await assert.rejects(session.prompt("DENY_THIS"), AdmissionError); assert.equal(session.history.length, 0); assert.equal(session.entries.length, 0); diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index d3a55e02..e6020952 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -58,8 +58,8 @@ case "$action" in if ! $print_only; then : "${EGRESS_GATE_HOST:?Set the service hostname or IPv4 address in .env}" : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" - if [[ ! -f $example/model.json ]]; then - echo 'Create model.json from model.json.example and configure your model first.' >&2 + if [[ ! -f $example/models.json ]]; then + echo 'Create models.json from models.json.example and configure your model first.' >&2 exit 1 fi fi @@ -67,9 +67,9 @@ case "$action" in if $print_only; then printf '%q ' "${openshell[@]}" gateway list --output json printf '| ' - run uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" + run uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" --model "${PI_MODEL:-}" else - "${openshell[@]}" gateway list --output json | uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" + "${openshell[@]}" gateway list --output json | uv run --frozen python "$example/prepare.py" --state "$state" --host "$service_host" --gateway "$gateway" --model "${PI_MODEL:-}" fi run docker build --tag pi-admission:local "$state/image" ;; diff --git a/projects/egress-gate/examples/pi-attested-admission/model.json.example b/projects/egress-gate/examples/pi-attested-admission/model.json.example deleted file mode 100644 index 61be28b7..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/model.json.example +++ /dev/null @@ -1,17 +0,0 @@ -{ - "id": "YOUR_MODEL_ID", - "name": "Your model", - "provider": "pi-egress", - "api": "openai-completions", - "baseUrl": "https://api.example.com/v1", - "reasoning": false, - "input": ["text"], - "contextWindow": 128000, - "maxTokens": 4096, - "cost": { "input": 0, "output": 0, "cacheRead": 0, "cacheWrite": 0 }, - "compat": { - "maxTokensField": "max_tokens", - "supportsDeveloperRole": false, - "supportsReasoningEffort": false - } -} diff --git a/projects/egress-gate/examples/pi-attested-admission/models.json.example b/projects/egress-gate/examples/pi-attested-admission/models.json.example new file mode 100644 index 00000000..ee75d4db --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/models.json.example @@ -0,0 +1,31 @@ +{ + "providers": { + "example": { + "baseUrl": "https://api.example.com/v1", + "api": "openai-completions", + "compat": { + "maxTokensField": "max_tokens", + "supportsDeveloperRole": false, + "supportsReasoningEffort": false + }, + "models": [ + { + "id": "YOUR_MODEL_ID", + "name": "Your model", + "reasoning": false, + "input": [ + "text" + ], + "contextWindow": 128000, + "maxTokens": 4096, + "cost": { + "input": 0, + "output": 0, + "cacheRead": 0, + "cacheWrite": 0 + } + } + ] + } + } +} diff --git a/projects/egress-gate/examples/pi-attested-admission/policy.yaml b/projects/egress-gate/examples/pi-attested-admission/policy.yaml index 7530e679..041bf923 100644 --- a/projects/egress-gate/examples/pi-attested-admission/policy.yaml +++ b/projects/egress-gate/examples/pi-attested-admission/policy.yaml @@ -14,7 +14,7 @@ network_policies: model_provider: name: Configured model endpoint endpoints: - - host: api.example.com # prepare replaces this with the model.json endpoint. + - host: api.example.com # prepare replaces this with the models.json endpoint. port: 443 protocol: rest enforcement: enforce @@ -80,4 +80,4 @@ network_middlewares: on_error: fail_closed endpoints: include: - - api.example.com # prepare replaces this with the model.json endpoint. + - api.example.com # prepare replaces this with the models.json endpoint. diff --git a/projects/egress-gate/examples/pi-attested-admission/prepare.py b/projects/egress-gate/examples/pi-attested-admission/prepare.py index c08ae322..b6fbfb64 100644 --- a/projects/egress-gate/examples/pi-attested-admission/prepare.py +++ b/projects/egress-gate/examples/pi-attested-admission/prepare.py @@ -27,12 +27,33 @@ def prepare( - example: Path, state: Path, host: str, gateway_public_key: Path, gateway_issuer: str + example: Path, + state: Path, + host: str, + gateway_public_key: Path, + gateway_issuer: str, + model_selection: str = "", ) -> None: """Keep keys outside the image; copy only the public CA and explicit demo files.""" endpoint = urlparse(f"https://{host}:5443") if endpoint.hostname != host or endpoint.port != 5443 or endpoint.path: raise ValueError("Use a DNS hostname or IPv4 address, without a URL or port") + catalog, selection, base_url = select_model( + example / "models.json", model_selection + ) + target = urlparse(base_url) + if ( + target.scheme != "https" + or not target.hostname + or target.username + or target.query + or target.fragment + ): + raise ValueError( + "The model must use an HTTPS endpoint without credentials or query" + ) + if target.hostname == host: + raise ValueError("Model and admission endpoints must be separate") public_key = serialization.load_pem_public_key(gateway_public_key.read_bytes()) if not isinstance(public_key, ed25519.Ed25519PublicKey): raise ValueError("Provide the gateway's Ed25519 public signing key") @@ -49,12 +70,6 @@ def prepare( _create_certificates(tls, host) print("Service TLS created: install tls/ca.crt in the gateway's trust config.") (state / "service-host").write_text(host) - model = json.loads((example / "model.json").read_text()) - target = urlparse(model["baseUrl"]) - if target.scheme != "https" or not target.hostname or target.username: - raise ValueError("The model must use an HTTPS endpoint without credentials") - if target.hostname == host: - raise ValueError("Model and admission endpoints must be separate") model_path = target.path.rstrip("/") + "/chat/completions" policy = yaml.safe_load((example / "policy.yaml").read_text()) model_endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] @@ -142,9 +157,56 @@ def prepare( destination = image / "project" / name destination.parent.mkdir(parents=True, exist_ok=True) shutil.copyfile(example / "project" / name, destination) - shutil.copyfile(example / "model.json", image / "model.json") + (image / "models.json").write_text(json.dumps(catalog, indent=2) + "\n") + (image / "model-selection.json").write_text(json.dumps(selection) + "\n") shutil.copyfile(example / "sandbox/Dockerfile", image / "Dockerfile") shutil.copyfile(tls / "ca.crt", image / "admission-ca.crt") + print(f"Selected model: {selection['provider']}/{selection['id']}") + + +def select_model( + path: Path, requested: str +) -> tuple[dict[str, object], dict[str, str], str]: + """Stage one native Pi model; provider credentials remain owned by OpenShell.""" + providers = json.loads(path.read_text()).get("providers") + if not isinstance(providers, dict): + raise ValueError( + "Use Pi's native models.json providers catalog; see models.json.example" + ) + choices = [ + (provider_id, provider, model) + for provider_id, provider in providers.items() + for model in provider.get("models", []) + if not requested or f"{provider_id}/{model['id']}" == requested + ] + if len(choices) != 1: + raise ValueError( + "Set PI_MODEL=provider/model to select exactly one declared model" + ) + provider_id, provider, model = choices[0] + overrides = provider.get("modelOverrides", {}).get(model["id"], {}) + if any(config.get("headers") for config in (provider, model, overrides)): + raise ValueError("Custom model headers are unsupported; use PI_MODEL_API_KEY") + if provider.get("oauth") or provider.get("authHeader"): + raise ValueError( + "Custom provider authentication is unsupported; use PI_MODEL_API_KEY" + ) + if model.get("api", provider.get("api")) != "openai-completions": + raise ValueError("Select an openai-completions model for this example") + base_url = model.get("baseUrl", provider.get("baseUrl")) + if not isinstance(base_url, str): + raise ValueError("Declare the selected model's baseUrl in models.json") + selected_provider = { + key: provider[key] for key in ("api", "baseUrl", "compat") if key in provider + } + selected_provider["models"] = [model] + if overrides: + selected_provider["modelOverrides"] = {model["id"]: overrides} + return ( + {"providers": {provider_id: selected_provider}}, + {"provider": provider_id, "id": model["id"]}, + base_url, + ) def _discover_gateway(gateway: dict[str, str]) -> tuple[bytes, str]: @@ -269,6 +331,7 @@ def _service_name(host: str) -> x509.GeneralName: parser.add_argument("--state", type=Path, required=True) parser.add_argument("--host", required=True) parser.add_argument("--gateway", required=True) + parser.add_argument("--model", default="") args = parser.parse_args() gateways = json.load(sys.stdin) gateway = next((item for item in gateways if item["name"] == args.gateway), None) @@ -285,4 +348,5 @@ def _service_name(host: str) -> x509.GeneralName: args.host, public_path, issuer, + args.model, ) diff --git a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile index ba1f0a6c..688c3c6e 100644 --- a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile +++ b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile @@ -15,10 +15,13 @@ COPY app/package.json app/package-lock.json ./ RUN npm ci --ignore-scripts --no-audit --no-fund COPY app/ ./ RUN npm run build && mkdir /app/agent -COPY model.json /app/model.json +COPY models.json /app/models.json +COPY model-selection.json /app/model-selection.json COPY --chown=sandbox:sandbox project/ /sandbox/project/ RUN mkdir /sandbox/sessions && chown sandbox:sandbox /sandbox/sessions \ && chmod -R a+rX /app /sandbox/project ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt +# Hide only EnvHttpProxyAgent's experimental warning, not other Node warnings. +ENV NODE_OPTIONS="--disable-warning=UNDICI-EHPA" WORKDIR /sandbox/project USER sandbox diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py index 214a455e..18faf7ea 100644 --- a/projects/egress-gate/tests/service/test_http_admission.py +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -164,9 +164,14 @@ async def test_pi_session_through_admission_and_authenticated_egress( shutil.copytree( source, example, - ignore=shutil.ignore_patterns(".env", "model.json", "node_modules", "dist"), + ignore=shutil.ignore_patterns( + ".env", "model.json", "models.json", "node_modules", "dist" + ), ) - shutil.copyfile(example / "model.json.example", example / "model.json") + shutil.copyfile(example / "models.json.example", example / "models.json") + # Match the image: the sandbox user cannot create Pi auth/cache files in /app. + agent_dir = tmp_path / "agent" + agent_dir.mkdir(mode=0o555) async with _clients(tmp_path) as (_, stub, config, token, middleware): runpy.run_path(str(example / "prepare.py"))["prepare"]( example, @@ -304,6 +309,9 @@ async def provider(request: web.Request) -> web.Response: process.kill() await process.wait() assert process.returncode == 0, (stdout + stderr).decode() + assert not list(agent_dir.iterdir()), ( + "Pi must not write auth or model caches" + ) assert len(calls) == (4 if tui else 7) assert any(m["role"] == "tool" for m in json.loads(calls[2])["messages"]) finally: diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index f50c8120..6dfd0a16 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -32,6 +32,46 @@ EXAMPLE = PROJECT / "examples/pi-attested-admission" +def test_native_model_selection_keeps_only_selected_configuration( + tmp_path: Path, +) -> None: + catalog = json.loads((EXAMPLE / "models.json.example").read_text()) + provider = catalog["providers"]["example"] + provider["apiKey"] = "!do-not-execute-or-copy" + provider["models"].append( + { + "id": "vendor/second", + "baseUrl": "https://selected.example/custom/v1", + "api": "openai-completions", + } + ) + provider["modelOverrides"] = {"vendor/second": {"maxTokens": 2048}} + catalog["providers"]["unselected"] = { + "apiKey": "private", + "models": [{"id": "third"}], + } + path = tmp_path / "models.json" + path.write_text(json.dumps(catalog)) + select = runpy.run_path(str(EXAMPLE / "prepare.py"))["select_model"] + for selection in ("", "example/missing"): + with pytest.raises(ValueError, match="PI_MODEL"): + select(path, selection) + staged, selection, endpoint = select(path, "example/vendor/second") + assert selection == {"provider": "example", "id": "vendor/second"} + assert endpoint == "https://selected.example/custom/v1" + assert staged == { + "providers": { + "example": { + "api": provider["api"], + "baseUrl": provider["baseUrl"], + "compat": provider["compat"], + "models": [provider["models"][1]], + "modelOverrides": provider["modelOverrides"], + } + } + } + + @pytest.fixture def gateway_discovery( tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -401,8 +441,33 @@ def test_prepare_requires_operator_model_configuration(tmp_path: Path) -> None: text=True, ) assert result.returncode == 1 - assert "Create model.json from model.json.example" in result.stderr - assert not (tmp_path / "model.json").exists() + assert "Create models.json from models.json.example" in result.stderr + assert not (tmp_path / "models.json").exists() + + +def test_image_suppresses_only_the_proxy_agent_warning() -> None: + dockerfile = (EXAMPLE / "sandbox/Dockerfile").read_text() + options = next( + line.removeprefix("ENV NODE_OPTIONS=").strip('"') + for line in dockerfile.splitlines() + if line.startswith("ENV NODE_OPTIONS=") + ) + result = subprocess.run( + [ + "node", + "-e", + "process.emitWarning('proxy notice', {code: 'UNDICI-EHPA'});" + "process.emitWarning('unrelated notice', {code: 'OTHER_WARNING'});", + ], + env=os.environ | {"NODE_OPTIONS": options}, + capture_output=True, + text=True, + check=True, + ) + assert "UNDICI-EHPA" not in result.stderr + assert "proxy notice" not in result.stderr + assert "OTHER_WARNING" in result.stderr + assert "unrelated notice" in result.stderr @pytest.mark.parametrize("host", ["192.0.2.10", "host.docker.internal"]) @@ -416,9 +481,17 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( shutil.copytree( EXAMPLE, example, - ignore=shutil.ignore_patterns(".env", "model.json", "node_modules", "dist"), + ignore=shutil.ignore_patterns( + ".env", "model.json", "models.json", "node_modules", "dist" + ), ) - shutil.copyfile(example / "model.json.example", example / "model.json") + catalog = json.loads((example / "models.json.example").read_text()) + provider = catalog["providers"]["example"] + provider["apiKey"] = "must-not-enter-image" + provider["models"][0]["baseUrl"] = provider["baseUrl"] + provider["baseUrl"] = "https://unselected.example/v1" + provider["models"].append({"id": "unselected"}) + (example / "models.json").write_text(json.dumps(catalog)) gateway, public = gateway_discovery public_path = state / "gateway-public.pem" command = [ @@ -430,6 +503,8 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( host, "--gateway", gateway["name"], + "--model", + "example/YOUR_MODEL_ID", ] subprocess.run(command, input=json.dumps([gateway]), text=True, check=True) config = AdmissionServerConfig.model_validate_json( @@ -473,7 +548,14 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( assert not (image / "admission.json").exists() assert not (image / "app/node_modules").exists() assert (image / "project/.pi/skills/review/SKILL.md").is_file() - assert json.loads((image / "model.json").read_text())["id"] == "YOUR_MODEL_ID" + catalog = json.loads((image / "models.json").read_text()) + assert catalog["providers"]["example"]["models"][0]["id"] == "YOUR_MODEL_ID" + assert len(catalog["providers"]["example"]["models"]) == 1 + assert "must-not-enter-image" not in (image / "models.json").read_text() + assert json.loads((image / "model-selection.json").read_text()) == { + "provider": "example", + "id": "YOUR_MODEL_ID", + } middleware = tomllib.loads((state / "middleware.toml").read_text()) registration = middleware["openshell"]["supervisor"]["middleware"][0] assert registration["grpc_endpoint"] == f"https://{host}:50051" From a5e7604d3fc5057116eb546a29a5c2eb4a14033b Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 10 Sep 2026 02:17:51 +0000 Subject: [PATCH 64/70] Pass Pi warning suppression directly through sandbox exec --- .../examples/pi-attested-admission/README.md | 6 +++-- .../examples/pi-attested-admission/demo.sh | 4 ++-- .../pi-attested-admission/sandbox/Dockerfile | 2 -- .../tests/test_pi_example_commands.py | 22 +++++++++++++------ 4 files changed, 21 insertions(+), 13 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index dbf157ff..67e6e098 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -185,8 +185,10 @@ boundary: OpenShell's filesystem policy supplies that boundary. Responses and tool output are buffered until approved, rather than streamed unchecked into the transcript. Pi still shows activity while waiting. -The image suppresses only Node warning `UNDICI-EHPA` (the experimental -`EnvHttpProxyAgent` notice); other warnings and errors remain visible. +The launch and verification commands pass `--disable-warning=UNDICI-EHPA` +directly to Node to hide only the experimental `EnvHttpProxyAgent` notice; +other warnings and errors remain visible. This does not depend on Docker image +environment variables being inherited by `sandbox exec`. Use Ctrl+O to expand tool output and `/session` to inspect session information; Pi saves JSONL under `/sandbox/sessions`. The former custom `/history` and `/exit` commands are gone; use Pi's chat view and `/quit`. diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index e6020952..8bb533cc 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -108,11 +108,11 @@ case "$action" in ;; launch) if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${EGRESS_GATE_HOST:?Set the service host in .env}"; fi - run "${openshell[@]}" sandbox exec --tty --name pi-admission -- /usr/local/bin/node /app/dist/src/cli.js --admission "https://$service_host:5443/v1/admission" + run "${openshell[@]}" sandbox exec --tty --name pi-admission -- /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/cli.js --admission "https://$service_host:5443/v1/admission" ;; verify) if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${EGRESS_GATE_HOST:?Set the service host in .env}"; fi - run "${openshell[@]}" sandbox exec --no-tty --name pi-admission -- /usr/local/bin/node /app/dist/src/verify.js --admission "https://$service_host:5443/v1/admission" + run "${openshell[@]}" sandbox exec --no-tty --name pi-admission -- /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/verify.js --admission "https://$service_host:5443/v1/admission" ;; cleanup) if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}"; fi diff --git a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile index 688c3c6e..781cbfcf 100644 --- a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile +++ b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile @@ -21,7 +21,5 @@ COPY --chown=sandbox:sandbox project/ /sandbox/project/ RUN mkdir /sandbox/sessions && chown sandbox:sandbox /sandbox/sessions \ && chmod -R a+rX /app /sandbox/project ENV NODE_EXTRA_CA_CERTS=/etc/ssl/certs/ca-certificates.crt -# Hide only EnvHttpProxyAgent's experimental warning, not other Node warnings. -ENV NODE_OPTIONS="--disable-warning=UNDICI-EHPA" WORKDIR /sandbox/project USER sandbox diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 6dfd0a16..96400034 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -7,6 +7,7 @@ import json import os import runpy +import shlex import shutil import ssl import subprocess @@ -445,21 +446,28 @@ def test_prepare_requires_operator_model_configuration(tmp_path: Path) -> None: assert not (tmp_path / "models.json").exists() -def test_image_suppresses_only_the_proxy_agent_warning() -> None: - dockerfile = (EXAMPLE / "sandbox/Dockerfile").read_text() - options = next( - line.removeprefix("ENV NODE_OPTIONS=").strip('"') - for line in dockerfile.splitlines() - if line.startswith("ENV NODE_OPTIONS=") +@pytest.mark.parametrize("action", ["launch", "verify"]) +def test_commands_suppress_only_the_proxy_agent_warning(action: str) -> None: + printed = subprocess.run( + ["bash", str(EXAMPLE / "demo.sh"), "--print", action], + capture_output=True, + text=True, + check=True, ) + command = shlex.split(printed.stdout) + node_index = command.index("/usr/local/bin/node") + assert command[node_index + 1] == "--disable-warning=UNDICI-EHPA" + environment = os.environ.copy() + environment.pop("NODE_OPTIONS", None) result = subprocess.run( [ "node", + command[node_index + 1], "-e", "process.emitWarning('proxy notice', {code: 'UNDICI-EHPA'});" "process.emitWarning('unrelated notice', {code: 'OTHER_WARNING'});", ], - env=os.environ | {"NODE_OPTIONS": options}, + env=environment, capture_output=True, text=True, check=True, From 572182bb5c9849c7b3b050f5e5dca43858bb8d67 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 10 Sep 2026 13:57:31 +0000 Subject: [PATCH 65/70] Honor Pi model settings without application token caps --- .../examples/pi-attested-admission/README.md | 8 ++++++-- .../pi-attested-admission/app/src/agent.ts | 5 +---- .../pi-attested-admission/app/src/session.ts | 5 ++--- .../src/egress_gate/admission/adapters.py | 1 + .../tests/service/test_http_admission.py | 17 ++++++++++++++++- 5 files changed, 26 insertions(+), 10 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 67e6e098..d9ea5759 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -49,8 +49,11 @@ you have access, not a requirement. Set: - `providers..baseUrl`: the HTTPS API base, such as `https://your-provider.example/v1`; the application appends `/chat/completions`. - `contextWindow` and `maxTokens`: the model's context limit and your desired - response limit, in tokens. The POC caps each response at the smaller of - `maxTokens` and 4,096 tokens. The template's numbers are examples. + response limit, in tokens. Pi applies its normal context-fit adjustment; the + application adds no response-token cap. The template's numbers are examples. +- `samplingParams`: Pi forwards model sampling settings such as `temperature` + and `top_p` without application overrides. The gate's supported request shape + still applies; unknown provider-specific fields are rejected, not dropped. - `compat.maxTokensField`: the field your provider accepts (`max_tokens` or `max_completion_tokens`). The other compatibility settings are conservative defaults; adjust them if your endpoint requires it. @@ -65,6 +68,7 @@ Pi resolves its defaults and compatibility settings; a model-level `baseUrl` or at one of those levels. This POC does not support OAuth or custom headers. Credentials and model caches stay in memory; no writable `/app/agent/auth.json` is needed. +Compaction uses Pi's normal summary budget, bounded by the model's `maxTokens`. If you used the earlier single-object `model.json`, start from the new template and transfer your endpoint and model settings; renaming the file alone is not enough. diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts index be1be8ec..d6bf5a0e 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts @@ -161,10 +161,7 @@ export class AdmissionAgent extends Agent { messages: convertToLlm(this.live.messages), tools: this.live.tools, }, - { - signal: this.signal, - maxTokens: Math.min(4096, this.live.model.maxTokens), - }, + { signal: this.signal }, ) ).result(); this.signal!.throwIfAborted(); diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts index a3884aa3..ca5e0de6 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts @@ -162,7 +162,6 @@ function sessionFactory(options: SessionOptions) { }: Parameters[0]) => { if (sessionManager.getEntries().length) return unsupported("Restoring existing history"); - const reserveTokens = Math.min(4096, options.model.maxTokens); const services = await createAgentSessionServices({ cwd, agentDir, @@ -179,7 +178,7 @@ function sessionFactory(options: SessionOptions) { keepRecentTokens: 0, reserveTokens: options.compactAtTokens === undefined - ? reserveTokens + ? undefined : options.model.contextWindow - options.compactAtTokens, }, retry: { enabled: false }, @@ -213,7 +212,7 @@ function sessionFactory(options: SessionOptions) { const summary = await generateSummaryWithUsage( messages, options.model, - reserveTokens, + event.preparation.settings.reserveTokens, options.apiKey, undefined, event.signal, diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index 1311d8f1..488ea117 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -485,6 +485,7 @@ class _ProviderRequest(StrictDomainModel): tools: tuple[_ProviderTool, ...] = () tool_choice: Literal["auto", "none", "required"] | _ProviderNamedToolChoice = "auto" temperature: int | float | None = Field(default=None, allow_inf_nan=False) + top_p: int | float | None = Field(default=None, allow_inf_nan=False) max_completion_tokens: int | None = Field(default=None, ge=1) max_tokens: int | None = Field(default=None, ge=1) stream: Literal[True] diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py index 18faf7ea..777146a7 100644 --- a/projects/egress-gate/tests/service/test_http_admission.py +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -154,10 +154,12 @@ def test_gateway_authentication_rejects_invalid_trust_claims(change: str) -> Non @pytest.mark.asyncio @pytest.mark.parametrize("tui", [False, True], ids=["sdk", "native-tui"]) +@pytest.mark.parametrize("max_tokens", [2048, 16384]) async def test_pi_session_through_admission_and_authenticated_egress( tmp_path: Path, unused_tcp_port: int, tui: bool, + max_tokens: int, ) -> None: source = PROJECT / "examples/pi-attested-admission" example = tmp_path / "example" @@ -168,7 +170,11 @@ async def test_pi_session_through_admission_and_authenticated_egress( ".env", "model.json", "models.json", "node_modules", "dist" ), ) - shutil.copyfile(example / "models.json.example", example / "models.json") + catalog = json.loads((example / "models.json.example").read_text()) + model = catalog["providers"]["example"]["models"][0] + model["maxTokens"] = max_tokens + model["samplingParams"] = {"temperature": 0.25, "top_p": 0.9} + (example / "models.json").write_text(json.dumps(catalog)) # Match the image: the sandbox user cannot create Pi auth/cache files in /app. agent_dir = tmp_path / "agent" agent_dir.mkdir(mode=0o555) @@ -212,6 +218,15 @@ async def provider(request: web.Request) -> web.Response: "REDACT_THIS" not in body.decode() and "DENY_THIS" not in body.decode() ) calls.append(body) + payload = json.loads(body) + assert payload["temperature"] == 0.25 + assert payload["top_p"] == 0.9 + if len(calls) not in (4, 7): + # Real Pi serialization must honor limits below and above 4096. + assert payload["max_tokens"] == max_tokens + elif len(calls) == 4: + # Pi's default compaction reserve is 16384; its summary uses 80%. + assert payload["max_tokens"] == min(int(0.8 * 16384), max_tokens) if len(calls) == 1: changed = json.loads(body) next(m for m in changed["messages"] if m["role"] == "user")[ From aee97dee821215076689f02a34fbccd5bf8e6754 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 10 Sep 2026 16:03:44 +0000 Subject: [PATCH 66/70] Preserve Pi behavior and prompt caching in admission demo --- .../.openshell-middleware-manifest.json | 7 +- .../docs/architecture/admission.md | 14 ++- .../pi-attested-admission/.env.example | 2 + .../examples/pi-attested-admission/README.md | 16 ++++ .../pi-attested-admission/app/src/agent.ts | 35 ++++++-- .../pi-attested-admission/app/src/session.ts | 45 +++++----- .../pi-attested-admission/app/src/verify.ts | 36 +++++--- .../app/test/service-integration.ts | 16 ++-- .../app/test/session.test.ts | 90 +++++++++++++++---- .../examples/pi-attested-admission/demo.sh | 8 +- .../examples/pi-attested-admission/prepare.py | 2 +- .../src/egress_gate/admission/adapters.py | 27 +++--- .../tests/admission/test_admission.py | 6 ++ .../tests/service/test_http_admission.py | 47 +++++++++- .../tests/test_pi_example_commands.py | 33 ++++++- 15 files changed, 292 insertions(+), 92 deletions(-) diff --git a/projects/egress-gate/.openshell-middleware-manifest.json b/projects/egress-gate/.openshell-middleware-manifest.json index fa26f4f4..e5f95bc9 100644 --- a/projects/egress-gate/.openshell-middleware-manifest.json +++ b/projects/egress-gate/.openshell-middleware-manifest.json @@ -1,8 +1,7 @@ { - "openshell_version": "johnnygreco/OpenShell@08aa6a26381ef8ad10b67394201394a33db54835", - "proto_source": "https://raw.githubusercontent.com/johnnygreco/OpenShell/08aa6a26381ef8ad10b67394201394a33db54835/proto/supervisor_middleware.proto", - "proto_sha256": "eb73e8fa9c9a2bb7a733da5110944712010153543c50a41fe18a7ddfeba5ccdd", - "contract_note": "EvaluateAgentConversation is fork-only until the agent-conversation contract is upstreamed.", + "openshell_version": "0.0.116", + "proto_source": "https://raw.githubusercontent.com/NVIDIA/OpenShell/d1155aa70042d3e2ee49dbfa15346b108b7c1d92/proto/supervisor_middleware.proto", + "proto_sha256": "d96a963321c74c261a912dcd0b8cda690741b32b8c3d90ff3ef38dafe6681bad", "languages": [ "python" ], diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md index 7c1d57d3..972aa203 100644 --- a/projects/egress-gate/docs/architecture/admission.md +++ b/projects/egress-gate/docs/architecture/admission.md @@ -78,8 +78,18 @@ needs a fresh receipt; its finished summary needs fresh insertion approval. The trusted `session_before_compact` extension supplies an admitted summary or explicitly cancels, including on failure; it never falls through to an unchecked default summary. Denial leaves the preceding context and file unchanged. -Native automatic compaction also runs between tool turns; overflow gets at most -one compact/retry. Old approved entries remain in the append-only JSONL file. +Native automatic compaction also runs between tool turns; when enabled, overflow +gets at most one compact/retry. Disabling it also disables automatic overflow +recovery, not manual compaction. This whole-turn POC cannot compact a long first +tool turn because no older user turn exists. Transient chat and summary failures +are not automatically retried; unchecked provider errors stay out of history. +Old approved entries remain in the append-only JSONL file. + +Pi's synchronous system-prompt rebuilds are staged as private candidates. Public +agent/session state retains the last approved system prompt, including while a +new user candidate is pending or denied. Provider calls use the newly approved +snapshot. In-memory TUI preferences survive `/new`; conversation state and its +provider session identity do not carry over. One cwd scopes resources, tools and storage. It is not confinement; OpenShell filesystem policy is. The application is installed outside the writable project diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example index 6f53970c..566d4b4c 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.env.example +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -5,5 +5,7 @@ OPENSHELL_GATEWAY=your-gateway EGRESS_GATE_HOST=your-service-host # Required only when models.json declares more than one model. # PI_MODEL=example/YOUR_MODEL_ID +# Optional Pi preference, forwarded to launch/verify. Omit for Pi's default. +# PI_CACHE_RETENTION=long # Real key for the HTTPS endpoint/model in models.json. Never copied into the image. PI_MODEL_API_KEY=your-provider-key diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index d9ea5759..7a2fb635 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -70,6 +70,14 @@ Credentials and model caches stay in memory; no writable `/app/agent/auth.json` is needed. Compaction uses Pi's normal summary budget, bounded by the model's `maxTokens`. +Prompt caching stays under Pi's control: the gate accepts its cache keys, +retention fields, and compatibility-generated `cache_control` metadata without +rewriting them. To request Pi's longer retention where supported, optionally set +`PI_CACHE_RETENTION=long` in `.env` or export it before `launch`/`verify`; leave it +unset for Pi's default. No rebuild is needed. Pi disables cache retention for +one-off compaction requests. Redaction and compaction can change prompt content +and therefore cache hits; approval receipts are not added to the prompt. + If you used the earlier single-object `model.json`, start from the new template and transfer your endpoint and model settings; renaming the file alone is not enough. The catalog can contain many models, but each prepared demo uses **one**. To change @@ -196,6 +204,9 @@ environment variables being inherited by `sandbox exec`. Use Ctrl+O to expand tool output and `/session` to inspect session information; Pi saves JSONL under `/sandbox/sessions`. The former custom `/history` and `/exit` commands are gone; use Pi's chat view and `/quit`. +Preferences changed in the TUI survive `/new` within this running application; +they are not saved across launcher restarts. Provider session-affinity/cache +identity follows Pi's normal behavior, including a new identity for `/new`. Compaction retains the latest whole turn; older **approved** entries remain in the append-only file. Automatic compaction uses the same summary path at Pi's context thresholds, including between tool @@ -203,6 +214,11 @@ turns. Esc cancels the current operation. Steering and follow-up inputs are admitted after skill expansion, before joining the transcript. Drafts and pending input queues are not approved history. An unfinished tool batch that cannot be safely closed requires `/new`. +Turning automatic compaction off also disables automatic overflow recovery; +manual `/compact` remains available. The whole-turn policy cannot compact a +long first tool turn: there is no older turn to summarize. Transient chat and +summary failures stop the operation rather than automatically retrying in this +POC; unchecked provider errors are never appended to history. This POC deliberately blocks `!`/`!!`, resume/import, branching, renaming, model switching, and resource reload: these need additional handling before diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts index d6bf5a0e..403f29ea 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts @@ -28,6 +28,8 @@ export class ContextOverflowError extends Error {} */ export class AdmissionAgent extends Agent { private readonly live; + private systemPromptCandidate = ""; + private approvedSystemPrompt = ""; private readonly subscribers = new Set< (event: AgentEvent, signal: AbortSignal) => Promise | void >(); @@ -45,7 +47,30 @@ export class AdmissionAgent extends Agent { super({ initialState: { model, thinkingLevel: "off" }, streamFn }); // Pi's base lifecycle fields are readonly. This engine owns its own public // state and lifecycle; it never invokes the base execution/state reducer. - this.live = { ...super.state, pendingToolCalls: new Set() }; + const owner = this; + this.live = { + ...super.state, + // Pi rebuilds this field synchronously. Stage those writes as candidates; + // public state continues to expose only the last approved system prompt. + get systemPrompt(): string { + return owner.approvedSystemPrompt; + }, + set systemPrompt(value: string) { + owner.systemPromptCandidate = value; + }, + pendingToolCalls: new Set(), + }; + } + + async approveSystemPrompt(signal?: AbortSignal): Promise { + const approved = await this.admission.text( + "system", + this.systemPromptCandidate, + signal, + ); + signal?.throwIfAborted(); + this.approvedSystemPrompt = approved; + return approved; } override get state() { @@ -148,11 +173,7 @@ export class AdmissionAgent extends Agent { await this.emit({ type: "turn_start" }); // AgentSession rebuilds system context when tools/settings change. // Approve that snapshot before every provider call. - const systemPrompt = await this.admission.text( - "system", - this.live.systemPrompt, - this.signal, - ); + const systemPrompt = await this.approveSystemPrompt(this.signal); const response = await ( await this.streamFunction( this.live.model, @@ -161,7 +182,7 @@ export class AdmissionAgent extends Agent { messages: convertToLlm(this.live.messages), tools: this.live.tools, }, - { signal: this.signal }, + { signal: this.signal, sessionId: this.sessionId }, ) ).result(); this.signal!.throwIfAborted(); diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts index ca5e0de6..85fa6b79 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts @@ -75,7 +75,8 @@ export class AdmissionSession extends AgentSession { try { await super.prompt(text, options); } catch (error) { - if (!(error instanceof ContextOverflowError)) throw error; + if (!(error instanceof ContextOverflowError) || !this.autoCompactionEnabled) + throw error; // The failed provider response was never published. Compact only approved // history, then retry that unfinished turn once. await this.compact(); @@ -142,6 +143,20 @@ function sessionFactory(options: SessionOptions) { new URL(options.model.baseUrl).protocol !== "https:" ) throw new AdmissionError("unsupported"); + // Preferences belong to the runtime, not to an individual conversation. + const settingsManager = SettingsManager.inMemory({ + packages: [], + enableInstallTelemetry: false, + compaction: { + enabled: true, + keepRecentTokens: 0, + reserveTokens: + options.compactAtTokens === undefined + ? undefined + : options.model.contextWindow - options.compactAtTokens, + }, + retry: { enabled: false }, + }); const stream: StreamFn = async (model, context, streamOptions) => { const receipt = await options.admission.receipt( context, @@ -150,7 +165,6 @@ function sessionFactory(options: SessionOptions) { return (options.stream ?? streamSimple)(model, context, { ...streamOptions, apiKey: options.apiKey, - maxRetries: 0, headers: { ...streamOptions?.headers, [RECEIPT_HEADER]: receipt }, }); }; @@ -170,19 +184,7 @@ function sessionFactory(options: SessionOptions) { credentials: new InMemoryCredentialStore(), modelsPath: null, }), - settingsManager: SettingsManager.inMemory({ - packages: [], - enableInstallTelemetry: false, - compaction: { - enabled: true, - keepRecentTokens: 0, - reserveTokens: - options.compactAtTokens === undefined - ? undefined - : options.model.contextWindow - options.compactAtTokens, - }, - retry: { enabled: false }, - }), + settingsManager, resourceLoaderOptions: { noExtensions: true, noPromptTemplates: true, @@ -223,6 +225,8 @@ function sessionFactory(options: SessionOptions) { stream, undefined, { enabled: false, maxRetries: 0, baseDelayMs: 0 }, + undefined, + sessionManager.getSessionId(), ); const approved = await options.admission.text( "compaction_summary", @@ -256,8 +260,12 @@ function sessionFactory(options: SessionOptions) { options.apiKey, ); const tools = options.tools ?? projectTools(cwd); + const agent = new AdmissionAgent(options.model, stream, options.admission); + agent.sessionId = sessionManager.getSessionId(); + agent.steeringMode = settingsManager.getSteeringMode(); + agent.followUpMode = settingsManager.getFollowUpMode(); const session = new AdmissionSession({ - agent: new AdmissionAgent(options.model, stream, options.admission), + agent, cwd, sessionManager, sessionStartEvent, @@ -271,10 +279,7 @@ function sessionFactory(options: SessionOptions) { allowedToolNames: tools.map((tool) => tool.name), }); // Check project instructions and skill metadata before exposing the session. - session.agent.state.systemPrompt = await options.admission.text( - "system", - session.systemPrompt, - ); + await agent.approveSystemPrompt(); return { session, services, diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts b/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts index e579dc45..b94fe721 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts @@ -5,11 +5,28 @@ import assert from "node:assert/strict"; import { randomUUID } from "node:crypto"; import { readFile } from "node:fs/promises"; import { parseArgs } from "node:util"; +import type { Message } from "@earendil-works/pi-ai"; import { Admission, AdmissionError, createHttpEvaluator } from "./admission.js"; import { AdmissionSession } from "./session.js"; import { configureProxy } from "./network.js"; import { loadSelectedModel } from "./model.js"; +export function assertProjectRead(history: readonly Message[]): void { + assert.ok( + history.some((message) => + message.role === "toolResult" && + message.toolName === "read" && + !message.isError && + message.content.some((block) => + block.type === "text" && + block.text.includes("This is a real file in the sandbox project.") && + block.text.includes("[REDACTED]"), + ), + ), + "The read tool must successfully return the approved notes.txt content", + ); +} + /** Real service, upstream runtime, real project tools, and the configured model. */ async function verify(): Promise { configureProxy(); @@ -66,10 +83,7 @@ async function verify(): Promise { await session.prompt( "/skill:review Use the read tool to read notes.txt; do not guess its contents.", ); - assert.ok( - session.history.some((message) => message.role === "toolResult"), - "The real model must actually use the project tool", - ); + assertProjectRead(session.history); assert.ok( await session.compact(), "Manual compaction must summarize an older turn", @@ -98,9 +112,11 @@ async function verify(): Promise { ); } -verify().catch(() => { - console.error( - "FAIL end-to-end verification. Check service availability, credentials, model compatibility, and the last PASS line; no checks were skipped.", - ); - process.exitCode = 1; -}); +if (import.meta.main) { + verify().catch(() => { + console.error( + "FAIL end-to-end verification. Check service availability, credentials, model compatibility, and the last PASS line; no checks were skipped.", + ); + process.exitCode = 1; + }); +} diff --git a/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts b/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts index 32a0d9b5..2e831abc 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts @@ -15,6 +15,7 @@ import { } from "../src/admission.js"; import { AdmissionSession, createAdmissionRuntime } from "../src/session.js"; import { loadSelectedModel } from "../src/model.js"; +import { assertProjectRead } from "../src/verify.js"; const [endpoint, directory] = process.argv.slice(2); const model = await loadSelectedModel(join(directory, "image")); @@ -58,16 +59,13 @@ assert.equal(session.history.length, 0); assert.equal(session.entries.length, 0); await session.prompt("Please repeat REDACT_THIS and café."); await session.prompt("/skill:review"); -const readResult = session.history.find( - (message) => message.role === "toolResult" && message.toolName === "read", +assertProjectRead(session.history); +assert.throws(() => + assertProjectRead(session.history.map((message) => + message.role === "toolResult" ? { ...message, isError: true } : message, + )), ); -assert.ok(readResult?.role === "toolResult"); -assert.equal(readResult.isError, false); -assert.match( - JSON.stringify(readResult.content), - /This is a real file in the sandbox project\./, -); -assert.match(JSON.stringify(readResult.content), /\[REDACTED\]/); +assert.throws(() => assertProjectRead([])); for (const snapshot of [ JSON.stringify(session.history), await readFile(session.sessionFile, "utf8"), diff --git a/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts b/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts index b2814269..19fcf97a 100644 --- a/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts +++ b/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts @@ -418,24 +418,69 @@ test("cancelled admission cannot append even when the service subsequently allow assert.equal(requests.length, 0); }); -test("context overflow makes one admitted summary and one retry", async () => { - const overflow = { - ...answer(""), - stopReason: "error" as const, - errorMessage: "exceeds the context window", - }; - const { session, requests, kinds } = await fixture(undefined, [ - answer("first"), - overflow, - answer("summary"), - answer("retry result"), - ]); - await session.prompt("first turn"); - await session.prompt("next turn"); - assert.equal(requests.length, 4); - assert.equal(kinds.filter((kind) => kind === "compaction_summary").length, 1); - assert.ok(JSON.stringify(session.history).includes("retry result")); - assert.ok(!(await disk(session)).includes("exceeds the context window")); +for (const enabled of [true, false]) { + test(`overflow respects auto-compaction ${enabled}`, async () => { + const overflow = { + ...answer(""), + stopReason: "error" as const, + errorMessage: "exceeds the context window", + }; + const { session, requests, kinds } = await fixture(undefined, [ + answer("first"), + overflow, + answer("summary"), + answer("retry result"), + ]); + await session.prompt("first turn"); + session.setAutoCompactionEnabled(enabled); + if (enabled) await session.prompt("next turn"); + else await assert.rejects(session.prompt("next turn"), /Context is too large/); + assert.equal(requests.length, enabled ? 4 : 2); + assert.equal( + kinds.filter((kind) => kind === "compaction_summary").length, + enabled ? 1 : 0, + ); + assert.equal(JSON.stringify(session.history).includes("retry result"), enabled); + assert.ok(!(await disk(session)).includes("exceeds the context window")); + }); +} + +test("system rebuilds remain private while user admission is pending or denied", async () => { + let release!: () => void; + let reached!: () => void; + const hold = new Promise((resolve) => { + release = resolve; + }); + const seen = new Promise((resolve) => { + reached = resolve; + }); + let denyUser = true; + const { session, requests } = await fixture(async (kind, body) => { + if (kind === "system_context") return { + ...allow, + decision: "replace", + replacement: { ...body, text: "APPROVED_SYSTEM" }, + }; + if (kind === "user_message" && denyUser) { + reached(); + await hold; + return deny; + } + return kind === "provider_context" ? { ...allow, receipt: "receipt" } : allow; + }); + assert.equal(session.systemPrompt, "APPROVED_SYSTEM"); + const running = session.prompt("denied"); + const rejected = assert.rejects(running); + await seen; + assert.equal(session.agent.state.systemPrompt, "APPROVED_SYSTEM"); + release(); + await rejected; + assert.equal(session.systemPrompt, "APPROVED_SYSTEM"); + assert.equal(session.messages.length, 0); + denyUser = false; + await session.prompt("allowed"); + assert.equal(requests[0].systemPrompt, "APPROVED_SYSTEM"); + assert.equal(session.systemPrompt, "APPROVED_SYSTEM"); }); test("native session persists each approved message once and never renders tool details", async () => { @@ -631,8 +676,17 @@ test("native alternate writes fail closed; /new reuses the admission factory", a assert.deepEqual(runtime.session.sessionManager.getEntries(), entries); await assert.rejects(readFile(join(cwd, "must-not-exist"))); const previous = runtime.session; + previous.setAutoCompactionEnabled(false); + previous.setSteeringMode("all"); + previous.setFollowUpMode("all"); + assert.equal(previous.agent.sessionId, previous.sessionId); await runtime.newSession(); assert.notEqual(runtime.session, previous); + assert.equal(runtime.session.autoCompactionEnabled, false); + assert.equal(runtime.session.steeringMode, "all"); + assert.equal(runtime.session.followUpMode, "all"); + assert.notEqual(runtime.session.sessionId, previous.sessionId); + assert.equal(runtime.session.agent.sessionId, runtime.session.sessionId); await runtime.session.bindExtensions({}); await assert.rejects(runtime.session.prompt("DENIED")); assert.equal(runtime.session.messages.length, 0); diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 8bb533cc..5e0cca7c 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -20,6 +20,10 @@ fi service_host=${EGRESS_GATE_HOST:-YOUR_SERVICE_HOST} gateway=${OPENSHELL_GATEWAY:-YOUR_GATEWAY} openshell=(openshell --gateway "$gateway") +pi_env=(/usr/bin/env) +if [[ -n ${PI_CACHE_RETENTION:-} ]]; then + pi_env+=("PI_CACHE_RETENTION=$PI_CACHE_RETENTION") +fi run() { if $print_only; then printf '%q ' "$@"; printf '\n'; else "$@"; fi } @@ -108,11 +112,11 @@ case "$action" in ;; launch) if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${EGRESS_GATE_HOST:?Set the service host in .env}"; fi - run "${openshell[@]}" sandbox exec --tty --name pi-admission -- /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/cli.js --admission "https://$service_host:5443/v1/admission" + run "${openshell[@]}" sandbox exec --tty --name pi-admission -- "${pi_env[@]}" /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/cli.js --admission "https://$service_host:5443/v1/admission" ;; verify) if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}" "${EGRESS_GATE_HOST:?Set the service host in .env}"; fi - run "${openshell[@]}" sandbox exec --no-tty --name pi-admission -- /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/verify.js --admission "https://$service_host:5443/v1/admission" + run "${openshell[@]}" sandbox exec --no-tty --name pi-admission -- "${pi_env[@]}" /usr/local/bin/node --disable-warning=UNDICI-EHPA /app/dist/src/verify.js --admission "https://$service_host:5443/v1/admission" ;; cleanup) if ! $print_only; then : "${OPENSHELL_GATEWAY:?Select your existing gateway in .env}"; fi diff --git a/projects/egress-gate/examples/pi-attested-admission/prepare.py b/projects/egress-gate/examples/pi-attested-admission/prepare.py index b6fbfb64..7bdf749e 100644 --- a/projects/egress-gate/examples/pi-attested-admission/prepare.py +++ b/projects/egress-gate/examples/pi-attested-admission/prepare.py @@ -187,7 +187,7 @@ def select_model( overrides = provider.get("modelOverrides", {}).get(model["id"], {}) if any(config.get("headers") for config in (provider, model, overrides)): raise ValueError("Custom model headers are unsupported; use PI_MODEL_API_KEY") - if provider.get("oauth") or provider.get("authHeader"): + if provider.get("oauth"): raise ValueError( "Custom provider authentication is unsupported; use PI_MODEL_API_KEY" ) diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index 488ea117..018aa913 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -272,7 +272,8 @@ def prepare( timeout: Timeout, ) -> PreparedHarnessRequest: native = _parse_pi_tool_result(request.request_body, timeout) - _tool_result_entry(native) + if any(block.type == "image" for block in native.content): + raise AdmissionShapeError("Pi tool-result images are unsupported") return PreparedHarnessRequest( native=native, projected_body=canonical_json_bytes(native), @@ -388,9 +389,15 @@ def resolve(self, context: HarnessAdmissionContext) -> HarnessAdapter: ) from None +class _ProviderCacheControl(StrictDomainModel): + type: Literal["ephemeral"] + ttl: Literal["1h"] | None = None + + class _ProviderTextBlock(StrictDomainModel): type: Literal["text"] text: ScalarString + cache_control: _ProviderCacheControl | None = None class _ProviderFunction(StrictDomainModel): @@ -464,6 +471,7 @@ def _optional_strict_is_not_null(self) -> _ProviderFunctionDefinition: class _ProviderTool(StrictDomainModel): type: Literal["function"] function: _ProviderFunctionDefinition + cache_control: _ProviderCacheControl | None = None class _ProviderNamedChoiceFunction(StrictDomainModel): @@ -489,7 +497,7 @@ class _ProviderRequest(StrictDomainModel): max_completion_tokens: int | None = Field(default=None, ge=1) max_tokens: int | None = Field(default=None, ge=1) stream: Literal[True] - stream_options: _ProviderStreamOptions + stream_options: _ProviderStreamOptions | None = None store: Literal[False] | None = None prompt_cache_key: ScalarString | None = None prompt_cache_retention: Literal["24h"] | None = None @@ -505,7 +513,7 @@ def _provider_collections_are_tuples(cls, value: object) -> object: def _compatibility_fields_have_one_representation(self) -> _ProviderRequest: if (self.max_completion_tokens is None) == (self.max_tokens is None): raise ValueError("provider request requires exactly one max-token field") - for field_name in ("store", "enable_thinking"): + for field_name in ("store", "enable_thinking", "stream_options"): if ( field_name in self.model_fields_set and getattr(self, field_name) is None @@ -625,19 +633,6 @@ def _parse_pi_provider_context(body: bytes, timeout: Timeout) -> PiProviderConte return parsed -def _tool_result_entry(result: PiToolResultV1) -> ToolContextEntryV1: - if any(block.type == "image" for block in result.content): - raise AdmissionShapeError("Pi tool-result images are unsupported") - text = "\n".join( - block.text for block in result.content if isinstance(block, PiTextContentV1) - ) - return ToolContextEntryV1( - role="tool", - text=text or "(no tool output)", - tool_call_id=_provider_tool_call_id(result.tool_call_id), - ) - - def context_entries_subject(entries: AttestedEntries) -> tuple[str, int]: """Return the v2 hash and count for one ordered entry list.""" body = json.dumps( diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index e6950788..9c2fba9b 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -632,6 +632,12 @@ def test_provider_shape_validation_and_optional_reasoning_field_are_preserved() [ lambda body: body.update({"max_completion_tokens": 128}), lambda body: body.update({"store": None}), + lambda body: body.update({"stream_options": None}), + lambda body: body.update({"stream_options": {"include_usage": "true"}}), + lambda body: body["tools"][0].update({"cache_control": {"type": "persistent"}}), + lambda body: body["tools"][0].update( + {"cache_control": {"type": "ephemeral", "ttl": "forever"}} + ), lambda body: body["tools"][0]["function"].update({"strict": None}), lambda body: body.update({"input": []}), lambda body: body["messages"][1].update({"tool_call_id": "wrong-role"}), diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py index 777146a7..0c1b5304 100644 --- a/projects/egress-gate/tests/service/test_http_admission.py +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -154,12 +154,18 @@ def test_gateway_authentication_rejects_invalid_trust_claims(change: str) -> Non @pytest.mark.asyncio @pytest.mark.parametrize("tui", [False, True], ids=["sdk", "native-tui"]) -@pytest.mark.parametrize("max_tokens", [2048, 16384]) +@pytest.mark.parametrize( + ("max_tokens", "cache_control", "cache_retention"), + [(2048, False, ""), (16384, True, ""), (16384, True, "long")], + ids=["default-cache", "compat-cache", "long-cache"], +) async def test_pi_session_through_admission_and_authenticated_egress( tmp_path: Path, unused_tcp_port: int, tui: bool, max_tokens: int, + cache_control: bool, + cache_retention: str, ) -> None: source = PROJECT / "examples/pi-attested-admission" example = tmp_path / "example" @@ -174,6 +180,14 @@ async def test_pi_session_through_admission_and_authenticated_egress( model = catalog["providers"]["example"]["models"][0] model["maxTokens"] = max_tokens model["samplingParams"] = {"temperature": 0.25, "top_p": 0.9} + model["compat"] = { + "supportsUsageInStreaming": not tui, + "sendSessionAffinityHeaders": True, + "sessionAffinityFormat": "openai", + "supportsLongCacheRetention": True, + } + if cache_control: + model["compat"]["cacheControlFormat"] = "anthropic" (example / "models.json").write_text(json.dumps(catalog)) # Match the image: the sandbox user cannot create Pi auth/cache files in /app. agent_dir = tmp_path / "agent" @@ -196,6 +210,7 @@ async def test_pi_session_through_admission_and_authenticated_egress( } ) calls: list[bytes] = [] + session_ids: list[str | None] = [] async def provider(request: web.Request) -> web.Response: body = await request.read() @@ -213,12 +228,37 @@ async def provider(request: web.Request) -> web.Response: metadata = (("authorization", f"Bearer {token}"),) result = await stub.EvaluateHttpRequest(evaluation, metadata=metadata) assert result.decision == pb.DECISION_ALLOW, result.reason_code + assert not result.has_body or result.body == body assert result.header_mutations[-1].remove.name == RECEIPT_HEADER assert ( "REDACT_THIS" not in body.decode() and "DENY_THIS" not in body.decode() ) calls.append(body) + session_ids.append(request.headers.get("x-session-affinity")) + # Pi deliberately disables cache/affinity headers for summaries. + assert (session_ids[-1] is None) == (len(calls) in (4, 7)) payload = json.loads(body) + summary = len(calls) in (4, 7) + if cache_retention == "long" and not summary: + assert payload["prompt_cache_key"] == session_ids[-1] + assert payload["prompt_cache_retention"] == "24h" + else: + assert "prompt_cache_key" not in payload + assert "prompt_cache_retention" not in payload + if cache_control and not summary: + expected_cache = {"type": "ephemeral"} + if cache_retention == "long": + expected_cache["ttl"] = "1h" + assert payload["messages"][0]["content"][-1]["cache_control"] == ( + expected_cache + ) + assert payload["messages"][-1]["content"][-1]["cache_control"] == ( + expected_cache + ) + assert payload["tools"][-1]["cache_control"] == expected_cache + else: + assert '"cache_control"' not in body.decode() + assert ("stream_options" in payload) is not tui assert payload["temperature"] == 0.25 assert payload["top_p"] == 0.9 if len(calls) not in (4, 7): @@ -290,6 +330,7 @@ async def provider(request: web.Request) -> web.Response: "PI_OFFLINE": "1", "PI_CODING_AGENT_DIR": str(tmp_path / "agent"), "TERM": "xterm-256color", + "PI_CACHE_RETENTION": cache_retention, }, stdin=terminal[1] if terminal else None, stdout=terminal[1] if terminal else asyncio.subprocess.PIPE, @@ -328,6 +369,10 @@ async def provider(request: web.Request) -> web.Response: "Pi must not write auth or model caches" ) assert len(calls) == (4 if tui else 7) + assert len(set(session_ids[:3])) == 1 + if not tui: + assert len(set(session_ids[4:6])) == 1 + assert session_ids[0] != session_ids[4] assert any(m["role"] == "tool" for m in json.loads(calls[2])["messages"]) finally: if terminal: diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 96400034..d7cc4146 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -3,6 +3,7 @@ from __future__ import annotations +import hashlib import ipaddress import json import os @@ -39,6 +40,7 @@ def test_native_model_selection_keeps_only_selected_configuration( catalog = json.loads((EXAMPLE / "models.json.example").read_text()) provider = catalog["providers"]["example"] provider["apiKey"] = "!do-not-execute-or-copy" + provider["authHeader"] = True provider["models"].append( { "id": "vendor/second", @@ -447,25 +449,32 @@ def test_prepare_requires_operator_model_configuration(tmp_path: Path) -> None: @pytest.mark.parametrize("action", ["launch", "verify"]) -def test_commands_suppress_only_the_proxy_agent_warning(action: str) -> None: +@pytest.mark.parametrize("cache_retention", ["", "long"]) +def test_commands_forward_cache_preference_and_suppress_only_proxy_warning( + action: str, cache_retention: str +) -> None: printed = subprocess.run( ["bash", str(EXAMPLE / "demo.sh"), "--print", action], capture_output=True, text=True, check=True, + env=os.environ | {"PI_CACHE_RETENTION": cache_retention}, ) command = shlex.split(printed.stdout) node_index = command.index("/usr/local/bin/node") assert command[node_index + 1] == "--disable-warning=UNDICI-EHPA" environment = os.environ.copy() environment.pop("NODE_OPTIONS", None) + environment.pop("PI_CACHE_RETENTION", None) result = subprocess.run( [ + *command[command.index("--") + 1 : node_index], "node", command[node_index + 1], "-e", "process.emitWarning('proxy notice', {code: 'UNDICI-EHPA'});" - "process.emitWarning('unrelated notice', {code: 'OTHER_WARNING'});", + "process.emitWarning('unrelated notice', {code: 'OTHER_WARNING'});" + "console.log(process.env.PI_CACHE_RETENTION ?? '');", ], env=environment, capture_output=True, @@ -476,6 +485,7 @@ def test_commands_suppress_only_the_proxy_agent_warning(action: str) -> None: assert "proxy notice" not in result.stderr assert "OTHER_WARNING" in result.stderr assert "unrelated notice" in result.stderr + assert result.stdout.strip() == cache_retention @pytest.mark.parametrize("host", ["192.0.2.10", "host.docker.internal"]) @@ -667,3 +677,22 @@ def test_pi_dependencies_are_exact_upstream_packages() -> None: assert resolved["version"] == version assert resolved["resolved"].startswith("https://registry.npmjs.org/") assert resolved["integrity"].startswith("sha512-") + + +def test_middleware_manifest_matches_upstream_protocol() -> None: + manifest = json.loads((PROJECT / ".openshell-middleware-manifest.json").read_text()) + revision = "d1155aa70042d3e2ee49dbfa15346b108b7c1d92" + assert manifest["openshell_version"] == "0.0.116" + assert manifest["proto_source"] == ( + f"https://raw.githubusercontent.com/NVIDIA/OpenShell/{revision}" + "/proto/supervisor_middleware.proto" + ) + assert ( + manifest["proto_sha256"] + == hashlib.sha256( + (PROJECT / "proto/supervisor_middleware.proto").read_bytes() + ).hexdigest() + ) + assert ( + f"revision={revision}" in (PROJECT / "scripts/generate-bindings.sh").read_text() + ) From f9860a54d242f3225c8a787cba19a52b50db12e5 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 10 Sep 2026 16:28:20 +0000 Subject: [PATCH 67/70] docs: require omm for middleware management --- AGENTS.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/AGENTS.md b/AGENTS.md index bda77c67..a14aa71a 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,6 +22,12 @@ validation commands. When adding a project, also follow ## Repository rules - Make the smallest change that satisfies the task and preserve unrelated work. +- Manage OpenShell middleware with `omm` from + `projects/openshell-middleware-manager/`, including project creation and + protocol, generated binding, and manifest updates. If functionality is missing, + open an issue for the manager; implement a fix only when explicitly requested. + Do not bypass the manager with standalone generators or hand-edit generated + artifacts. - Prefer explicit, clear names and language over concise but ambiguous alternatives. Value concision when it does not reduce clarity. - Use `uv` for Python dependency management, environments, locking, builds, and From 22a1bb9b78cb628576c6ef6068add1bf8a8f50d4 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 10 Sep 2026 21:59:09 +0000 Subject: [PATCH 68/70] refactor(pi-example): rename app to pi-harness --- projects/egress-gate/README.md | 4 ++-- .../egress-gate/examples/pi-attested-admission/README.md | 8 ++++---- .../{app => pi-harness}/package-lock.json | 0 .../{app => pi-harness}/package.json | 0 .../{app => pi-harness}/src/admission.ts | 0 .../{app => pi-harness}/src/agent.ts | 0 .../pi-attested-admission/{app => pi-harness}/src/cli.ts | 0 .../{app => pi-harness}/src/model.ts | 0 .../{app => pi-harness}/src/network.ts | 0 .../{app => pi-harness}/src/session.ts | 0 .../{app => pi-harness}/src/tools.ts | 0 .../{app => pi-harness}/src/verify.ts | 0 .../{app => pi-harness}/test/admission.test.ts | 0 .../{app => pi-harness}/test/service-integration.ts | 0 .../{app => pi-harness}/test/session.test.ts | 0 .../{app => pi-harness}/tsconfig.json | 0 .../egress-gate/examples/pi-attested-admission/prepare.py | 4 ++-- .../examples/pi-attested-admission/sandbox/Dockerfile | 4 ++-- projects/egress-gate/scripts/check.sh | 6 +++--- projects/egress-gate/tests/service/test_http_admission.py | 2 +- projects/egress-gate/tests/test_pi_example_commands.py | 6 +++--- 21 files changed, 17 insertions(+), 17 deletions(-) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/package-lock.json (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/package.json (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/src/admission.ts (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/src/agent.ts (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/src/cli.ts (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/src/model.ts (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/src/network.ts (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/src/session.ts (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/src/tools.ts (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/src/verify.ts (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/test/admission.test.ts (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/test/service-integration.ts (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/test/session.test.ts (100%) rename projects/egress-gate/examples/pi-attested-admission/{app => pi-harness}/tsconfig.json (100%) diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 07a77f81..1fb2b758 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -155,8 +155,8 @@ Full checks also require Node 22.19+ and npm for the locked upstream Pi example. The first run installs its JavaScript dependencies. `make check` builds the Pi application before running Python tests, including the local cross-language integration test. To run that test directly, first run -`npm --prefix examples/pi-attested-admission/app ci --ignore-scripts` and -`npm --prefix examples/pi-attested-admission/app run build`. +`npm --prefix examples/pi-attested-admission/pi-harness ci --ignore-scripts` and +`npm --prefix examples/pi-attested-admission/pi-harness run build`. ```bash make help diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 7a2fb635..e81cf30e 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -238,7 +238,7 @@ calls and normal provider charges. It checks a raw request without a receipt, deny/redact history, a real skill/tool continuation, and manual and automatic compaction. It exits unsuccessfully on any missing capability or failed check; it does not skip checks or substitute a mock model. Deterministic failure and -pending-admission tests live in [app/test/](app/test/). +pending-admission tests live in [pi-harness/test/](pi-harness/test/). Cleanup deletes only this demo sandbox and its provider instances/profiles, then removes the registration created by `register` and restarts the gateway. @@ -281,17 +281,17 @@ OpenShell supervisor ----------> verify actual request + policy attach real provider key --> model ``` -[agent.ts](app/src/agent.ts) supplies Pi's public `AgentSessionConfig.agent` +[agent.ts](pi-harness/src/agent.ts) supplies Pi's public `AgentSessionConfig.agent` with an admission-controlled execution loop. It approves each candidate before updating live state or emitting message events. Pi's native `AgentSession` is the **only persistence owner**; it saves those approved events. -[session.ts](app/src/session.ts) wires the runtime and the +[session.ts](pi-harness/src/session.ts) wires the runtime and the `session_before_compact` extension, and blocks alternate unchecked write paths. Finalized assistant text and tool calls are admitted before execution. Tool output, missing-tool/argument/execution errors, rendered skills, and completed summaries all pass the same boundary. -[admission.ts](app/src/admission.ts) translates these candidates into the existing +[admission.ts](pi-harness/src/admission.ts) translates these candidates into the existing Egress Gate schemas. A provider-context replacement is rejected: silently redacting only the outbound request would leave saved history inconsistent. diff --git a/projects/egress-gate/examples/pi-attested-admission/app/package-lock.json b/projects/egress-gate/examples/pi-attested-admission/pi-harness/package-lock.json similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/package-lock.json rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/package-lock.json diff --git a/projects/egress-gate/examples/pi-attested-admission/app/package.json b/projects/egress-gate/examples/pi-attested-admission/pi-harness/package.json similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/package.json rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/package.json diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/admission.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/admission.ts similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/src/admission.ts rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/src/admission.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/agent.ts similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/src/agent.ts rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/src/agent.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/cli.ts similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/src/cli.ts rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/src/cli.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/model.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/model.ts similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/src/model.ts rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/src/model.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/network.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/network.ts similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/src/network.ts rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/src/network.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/session.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/session.ts similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/src/session.ts rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/src/session.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/tools.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/tools.ts similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/src/tools.ts rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/src/tools.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/verify.ts similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/src/verify.ts rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/src/verify.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/app/test/admission.test.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/admission.test.ts similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/test/admission.test.ts rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/test/admission.test.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/service-integration.ts similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/test/service-integration.ts rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/test/service-integration.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/session.test.ts similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/test/session.test.ts rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/test/session.test.ts diff --git a/projects/egress-gate/examples/pi-attested-admission/app/tsconfig.json b/projects/egress-gate/examples/pi-attested-admission/pi-harness/tsconfig.json similarity index 100% rename from projects/egress-gate/examples/pi-attested-admission/app/tsconfig.json rename to projects/egress-gate/examples/pi-attested-admission/pi-harness/tsconfig.json diff --git a/projects/egress-gate/examples/pi-attested-admission/prepare.py b/projects/egress-gate/examples/pi-attested-admission/prepare.py index 7bdf749e..7a1af306 100644 --- a/projects/egress-gate/examples/pi-attested-admission/prepare.py +++ b/projects/egress-gate/examples/pi-attested-admission/prepare.py @@ -148,10 +148,10 @@ def prepare( if image.exists(): shutil.rmtree(image) image.mkdir() - for directory in ("app/src", "app/test"): + for directory in ("pi-harness/src", "pi-harness/test"): shutil.copytree(example / directory, image / directory, dirs_exist_ok=True) for name in ("package.json", "package-lock.json", "tsconfig.json"): - shutil.copyfile(example / "app" / name, image / "app" / name) + shutil.copyfile(example / "pi-harness" / name, image / "pi-harness" / name) # This is one explicit project, not a recursive upload of the operator's cwd. for name in ("AGENTS.md", "notes.txt", ".pi/skills/review/SKILL.md"): destination = image / "project" / name diff --git a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile index 781cbfcf..ffd4c02b 100644 --- a/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile +++ b/projects/egress-gate/examples/pi-attested-admission/sandbox/Dockerfile @@ -11,9 +11,9 @@ RUN apt-get update \ COPY admission-ca.crt /usr/local/share/ca-certificates/admission-ca.crt RUN update-ca-certificates WORKDIR /app -COPY app/package.json app/package-lock.json ./ +COPY pi-harness/package.json pi-harness/package-lock.json ./ RUN npm ci --ignore-scripts --no-audit --no-fund -COPY app/ ./ +COPY pi-harness/ ./ RUN npm run build && mkdir /app/agent COPY models.json /app/models.json COPY model-selection.json /app/model-selection.json diff --git a/projects/egress-gate/scripts/check.sh b/projects/egress-gate/scripts/check.sh index 1c34563e..7552e3fa 100755 --- a/projects/egress-gate/scripts/check.sh +++ b/projects/egress-gate/scripts/check.sh @@ -15,14 +15,14 @@ if [[ $# -gt 0 ]]; then uv_run+=(--python "$2") fi -npm --prefix examples/pi-attested-admission/app ci --ignore-scripts --no-audit --no-fund -npm --prefix examples/pi-attested-admission/app run build +npm --prefix examples/pi-attested-admission/pi-harness ci --ignore-scripts --no-audit --no-fund +npm --prefix examples/pi-attested-admission/pi-harness run build "${uv_run[@]}" pytest -q "${uv_run[@]}" ruff format --check . "${uv_run[@]}" ruff check . "${uv_run[@]}" ty check "${uv_run[@]}" python -c "import egress_gate" -npm --prefix examples/pi-attested-admission/app test +npm --prefix examples/pi-attested-admission/pi-harness test "${uv_run[@]}" pip-audit \ --progress-spinner off \ --local diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py index 0c1b5304..afdbc063 100644 --- a/projects/egress-gate/tests/service/test_http_admission.py +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -320,7 +320,7 @@ async def provider(request: web.Request) -> web.Response: os.set_blocking(terminal[0], False) process = await asyncio.create_subprocess_exec( "node", - str(source / "app/dist/test/service-integration.js"), + str(source / "pi-harness/dist/test/service-integration.js"), str(server.make_url("/")).rstrip("/"), str(tmp_path), *(["--tui"] if tui else []), diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index d7cc4146..45b31f46 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -564,7 +564,7 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( assert not list(image.rglob("*.pem")) assert not list(image.rglob(".env")) assert not (image / "admission.json").exists() - assert not (image / "app/node_modules").exists() + assert not (image / "pi-harness/node_modules").exists() assert (image / "project/.pi/skills/review/SKILL.md").is_file() catalog = json.loads((image / "models.json").read_text()) assert catalog["providers"]["example"]["models"][0]["id"] == "YOUR_MODEL_ID" @@ -668,8 +668,8 @@ def test_sandbox_binding_accepts_only_operator_cli_output(tmp_path: Path) -> Non def test_pi_dependencies_are_exact_upstream_packages() -> None: - package = json.loads((EXAMPLE / "app/package.json").read_text()) - lock = json.loads((EXAMPLE / "app/package-lock.json").read_text()) + package = json.loads((EXAMPLE / "pi-harness/package.json").read_text()) + lock = json.loads((EXAMPLE / "pi-harness/package-lock.json").read_text()) for name, version in package["dependencies"].items(): if name.startswith("@earendil-works/"): assert version == "0.85.1" From 5a53dd9a48e307b8604f461af29b52d2e5194b1d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 10 Sep 2026 21:59:21 +0000 Subject: [PATCH 69/70] build(egress-gate): manage protocol bindings through omm Delegate generation to omm with project validation and publish its generated bindings and manifest. Align project guidance with the issue-first policy for manager gaps. The generation helper depends on the --check-command option in PR #60. --- .../.openshell-middleware-manifest.json | 4 +- projects/egress-gate/AGENTS.md | 5 + projects/egress-gate/README.md | 12 ++ .../egress-gate/scripts/generate-bindings.sh | 16 +-- .../bindings/supervisor_middleware_pb2.py | 136 +++++++++--------- .../supervisor_middleware_pb2_grpc.py | 46 +++--- .../tests/test_pi_example_commands.py | 12 +- 7 files changed, 119 insertions(+), 112 deletions(-) diff --git a/projects/egress-gate/.openshell-middleware-manifest.json b/projects/egress-gate/.openshell-middleware-manifest.json index e5f95bc9..faf55558 100644 --- a/projects/egress-gate/.openshell-middleware-manifest.json +++ b/projects/egress-gate/.openshell-middleware-manifest.json @@ -1,6 +1,6 @@ { - "openshell_version": "0.0.116", - "proto_source": "https://raw.githubusercontent.com/NVIDIA/OpenShell/d1155aa70042d3e2ee49dbfa15346b108b7c1d92/proto/supervisor_middleware.proto", + "openshell_version": "v0.0.116", + "proto_source": "https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.116/proto/supervisor_middleware.proto", "proto_sha256": "d96a963321c74c261a912dcd0b8cda690741b32b8c3d90ff3ef38dafe6681bad", "languages": [ "python" diff --git a/projects/egress-gate/AGENTS.md b/projects/egress-gate/AGENTS.md index aaeb8db9..515cacf4 100644 --- a/projects/egress-gate/AGENTS.md +++ b/projects/egress-gate/AGENTS.md @@ -18,6 +18,11 @@ Run focused tests while working and `make check` before handoff. ## Engineering approach +- Update the OpenShell protocol, generated bindings and manifest only through + `openshell-middleware-manager`. `scripts/generate-bindings.sh` delegates to it. + Open an issue for generator gaps; implement a fix only when explicitly + requested. Do not add a separate protoc workflow or edit generated artifacts + by hand. - Backwards compatibility with the removed legacy policy API is not a concern. Do not restore old schemas, imports, names, aliases, or obsolete pipeline terms. - Gates are trusted application code. Capabilities enforce declared output diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 1fb2b758..bea22ebe 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -166,3 +166,15 @@ make check Only `service/` imports generated protobuf/gRPC bindings. Do not edit `plans/egress-gate-refactor.md` as part of implementation work. + +Update the protocol and bindings only through the repository's +`openshell-middleware-manager` package: + +```bash +scripts/generate-bindings.sh +``` + +This delegates to `omm update` for the pinned OpenShell release, with `make check` +as validation. The manager downloads the proto, regenerates bindings with an +isolated compiler, and updates the lockfile and manifest together only after +checks pass. Do not edit these generated artifacts or run protoc separately. diff --git a/projects/egress-gate/scripts/generate-bindings.sh b/projects/egress-gate/scripts/generate-bindings.sh index de664006..ec867947 100755 --- a/projects/egress-gate/scripts/generate-bindings.sh +++ b/projects/egress-gate/scripts/generate-bindings.sh @@ -5,16 +5,6 @@ set -euo pipefail cd "$(dirname "${BASH_SOURCE[0]}")/.." -# OpenShell v0.0.116, unchanged upstream protocol. The generator is isolated -# because it requires protobuf 6; the application uses the patched protobuf 7. -revision=d1155aa70042d3e2ee49dbfa15346b108b7c1d92 -binding_tmp=$(mktemp -d) -trap 'rm -r -- "$binding_tmp"' EXIT -mkdir -p "$binding_tmp/egress_gate/bindings" -curl --fail --silent --show-error \ - "https://raw.githubusercontent.com/NVIDIA/OpenShell/$revision/proto/supervisor_middleware.proto" \ - -o "$binding_tmp/egress_gate/bindings/supervisor_middleware.proto" -cp "$binding_tmp/egress_gate/bindings/supervisor_middleware.proto" proto/supervisor_middleware.proto -uvx --from grpcio-tools==1.81.1 python -m grpc_tools.protoc \ - -I "$binding_tmp" --python_out=src --pyi_out=src --grpc_python_out=src \ - "$binding_tmp/egress_gate/bindings/supervisor_middleware.proto" +# Keep the protocol, bindings, lockfile and provenance under one owner. +exec uv run --frozen --project ../openshell-middleware-manager omm update . \ + --openshell-version v0.0.116 --check-command 'make check' diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py index 38203ece..20b178a6 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # Generated by the protocol buffer compiler. DO NOT EDIT! # NO CHECKED-IN PROTOBUF GENCODE -# source: egress_gate/bindings/supervisor_middleware.proto +# source: supervisor_middleware.proto # Protobuf Python Version: 6.33.5 """Generated protocol buffer code.""" from google.protobuf import descriptor as _descriptor @@ -15,7 +15,7 @@ 33, 5, '', - 'egress_gate/bindings/supervisor_middleware.proto' + 'supervisor_middleware.proto' ) # @@protoc_insertion_point(imports) @@ -26,11 +26,11 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n0egress_gate/bindings/supervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\xcd\x01\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xb9\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02*\xa5\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xd4\x03\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\xcd\x01\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xb9\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02*\xa5\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xd4\x03\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'egress_gate.bindings.supervisor_middleware_pb2', _globals) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'supervisor_middleware_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._loaded_options = None @@ -39,68 +39,68 @@ _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_HTTPREQUESTRESULT_METADATAENTRY']._loaded_options = None _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=3878 - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=4063 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=4066 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=4231 - _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=4234 - _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=4812 - _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=4815 - _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=5003 - _globals['_DECISION']._serialized_start=5005 - _globals['_DECISION']._serialized_end=5080 - _globals['_EXISTINGHEADERACTION']._serialized_start=5083 - _globals['_EXISTINGHEADERACTION']._serialized_end=5251 - _globals['_MIDDLEWAREMANIFEST']._serialized_start=137 - _globals['_MIDDLEWAREMANIFEST']._serialized_end=285 - _globals['_MIDDLEWAREBINDING']._serialized_start=288 - _globals['_MIDDLEWAREBINDING']._serialized_end=493 - _globals['_VALIDATECONFIGREQUEST']._serialized_start=495 - _globals['_VALIDATECONFIGREQUEST']._serialized_end=584 - _globals['_VALIDATECONFIGRESPONSE']._serialized_start=586 - _globals['_VALIDATECONFIGRESPONSE']._serialized_end=641 - _globals['_HTTPREQUESTEVALUATION']._serialized_start=644 - _globals['_HTTPREQUESTEVALUATION']._serialized_end=986 - _globals['_HTTPHEADER']._serialized_start=988 - _globals['_HTTPHEADER']._serialized_end=1029 - _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1032 - _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1334 - _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1337 - _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1660 - _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1662 - _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1715 - _globals['_WEBSOCKETMESSAGE']._serialized_start=1717 - _globals['_WEBSOCKETMESSAGE']._serialized_end=1798 - _globals['_WEBSOCKETSESSIONEND']._serialized_start=1800 - _globals['_WEBSOCKETSESSIONEND']._serialized_end=1889 - _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1892 - _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2210 - _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2163 - _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2210 - _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2213 - _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2576 - _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2163 - _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2210 - _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2579 - _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2776 - _globals['_REQUESTCONTEXT']._serialized_start=2779 - _globals['_REQUESTCONTEXT']._serialized_end=2939 - _globals['_HTTPREQUESTTARGET']._serialized_start=2941 - _globals['_HTTPREQUESTTARGET']._serialized_end=3049 - _globals['_PROCESS']._serialized_start=3051 - _globals['_PROCESS']._serialized_end=3108 - _globals['_FINDING']._serialized_start=3110 - _globals['_FINDING']._serialized_end=3201 - _globals['_WRITEHEADER']._serialized_start=3203 - _globals['_WRITEHEADER']._serialized_end=3313 - _globals['_REMOVEHEADER']._serialized_start=3315 - _globals['_REMOVEHEADER']._serialized_end=3343 - _globals['_HEADERMUTATION']._serialized_start=3346 - _globals['_HEADERMUTATION']._serialized_end=3487 - _globals['_HTTPREQUESTRESULT']._serialized_start=3490 - _globals['_HTTPREQUESTRESULT']._serialized_end=3875 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2163 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2210 - _globals['_SUPERVISORMIDDLEWARE']._serialized_start=5254 - _globals['_SUPERVISORMIDDLEWARE']._serialized_end=5722 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=3857 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=4042 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=4045 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=4210 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=4213 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=4791 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=4794 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=4982 + _globals['_DECISION']._serialized_start=4984 + _globals['_DECISION']._serialized_end=5059 + _globals['_EXISTINGHEADERACTION']._serialized_start=5062 + _globals['_EXISTINGHEADERACTION']._serialized_end=5230 + _globals['_MIDDLEWAREMANIFEST']._serialized_start=116 + _globals['_MIDDLEWAREMANIFEST']._serialized_end=264 + _globals['_MIDDLEWAREBINDING']._serialized_start=267 + _globals['_MIDDLEWAREBINDING']._serialized_end=472 + _globals['_VALIDATECONFIGREQUEST']._serialized_start=474 + _globals['_VALIDATECONFIGREQUEST']._serialized_end=563 + _globals['_VALIDATECONFIGRESPONSE']._serialized_start=565 + _globals['_VALIDATECONFIGRESPONSE']._serialized_end=620 + _globals['_HTTPREQUESTEVALUATION']._serialized_start=623 + _globals['_HTTPREQUESTEVALUATION']._serialized_end=965 + _globals['_HTTPHEADER']._serialized_start=967 + _globals['_HTTPHEADER']._serialized_end=1008 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1011 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1313 + _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1316 + _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1639 + _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1641 + _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1694 + _globals['_WEBSOCKETMESSAGE']._serialized_start=1696 + _globals['_WEBSOCKETMESSAGE']._serialized_end=1777 + _globals['_WEBSOCKETSESSIONEND']._serialized_start=1779 + _globals['_WEBSOCKETSESSIONEND']._serialized_end=1868 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1871 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2189 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2142 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2189 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2192 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2555 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2142 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2189 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2558 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2755 + _globals['_REQUESTCONTEXT']._serialized_start=2758 + _globals['_REQUESTCONTEXT']._serialized_end=2918 + _globals['_HTTPREQUESTTARGET']._serialized_start=2920 + _globals['_HTTPREQUESTTARGET']._serialized_end=3028 + _globals['_PROCESS']._serialized_start=3030 + _globals['_PROCESS']._serialized_end=3087 + _globals['_FINDING']._serialized_start=3089 + _globals['_FINDING']._serialized_end=3180 + _globals['_WRITEHEADER']._serialized_start=3182 + _globals['_WRITEHEADER']._serialized_end=3292 + _globals['_REMOVEHEADER']._serialized_start=3294 + _globals['_REMOVEHEADER']._serialized_end=3322 + _globals['_HEADERMUTATION']._serialized_start=3325 + _globals['_HEADERMUTATION']._serialized_end=3466 + _globals['_HTTPREQUESTRESULT']._serialized_start=3469 + _globals['_HTTPREQUESTRESULT']._serialized_end=3854 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2142 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2189 + _globals['_SUPERVISORMIDDLEWARE']._serialized_start=5233 + _globals['_SUPERVISORMIDDLEWARE']._serialized_end=5701 # @@protoc_insertion_point(module_scope) diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py index 67ce935c..1c44750a 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py @@ -3,8 +3,8 @@ import grpc import warnings -from egress_gate.bindings import supervisor_middleware_pb2 as egress__gate_dot_bindings_dot_supervisor__middleware__pb2 from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 +from . import supervisor_middleware_pb2 as supervisor__middleware__pb2 GRPC_GENERATED_VERSION = '1.81.1' GRPC_VERSION = grpc.__version__ @@ -19,7 +19,7 @@ if _version_not_supported: raise RuntimeError( f'The grpc package installed is at version {GRPC_VERSION},' - + ' but the generated code in egress_gate/bindings/supervisor_middleware_pb2_grpc.py depends on' + + ' but the generated code in supervisor_middleware_pb2_grpc.py depends on' + f' grpcio>={GRPC_GENERATED_VERSION}.' + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' @@ -41,22 +41,22 @@ def __init__(self, channel): self.Describe = channel.unary_unary( '/openshell.middleware.v1.SupervisorMiddleware/Describe', request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, - response_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.MiddlewareManifest.FromString, + response_deserializer=supervisor__middleware__pb2.MiddlewareManifest.FromString, _registered_method=True) self.ValidateConfig = channel.unary_unary( '/openshell.middleware.v1.SupervisorMiddleware/ValidateConfig', - request_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, - response_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigResponse.FromString, + request_serializer=supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, + response_deserializer=supervisor__middleware__pb2.ValidateConfigResponse.FromString, _registered_method=True) self.EvaluateHttpRequest = channel.unary_unary( '/openshell.middleware.v1.SupervisorMiddleware/EvaluateHttpRequest', - request_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, - response_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestResult.FromString, + request_serializer=supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, + response_deserializer=supervisor__middleware__pb2.HttpRequestResult.FromString, _registered_method=True) self.EvaluateWebSocketSession = channel.stream_stream( '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', - request_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, - response_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + request_serializer=supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + response_deserializer=supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, _registered_method=True) @@ -107,22 +107,22 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): 'Describe': grpc.unary_unary_rpc_method_handler( servicer.Describe, request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, - response_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.MiddlewareManifest.SerializeToString, + response_serializer=supervisor__middleware__pb2.MiddlewareManifest.SerializeToString, ), 'ValidateConfig': grpc.unary_unary_rpc_method_handler( servicer.ValidateConfig, - request_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigRequest.FromString, - response_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigResponse.SerializeToString, + request_deserializer=supervisor__middleware__pb2.ValidateConfigRequest.FromString, + response_serializer=supervisor__middleware__pb2.ValidateConfigResponse.SerializeToString, ), 'EvaluateHttpRequest': grpc.unary_unary_rpc_method_handler( servicer.EvaluateHttpRequest, - request_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestEvaluation.FromString, - response_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestResult.SerializeToString, + request_deserializer=supervisor__middleware__pb2.HttpRequestEvaluation.FromString, + response_serializer=supervisor__middleware__pb2.HttpRequestResult.SerializeToString, ), 'EvaluateWebSocketSession': grpc.stream_stream_rpc_method_handler( servicer.EvaluateWebSocketSession, - request_deserializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEvent.FromString, - response_serializer=egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEventResult.SerializeToString, + request_deserializer=supervisor__middleware__pb2.WebSocketSessionEvent.FromString, + response_serializer=supervisor__middleware__pb2.WebSocketSessionEventResult.SerializeToString, ), } generic_handler = grpc.method_handlers_generic_handler( @@ -154,7 +154,7 @@ def Describe(request, target, '/openshell.middleware.v1.SupervisorMiddleware/Describe', google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, - egress__gate_dot_bindings_dot_supervisor__middleware__pb2.MiddlewareManifest.FromString, + supervisor__middleware__pb2.MiddlewareManifest.FromString, options, channel_credentials, insecure, @@ -180,8 +180,8 @@ def ValidateConfig(request, request, target, '/openshell.middleware.v1.SupervisorMiddleware/ValidateConfig', - egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, - egress__gate_dot_bindings_dot_supervisor__middleware__pb2.ValidateConfigResponse.FromString, + supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, + supervisor__middleware__pb2.ValidateConfigResponse.FromString, options, channel_credentials, insecure, @@ -207,8 +207,8 @@ def EvaluateHttpRequest(request, request, target, '/openshell.middleware.v1.SupervisorMiddleware/EvaluateHttpRequest', - egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, - egress__gate_dot_bindings_dot_supervisor__middleware__pb2.HttpRequestResult.FromString, + supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, + supervisor__middleware__pb2.HttpRequestResult.FromString, options, channel_credentials, insecure, @@ -234,8 +234,8 @@ def EvaluateWebSocketSession(request_iterator, request_iterator, target, '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', - egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, - egress__gate_dot_bindings_dot_supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, options, channel_credentials, insecure, diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 45b31f46..7c4cc8cd 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -681,10 +681,9 @@ def test_pi_dependencies_are_exact_upstream_packages() -> None: def test_middleware_manifest_matches_upstream_protocol() -> None: manifest = json.loads((PROJECT / ".openshell-middleware-manifest.json").read_text()) - revision = "d1155aa70042d3e2ee49dbfa15346b108b7c1d92" - assert manifest["openshell_version"] == "0.0.116" + assert manifest["openshell_version"] == "v0.0.116" assert manifest["proto_source"] == ( - f"https://raw.githubusercontent.com/NVIDIA/OpenShell/{revision}" + "https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.116" "/proto/supervisor_middleware.proto" ) assert ( @@ -693,6 +692,7 @@ def test_middleware_manifest_matches_upstream_protocol() -> None: (PROJECT / "proto/supervisor_middleware.proto").read_bytes() ).hexdigest() ) - assert ( - f"revision={revision}" in (PROJECT / "scripts/generate-bindings.sh").read_text() - ) + script = (PROJECT / "scripts/generate-bindings.sh").read_text() + assert "--project ../openshell-middleware-manager omm update ." in script + assert "--openshell-version v0.0.116 --check-command 'make check'" in script + assert "grpc_tools.protoc" not in script and "curl" not in script From 4d6909f08f5c37cdad67d1556355c21447f0ea31 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Fri, 11 Sep 2026 22:06:23 +0000 Subject: [PATCH 70/70] feat(egress-gate): admit Pi reasoning and preserve native session behavior Preserve allowed native messages and replay metadata, use Pi's native compaction and thinking controls, and validate admission before history publication. Simplify unused admission abstractions and test scaffolding, protect registration ownership, and align the example and architecture docs. --- .../docs/architecture/admission.md | 90 ++-- .../pi-attested-admission/.env.example | 5 +- .../examples/pi-attested-admission/README.md | 161 ++++--- .../gateway-registration.py | 20 +- .../pi-attested-admission/models.json.example | 25 +- .../pi-harness/src/admission.ts | 126 ++--- .../pi-harness/src/agent.ts | 58 +-- .../pi-harness/src/session.ts | 42 +- .../pi-harness/src/verify.ts | 3 + .../pi-harness/test/admission.test.ts | 24 +- .../pi-harness/test/service-integration.ts | 29 +- .../pi-harness/test/session.test.ts | 188 ++++---- .../src/egress_gate/admission/__init__.py | 18 - .../src/egress_gate/admission/adapters.py | 442 ++++-------------- .../src/egress_gate/admission/processor.py | 38 +- projects/egress-gate/src/egress_gate/cli.py | 16 +- .../egress-gate/src/egress_gate/logging.py | 41 +- .../egress-gate/src/egress_gate/request.py | 35 -- .../src/egress_gate/service/servicer.py | 3 - .../tests/admission/test_admission.py | 206 ++------ .../tests/service/test_http_admission.py | 110 ++++- .../tests/service/test_servicer.py | 19 - projects/egress-gate/tests/test_cli.py | 1 - projects/egress-gate/tests/test_logging.py | 35 -- .../tests/test_pi_example_commands.py | 175 ++----- 25 files changed, 649 insertions(+), 1261 deletions(-) diff --git a/projects/egress-gate/docs/architecture/admission.md b/projects/egress-gate/docs/architecture/admission.md index 972aa203..81a3996f 100644 --- a/projects/egress-gate/docs/architecture/admission.md +++ b/projects/egress-gate/docs/architecture/admission.md @@ -56,12 +56,15 @@ after insertion. | --- | --- | | User / explicit skill | Final text after supported skill rendering | | Project context | Loaded project instructions and model-visible skill metadata | -| Assistant | Finalized text and tool-call IDs, names, arguments | +| Assistant | Finalized answer/reasoning text, replay metadata and tool calls | | Tool | Final output, including invalid-argument, missing-tool and execution errors | | Compaction | Complete summary, for both manual and automatic triggers | Tool-call fields are inspectable but immutable: attempted executable-argument -redaction fails closed. Unsupported images/reasoning/provider state is rejected, +redaction fails closed. Reasoning text is admitted along with immutable replay +metadata; plain reasoning can be redacted, but reasoning with signed or structured replay metadata +cannot be changed independently. Allowed native messages retain their block +order, signatures and metadata. Unsupported images/provider state is rejected, not stored as unchecked sidecars. Tool details and progress are not published; the TUI receives only admitted final results, with activity indicators while waiting. Editor drafts and pending input queues are distinct from admitted @@ -73,17 +76,21 @@ content-free failures for outstanding calls through the same boundary. If those cannot be admitted, the session stops without claiming crash recovery. Tool side effects themselves are not reversible by result admission. -Compaction keeps the latest whole user turn. Its summary-generation request -needs a fresh receipt; its finished summary needs fresh insertion approval. -The trusted `session_before_compact` extension supplies an admitted summary or -explicitly cancels, including on failure; it never falls through to an unchecked -default summary. Denial leaves the preceding context and file unchanged. -Native automatic compaction also runs between tool turns; when enabled, overflow -gets at most one compact/retry. Disabling it also disables automatic overflow -recovery, not manual compaction. This whole-turn POC cannot compact a long first -tool turn because no older user turn exists. Transient chat and summary failures -are not automatically retried; unchecked provider errors stay out of history. -Old approved entries remain in the append-only JSONL file. +Compaction uses Pi's public `compact()` computation and native recent-context +retention, including split turns. Its model requests pass through the +receipt-wrapped stream. The complete returned summary, including file-operation +text, is admitted before the trusted `session_before_compact` extension returns +it. Unchecked `details` are omitted. Failure explicitly cancels; it never falls +through to an unchecked default summary. Denial leaves the preceding context and +file unchanged. + +Manual and automatic compaction use that same checked path. Native automatic +compaction also runs between tool turns; when enabled, overflow gets at most one +compact/retry. Disabling it disables automatic overflow recovery, not manual +compaction. Short sessions may have nothing to compact under Pi's retention +budget. Transient failures are not automatically retried, and unchecked provider +errors stay out of history. Old approved entries remain in the append-only JSONL +file. Pi's synchronous system-prompt rebuilds are staged as private candidates. Public agent/session state retains the last approved system prompt, including while a @@ -99,7 +106,7 @@ and does not load third-party extensions or implicitly resume saved transcripts. The service adds one bounded `POST /v1/admission` HTTPS endpoint alongside ordinary OpenShell middleware gRPC. It reuses the transport-neutral admission -models, shape adapters, policy pipeline and receipt authority. Provider validation +models, fixed Pi shape validation, policy pipeline and receipt authority. Provider validation supports Chat Completions only and extracts ordered user/tool entries directly; there is no second normalized model-request representation. Branching, extension messages and standalone bash-execution envelopes are not admission APIs in this @@ -119,11 +126,9 @@ and derives the endpoint policy from it. Pi's own parser resolves model defaults and compatibility settings. Credential and model-cache stores are in memory; the image does not need a writable `auth.json`. Changing the selection requires repreparing and recreating the demo, not live switching. -For local installer-managed gateways, the same `register` command selects the -config and service manager (Homebrew or the DEB/RPM user service), adds the demo's -middleware entry, and restarts the gateway; `cleanup` removes that entry and -restarts it again. Other deployments use the printed -registration with their own gateway operator. The demo does not generate +For local installer-managed gateways, `register` adds the demo entry and restarts +the service; `cleanup` removes it only if it still matches the recorded entry. +Other deployments use operator-managed registration. The demo does not generate gateway credentials or download OpenShell binaries. The sandbox cannot select its authoritative identity or submit a policy. The single host-owned identity file is populated @@ -154,7 +159,7 @@ At egress the verifier: 5. rechecks that mutations did not change receipt-covered content; and 6. removes the receipt header before forwarding. -The existing `agent-attestation.v2` wire claim format is retained internally. +Receipts use the internal `agent-attestation.v2` claim format. Its ephemeral service signing key and five-minute lifetime permit identical retries, not one-time delivery. Restarting the service invalidates old receipts. @@ -172,7 +177,8 @@ steering/follow-ups, compaction and `/new`. Direct `!`/`!!` shell execution, custom extension messages, import/resume, branching, renaming, model switching, and resource reload are blocked at their public session/runtime entry points. Shell work through the model's bash tool remains supported. -No RPC mode, arbitrary extensions, reasoning, images, WebSockets, transport +OpenRouter reasoning requests and replay are supported without a Pi patch. +No RPC mode, arbitrary extensions, images, WebSockets, transport switching, or crash resume. Network policy allows only the chosen POST model path and separately scopes the admission endpoint. Unknown shapes fail closed; admission requests do not @@ -198,37 +204,17 @@ The example's `demo.sh verify` is a separate real-model end-to-end acceptance command, not a simulated demonstration. Its success must be observed, not inferred from unit tests. -The native-TUI update was validated locally on **2026-09-10**: 387 Python tests, -24 Node tests, lint/type checks, dependency audit and the documentation build -passed. This includes the terminal-driven integration above, not a live -OpenShell/real-model acceptance run. - -Protocol and application validation on **2026-09-09** used: - -| Component | Tested pin | -| --- | --- | -| Pi public npm packages | `0.85.1`, exact dependencies and integrity hashes in the example lockfile | -| OpenShell CLI, gateway and supervisor | `0.0.116`, release commit `d1155aa70042d3e2ee49dbfa15346b108b7c1d92`; the launcher now uses the operator's installed runtime | -| Node image | `22.22.2-bookworm-slim@sha256:9f6d5975c7dca860947d3915877f85607946403fc55349f39b4bc3688448bb6e` | -| HTTP client | Undici `8.9.0`; explicit public proxy configuration after loading Pi | - -Earlier validation using the now-removed isolated launcher exercised TLS/JWT bootstrap, -endpoint-bound admission credentials, allow/deny/replacement, a real Pi session -denial before history, and rejection of a raw provider request without a receipt. -Pi's actual tool-capable serialized request with a receipt passed the gate and -received HTTP 401 from the real endpoint when deliberately given an invalid test -credential. This establishes the transport seam, **not** successful model output. - -**The updated native-TUI workflow still needs live OpenShell/real-model acceptance.** -Preparation is tested with both DNS and IPv4 service addresses against a local -mTLS discovery server. Local cross-language tests exercise service TLS and -gateway public-key verification. -The host launcher contains no Linux-specific binary bootstrap; Linux tests do -not establish macOS deployment support. The checked-in -verification command passed its bypass/denial checks and then failed at the model -call with that invalid credential; it did not skip ahead. Tool continuations, -skills and compaction have deterministic application coverage but must also pass -that real-model command before describing the whole example as e2e-verified. +The deterministic integration uses HTTPS admission/provider endpoints and gateway +JWT authentication over a local insecure gRPC channel. It does not exercise +production TLS gRPC startup or a live OpenShell gateway. + +**The native-TUI workflow still needs live OpenShell/real-model acceptance.** +Run `demo.sh verify` with a valid provider credential before describing the +deployment as e2e-verified. The verifier deliberately lowers retention thresholds +for its short conversations; the interactive launcher keeps native Pi defaults. +The pinned package/image versions are recorded in the example's package lock, +Dockerfile and middleware manifest. Current validation results belong in the PR, +not a second historical log here. A useful Dev Note, **“Gating at the network layer is not enough,”** can follow: diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example index 566d4b4c..59380f72 100644 --- a/projects/egress-gate/examples/pi-attested-admission/.env.example +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -4,8 +4,9 @@ OPENSHELL_GATEWAY=your-gateway # Use reachable DNS or a LAN IPv4 address; Docker-only names may fail on the host. EGRESS_GATE_HOST=your-service-host # Required only when models.json declares more than one model. -# PI_MODEL=example/YOUR_MODEL_ID +# PI_MODEL=openrouter/z-ai/glm-5.3-flash # Optional Pi preference, forwarded to launch/verify. Omit for Pi's default. # PI_CACHE_RETENTION=long -# Real key for the HTTPS endpoint/model in models.json. Never copied into the image. +# Key for the provider in models.json (OpenRouter in the supplied example). +# Never copied into the image. Create an OpenRouter key at https://openrouter.ai/settings/keys. PI_MODEL_API_KEY=your-provider-key diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index e81cf30e..cacd8383 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -18,39 +18,55 @@ CLI configured with its name. Use OpenShell **0.0.116** (the tested protocol baseline) or a compatible newer release with middleware authentication and proxy credential delivery. This example does not install or start OpenShell. -Also needed: Bash, Python 3.11+, uv 0.11+, Docker, and a real key for a text-only -Chat Completions model. The simple image workflow assumes your gateway's Docker +Also needed: Bash, Python 3.11+, uv 0.11+, Docker, and a provider API key with +available credit/quota for a tool-capable Chat Completions model. This demo uses +text input/output, including model reasoning. The simple image workflow +assumes your gateway's Docker driver uses the same Docker daemon as `docker build`. Remote drivers/image distribution are outside this example. The same commands below work with a local, installer-managed gateway on macOS or Linux; the helper selects its config and service manager automatically. Remote and custom gateway deployments require operator-managed registration (see below). -From this directory: +For a fresh setup, from `projects/egress-gate/examples/pi-attested-admission/` +(keep your existing `.env` and `models.json` when updating an installed demo): ```sh +openshell gateway list cp .env.example .env cp models.json.example models.json -# Fill in the gateway name, reachable Egress Gate host, and model API key. -# Edit models.json for your endpoint, model ID, and token limits (see below). +# Edit .env: gateway name, reachable service host, and your provider API key. +# The model template is ready for OpenRouter; edit it only to choose another model/provider. ./demo.sh prepare ``` +The template uses [GLM-5.3-Flash on OpenRouter](https://openrouter.ai/z-ai/glm-5.3-flash), +which supports tool calling and always-on reasoning. Put an [OpenRouter API key](https://openrouter.ai/settings/keys) +in `PI_MODEL_API_KEY` in `.env`. No NVIDIA account, inference hub, or +OpenRouter-specific SDK is required. OpenRouter's [Chat Completions API](https://openrouter.ai/docs/quickstart) +uses `https://openrouter.ai/api/v1`; its attribution headers are optional and +are not needed here. Usage is billed by your provider. + Create your own `models.json` from [models.json.example](models.json.example). It uses **Pi's native `{"providers": {...}}` catalog format**. Add as many providers and models as you like. One declared model is selected automatically; with more than one, set `PI_MODEL=provider/model` in `.env` (for example, -`PI_MODEL=example/YOUR_MODEL_ID`). Model IDs can contain slashes. -No working provider configuration is shipped. Use a text-only, tool-capable -OpenAI-compatible Chat Completions endpoint; NVIDIA inference is one option if -you have access, not a requirement. Set: +`PI_MODEL=openrouter/z-ai/glm-5.3-flash`). Model IDs can contain slashes. +Leave `PI_MODEL` unset for the single-model template. To use another provider, +replace the provider name, HTTPS base URL, model ID and model limits, and supply +that provider's key in the same `PI_MODEL_API_KEY` variable. No script or policy +edits are needed: preparation derives the permitted host and request path from +the selected model. Use a tool-capable model through an +OpenAI-compatible Chat Completions endpoint. Set: - `providers..models`: your models, each with an `id` and optional `name`. - `providers..baseUrl`: the HTTPS API base, such as `https://your-provider.example/v1`; the application appends `/chat/completions`. - `contextWindow` and `maxTokens`: the model's context limit and your desired response limit, in tokens. Pi applies its normal context-fit adjustment; the - application adds no response-token cap. The template's numbers are examples. + application adds no response-token cap. The template uses the model's published + context window and chooses a 32,768-token output budget, shared by reasoning + and the answer; adjust it as desired. - `samplingParams`: Pi forwards model sampling settings such as `temperature` and `top_p` without application overrides. The gate's supported request shape still applies; unknown provider-specific fields are rejected, not dropped. @@ -58,7 +74,12 @@ you have access, not a requirement. Set: `max_completion_tokens`). The other compatibility settings are conservative defaults; adjust them if your endpoint requires it. -Keep `api`, `reasoning`, and `input` as shown for this demo. +Keep `api` and text-only `input` as shown. Set `reasoning` to match your model. +GLM-5.3-Flash cannot disable thinking; its [documented levels](https://docs.z.ai/guides/vlm/glm-5.3-flash) +are `low`, `high`, and `max`. The template's native Pi `thinkingLevelMap` exposes +those levels. Pi clamps its default `medium` to `high`; its normal thinking +controls can select another supported level. Ordinary requests and compaction +use the selected level. The zero `cost` values disable cost estimates; provider usage is not free. Put the API key only in `.env`, never in `models.json`. Both files are ignored by Git. Preparation copies **only the selected model** and @@ -78,8 +99,6 @@ unset for Pi's default. No rebuild is needed. Pi disables cache retention for one-off compaction requests. Redaction and compaction can change prompt content and therefore cache hits; approval receipts are not added to the prompt. -If you used the earlier single-object `model.json`, start from the new template -and transfer your endpoint and model settings; renaming the file alone is not enough. The catalog can contain many models, but each prepared demo uses **one**. To change the selection, run `./demo.sh cleanup`, stop `serve`, update `PI_MODEL` and its key, then repeat `prepare`, `serve`, `register`, and `setup`. Live model switching is @@ -143,7 +162,12 @@ gateway. Those operator-managed registrations must also be removed manually. `setup` displays the selected gateway and creates the `pi-admission` sandbox and its two provider profiles/instances. Reserve those names for this demo. Egress Gate listens on **50051** (authenticated middleware gRPC) and **5443** -(authenticated admission HTTPS). Restrict access to the gateway/sandbox network. +(authenticated admission HTTPS). The gateway must reach the first port and the +sandbox must reach the second at `EGRESS_GATE_HOST`; allow those connections +through the service host's firewall. Restrict access to the gateway/sandbox network. +If setup stops partway through, run `cleanup` before retrying `register` and +`setup`; do not repeatedly import the same provider profiles. Keep `serve` +running until cleanup has removed the gateway registration. Every action can print its commands without executing them, loading `.env`, or printing secrets: @@ -165,7 +189,8 @@ remain outside the image and repository. real admission HTTP/RPC traffic, tool output, compaction, denial and saved JSONL. Provider responses in those tests are controlled fixtures. The updated native-TUI workflow still needs acceptance testing with a running OpenShell gateway and a -real model. Registration lifecycle tests cover both supported service managers. +real model, including the OpenRouter example. Registration lifecycle tests cover +both supported service managers. `./demo.sh verify` below is that separate real-model acceptance check. ## What to try @@ -188,7 +213,8 @@ The first is denied; the second becomes `[REDACTED]` before insertion. Policy detection is only as good as its configured rules. The selected project is `/sandbox/project`, copied from [project/](project/). -The same directory scopes Pi's resource loader, tools, and session store. +Pi's resource loader and tools use that directory; saved sessions live separately +under `/sandbox/sessions`. Its `AGENTS.md` and skill metadata are admitted as system context. `/skill:review` loads and renders the actual skill before user-message admission. The skill asks the model to read the real `notes.txt`; that result is admitted @@ -197,28 +223,35 @@ boundary: OpenShell's filesystem policy supplies that boundary. Responses and tool output are buffered until approved, rather than streamed unchecked into the transcript. Pi still shows activity while waiting. +Reasoning text and its provider replay metadata are admitted before the thinking +block reaches live history or JSONL. On allow, native messages are preserved, +including block order and signatures. Plain reasoning can be redacted; +reasoning carrying signed or structured replay metadata is immutable, so a policy attempting to +redact it denies that candidate instead of breaking replay. Opaque metadata is +inspected as supplied, not decrypted. The same applies +to executable tool-call fields. Text redaction across multiple assistant/user +blocks is rejected when the joined projection cannot identify the original block. The launch and verification commands pass `--disable-warning=UNDICI-EHPA` directly to Node to hide only the experimental `EnvHttpProxyAgent` notice; other warnings and errors remain visible. This does not depend on Docker image environment variables being inherited by `sandbox exec`. Use Ctrl+O to expand tool output and `/session` to inspect session information; -Pi saves JSONL under `/sandbox/sessions`. The former custom `/history` and -`/exit` commands are gone; use Pi's chat view and `/quit`. +Pi saves JSONL under `/sandbox/sessions`. Preferences changed in the TUI survive `/new` within this running application; they are not saved across launcher restarts. Provider session-affinity/cache identity follows Pi's normal behavior, including a new identity for `/new`. -Compaction retains the latest whole turn; older **approved** entries remain in -the append-only file. Automatic compaction -uses the same summary path at Pi's context thresholds, including between tool -turns. Esc cancels the current operation. Steering and follow-up inputs are +Compaction uses Pi's native retention policy and summary computation; only the +admitted final summary enters history. Older **approved** entries remain in the +append-only file. Automatic compaction uses the same checked path at Pi's +context thresholds, including between tool turns. Esc cancels the current operation. Steering and follow-up inputs are admitted after skill expansion, before joining the transcript. Drafts and pending input queues are not approved history. An unfinished tool batch that cannot be safely closed requires `/new`. Turning automatic compaction off also disables automatic overflow recovery; -manual `/compact` remains available. The whole-turn policy cannot compact a -long first tool turn: there is no older turn to summarize. Transient chat and -summary failures stop the operation rather than automatically retrying in this -POC; unchecked provider errors are never appended to history. +manual `/compact` remains available. Pi can split a long turn, but reports +“Nothing to compact” when the conversation fits its recent-context budget. +Transient chat and summary failures stop the operation rather than automatically +retrying; unchecked provider errors are never appended to history. This POC deliberately blocks `!`/`!!`, resume/import, branching, renaming, model switching, and resource reload: these need additional handling before @@ -236,7 +269,8 @@ not unchecked progress or extra tool metadata such as edit diffs. Verification uses the **real configured model** and can incur several model calls and normal provider charges. It checks a raw request without a receipt, deny/redact history, a real skill/tool continuation, and manual and automatic -compaction. It exits unsuccessfully on any missing capability or failed check; +compaction. The verifier lowers retention thresholds for its short test conversations; +the interactive launcher retains Pi's defaults. It exits unsuccessfully on any missing capability or failed check; it does not skip checks or substitute a mock model. Deterministic failure and pending-admission tests live in [pi-harness/test/](pi-harness/test/). @@ -246,68 +280,33 @@ then removes the registration created by `register` and restarts the gateway. Copy out anything wanted first, then stop `serve` with Ctrl-C. Host configuration and the local Docker image remain for reuse. To remove only the registration (without deleting the sandbox), use `./demo.sh unregister`. This also works -after a failed registration restart; fix the service problem and retry. +after a failed registration restart; fix the service problem and retry. If an +operator changed the registration, cleanup refuses to remove it. Older ownership +records without the installed entry also require operator-managed removal. For source/model/policy changes, clean up the old demo sandbox, run `prepare`, restart `serve`, then run `register`, `setup`, and `launch`. This rebuild is -also required when upgrading from the earlier line-based interface to the TUI; -launching an existing sandbox continues using its old image. Valid service certificates are -reused for the same host. After 30 days or a host change, `prepare` generates new service -TLS: rerun `register` to reload the new CA before setup (other deployments must +required because existing sandboxes continue using their old image. Valid service +certificates are reused for the same host. After 30 days or a host change, +`prepare` generates new service TLS: rerun `register` to reload the new CA before setup (other deployments must update their gateway-visible CA and restart manually). Refresh the gateway identity by rerunning `prepare` and restarting `serve` if the gateway rotates its signing key. Discovery currently expects one published signing key. -The earlier isolated launcher's `.workspaces/pi-no-fork/` directory is no longer -used. Any old isolated gateway must be stopped separately; this launcher does -not manage it. - ## How the pieces fit -```text -Pi application Egress Gate (outside sandbox) - candidate ------------------> policy: allow / replace / deny - approved entry <-------------+ - | - +--> live context + Pi JSONL - | - next user/tool context ------> policy + signed receipt - model request + receipt - | - v -OpenShell supervisor ----------> verify actual request + policy - | strip receipt header - v -attach real provider key --> model -``` +[agent.ts](pi-harness/src/agent.ts) approves candidates before publishing message +events; Pi's native session is the only history writer. +[session.ts](pi-harness/src/session.ts) connects that agent to the native TUI, +checked compaction, and guards on unsupported write paths. +[admission.ts](pi-harness/src/admission.ts) calls the external service and +attaches receipts to model requests. OpenShell invokes Egress Gate to verify +those requests before adding real provider credentials. -[agent.ts](pi-harness/src/agent.ts) supplies Pi's public `AgentSessionConfig.agent` -with an admission-controlled execution loop. It approves each candidate before -updating live state or emitting message events. Pi's native `AgentSession` -is the **only persistence owner**; it saves those approved events. -[session.ts](pi-harness/src/session.ts) wires the runtime and the -`session_before_compact` extension, and blocks alternate unchecked write paths. -Finalized assistant text and tool calls are admitted -before execution. Tool output, missing-tool/argument/execution errors, rendered -skills, and completed summaries all pass the same boundary. - -[admission.ts](pi-harness/src/admission.ts) translates these candidates into the existing -Egress Gate schemas. A provider-context replacement is rejected: silently -redacting only the outbound request would leave saved history inconsistent. - -[prepare.py](prepare.py) is **trusted host-side operator code**, not the -in-sandbox harness. It provisions policy, service TLS, and endpoint-bound provider -profiles. Setup reads the actual sandbox ID from OpenShell and binds the -admission credential to it. The application cannot supply an authoritative -sandbox ID or choose a policy. Upstream credential delivery gives it placeholders, -not the real model/admission secrets. Placeholders are usable capabilities, not -proof of which code used them; the application removes them from child-process -environments as hygiene, not a security boundary. - -The service uses standard OpenShell RPCs and verifies the gateway's signed -extension JWT, including the supervisor's sandbox identity. Its additional -HTTPS listener accepts candidates. There is no loopback bridge, custom RPC, -custom OpenShell protobuf field, or second model proxy. +[prepare.py](prepare.py) runs on the trusted host, not in the sandbox. It owns +policy, TLS and credential provisioning. Setup binds the real sandbox ID. +See the [architecture guide](../../docs/architecture/admission.md) for the data +flow, exact receipt contract and verification evidence. ## Honest boundaries @@ -321,8 +320,8 @@ custom OpenShell protobuf field, or second model proxy. five minutes; service restarts invalidate them. - One text-only Chat Completions model, sequential tools, and new sessions. The real TUI is used, but not every stock CLI feature is supported. - No RPC mode, third-party extensions, resume/branching, images, reasoning - payloads, WebSockets, or model switching. Unsupported content fails closed. + No RPC mode, third-party extensions, resume/branching, images, WebSockets, + or model switching. Unsupported content fails closed. - Redaction can change ordinary text, not executable tool arguments or call identifiers. Admitting a tool result cannot reverse tool side effects. Bash output is bounded before Pi's unchecked spill-to-file behavior. diff --git a/projects/egress-gate/examples/pi-attested-admission/gateway-registration.py b/projects/egress-gate/examples/pi-attested-admission/gateway-registration.py index f390454d..1a37bc96 100644 --- a/projects/egress-gate/examples/pi-attested-admission/gateway-registration.py +++ b/projects/egress-gate/examples/pi-attested-admission/gateway-registration.py @@ -43,7 +43,8 @@ def configure(action: str, state: Path, gateway: str) -> None: config, restart = _local_service() registration = {"gateway": gateway, "config": str(config)} - if record.exists() and json.loads(record.read_text()) != registration: + saved = json.loads(record.read_text()) if record.exists() else None + if saved is not None and any(saved.get(k) != v for k, v in registration.items()): raise ValueError( "Gateway/config changed; restore the previous selection to clean up first." ) @@ -61,7 +62,7 @@ def configure(action: str, state: Path, gateway: str) -> None: if matches: entries = tomllib.loads(original)["openshell"]["supervisor"]["middleware"] existing = [entry for entry in entries if entry.get("name") == "pi-egress"] - if existing != [desired] or not record.exists(): + if existing != [desired] or saved is None or saved.get("entry") != desired: raise ValueError( "pi-egress is already registered; refusing to overwrite it." ) @@ -69,7 +70,7 @@ def configure(action: str, state: Path, gateway: str) -> None: updated = original.rstrip() + "\n\n" + fragment tomllib.loads(updated) # Remember ownership so cleanup also works after a failed restart. - record.write_text(json.dumps(registration) + "\n") + record.write_text(json.dumps({**registration, "entry": desired}) + "\n") config.parent.mkdir(parents=True, exist_ok=True) with tempfile.NamedTemporaryFile( mode="w", dir=config.parent, delete=False @@ -84,6 +85,19 @@ def configure(action: str, state: Path, gateway: str) -> None: Path(temporary.name).unlink(missing_ok=True) print(f"Registered pi-egress in {config}", flush=True) else: + entries = ( + ( + tomllib.loads(config.read_text()) + .get("openshell", {}) + .get("supervisor", {}) + .get("middleware", []) + ) + if config.exists() + else [] + ) + existing = [entry for entry in entries if entry.get("name") == "pi-egress"] + if existing and (saved is None or existing != [saved.get("entry")]): + raise ValueError("pi-egress registration changed; refusing to remove it.") remove_gateway_config(config, middleware_name="pi-egress") print(f"Removed pi-egress from {config}", flush=True) diff --git a/projects/egress-gate/examples/pi-attested-admission/models.json.example b/projects/egress-gate/examples/pi-attested-admission/models.json.example index ee75d4db..4c0e6335 100644 --- a/projects/egress-gate/examples/pi-attested-admission/models.json.example +++ b/projects/egress-gate/examples/pi-attested-admission/models.json.example @@ -1,23 +1,32 @@ { "providers": { - "example": { - "baseUrl": "https://api.example.com/v1", + "openrouter": { + "baseUrl": "https://openrouter.ai/api/v1", "api": "openai-completions", "compat": { "maxTokensField": "max_tokens", "supportsDeveloperRole": false, - "supportsReasoningEffort": false + "supportsReasoningEffort": true }, "models": [ { - "id": "YOUR_MODEL_ID", - "name": "Your model", - "reasoning": false, + "id": "z-ai/glm-5.3-flash", + "name": "GLM-5.3-Flash (OpenRouter)", + "reasoning": true, + "thinkingLevelMap": { + "off": null, + "minimal": null, + "low": "low", + "medium": null, + "high": "high", + "xhigh": null, + "max": "max" + }, "input": [ "text" ], - "contextWindow": 128000, - "maxTokens": 4096, + "contextWindow": 1048576, + "maxTokens": 32768, "cost": { "input": 0, "output": 0, diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/admission.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/admission.ts index a8fd8e16..207ee38f 100644 --- a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/admission.ts @@ -7,7 +7,6 @@ import type { Context, Message, TextContent, - ToolResultMessage, } from "@earendil-works/pi-ai"; export const RECEIPT_HEADER = "x-egress-admission"; @@ -43,7 +42,7 @@ export class AdmissionError extends Error { unavailable: "Admission is unavailable; no unchecked content will be added.", unsupported: - "This example supports text content without reasoning payloads only.", + "This content is outside the example’s supported Chat Completions format.", invalid: "Admission returned an inconsistent result; the operation was stopped.", }[kind], @@ -146,88 +145,91 @@ export class Admission { async message(message: Message, signal?: AbortSignal): Promise { if (message.role === "user") { - return { - role: "user", - content: await this.text("user", textOnly(message.content), signal), - timestamp: message.timestamp, - }; + const original = textOnly(message.content); + const approved = await this.text("user", original, signal); + if (approved === original) return message; + if (typeof message.content === "string") + return { ...message, content: approved }; + if (message.content.length !== 1 || message.content[0].type !== "text" || + message.content[0].textSignature) + throw new AdmissionError("invalid"); + return { ...message, content: [{ ...message.content[0], text: approved }] }; } if (message.role === "assistant") { - if ( - message.content.some( - (block) => block.type !== "text" && block.type !== "toolCall", - ) - ) + if (message.content.some((block) => + block.type !== "text" && block.type !== "toolCall" && block.type !== "thinking")) throw new AdmissionError("unsupported"); - const calls = message.content - .filter((block) => block.type === "toolCall") - .map(({ id, name, arguments: args }) => ({ - id, - name, - arguments: args, - })); + const texts = message.content.filter((block) => block.type === "text"); + const thinking = message.content.filter((block) => block.type === "thinking"); + const calls = message.content.filter((block) => block.type === "toolCall").map((call) => ({ + id: call.id, name: call.name, arguments: call.arguments, + thought_signature: call.thoughtSignature ?? null, + })); const envelope = { schema_version: "openshell.pi-assistant-message.v1", - text: message.content - .filter((block) => block.type === "text") - .map((block) => block.text) - .join("\n"), + text: texts.map((block) => block.text).join("\n"), tool_calls: calls, + thinking: thinking.map((block) => ({ + text: block.thinking, signature: block.thinkingSignature ?? null, + })), }; const admitted = await this.apply("assistant_message", envelope, signal); - if ( - typeof admitted.text !== "string" || - !isDeepStrictEqual(admitted.tool_calls, calls) - ) + // Preserve the complete native message on allow, including block order, + // signatures, usage and provider metadata. + if (admitted === envelope) return message; + if (typeof admitted.text !== "string" || + !isDeepStrictEqual(admitted.tool_calls, calls) || + !Array.isArray(admitted.thinking) || admitted.thinking.length !== thinking.length) throw new AdmissionError("invalid"); + const changedText = admitted.text !== envelope.text; + // A joined text projection cannot safely identify edits across multiple blocks. + if (changedText && (texts.length !== 1 || texts[0].textSignature)) + throw new AdmissionError("invalid"); + const replacements = admitted.thinking.map((value: unknown, index: number) => { + const original = envelope.thinking[index]; + if (!isRecord(value) || typeof value.text !== "string" || + value.signature !== original.signature || + (original.signature !== null && + !["reasoning", "reasoning_content", "reasoning_text"].includes(original.signature) && + value.text !== original.text)) + throw new AdmissionError("invalid"); + return value.text; + }); + let index = 0; return { - role: "assistant", - api: message.api, - provider: message.provider, - model: message.model, - usage: message.usage, - stopReason: message.stopReason, - timestamp: message.timestamp, - content: [ - ...(admitted.text - ? [{ type: "text" as const, text: admitted.text }] - : []), - ...calls.map((call) => ({ type: "toolCall" as const, ...call })), - ], + ...message, + content: message.content.map((block) => { + if (block.type === "text" && changedText) + return { ...block, text: admitted.text as string }; + if (block.type === "thinking") + return { ...block, thinking: replacements[index++] }; + return block; + }), }; } + // Keep text block boundaries and metadata; images remain outside this POC. + textOnly(message.content); const envelope = { schema_version: "openshell.pi-tool-result.v1", tool_call_id: message.toolCallId, tool_name: message.toolName, - content: [{ type: "text", text: textOnly(message.content) }], + content: message.content.map((block) => ({ type: "text", text: (block as TextContent).text })), is_error: message.isError, }; const admitted = await this.apply("tool_result", envelope, signal); - if ( - admitted.tool_call_id !== message.toolCallId || - admitted.tool_name !== message.toolName || - admitted.is_error !== message.isError || - !Array.isArray(admitted.content) - ) + if (admitted === envelope) return message; + if (admitted.tool_call_id !== message.toolCallId || + admitted.tool_name !== message.toolName || admitted.is_error !== message.isError || + !Array.isArray(admitted.content) || admitted.content.length !== message.content.length) throw new AdmissionError("invalid"); - const content = admitted.content.map((block: unknown): TextContent => { - if ( - !isRecord(block) || - block.type !== "text" || - typeof block.text !== "string" - ) + const content = admitted.content.map((value: unknown, index: number): TextContent => { + const original = message.content[index] as TextContent; + if (!isRecord(value) || value.type !== "text" || typeof value.text !== "string" || + (original.textSignature && value.text !== original.text)) throw new AdmissionError("invalid"); - return { type: "text", text: block.text }; + return { ...original, text: value.text }; }); - return { - role: "toolResult", - toolCallId: message.toolCallId, - toolName: message.toolName, - content, - isError: message.isError, - timestamp: message.timestamp, - } satisfies ToolResultMessage; + return { ...message, content }; } async receipt(context: Context, signal?: AbortSignal): Promise { diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/agent.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/agent.ts index 403f29ea..f037c2e2 100644 --- a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/agent.ts +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/agent.ts @@ -9,6 +9,7 @@ import { type StreamFn, } from "@earendil-works/pi-agent-core"; import { + clampThinkingLevel, isContextOverflow, validateToolArguments, type AssistantMessage, @@ -44,7 +45,11 @@ export class AdmissionAgent extends Agent { streamFn: StreamFn, private readonly admission: Admission, ) { - super({ initialState: { model, thinkingLevel: "off" }, streamFn }); + // Match Pi's default thinking level; the session's native controls can change it. + super({ + initialState: { model, thinkingLevel: clampThinkingLevel(model, "medium") }, + streamFn, + }); // Pi's base lifecycle fields are readonly. This engine owns its own public // state and lifecycle; it never invokes the base execution/state reducer. const owner = this; @@ -182,7 +187,11 @@ export class AdmissionAgent extends Agent { messages: convertToLlm(this.live.messages), tools: this.live.tools, }, - { signal: this.signal, sessionId: this.sessionId }, + { + signal: this.signal, + sessionId: this.sessionId, + reasoning: this.live.thinkingLevel === "off" ? undefined : this.live.thinkingLevel, + }, ) ).result(); this.signal!.throwIfAborted(); @@ -195,16 +204,7 @@ export class AdmissionAgent extends Agent { throw new Error( "Model request failed or was cancelled; no response was saved.", ); - const assistant = (await this.admit({ - role: "assistant", - content: response.content, - api: this.live.model.api, - provider: this.live.model.provider, - model: this.live.model.id, - usage: retainedUsage(response.usage), - stopReason: response.stopReason, - timestamp: Date.now(), - })) as AssistantMessage; + const assistant = (await this.admit(response)) as AssistantMessage; await this.publish(assistant, published); const calls = assistant.content.filter( (block) => block.type === "toolCall", @@ -231,18 +231,13 @@ export class AdmissionAgent extends Agent { }); try { if (!tool) throw new Error("Requested tool is not available."); - args = validateToolArguments(tool, call); - const before = await this.beforeToolCall?.( - { - assistantMessage: assistant, - toolCall: call, - args, - context: this.context(), - }, - this.signal, - ); - if (before?.block) - throw new Error(before.reason ?? "Tool execution blocked."); + // Pi's edit tool normalizes common model argument shapes in place. + // Keep that preparation separate from the already-approved message. + const prepared = structuredClone(call); + prepared.arguments = (tool.prepareArguments + ? tool.prepareArguments(prepared.arguments) + : prepared.arguments) as typeof prepared.arguments; + args = validateToolArguments(tool, prepared); // No onUpdate callback: partial tool output is not approved yet. result = await tool.execute(call.id, args, this.signal); } catch (error) { @@ -260,23 +255,12 @@ export class AdmissionAgent extends Agent { details: undefined, }; } - const after = await this.afterToolCall?.( - { - assistantMessage: assistant, - toolCall: call, - args, - result, - isError, - context: this.context(), - }, - this.signal, - ); const approved = (await this.admit({ role: "toolResult", toolCallId: call.id, toolName: call.name, - content: after?.content ?? result.content, - isError: after?.isError ?? isError, + content: result.content, + isError: isError, timestamp: Date.now(), })) as ToolResultMessage; await this.publishTool(approved, published); diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/session.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/session.ts index 85fa6b79..78318af4 100644 --- a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/session.ts +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/session.ts @@ -2,7 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 import { resolve } from "node:path"; -import type { AgentTool, StreamFn } from "@earendil-works/pi-agent-core"; +import type { StreamFn } from "@earendil-works/pi-agent-core"; import { InMemoryCredentialStore, type Model } from "@earendil-works/pi-ai"; import { streamSimple } from "@earendil-works/pi-ai/compat"; import { @@ -13,8 +13,7 @@ import { ModelRuntime, createAgentSessionServices, convertToLlm, - generateSummaryWithUsage, - sessionEntryToContextMessages, + compact, type CreateAgentSessionRuntimeFactory, type PromptOptions, } from "@earendil-works/pi-coding-agent"; @@ -37,7 +36,6 @@ export interface SessionOptions { admission: Admission; /** Deterministic integration tests use Pi's public stream/tool seams. */ stream?: StreamFn; - tools?: AgentTool[]; compactAtTokens?: number; } @@ -138,7 +136,6 @@ export async function createAdmissionRuntime( function sessionFactory(options: SessionOptions) { if ( options.model.api !== "openai-completions" || - options.model.reasoning || options.model.input.some((type) => type !== "text") || new URL(options.model.baseUrl).protocol !== "https:" ) @@ -149,7 +146,6 @@ function sessionFactory(options: SessionOptions) { enableInstallTelemetry: false, compaction: { enabled: true, - keepRecentTokens: 0, reserveTokens: options.compactAtTokens === undefined ? undefined @@ -198,30 +194,14 @@ function sessionFactory(options: SessionOptions) { // throwing from an extension handler could fall back to Pi's // unchecked default summarizer. try { - const entries = sessionManager.buildContextEntries(); - const keepIndex = entries.findLastIndex( - (entry) => - entry.type === "message" && entry.message.role === "user", - ); - if (keepIndex <= 0) return { cancel: true }; - const previous = entries.slice(0, keepIndex); - const messages = previous.flatMap((entry) => - entry.type === "compaction" - ? [] - : sessionEntryToContextMessages(entry), - ); - if (!messages.length) return { cancel: true }; - const summary = await generateSummaryWithUsage( - messages, + const summary = await compact( + event.preparation, options.model, - event.preparation.settings.reserveTokens, options.apiKey, undefined, - event.signal, event.customInstructions, - previous.find((entry) => entry.type === "compaction") - ?.summary, - "off", + event.signal, + session.thinkingLevel, stream, undefined, { enabled: false, maxRetries: 0, baseDelayMs: 0 }, @@ -230,15 +210,17 @@ function sessionFactory(options: SessionOptions) { ); const approved = await options.admission.text( "compaction_summary", - summary.text, + summary.summary, event.signal, ); return { compaction: { summary: approved, - firstKeptEntryId: entries[keepIndex].id, + firstKeptEntryId: summary.firstKeptEntryId, tokensBefore: event.preparation.tokensBefore, - usage: retainedUsage(summary.usage), + ...(summary.usage + ? { usage: retainedUsage(summary.usage) } + : {}), }, }; } catch { @@ -259,7 +241,7 @@ function sessionFactory(options: SessionOptions) { options.model.provider, options.apiKey, ); - const tools = options.tools ?? projectTools(cwd); + const tools = projectTools(cwd); const agent = new AdmissionAgent(options.model, stream, options.admission); agent.sessionId = sessionManager.getSessionId(); agent.steeringMode = settingsManager.getSteeringMode(); diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/verify.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/verify.ts index b94fe721..48f4542a 100644 --- a/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/verify.ts +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/src/verify.ts @@ -84,6 +84,8 @@ async function verify(): Promise { "/skill:review Use the read tool to read notes.txt; do not guess its contents.", ); assertProjectRead(session.history); + // Force a short-demo compaction without changing the interactive launcher defaults. + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); assert.ok( await session.compact(), "Manual compaction must summarize an older turn", @@ -103,6 +105,7 @@ async function verify(): Promise { "PASS real model, redacted input, rendered skill, tool continuation, manual compaction, and JSONL history", ); const automatic = await makeSession(1); + automatic.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); await automatic.prompt("Reply with a brief greeting."); await automatic.prompt("Reply with a brief farewell."); assert.ok(automatic.entries.some((entry) => entry.type === "compaction")); diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/admission.test.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/admission.test.ts index d089777c..c3043013 100644 --- a/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/admission.test.ts +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/admission.test.ts @@ -11,7 +11,7 @@ import { textOnly, } from "../src/admission.js"; -test("unsupported content and changed executable fields fail closed", async () => { +test("allow preserves native messages; executable and signed reasoning changes fail closed", async () => { assert.throws(() => textOnly([{ type: "image" }]), AdmissionError); const message: AssistantMessage = { role: "assistant", @@ -21,12 +21,16 @@ test("unsupported content and changed executable fields fail closed", async () = timestamp: 0, stopReason: "toolUse", content: [ + { type: "text", text: "before" }, + { type: "thinking", thinking: "private reasoning", thinkingSignature: "provider-signature" }, { type: "toolCall", id: "call", name: "bash", arguments: { command: "original" }, + thoughtSignature: "tool-signature", }, + { type: "text", text: "after", textSignature: "text-signature" }, ], usage: { input: 0, @@ -37,6 +41,24 @@ test("unsupported content and changed executable fields fail closed", async () = cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }, }; + const allowed = new Admission(async () => ({ decision: "allow", replacement: null, receipt: null })); + assert.strictEqual(await allowed.message(message), message); + for (const role of ["user", "toolResult"] as const) { + const candidate = { role, content: [{ type: "text" as const, text: "one" }, { type: "text" as const, text: "two" }], + timestamp: 1, toolCallId: "call", toolName: "read", isError: false }; + assert.strictEqual(await allowed.message(candidate), candidate); + } + const signedChange = new Admission(async (_kind, body) => ({ + decision: "replace", receipt: null, + replacement: { ...body, thinking: [{ text: "changed", signature: "provider-signature" }] }, + })); + await assert.rejects(signedChange.message(message), AdmissionError); + const unsigned = { ...message, content: [{ type: "thinking" as const, thinking: "private reasoning" }] }; + const redactor = new Admission(async (_kind, body) => ({ + decision: "replace", receipt: null, + replacement: { ...body, thinking: [{ text: "approved reasoning", signature: null }] }, + })); + assert.deepEqual((await redactor.message(unsigned)).content, [{ type: "thinking", thinking: "approved reasoning" }]); const admission = new Admission(async (_kind, body) => ({ decision: "replace", replacement: { ...body, tool_calls: [] }, diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/service-integration.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/service-integration.ts index 2e831abc..a3b559b7 100644 --- a/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/service-integration.ts +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/service-integration.ts @@ -19,10 +19,10 @@ import { assertProjectRead } from "../src/verify.js"; const [endpoint, directory] = process.argv.slice(2); const model = await loadSelectedModel(join(directory, "image")); -assert.equal(model.id, "YOUR_MODEL_ID"); +assert.equal(model.id, "z-ai/glm-5.3-flash"); assert.equal(model.compat?.supportsDeveloperRole, false); // Only the endpoint changes: exercise the prepared catalog through Pi's parser. -model.baseUrl = `${endpoint}/v1`; +model.baseUrl = `${endpoint}/api/v1`; const options = (compactAtTokens?: number) => ({ cwd: join(directory, "image/project"), sessionDir: join(directory, "sessions"), @@ -42,17 +42,28 @@ const options = (compactAtTokens?: number) => ({ if (process.argv.includes("--tui")) { const runtime = await createAdmissionRuntime(options()); assert.equal(runtime.services.modelRuntime.getError(), undefined); - await new InteractiveMode(runtime, { + const interactive = new InteractiveMode(runtime, { initialMessage: "Please repeat REDACT_THIS and café.", initialMessages: ["/skill:review"], - }).run(); + }); + await interactive.init(); + runtime.session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); + await interactive.run(); process.exit(0); } -const create = (compactAtTokens?: number) => - AdmissionSession.create(options(compactAtTokens)); +const create = async (compactAtTokens?: number) => { + const session = await AdmissionSession.create(options(compactAtTokens)); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); + return session; +}; const session = await create(); +if (process.argv.includes("--settings")) { + // The Python provider checks the actual serialized model/cache settings. + await session.prompt("Check model settings"); + process.exit(0); +} assert.equal(session.modelRuntime.getError(), undefined); await assert.rejects(session.prompt("DENY_THIS"), AdmissionError); assert.equal(session.history.length, 0); @@ -60,12 +71,6 @@ assert.equal(session.entries.length, 0); await session.prompt("Please repeat REDACT_THIS and café."); await session.prompt("/skill:review"); assertProjectRead(session.history); -assert.throws(() => - assertProjectRead(session.history.map((message) => - message.role === "toolResult" ? { ...message, isError: true } : message, - )), -); -assert.throws(() => assertProjectRead([])); for (const snapshot of [ JSON.stringify(session.history), await readFile(session.sessionFile, "utf8"), diff --git a/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/session.test.ts b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/session.test.ts index 19fcf97a..e748789d 100644 --- a/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/session.test.ts +++ b/projects/egress-gate/examples/pi-attested-admission/pi-harness/test/session.test.ts @@ -122,6 +122,7 @@ async function fixture( stream, compactAtTokens, }); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); return { session, cwd, requests, kinds }; } @@ -138,6 +139,10 @@ for (const [kind, marker, prompt, responses] of [ ["user_message", "CANDIDATE", "CANDIDATE", [answer("done")]], ["user_message", "SKILL_CANDIDATE", "/skill:example", [answer("done")]], ["assistant_message", "CANDIDATE", "hello", [answer("CANDIDATE")]], + ["assistant_message", "REASONING_CANDIDATE", "hello", [{ + ...answer("done"), + content: [{ type: "thinking", thinking: "REASONING_CANDIDATE" }, { type: "text", text: "done" }] as AssistantMessage["content"], + }]], [ "tool_result", "CANDIDATE", @@ -196,100 +201,87 @@ for (const [kind, marker, prompt, responses] of [ }); } -test("redacted real tool output is the only version saved and sent on continuation", async () => { - const { session, cwd, requests } = await fixture( - async (kind, body) => { - if (kind === "tool_result") - return { - decision: "replace", - replacement: { - ...body, - content: [{ type: "text", text: "[REDACTED]" }], - }, - receipt: null, - }; - return kind === "provider_context" - ? { ...allow, receipt: "receipt" } - : allow; - }, - [ - answer("", [ - { id: "call", name: "read", arguments: { path: "candidate.txt" } }, - ]), - answer("Finished"), - ], - ); - await writeFile(join(cwd, "candidate.txt"), "RAW_TOOL_CONTENT"); - await session.prompt("Read candidate.txt"); - assert.equal(requests.length, 2); - assert.ok(JSON.stringify(requests[1]).includes("[REDACTED]")); - assert.ok(!JSON.stringify(session.entries).includes("RAW_TOOL_CONTENT")); - assert.ok(!(await disk(session)).includes("RAW_TOOL_CONTENT")); - assert.ok((await disk(session)).includes("[REDACTED]")); -}); - -test("manual compaction waits for approval and preserves the latest whole turn", async () => { - let release!: (result: AdmissionResponse) => void; - let reached!: () => void; - const seen = new Promise((resolve) => { - reached = resolve; - }); - const pending = new Promise((resolve) => { - release = resolve; +for (const [shape, edits] of [ + ["JSON string", { edits: JSON.stringify([{ oldText: "before", newText: "after" }]) }], + ["single edit", { edits: { oldText: "before", newText: "after" } }], + ["legacy", { oldText: "before", newText: "after" }], +] as const) { + test(`native edit preparation preserves approved ${shape} arguments`, async () => { + const args = { path: "edit.txt", ...edits }; + const { session, cwd } = await fixture(undefined, [ + answer("", [{ id: "edit", name: "edit", arguments: args }]), + answer("Done"), + ]); + await writeFile(join(cwd, "edit.txt"), "before"); + await session.prompt("Edit the file"); + assert.equal(await readFile(join(cwd, "edit.txt"), "utf8"), "after"); + const assistant = session.history.find( + (message) => message.role === "assistant", + )!; + const call = assistant.content.find((block) => block.type === "toolCall")!; + assert.deepEqual(call.arguments, args); + const saved = (await disk(session)) + .trim().split("\n").map((line) => JSON.parse(line)); + assert.deepEqual( + saved.find((entry) => entry.message?.role === "assistant").message.content + .find((block: { type: string }) => block.type === "toolCall").arguments, + args, + ); }); - let summaryBody: Record = {}; - const { session, requests } = await fixture( - async (kind, body) => { +} + +for (const decision of ["deny", "replace"] as const) { + test(`split-turn compaction waits for admission: ${decision}`, async () => { + let release!: (result: AdmissionResponse) => void; + let reached!: () => void; + const pending = new Promise((resolve) => { release = resolve; }); + const seen = new Promise((resolve) => { reached = resolve; }); + let candidate: Record = {}; + const { session, cwd } = await fixture(async (kind, body) => { if (kind === "compaction_summary") { - summaryBody = body; + candidate = body; reached(); return pending; } - return kind === "provider_context" - ? { ...allow, receipt: "receipt" } - : allow; - }, - [answer("first"), answer("second"), answer("SUMMARY_CANDIDATE")], - ); - await session.prompt("first turn"); - await session.prompt("second turn"); - const before = session.entries; - const fileBefore = await disk(session); - const compact = session.compact(); - await seen; - assert.deepEqual(session.entries, before); - assert.equal(await disk(session), fileBefore); - release({ - decision: "replace", - replacement: { ...summaryBody, text: "Approved summary" }, - receipt: null, + return kind === "provider_context" ? { ...allow, receipt: "receipt" } : allow; + }, [ + answer("", [{ id: "read", name: "read", arguments: { path: "notes.txt" } }]), + answer("Done"), + answer("RAW_SUMMARY"), + ]); + await writeFile(join(cwd, "notes.txt"), "Approved note"); + await session.prompt("Read notes.txt"); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 1 } }); + const before = structuredClone(session.history); + const saved = await disk(session); + const compact = session.compact(); + const settled = decision === "deny" ? assert.rejects(compact) : compact; + await seen; + // Native compaction appends file-operation text; that must be admitted too. + assert.ok(String(candidate.text).includes("RAW_SUMMARY")); + assert.ok(String(candidate.text).includes("notes.txt")); + assert.deepEqual(session.history, before); + assert.equal(await disk(session), saved); + release({ + decision, + replacement: decision === "replace" ? { ...candidate, text: "APPROVED_SUMMARY" } : null, + receipt: null, + }); + await settled; + if (decision === "deny") { + assert.deepEqual(session.history, before); + assert.equal(await disk(session), saved); + } else { + const entry = session.entries.find((entry) => entry.type === "compaction")!; + assert.equal(entry.summary, "APPROVED_SUMMARY"); + assert.equal(entry.details, undefined); + assert.ok(JSON.stringify(session.history).includes("APPROVED_SUMMARY")); + assert.ok((await disk(session)).includes("Read notes.txt"), "history is append-only"); + } + for (const snapshot of [JSON.stringify(session.history), await disk(session)]) + assert.ok(!snapshot.includes("RAW_SUMMARY")); }); - assert.equal((await compact).summary, "Approved summary"); - assert.equal(requests.length, 3); - assert.ok(!JSON.stringify(session.history).includes("SUMMARY_CANDIDATE")); - assert.ok(JSON.stringify(session.history).includes("Approved summary")); - assert.ok(JSON.stringify(session.history).includes("second turn")); - assert.ok(!(await disk(session)).includes("SUMMARY_CANDIDATE")); - assert.ok( - (await disk(session)).includes("first turn"), - "compaction is append-only", - ); -}); - -test("automatic compaction uses the same admitted summary path", async () => { - const { session, kinds } = await fixture( - undefined, - [answer("first"), answer("second"), answer("summary")], - 1, - ); - await session.prompt("one"); - await session.prompt("two"); - assert.equal(kinds.filter((kind) => kind === "compaction_summary").length, 1); - assert.equal( - session.entries.filter((entry) => entry.type === "compaction").length, - 1, - ); -}); +} test("real bash is bounded before Pi can spill an unchecked output log", async () => { const cwd = await mkdtemp(join(tmpdir(), "pi-bash-test-")); @@ -308,25 +300,6 @@ test("real bash is bounded before Pi can spill an unchecked output log", async ( assert.deepEqual(after, before); }); -test("denied summary leaves both histories unchanged", async () => { - const { session } = await fixture( - async (kind) => - kind === "compaction_summary" - ? deny - : kind === "provider_context" - ? { ...allow, receipt: "receipt" } - : allow, - [answer("first"), answer("second"), answer("UNAPPROVED_SUMMARY")], - ); - await session.prompt("first turn"); - await session.prompt("second turn"); - const before = session.entries; - const saved = await disk(session); - await assert.rejects(session.compact()); - assert.deepEqual(session.entries, before); - assert.equal(await disk(session), saved); -}); - test("provider errors and partial responses never become history", async () => { const error = { ...answer("PARTIAL_RESPONSE"), @@ -433,6 +406,7 @@ for (const enabled of [true, false]) { ]); await session.prompt("first turn"); session.setAutoCompactionEnabled(enabled); + session.settingsManager.applyOverrides({ compaction: { keepRecentTokens: 5 } }); if (enabled) await session.prompt("next turn"); else await assert.rejects(session.prompt("next turn"), /Context is too large/); assert.equal(requests.length, enabled ? 4 : 2); diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py index 72662631..27f4c82e 100644 --- a/projects/egress-gate/src/egress_gate/admission/__init__.py +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -6,24 +6,15 @@ from egress_gate.admission.adapters import ( AttestedEntries, ContextEntryV1, - HarnessAdapter, - HarnessAdapterRegistry, PiAssistantMessageV1, - PiAssistantMessageV1Adapter, PiAssistantToolCallV1, - PiImageContentV1, PiMessageV1, - PiMessageV1Adapter, PiProviderContextV1, - PiProviderContextV1Adapter, PiTextContentV1, PiToolResultV1, - PiToolResultV1Adapter, - PreparedHarnessRequest, ToolContextEntryV1, UserContextEntryV1, context_entries_subject, - create_pi_adapter_registry, extract_provider_entries, ) from egress_gate.admission.canonical import canonical_json_bytes @@ -56,8 +47,6 @@ "AttestedEntries", "AttestedEgressProcessor", "ContextEntryV1", - "HarnessAdapter", - "HarnessAdapterRegistry", "HarnessAdmissionContext", "HarnessAdmissionProcessor", "HarnessAdmissionRequest", @@ -65,17 +54,11 @@ "MAX_ADMISSION_BODY_BYTES", "PI_HARNESS_VERSION", "PiAssistantMessageV1", - "PiAssistantMessageV1Adapter", "PiAssistantToolCallV1", "PiMessageV1", - "PiImageContentV1", "PiTextContentV1", "PiToolResultV1", - "PiToolResultV1Adapter", - "PiMessageV1Adapter", "PiProviderContextV1", - "PiProviderContextV1Adapter", - "PreparedHarnessRequest", "RECEIPT_HEADER", "ReceiptAuthority", "ReceiptVerificationError", @@ -84,5 +67,4 @@ "canonical_json_bytes", "context_entries_subject", "extract_provider_entries", - "create_pi_adapter_registry", ] diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index 018aa913..3b6b3daa 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -1,14 +1,14 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Registered Pi and provider request-shape adapters.""" +"""Pi admission shapes and provider request validation.""" from __future__ import annotations import hashlib import json import math -from typing import Literal, Protocol, TypeAlias +from typing import Literal, TypeAlias from pydantic import ( Field, @@ -22,7 +22,6 @@ from egress_gate.admission.models import ( AdmissionHook, HarnessAdmissionContext, - HarnessAdmissionRequest, ) from egress_gate.base import StrictDomainModel from egress_gate.errors import BodyFormatError, GateInputError @@ -62,21 +61,13 @@ class PiTextContentV1(StrictDomainModel): text: ScalarString -class PiImageContentV1(StrictDomainModel): - """One Pi image content block.""" - - type: Literal["image"] - data: ScalarString - mimeType: ScalarString - - class PiToolResultV1(StrictDomainModel): """Provider-relevant fields from one Pi tool-result message.""" schema_version: Literal["openshell.pi-tool-result.v1"] tool_call_id: ScalarString tool_name: ScalarString - content: tuple[PiTextContentV1 | PiImageContentV1, ...] + content: tuple[PiTextContentV1, ...] is_error: bool @field_validator("content", mode="before") @@ -91,6 +82,14 @@ class PiAssistantToolCallV1(StrictDomainModel): id: ScalarString name: ScalarString arguments: dict[str, object] + thought_signature: ScalarString | None = None + + +class PiThinkingContentV1(StrictDomainModel): + """Reasoning text with immutable provider replay metadata.""" + + text: ScalarString + signature: ScalarString | None = None class PiAssistantMessageV1(StrictDomainModel): @@ -99,8 +98,9 @@ class PiAssistantMessageV1(StrictDomainModel): schema_version: Literal["openshell.pi-assistant-message.v1"] text: ScalarString tool_calls: tuple[PiAssistantToolCallV1, ...] + thinking: tuple[PiThinkingContentV1, ...] = () - @field_validator("tool_calls", mode="before") + @field_validator("tool_calls", "thinking", mode="before") @classmethod def _tool_calls_are_a_tuple(cls, value: object) -> object: return tuple(value) if isinstance(value, list) else value @@ -142,251 +142,56 @@ def _entries_are_a_tuple(cls, value: object) -> object: AttestedEntries: TypeAlias = tuple[ContextEntryV1, ...] -class PreparedHarnessRequest: - """Parsed Pi request plus its canonical Gate projection.""" - - def __init__( - self, - *, - native: HarnessNative, - projected_body: bytes, - ) -> None: - self.native = native - self.projected_body = projected_body - - -class HarnessAdapter(Protocol): - """Fixed-authority translation for one registered harness hook.""" - - def prepare( - self, - request: HarnessAdmissionRequest, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> PreparedHarnessRequest: ... - - def validate_result( - self, - prepared: PreparedHarnessRequest, - projected_body: bytes, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> tuple[bytes | None, HarnessNative]: ... - - def attestation_subject( - self, - prepared: PreparedHarnessRequest, - final: HarnessNative, - ) -> tuple[str, int] | None: ... - - -class _AppendHarnessAdapter: - def attestation_subject( - self, - prepared: PreparedHarnessRequest, - final: HarnessNative, - ) -> None: - return None - - -class PiMessageV1Adapter(_AppendHarnessAdapter): - """Strict adapter for one text-bearing Pi origin.""" - - def __init__(self, accepted_origin: PiMessageOrigin) -> None: - self._accepted_origin = accepted_origin - - def prepare( - self, - request: HarnessAdmissionRequest, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> PreparedHarnessRequest: - native = _parse_pi_body( - request.request_body, timeout, accepted_origin=self._accepted_origin - ) - return PreparedHarnessRequest( - native=native, - projected_body=canonical_json_bytes(native), - ) +def parse_pi_request( + body: bytes, context: HarnessAdmissionContext, timeout: Timeout +) -> HarnessNative: + """Validate one fixed Pi hook/schema pair before and after policy execution.""" + if context.harness != "pi": + raise AdmissionShapeError("harness admission shape is unsupported") + model = _PI_SHAPES[context.hook] + value = _load_json(body, AdmissionShapeError, timeout) + try: + native = model.model_validate(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi request body is unsupported") from None + if native.schema_version != context.schema_version: + raise AdmissionShapeError("Pi request schema is unsupported") + if isinstance(native, PiMessageV1) and native.origin != _PI_ORIGINS[context.hook]: + raise AdmissionShapeError("Pi message origin is unsupported") + if isinstance(native, PiMessageV1 | PiProviderContextV1): + if canonical_json_bytes(native) != body: + raise AdmissionShapeError("Pi request body is not canonical JSON") + return native - def validate_result( - self, - prepared: PreparedHarnessRequest, - projected_body: bytes, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> tuple[bytes | None, PiMessageV1]: - updated = _parse_pi_body( - projected_body, timeout, accepted_origin=self._accepted_origin - ) - encoded = canonical_json_bytes(updated) - replacement = ( - None - if canonical_json_bytes(updated) == canonical_json_bytes(prepared.native) - else encoded - ) - return replacement, updated - - -class PiAssistantMessageV1Adapter(_AppendHarnessAdapter): - """Strict adapter for Pi assistant text and tool calls.""" - - def prepare( - self, - request: HarnessAdmissionRequest, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> PreparedHarnessRequest: - native = _parse_pi_assistant_message(request.request_body, timeout) - return PreparedHarnessRequest( - native=native, - projected_body=canonical_json_bytes(native), - ) - def validate_result( - self, - prepared: PreparedHarnessRequest, - projected_body: bytes, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> tuple[bytes | None, PiAssistantMessageV1]: - updated = _parse_pi_assistant_message(projected_body, timeout) - if not isinstance(prepared.native, PiAssistantMessageV1): - raise AdmissionMutationError("assistant admission state is invalid") - if updated.tool_calls != prepared.native.tool_calls: +def validate_pi_replacement(before: HarnessNative, after: HarnessNative) -> None: + """Allow text replacement without changing executable fields or entry identity.""" + if isinstance(before, PiAssistantMessageV1) and isinstance( + after, PiAssistantMessageV1 + ): + if before.tool_calls != after.tool_calls: raise AdmissionMutationError("admission changed assistant tool calls") - encoded = canonical_json_bytes(updated) - replacement = ( - None if encoded == canonical_json_bytes(prepared.native) else encoded - ) - return replacement, updated - - -class PiToolResultV1Adapter(_AppendHarnessAdapter): - """Strict adapter for Pi tool-result content blocks.""" - - def prepare( - self, - request: HarnessAdmissionRequest, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> PreparedHarnessRequest: - native = _parse_pi_tool_result(request.request_body, timeout) - if any(block.type == "image" for block in native.content): - raise AdmissionShapeError("Pi tool-result images are unsupported") - return PreparedHarnessRequest( - native=native, - projected_body=canonical_json_bytes(native), - ) - - def validate_result( - self, - prepared: PreparedHarnessRequest, - projected_body: bytes, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> tuple[bytes | None, PiToolResultV1]: - updated = _parse_pi_tool_result(projected_body, timeout) - if not isinstance(prepared.native, PiToolResultV1): - raise AdmissionMutationError("tool-result admission state is invalid") - immutable_before = ( - prepared.native.schema_version, - prepared.native.tool_call_id, - prepared.native.tool_name, - prepared.native.is_error, - ) - immutable_after = ( - updated.schema_version, - updated.tool_call_id, - updated.tool_name, - updated.is_error, - ) - if immutable_after != immutable_before: + if len(before.thinking) != len(after.thinking): + raise AdmissionMutationError("admission changed reasoning structure") + for original, replacement in zip(before.thinking, after.thinking): + if original.signature != replacement.signature or ( + original.signature + not in (None, "reasoning", "reasoning_content", "reasoning_text") + and original.text != replacement.text + ): + raise AdmissionMutationError("admission changed reasoning replay data") + elif isinstance(before, PiToolResultV1) and isinstance(after, PiToolResultV1): + if before.model_dump(exclude={"content"}) != after.model_dump( + exclude={"content"} + ): raise AdmissionMutationError("admission changed tool-result metadata") - encoded = canonical_json_bytes(updated) - replacement = ( - None if encoded == canonical_json_bytes(prepared.native) else encoded - ) - return replacement, updated - - -class PiProviderContextV1Adapter: - """Strict adapter for the complete ordered provider context.""" - - def prepare( - self, - request: HarnessAdmissionRequest, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> PreparedHarnessRequest: - native = _parse_pi_provider_context(request.request_body, timeout) - return PreparedHarnessRequest( - native=native, - projected_body=canonical_json_bytes(native), - ) - - def validate_result( - self, - prepared: PreparedHarnessRequest, - projected_body: bytes, - context: HarnessAdmissionContext, - timeout: Timeout, - ) -> tuple[bytes | None, PiProviderContextV1]: - updated = _parse_pi_provider_context(projected_body, timeout) - if not isinstance(prepared.native, PiProviderContextV1): - raise AdmissionMutationError("provider-context admission state is invalid") - before = tuple( - (entry.role, getattr(entry, "tool_call_id", None)) - for entry in prepared.native.entries - ) - after = tuple( - (entry.role, getattr(entry, "tool_call_id", None)) - for entry in updated.entries - ) - if after != before: + elif isinstance(before, PiProviderContextV1) and isinstance( + after, PiProviderContextV1 + ): + if tuple( + (e.role, getattr(e, "tool_call_id", None)) for e in before.entries + ) != tuple((e.role, getattr(e, "tool_call_id", None)) for e in after.entries): raise AdmissionMutationError("admission changed provider-context structure") - encoded = canonical_json_bytes(updated) - replacement = ( - None if encoded == canonical_json_bytes(prepared.native) else encoded - ) - return replacement, updated - - def attestation_subject( - self, - prepared: PreparedHarnessRequest, - final: HarnessNative, - ) -> tuple[str, int]: - if not isinstance(final, PiProviderContextV1): - raise AdmissionMutationError("provider-context admission state is invalid") - return context_entries_subject(final.entries) - - -class HarnessAdapterRegistry: - """Small explicit registry for supported harness admission shapes.""" - - def __init__(self) -> None: - self._adapters: dict[tuple[str, str, str], HarnessAdapter] = {} - - def register( - self, - harness: str, - hook: AdmissionHook, - schema_version: str, - adapter: HarnessAdapter, - ) -> None: - key = (harness, hook.value, schema_version) - if key in self._adapters: - raise ValueError("harness adapter is already registered") - self._adapters[key] = adapter - - def resolve(self, context: HarnessAdmissionContext) -> HarnessAdapter: - key = (context.harness, context.hook.value, context.schema_version) - try: - return self._adapters[key] - except KeyError: - raise AdmissionShapeError( - "harness admission shape is unsupported" - ) from None class _ProviderCacheControl(StrictDomainModel): @@ -418,8 +223,12 @@ class _ProviderMessage(StrictDomainModel): tool_call_id: ScalarString | None = None tool_calls: tuple[_ProviderToolCall, ...] = () reasoning_content: ScalarString | None = None + reasoning: ScalarString | None = None + reasoning_text: ScalarString | None = None + # Provider-owned replay objects are inspected by request policy, never rewritten. + reasoning_details: tuple[dict[str, object], ...] = () - @field_validator("content", "tool_calls", mode="before") + @field_validator("content", "tool_calls", "reasoning_details", mode="before") @classmethod def _provider_sequences_are_tuples(cls, value: object) -> object: return tuple(value) if isinstance(value, list) else value @@ -433,7 +242,17 @@ def _role_fields_are_consistent(self) -> _ProviderMessage: raise ValueError("only tool messages may carry tool_call_id") if self.tool_calls and self.role != "assistant": raise ValueError("only assistant messages may carry tool calls") - if self.content is None and not self.tool_calls: + has_reasoning = any( + ( + self.reasoning, + self.reasoning_text, + self.reasoning_content, + self.reasoning_details, + ) + ) + if has_reasoning and self.role != "assistant": + raise ValueError("only assistant messages may carry reasoning") + if self.content is None and not self.tool_calls and not has_reasoning: raise ValueError("messages require content or tool calls") return self @@ -487,6 +306,11 @@ class _ProviderStreamOptions(StrictDomainModel): include_usage: Literal[True] +class _ProviderReasoning(StrictDomainModel): + effort: ScalarString | None = None + enabled: bool | None = None + + class _ProviderRequest(StrictDomainModel): model: ScalarString messages: tuple[_ProviderMessage, ...] @@ -502,6 +326,7 @@ class _ProviderRequest(StrictDomainModel): prompt_cache_key: ScalarString | None = None prompt_cache_retention: Literal["24h"] | None = None reasoning_effort: ScalarString | None = None + reasoning: _ProviderReasoning | None = None enable_thinking: bool | None = None @field_validator("messages", "tools", mode="before") @@ -554,85 +379,6 @@ def extract_provider_entries(request: HttpRequest, timeout: Timeout) -> Attested return tuple(entries) -def create_pi_adapter_registry() -> HarnessAdapterRegistry: - """Return the built-in Pi v1 admission registry.""" - registry = HarnessAdapterRegistry() - for hook, origin in ( - (AdmissionHook.USER_MESSAGE, "user"), - (AdmissionHook.SYSTEM_CONTEXT, "system"), - (AdmissionHook.COMPACTION_SUMMARY, "compaction_summary"), - ): - registry.register( - "pi", - hook, - "openshell.pi-message.v1", - PiMessageV1Adapter(origin), - ) - registry.register( - "pi", - AdmissionHook.TOOL_RESULT, - "openshell.pi-tool-result.v1", - PiToolResultV1Adapter(), - ) - registry.register( - "pi", - AdmissionHook.ASSISTANT_MESSAGE, - "openshell.pi-assistant-message.v1", - PiAssistantMessageV1Adapter(), - ) - registry.register( - "pi", - AdmissionHook.PROVIDER_CONTEXT, - "openshell.pi-provider-context.v1", - PiProviderContextV1Adapter(), - ) - return registry - - -def _parse_pi_body( - body: bytes, timeout: Timeout, *, accepted_origin: PiMessageOrigin = "user" -) -> PiMessageV1: - value = _load_json(body, AdmissionShapeError, timeout) - try: - parsed = _PI_ADAPTER.validate_python(value, strict=True) - except ValidationError: - raise AdmissionShapeError("Pi request body is unsupported") from None - if parsed.origin != accepted_origin: - raise AdmissionShapeError("Pi message origin is unsupported") - if canonical_json_bytes(parsed) != body: - raise AdmissionShapeError("Pi request body is not canonical JSON") - return parsed - - -def _parse_pi_assistant_message(body: bytes, timeout: Timeout) -> PiAssistantMessageV1: - value = _load_json(body, AdmissionShapeError, timeout) - try: - parsed = _PI_ASSISTANT_MESSAGE_ADAPTER.validate_python(value, strict=True) - except ValidationError: - raise AdmissionShapeError("Pi assistant-message body is unsupported") from None - return parsed - - -def _parse_pi_tool_result(body: bytes, timeout: Timeout) -> PiToolResultV1: - value = _load_json(body, AdmissionShapeError, timeout) - try: - parsed = _PI_TOOL_RESULT_ADAPTER.validate_python(value, strict=True) - except ValidationError: - raise AdmissionShapeError("Pi tool-result body is unsupported") from None - return parsed - - -def _parse_pi_provider_context(body: bytes, timeout: Timeout) -> PiProviderContextV1: - value = _load_json(body, AdmissionShapeError, timeout) - try: - parsed = _PI_PROVIDER_CONTEXT_ADAPTER.validate_python(value, strict=True) - except ValidationError: - raise AdmissionShapeError("Pi provider-context body is unsupported") from None - if canonical_json_bytes(parsed) != body: - raise AdmissionShapeError("Pi provider-context body is not canonical JSON") - return parsed - - def context_entries_subject(entries: AttestedEntries) -> tuple[str, int]: """Return the v2 hash and count for one ordered entry list.""" body = json.dumps( @@ -682,10 +428,19 @@ def _finite_json_float(value: str) -> float: return number -_PI_ADAPTER = TypeAdapter(PiMessageV1) -_PI_TOOL_RESULT_ADAPTER = TypeAdapter(PiToolResultV1) -_PI_ASSISTANT_MESSAGE_ADAPTER = TypeAdapter(PiAssistantMessageV1) -_PI_PROVIDER_CONTEXT_ADAPTER = TypeAdapter(PiProviderContextV1) +_PI_ORIGINS = { + AdmissionHook.USER_MESSAGE: "user", + AdmissionHook.SYSTEM_CONTEXT: "system", + AdmissionHook.COMPACTION_SUMMARY: "compaction_summary", +} +_PI_SHAPES: dict[AdmissionHook, type[HarnessNative]] = { + AdmissionHook.USER_MESSAGE: PiMessageV1, + AdmissionHook.SYSTEM_CONTEXT: PiMessageV1, + AdmissionHook.COMPACTION_SUMMARY: PiMessageV1, + AdmissionHook.TOOL_RESULT: PiToolResultV1, + AdmissionHook.ASSISTANT_MESSAGE: PiAssistantMessageV1, + AdmissionHook.PROVIDER_CONTEXT: PiProviderContextV1, +} _PROVIDER_ADAPTER = TypeAdapter(_ProviderRequest) @@ -694,24 +449,17 @@ def _finite_json_float(value: str) -> float: "AdmissionShapeError", "AttestedEntries", "ContextEntryV1", - "HarnessAdapter", - "HarnessAdapterRegistry", "PiMessageV1", - "PiImageContentV1", "PiAssistantMessageV1", - "PiAssistantMessageV1Adapter", "PiAssistantToolCallV1", "PiTextContentV1", "PiToolResultV1", - "PiToolResultV1Adapter", - "PiMessageV1Adapter", "PiProviderContextV1", - "PiProviderContextV1Adapter", - "PreparedHarnessRequest", "ProviderShapeError", "ToolContextEntryV1", "UserContextEntryV1", + "parse_pi_request", + "validate_pi_replacement", "extract_provider_entries", "context_entries_subject", - "create_pi_adapter_registry", ] diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py index 561fa812..07a40f54 100644 --- a/projects/egress-gate/src/egress_gate/admission/processor.py +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -14,11 +14,14 @@ from egress_gate.admission.adapters import ( AdmissionMutationError, AdmissionShapeError, - HarnessAdapterRegistry, + PiProviderContextV1, ProviderShapeError, context_entries_subject, extract_provider_entries, + parse_pi_request, + validate_pi_replacement, ) +from egress_gate.admission.canonical import canonical_json_bytes from egress_gate.admission.models import ( MAX_ADMISSION_BODY_BYTES, AdmissionDecision, @@ -31,8 +34,6 @@ from egress_gate.constants import MAX_AGENT_ATTESTATION_BYTES from egress_gate.errors import EgressGateError, GateError, TimeoutExpiredError from egress_gate.request import ( - EnforcementPoint, - HarnessAdmissionMetadata, HttpRequest, RemoveHeaderMutation, RequestContext, @@ -51,19 +52,17 @@ class HarnessAdmissionProcessor: - """Apply the configured Gate pipeline through one registered harness adapter.""" + """Apply the configured Gate pipeline through the fixed Pi admission shapes.""" def __init__( self, request_processor: RequestProcessor, - adapters: HarnessAdapterRegistry, receipt_authority: ReceiptAuthority, ) -> None: fingerprint = request_processor.policy_fingerprint if not fingerprint: raise ValueError("admission requires a policy fingerprint") self._request_processor = request_processor - self._adapters = adapters self._receipt_authority = receipt_authority self._policy_fingerprint = fingerprint @@ -76,23 +75,16 @@ def process( ) -> HarnessAdmissionResult: """Return an explicit allow, replacement, or fail-closed denial.""" try: - adapter = self._adapters.resolve(context) - prepared = adapter.prepare(request, context, timeout) + native = parse_pi_request(request.request_body, context, timeout) + projected_body = canonical_json_bytes(native) projected = HttpRequest( context=RequestContext( request_id=context.request_id, sandbox_id=context.sandbox_id, - enforcement_point=EnforcementPoint.HARNESS_ADMISSION, - harness_admission=HarnessAdmissionMetadata( - harness=context.harness, - harness_version=context.harness_version, - hook=context.hook.value, - schema_version=context.schema_version, - ), ), target=context.provider_target, headers=(), - body=prepared.projected_body, + body=projected_body, ) gate_result = self._request_processor.process(projected, timeout=timeout) timeout.raise_if_expired() @@ -109,17 +101,17 @@ def process( final_request = apply_request_mutations( projected, gate_result.request_mutations ) - replacement, final = adapter.validate_result( - prepared, final_request.body, context, timeout - ) + final = parse_pi_request(final_request.body, context, timeout) + validate_pi_replacement(native, final) + encoded = canonical_json_bytes(final) + replacement = None if encoded == projected_body else encoded if replacement is not None and len(replacement) > MAX_ADMISSION_BODY_BYTES: raise AdmissionMutationError("admission replacement body is too large") timeout.raise_if_expired() - subject = adapter.attestation_subject(prepared, final) attestation = None - if subject is not None: + if isinstance(final, PiProviderContextV1): attestation = self._receipt_authority.issue_attestation( - *subject, + *context_entries_subject(final.entries), context, request.provenance, policy_fingerprint=self._policy_fingerprint, @@ -182,8 +174,6 @@ def process( timeout: Timeout, ) -> EgressResult: """Deny any unattested or semantically changed provider request.""" - if request.context.enforcement_point is not EnforcementPoint.NETWORK_EGRESS: - return self._deny("network_context_invalid") receipts = [ h.value for h in request.headers if h.name.lower() == RECEIPT_HEADER ] diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 3b87609f..02b3a688 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -64,12 +64,7 @@ validate_gateway_timeout, validate_middleware_name, ) -from egress_gate.logging import ( - LoggingConfig, - configure_json_log, - configure_logging, - get_logger, -) +from egress_gate.logging import LoggingConfig, configure_logging, get_logger from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext from egress_gate.result import EgressResult, GateDecisionSource from egress_gate.string_validators import BoundedMetadataString @@ -172,13 +167,6 @@ def serve( ), ), ] = f"{DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING:g}s", - json_log: Annotated[ - Path | None, - typer.Option( - "--json-log", - help="Write content-safe evaluation records as newline-delimited JSON.", - ), - ] = None, admission_config: Annotated[ Path | None, typer.Option( @@ -228,8 +216,6 @@ def serve( remembered.middleware_name, remembered.config_path, ) - if json_log is not None: - configure_json_log(json_log) try: admission = ( AdmissionServerConfig.model_validate_json(admission_config.read_bytes()) diff --git a/projects/egress-gate/src/egress_gate/logging.py b/projects/egress-gate/src/egress_gate/logging.py index 38bb65ec..ed135326 100644 --- a/projects/egress-gate/src/egress_gate/logging.py +++ b/projects/egress-gate/src/egress_gate/logging.py @@ -6,12 +6,10 @@ from __future__ import annotations import copy -import json import logging import os from dataclasses import dataclass from enum import StrEnum -from pathlib import Path from typing import TextIO @@ -67,26 +65,13 @@ def configure_logging( package_logger.propagate = False -def configure_json_log(path: Path) -> None: - """Write content-safe Egress Gate records as newline-delimited JSON.""" - package_logger = get_logger("egress_gate") - for handler in package_logger.handlers[:]: - if isinstance(handler, _EgressGateJsonHandler): - package_logger.removeHandler(handler) - handler.close() - - handler = _EgressGateJsonHandler(path, encoding="utf-8") - handler.setFormatter(_EgressGateJsonFormatter()) - package_logger.addHandler(handler) - - def reset_logging() -> None: """Remove logging configuration installed by :func:`configure_logging`.""" package_logger = get_logger("egress_gate") managed_handlers = [ handler for handler in package_logger.handlers - if isinstance(handler, _EgressGateStreamHandler | _EgressGateJsonHandler) + if isinstance(handler, _EgressGateStreamHandler) ] if not managed_handlers: return @@ -102,29 +87,6 @@ class _EgressGateStreamHandler(logging.StreamHandler[TextIO]): """Stream handler owned by Egress Gate's logging configuration.""" -class _EgressGateJsonHandler(logging.FileHandler): - """Optional content-safe JSON sink owned by Egress Gate.""" - - -class _EgressGateJsonFormatter(logging.Formatter): - """Serialize only the bounded evaluation fields used by verification.""" - - def format(self, record: logging.LogRecord) -> str: - return json.dumps( - { - "event": getattr(record, "event", record.getMessage()), - "request_id": getattr(record, "request_id", None), - "duration_ms": getattr(record, "duration_ms", None), - "action": getattr(record, "action", None), - "reason_code": getattr(record, "reason_code", None), - "finding_count": getattr(record, "finding_count", None), - "decision_source_kind": getattr(record, "decision_source_kind", None), - "error_code": getattr(record, "error_code", None), - }, - separators=(",", ":"), - ) - - class _EgressGateFormatter(logging.Formatter): """Readable console formatter with optional level-aware color.""" @@ -167,7 +129,6 @@ def format(self, record: logging.LogRecord) -> str: "ColorMode", "DEFAULT_LOGGING_CONFIG", "LoggingConfig", - "configure_json_log", "configure_logging", "get_logger", "reset_logging", diff --git a/projects/egress-gate/src/egress_gate/request.py b/projects/egress-gate/src/egress_gate/request.py index 7ac9fa1c..98201ffe 100644 --- a/projects/egress-gate/src/egress_gate/request.py +++ b/projects/egress-gate/src/egress_gate/request.py @@ -26,22 +26,6 @@ HeaderValue = ScalarString -class EnforcementPoint(StrEnum): - """The trusted boundary at which a request is being evaluated.""" - - NETWORK_EGRESS = "network_egress" - HARNESS_ADMISSION = "harness_admission" - - -class HarnessAdmissionMetadata(StrictDomainModel): - """Bounded harness-shape metadata stamped by the trusted transport.""" - - harness: ScalarString - harness_version: ScalarString - hook: ScalarString - schema_version: ScalarString - - class Process(StrictDomainModel): """The originating workload process and its executable ancestry.""" @@ -56,8 +40,6 @@ class RequestContext(StrictDomainModel): request_id: ScalarString sandbox_id: ScalarString originating_process: Process | None = None - enforcement_point: EnforcementPoint = EnforcementPoint.NETWORK_EGRESS - harness_admission: HarnessAdmissionMetadata | None = None @model_validator(mode="after") def _context_strings_are_bounded(self) -> RequestContext: @@ -70,23 +52,8 @@ def _context_strings_are_bounded(self) -> RequestContext: len(ancestor.encode("utf-8")) for ancestor in self.originating_process.ancestors ) - if self.harness_admission is not None: - string_bytes += sum( - len(value.encode("utf-8")) - for value in ( - self.harness_admission.harness, - self.harness_admission.harness_version, - self.harness_admission.hook, - self.harness_admission.schema_version, - ) - ) if string_bytes > MAX_PROTO_CONTEXT_BYTES: raise ValueError("request context strings exceed the size limit") - if self.enforcement_point is EnforcementPoint.HARNESS_ADMISSION: - if self.harness_admission is None: - raise ValueError("harness admission requires trusted metadata") - elif self.harness_admission is not None: - raise ValueError("network egress cannot carry harness metadata") return self @@ -211,12 +178,10 @@ def is_empty(self) -> bool: __all__ = [ - "EnforcementPoint", "ExistingHeaderAction", "HeaderMutation", "HeaderName", "HeaderValue", - "HarnessAdmissionMetadata", "HttpHeader", "HttpRequest", "HttpTarget", diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index bf43a066..22d68fb7 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -27,7 +27,6 @@ HarnessAdmissionRequest, HarnessAdmissionResult, ReceiptAuthority, - create_pi_adapter_registry, ) from egress_gate.bindings import supervisor_middleware_pb2 as pb2 from egress_gate.bindings import supervisor_middleware_pb2_grpc as pb2_grpc @@ -105,7 +104,6 @@ def __init__( ) self._policy = _ActivePolicy(registry) self._receipt_authority = ReceiptAuthority() - self._admission_adapters = create_pi_adapter_registry() self._require_agent_attestation = require_agent_attestation self._expected_audience = expected_audience self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING) @@ -183,7 +181,6 @@ async def admit( return await self._run_in_worker( lambda: HarnessAdmissionProcessor( self._policy.processor_for(policy, timeout=timeout), - self._admission_adapters, self._receipt_authority, ).process(request, context, timeout=timeout), timeout=timeout, diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index 9c2fba9b..45ecb52a 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -1,16 +1,16 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Conformance tests for managed Pi context admission and attested egress.""" +"""Boundary cases not covered by the native Pi HTTP/RPC integration journeys.""" from __future__ import annotations import base64 import json from pathlib import Path -from typing import Literal import pytest +import yaml from egress_gate.admission import ( MAX_ADMISSION_BODY_BYTES, @@ -26,11 +26,8 @@ PiAssistantToolCallV1, PiMessageV1, PiProviderContextV1, - PiTextContentV1, - PiToolResultV1, ReceiptAuthority, canonical_json_bytes, - create_pi_adapter_registry, extract_provider_entries, ) from egress_gate.gates import create_builtin_registry @@ -50,65 +47,19 @@ def _processors( *, replacement_template: str = "[REDACTED]" ) -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor, ReceiptAuthority]: registry = create_builtin_registry() - config = registry.validate_config( - { - "gates": [ - { - "name": "deny-marker", - "kind": "regex", - "scan": {"kind": "body", "action": {"kind": "deny"}}, - "pattern_catalog": { - "entities": [ - { - "name": "unsafe-marker", - "rules": [ - { - "name": "exact-marker", - "pattern": DENY_TEXT, - "confidence": "high", - } - ], - } - ] - }, - }, - { - "name": "replace-marker", - "kind": "regex", - "scan": { - "kind": "body", - "action": { - "kind": "replace", - "template": replacement_template, - }, - }, - "pattern_catalog": { - "entities": [ - { - "name": "replacement-marker", - "rules": [ - { - "name": "exact-marker", - "pattern": REDACT_TEXT, - "confidence": "high", - } - ], - } - ] - }, - }, - ], - "default_decision": "allow", - } - ) + policy = yaml.safe_load( + ( + Path(__file__).parents[2] / "examples/pi-attested-admission/policy.yaml" + ).read_text() + )["network_middlewares"]["pi_egress_gate"]["config"] + policy["gates"][1]["scan"]["action"]["template"] = replacement_template + config = registry.validate_config(policy) request_processor = registry.prepare_processor( config, timeout=Timeout.from_seconds(1) ) authority = ReceiptAuthority() return ( - HarnessAdmissionProcessor( - request_processor, create_pi_adapter_registry(), authority - ), + HarnessAdmissionProcessor(request_processor, authority), AttestedEgressProcessor( request_processor, authority, @@ -158,7 +109,7 @@ def _context( def _admit( processor: HarnessAdmissionProcessor, - value: (PiMessageV1 | PiToolResultV1 | PiAssistantMessageV1 | PiProviderContextV1), + value: PiMessageV1 | PiAssistantMessageV1 | PiProviderContextV1, *, target: HttpTarget | None = None, timeout: Timeout | None = None, @@ -169,8 +120,6 @@ def _admit( "system": AdmissionHook.SYSTEM_CONTEXT, "compaction_summary": AdmissionHook.COMPACTION_SUMMARY, }[value.origin] - elif isinstance(value, PiToolResultV1): - hook = AdmissionHook.TOOL_RESULT elif isinstance(value, PiAssistantMessageV1): hook = AdmissionHook.ASSISTANT_MESSAGE else: @@ -187,20 +136,12 @@ def _admit( ) -def _message( - text: str, - *, - origin: Literal["user", "system", "compaction_summary"] = "user", -) -> PiMessageV1: +def _user(text: str) -> PiMessageV1: return PiMessageV1( - schema_version="openshell.pi-message.v1", origin=origin, text=text + schema_version="openshell.pi-message.v1", origin="user", text=text ) -def _user(text: str) -> PiMessageV1: - return _message(text) - - def _assistant( text: str, *, arguments: dict[str, object] | None = None ) -> PiAssistantMessageV1: @@ -215,26 +156,6 @@ def _assistant( ) -def _tool_result( - text: str, *, image: bool = False, tool_call_id: str = "call-1" -) -> PiToolResultV1: - content: list[dict[str, object]] = ( - [{"type": "image", "data": "AA==", "mimeType": "image/png"}] - if image - else [{"type": "text", "text": text}] - ) - return PiToolResultV1.model_validate( - { - "schema_version": "openshell.pi-tool-result.v1", - "tool_call_id": tool_call_id, - "tool_name": "read", - "content": content, - "is_error": False, - }, - strict=True, - ) - - def _provider_request( prompt: str, *, @@ -392,16 +313,6 @@ def test_provider_context_redaction_binds_only_the_replacement() -> None: ) -def test_restored_context_with_denied_text_is_blocked_at_send_time() -> None: - admission, _, _ = _processors() - - denied = _admit_provider_request(admission, _provider_request(DENY_TEXT)) - - assert denied.decision is AdmissionDecision.DENY - assert denied.replacement_body is None - assert denied.reason_code == "egress_gate_regex_denied" - - def test_attestation_uses_stable_destination_across_tls_proxy_normalization() -> None: admission, egress, _ = _processors() normalized = HttpTarget( @@ -428,52 +339,31 @@ def test_attestation_uses_stable_destination_across_tls_proxy_normalization() -> assert wrong_host.reason_code == "attestation_context_mismatch" -def test_tool_result_denial_redaction_and_images_fail_closed() -> None: - admission, _, _ = _processors() - - denied = _admit(admission, _tool_result(DENY_TEXT)) - redacted = _admit(admission, _tool_result(REDACT_TEXT)) - image = _admit(admission, _tool_result("", image=True)) - - assert denied.decision is AdmissionDecision.DENY - assert denied.attestation is None - assert redacted.decision is AdmissionDecision.REPLACE - assert redacted.replacement_body is not None - redacted_tool_result = PiToolResultV1.model_validate_json( - redacted.replacement_body, strict=True - ) - assert isinstance(redacted_tool_result.content[0], PiTextContentV1) - assert redacted_tool_result.content[0].text == "[REDACTED]" - assert image.decision is AdmissionDecision.DENY - assert image.reason_code == "admission_contract_invalid" - - -@pytest.mark.parametrize( - "origin", - ["user", "system", "compaction_summary"], -) -def test_text_message_origins_allow_replace_and_deny(origin) -> None: +def test_tool_images_fail_closed() -> None: admission, _, _ = _processors() - - allowed = _admit(admission, _message("safe", origin=origin)) - redacted = _admit(admission, _message(REDACT_TEXT, origin=origin)) - denied = _admit(admission, _message(DENY_TEXT, origin=origin)) - - assert allowed.decision is AdmissionDecision.ALLOW - assert allowed.attestation is None - assert redacted.decision is AdmissionDecision.REPLACE - assert redacted.replacement_body is not None - replacement = PiMessageV1.model_validate_json( - redacted.replacement_body, strict=True + body = { + "schema_version": "openshell.pi-tool-result.v1", + "tool_call_id": "call-1", + "tool_name": "read", + "is_error": False, + "content": [{"type": "image", "data": "AA==", "mimeType": "image/png"}], + } + result = admission.process( + HarnessAdmissionRequest( + request_body=json.dumps(body).encode(), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="image" + ), + ), + _context(AdmissionHook.TOOL_RESULT), + timeout=Timeout.from_seconds(1), ) - assert replacement.origin == origin - assert replacement.text == "[REDACTED]" - assert denied.decision is AdmissionDecision.DENY + assert result.reason_code == "admission_contract_invalid" def test_text_message_binding_rejects_a_different_origin() -> None: admission, _, _ = _processors() - value = _message("safe", origin="user") + value = _user("safe") result = admission.process( HarnessAdmissionRequest( @@ -489,24 +379,6 @@ def test_text_message_binding_rejects_a_different_origin() -> None: assert result.reason_code == "admission_contract_invalid" -def test_assistant_message_allows_text_replacement_and_denial() -> None: - admission, _, _ = _processors() - - allowed = _admit(admission, _assistant("safe")) - redacted = _admit(admission, _assistant(REDACT_TEXT)) - denied = _admit(admission, _assistant(DENY_TEXT)) - - assert allowed.decision is AdmissionDecision.ALLOW - assert redacted.decision is AdmissionDecision.REPLACE - assert redacted.replacement_body is not None - replacement = PiAssistantMessageV1.model_validate_json( - redacted.replacement_body, strict=True - ) - assert replacement.text == "[REDACTED]" - assert replacement.tool_calls == _assistant("safe").tool_calls - assert denied.decision is AdmissionDecision.DENY - - def test_assistant_message_accepts_javascript_number_serialization() -> None: admission, _, _ = _processors() body = ( @@ -529,10 +401,15 @@ def test_assistant_message_accepts_javascript_number_serialization() -> None: assert result.decision is AdmissionDecision.ALLOW -def test_assistant_message_rejects_tool_call_mutation() -> None: +@pytest.mark.parametrize("field", ["tool_calls", "thinking"]) +def test_assistant_message_rejects_replay_mutation(field: str) -> None: admission, _, _ = _processors() - result = _admit(admission, _assistant("safe", arguments={"path": REDACT_TEXT})) + body = _assistant("safe", arguments={"path": REDACT_TEXT}).model_dump(mode="json") + if field == "thinking": + body["tool_calls"] = [] + body["thinking"] = [{"text": REDACT_TEXT, "signature": "provider-signature"}] + result = _admit(admission, PiAssistantMessageV1.model_validate(body, strict=True)) assert result.decision is AdmissionDecision.DENY assert result.reason_code == "admission_contract_invalid" @@ -631,15 +508,10 @@ def test_provider_shape_validation_and_optional_reasoning_field_are_preserved() "mutation", [ lambda body: body.update({"max_completion_tokens": 128}), - lambda body: body.update({"store": None}), - lambda body: body.update({"stream_options": None}), lambda body: body.update({"stream_options": {"include_usage": "true"}}), - lambda body: body["tools"][0].update({"cache_control": {"type": "persistent"}}), lambda body: body["tools"][0].update( {"cache_control": {"type": "ephemeral", "ttl": "forever"}} ), - lambda body: body["tools"][0]["function"].update({"strict": None}), - lambda body: body.update({"input": []}), lambda body: body["messages"][1].update({"tool_call_id": "wrong-role"}), lambda body: body["messages"][1].update({"role": "tool"}), lambda body: body["tools"][0]["function"].update( diff --git a/projects/egress-gate/tests/service/test_http_admission.py b/projects/egress-gate/tests/service/test_http_admission.py index afdbc063..c2375bd9 100644 --- a/projects/egress-gate/tests/service/test_http_admission.py +++ b/projects/egress-gate/tests/service/test_http_admission.py @@ -81,6 +81,7 @@ async def test_http_admission_allow_deny_replace_and_authentication( @pytest.mark.asyncio async def test_http_receipt_is_verified_and_stripped_by_standard_authenticated_rpc( tmp_path: Path, + monkeypatch: pytest.MonkeyPatch, ) -> None: async with _clients(tmp_path) as (client, stub, config, token, _): response = await client.post( @@ -111,12 +112,30 @@ async def test_http_receipt_is_verified_and_stripped_by_standard_authenticated_r request.headers.add(name=RECEIPT_HEADER, value=result["receipt"]) denied = await stub.EvaluateHttpRequest(request, metadata=metadata) assert denied.reason_code == "attestation_malformed" + # Issue a genuinely signed but expired receipt, without a wall-clock wait. + with monkeypatch.context() as clock: + clock.setattr("egress_gate.admission.receipts._now_seconds", lambda: 0) + response = await client.post( + "/v1/admission", + json=_call("safe", kind="provider_context"), + headers=AUTHORIZATION, + ) + expired = await response.json() + denied = await stub.EvaluateHttpRequest( + _network(config, expired["receipt"]), metadata=metadata + ) + assert denied.reason_code == "attestation_expired" empty = message_factory.GetMessageClass( empty_pb2.DESCRIPTOR.message_types_by_name["Empty"] )() manifest = await stub.Describe(empty, metadata=metadata) assert manifest.expected_audience == AUDIENCE assert len(manifest.bindings) == 1 + assert ( + manifest.bindings[0].operation + == pb.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST + ) + assert not hasattr(pb.HttpRequestEvaluation(), "agent_attestation") @pytest.mark.parametrize("change", ["issuer", "audience", "expired", "type", "key"]) @@ -153,20 +172,26 @@ def test_gateway_authentication_rejects_invalid_trust_claims(change: str) -> Non @pytest.mark.asyncio -@pytest.mark.parametrize("tui", [False, True], ids=["sdk", "native-tui"]) @pytest.mark.parametrize( - ("max_tokens", "cache_control", "cache_retention"), - [(2048, False, ""), (16384, True, ""), (16384, True, "long")], - ids=["default-cache", "compat-cache", "long-cache"], + ("mode", "max_tokens", "cache_control", "cache_retention"), + [ + ("tui", 2048, False, ""), + ("sdk", 16384, True, "long"), + ("settings", 2048, True, ""), + ("settings", 16384, False, ""), + ], + ids=["native-tui", "sdk-long-cache", "compat-cache", "default-cache"], ) async def test_pi_session_through_admission_and_authenticated_egress( tmp_path: Path, unused_tcp_port: int, - tui: bool, + mode: str, max_tokens: int, cache_control: bool, cache_retention: str, ) -> None: + tui = mode == "tui" + settings_only = mode == "settings" source = PROJECT / "examples/pi-attested-admission" example = tmp_path / "example" shutil.copytree( @@ -177,7 +202,7 @@ async def test_pi_session_through_admission_and_authenticated_egress( ), ) catalog = json.loads((example / "models.json.example").read_text()) - model = catalog["providers"]["example"]["models"][0] + model = catalog["providers"]["openrouter"]["models"][0] model["maxTokens"] = max_tokens model["samplingParams"] = {"temperature": 0.25, "top_p": 0.9} model["compat"] = { @@ -205,12 +230,18 @@ async def test_pi_session_through_admission_and_authenticated_egress( "tls_certificate": tmp_path / "tls/server/tls.crt", "tls_private_key": tmp_path / "tls/server/tls.key", "provider_target": config.provider_target.model_copy( - update={"host": "127.0.0.1", "port": unused_tcp_port} + update={ + "host": "127.0.0.1", + "port": unused_tcp_port, + "path": "/api/v1/chat/completions", + } ), } ) calls: list[bytes] = [] session_ids: list[str | None] = [] + chat_calls: list[bytes] = [] + summary_calls: list[bytes] = [] async def provider(request: web.Request) -> web.Response: body = await request.read() @@ -234,11 +265,16 @@ async def provider(request: web.Request) -> web.Response: "REDACT_THIS" not in body.decode() and "DENY_THIS" not in body.decode() ) calls.append(body) - session_ids.append(request.headers.get("x-session-affinity")) - # Pi deliberately disables cache/affinity headers for summaries. - assert (session_ids[-1] is None) == (len(calls) in (4, 7)) payload = json.loads(body) - summary = len(calls) in (4, 7) + summary = "You are a context summarization assistant." in json.dumps( + payload["messages"][0] + ) + (summary_calls if summary else chat_calls).append(body) + affinity = request.headers.get("x-session-affinity") + # Both native history and split-turn summaries disable caching. + assert (affinity is None) == summary + if not summary: + session_ids.append(affinity) if cache_retention == "long" and not summary: assert payload["prompt_cache_key"] == session_ids[-1] assert payload["prompt_cache_retention"] == "24h" @@ -261,10 +297,23 @@ async def provider(request: web.Request) -> web.Response: assert ("stream_options" in payload) is not tui assert payload["temperature"] == 0.25 assert payload["top_p"] == 0.9 - if len(calls) not in (4, 7): + assert payload["reasoning"] == {"effort": "high"} + if len(chat_calls) == 3 and not summary: + assistant = next( + m for m in reversed(payload["messages"]) if m["role"] == "assistant" + ) + assert assistant["reasoning_details"] == [ + { + "type": "reasoning.text", + "text": "APPROVED_REASONING", + "signature": "provider-signature", + "id": "reasoning-1", + } + ] + if not summary: # Real Pi serialization must honor limits below and above 4096. assert payload["max_tokens"] == max_tokens - elif len(calls) == 4: + elif not tui and len(chat_calls) == 3 and len(summary_calls) == 1: # Pi's default compaction reserve is 16384; its summary uses 80%. assert payload["max_tokens"] == min(int(0.8 * 16384), max_tokens) if len(calls) == 1: @@ -275,12 +324,22 @@ async def provider(request: web.Request) -> web.Response: evaluation.body = json.dumps(changed).encode() denied = await stub.EvaluateHttpRequest(evaluation, metadata=metadata) assert denied.reason_code == "context_hash_mismatch" - assert len(calls) <= 7, "unexpected model call" + assert len(chat_calls) <= 5, "unexpected chat call" delta: dict[str, object] = {"role": "assistant", "content": "REDACT_THIS"} + delta["reasoning"] = "REDACT_THIS" finish = "stop" - if len(calls) == 2: + if not summary and len(chat_calls) == 2: delta = { "role": "assistant", + "reasoning": "APPROVED_REASONING", + "reasoning_details": [ + { + "type": "reasoning.text", + "text": "APPROVED_REASONING", + "signature": "provider-signature", + "id": "reasoning-1", + } + ], "tool_calls": [ { "index": 0, @@ -294,7 +353,7 @@ async def provider(request: web.Request) -> web.Response: ], } finish = "tool_calls" - elif len(calls) in (4, 7): + elif summary: delta["content"] = "Approved summary" chunk = { "id": "local", @@ -309,7 +368,7 @@ async def provider(request: web.Request) -> web.Response: ) application = create_admission_application(middleware, config) - application.router.add_post("/v1/chat/completions", provider) + application.router.add_post("/api/v1/chat/completions", provider) server = TestServer(application, scheme="https", port=unused_tcp_port) await server.start_server(ssl=admission_tls_context(config)) terminal: tuple[int, int] | None = None @@ -323,7 +382,7 @@ async def provider(request: web.Request) -> web.Response: str(source / "pi-harness/dist/test/service-integration.js"), str(server.make_url("/")).rstrip("/"), str(tmp_path), - *(["--tui"] if tui else []), + *(["--tui"] if tui else ["--settings"] if settings_only else []), env=os.environ | { "NODE_EXTRA_CA_CERTS": str(tmp_path / "tls/ca.crt"), @@ -368,12 +427,17 @@ async def provider(request: web.Request) -> web.Response: assert not list(agent_dir.iterdir()), ( "Pi must not write auth or model caches" ) - assert len(calls) == (4 if tui else 7) + assert len(chat_calls) == (1 if settings_only else 3 if tui else 5) + # Native split-turn compaction may issue multiple summary requests. + assert bool(summary_calls) == (not settings_only) assert len(set(session_ids[:3])) == 1 - if not tui: - assert len(set(session_ids[4:6])) == 1 - assert session_ids[0] != session_ids[4] - assert any(m["role"] == "tool" for m in json.loads(calls[2])["messages"]) + if not tui and not settings_only: + assert len(set(session_ids[3:5])) == 1 + assert session_ids[0] != session_ids[3] + if not settings_only: + assert any( + m["role"] == "tool" for m in json.loads(calls[2])["messages"] + ) finally: if terminal: os.close(terminal[0]) diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index ce38c18f..f812889f 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -130,25 +130,6 @@ def test_manifest_leaves_the_gateway_rpc_timeout_to_the_operator() -> None: assert manifest.bindings[0].timeout == "" -def test_managed_manifest_uses_only_upstream_http_binding() -> None: - middleware = EgressGateMiddleware( - create_builtin_registry(), - require_agent_attestation=True, - expected_audience="urn:test", - ) - try: - manifest = asyncio.run(middleware.Describe(object(), Mock())) - finally: - asyncio.run(middleware.close()) - assert manifest.expected_audience == "urn:test" - assert len(manifest.bindings) == 1 - assert ( - manifest.bindings[0].operation - == pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST - ) - assert not hasattr(pb2.HttpRequestEvaluation(), "agent_attestation") - - def test_copied_proto_remains_the_current_five_field_finding_contract() -> None: evaluation = pb2.HttpRequestEvaluation() finding = pb2.Finding() diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index f7131da8..047e2b09 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -104,7 +104,6 @@ def serve_sync(self, listen: str) -> None: assert "Minimum 10ms" in serve_help assert "RPC timeout" in serve_help assert "--admission-config" in serve_help - assert "--json-log" in serve_help assert "--require-" + "pi-attestation" not in serve_help evaluate_help = CliRunner().invoke(app, ["evaluate", "--help"]) diff --git a/projects/egress-gate/tests/test_logging.py b/projects/egress-gate/tests/test_logging.py index 1b55ed41..4070c5b2 100644 --- a/projects/egress-gate/tests/test_logging.py +++ b/projects/egress-gate/tests/test_logging.py @@ -6,7 +6,6 @@ from __future__ import annotations import ast -import json import logging import re from collections.abc import Iterator @@ -19,7 +18,6 @@ DEFAULT_LOGGING_CONFIG, ColorMode, LoggingConfig, - configure_json_log, configure_logging, get_logger, reset_logging, @@ -126,39 +124,6 @@ def test_configure_logging_accepts_native_log_levels() -> None: ) -def test_configure_json_log_writes_only_content_safe_fields(tmp_path: Path) -> None: - path = tmp_path / "evaluations.jsonl" - configure_logging() - configure_json_log(path) - - logging.getLogger("egress_gate.service").info( - "egress_gate_evaluation", - extra={ - "event": "egress_gate_evaluation", - "request_id": "request-1", - "duration_ms": 1.25, - "action": "deny", - "reason_code": "attestation_missing", - "finding_count": 0, - "decision_source_kind": "gate", - "error_code": None, - "request_body": "must not be logged", - }, - ) - - record = json.loads(path.read_text()) - assert record == { - "event": "egress_gate_evaluation", - "request_id": "request-1", - "duration_ms": 1.25, - "action": "deny", - "reason_code": "attestation_missing", - "finding_count": 0, - "decision_source_kind": "gate", - "error_code": None, - } - - def test_configure_logging_replaces_its_previous_handler() -> None: first_stream = StringIO() second_stream = StringIO() diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 7c4cc8cd..09d3628d 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -3,7 +3,6 @@ from __future__ import annotations -import hashlib import ipaddress import json import os @@ -34,47 +33,6 @@ EXAMPLE = PROJECT / "examples/pi-attested-admission" -def test_native_model_selection_keeps_only_selected_configuration( - tmp_path: Path, -) -> None: - catalog = json.loads((EXAMPLE / "models.json.example").read_text()) - provider = catalog["providers"]["example"] - provider["apiKey"] = "!do-not-execute-or-copy" - provider["authHeader"] = True - provider["models"].append( - { - "id": "vendor/second", - "baseUrl": "https://selected.example/custom/v1", - "api": "openai-completions", - } - ) - provider["modelOverrides"] = {"vendor/second": {"maxTokens": 2048}} - catalog["providers"]["unselected"] = { - "apiKey": "private", - "models": [{"id": "third"}], - } - path = tmp_path / "models.json" - path.write_text(json.dumps(catalog)) - select = runpy.run_path(str(EXAMPLE / "prepare.py"))["select_model"] - for selection in ("", "example/missing"): - with pytest.raises(ValueError, match="PI_MODEL"): - select(path, selection) - staged, selection, endpoint = select(path, "example/vendor/second") - assert selection == {"provider": "example", "id": "vendor/second"} - assert endpoint == "https://selected.example/custom/v1" - assert staged == { - "providers": { - "example": { - "api": provider["api"], - "baseUrl": provider["baseUrl"], - "compat": provider["compat"], - "models": [provider["models"][1]], - "modelOverrides": provider["modelOverrides"], - } - } - } - - @pytest.fixture def gateway_discovery( tmp_path: Path, monkeypatch: pytest.MonkeyPatch @@ -213,28 +171,6 @@ def test_every_action_prints_without_secrets_or_side_effects(tmp_path: Path) -> assert "PRIVATE_TEST_VALUE" not in output assert not marker.exists() assert not (tmp_path / ".workspaces").exists() - assert "git clone" not in output - assert "sha256sum" not in output and "curl" not in output - assert "--gateway test-gateway" in output - assert "https://service.example:5443/v1/admission" in output - assert "middleware.toml" in output - assert "gateway list --output json" in output - assert "--gateway-public-key" not in output and "--gateway-issuer" not in output - assert "17672" not in output and "XDG_CONFIG_HOME" not in output - assert "docker build" in output - assert "--admission-config" in output - assert "provider create" in output - assert "--credential PI_MODEL_API_KEY" in output - assert "--credential EGRESS_ADMISSION_TOKEN" in output - assert "sandbox create" in output and "--from pi-admission:local" in output - assert "/app/dist/src/cli.js" in output and "/app/dist/src/verify.js" in output - assert "sandbox delete pi-admission" in output - assert "gateway-registration.py" in output - assert ( - "brew services restart openshell" - if sys.platform == "darwin" - else "systemctl --user restart openshell-gateway" - ) in output @pytest.mark.parametrize( @@ -419,6 +355,16 @@ def run(command: list[str], **_kwargs: object) -> subprocess.CompletedProcess[st tomllib.loads(registered)["openshell"]["supervisor"]["middleware"][-1] == (tomllib.loads(fragment)["openshell"]["supervisor"]["middleware"][0]) ) + changed = registered.replace( + "https://service.example:50051", "https://operator.example:50051" + ) + config.write_text(changed) + before = len(commands) + with pytest.raises(ValueError, match="registration changed"): + configure("unregister", state, "openshell") + assert config.read_text() == changed + assert tuple(restart) not in commands[before:] + config.write_text(registered) fail_restart = True with pytest.raises(subprocess.CalledProcessError): configure("unregister", state, "openshell") @@ -463,29 +409,11 @@ def test_commands_forward_cache_preference_and_suppress_only_proxy_warning( command = shlex.split(printed.stdout) node_index = command.index("/usr/local/bin/node") assert command[node_index + 1] == "--disable-warning=UNDICI-EHPA" - environment = os.environ.copy() - environment.pop("NODE_OPTIONS", None) - environment.pop("PI_CACHE_RETENTION", None) - result = subprocess.run( - [ - *command[command.index("--") + 1 : node_index], - "node", - command[node_index + 1], - "-e", - "process.emitWarning('proxy notice', {code: 'UNDICI-EHPA'});" - "process.emitWarning('unrelated notice', {code: 'OTHER_WARNING'});" - "console.log(process.env.PI_CACHE_RETENTION ?? '');", - ], - env=environment, - capture_output=True, - text=True, - check=True, - ) - assert "UNDICI-EHPA" not in result.stderr - assert "proxy notice" not in result.stderr - assert "OTHER_WARNING" in result.stderr - assert "unrelated notice" in result.stderr - assert result.stdout.strip() == cache_retention + prefix = command[command.index("--") + 1 : node_index] + expected = ["/usr/bin/env"] + if cache_retention: + expected.append(f"PI_CACHE_RETENTION={cache_retention}") + assert prefix == expected @pytest.mark.parametrize("host", ["192.0.2.10", "host.docker.internal"]) @@ -504,9 +432,10 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( ), ) catalog = json.loads((example / "models.json.example").read_text()) - provider = catalog["providers"]["example"] + provider = catalog["providers"]["openrouter"] provider["apiKey"] = "must-not-enter-image" provider["models"][0]["baseUrl"] = provider["baseUrl"] + provider["modelOverrides"] = {"z-ai/glm-5.3-flash": {"maxTokens": 2048}} provider["baseUrl"] = "https://unselected.example/v1" provider["models"].append({"id": "unselected"}) (example / "models.json").write_text(json.dumps(catalog)) @@ -522,14 +451,14 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( "--gateway", gateway["name"], "--model", - "example/YOUR_MODEL_ID", + "openrouter/z-ai/glm-5.3-flash", ] subprocess.run(command, input=json.dumps([gateway]), text=True, check=True) config = AdmissionServerConfig.model_validate_json( (state / "admission.json").read_bytes() ) assert config.provider_target.scheme == "https" - assert config.provider_target.host == "api.example.com" + assert config.provider_target.host == "openrouter.ai" assert config.gateway_public_key == public_path assert config.gateway_issuer == "existing-gateway-issuer" assert public_path.read_bytes() == public @@ -542,8 +471,15 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( x509.SubjectAlternativeName ).value assert host in [str(name.value) for name in names] - assert config.provider_target.path == "/v1/chat/completions" + assert config.provider_target.path == "/api/v1/chat/completions" assert not config.sandbox_id_file.exists() + subprocess.run( + [sys.executable, str(example / "bind-sandbox.py"), "--state", str(state)], + input=json.dumps({"id": "actual-sandbox-id"}), + text=True, + check=True, + ) + assert config.sandbox_id_file.read_text().strip() == "actual-sandbox-id" token = config.bearer_token.get_secret_value() assert len(token) >= 32 (state / "image/stale-config.json").write_text("{}") @@ -567,12 +503,16 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( assert not (image / "pi-harness/node_modules").exists() assert (image / "project/.pi/skills/review/SKILL.md").is_file() catalog = json.loads((image / "models.json").read_text()) - assert catalog["providers"]["example"]["models"][0]["id"] == "YOUR_MODEL_ID" - assert len(catalog["providers"]["example"]["models"]) == 1 + assert catalog["providers"]["openrouter"]["models"][0]["id"] == "z-ai/glm-5.3-flash" + assert len(catalog["providers"]["openrouter"]["models"]) == 1 + assert ( + catalog["providers"]["openrouter"]["modelOverrides"] + == provider["modelOverrides"] + ) assert "must-not-enter-image" not in (image / "models.json").read_text() assert json.loads((image / "model-selection.json").read_text()) == { - "provider": "example", - "id": "YOUR_MODEL_ID", + "provider": "openrouter", + "id": "z-ai/glm-5.3-flash", } middleware = tomllib.loads((state / "middleware.toml").read_text()) registration = middleware["openshell"]["supervisor"]["middleware"][0] @@ -584,7 +524,7 @@ def test_preparation_uses_existing_gateway_and_excludes_private_material( assert policy["network_middlewares"]["pi_egress_gate"]["on_error"] == "fail_closed" model_endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] assert model_endpoint["rules"] == [ - {"allow": {"method": "POST", "path": "/v1/chat/completions"}} + {"allow": {"method": "POST", "path": "/api/v1/chat/completions"}} ] assert "access" not in model_endpoint assert policy["network_policies"]["admission"]["endpoints"][0]["port"] == 5443 @@ -653,46 +593,3 @@ def test_discovery_rejects_untrusted_gateway_before_preparation( "wrong-hostname": "Hostname mismatch", }[failure] in result.stderr assert not (tmp_path / "state").exists() - - -def test_sandbox_binding_accepts_only_operator_cli_output(tmp_path: Path) -> None: - result = subprocess.run( - [sys.executable, str(EXAMPLE / "bind-sandbox.py"), "--state", str(tmp_path)], - input=json.dumps({"id": "actual-sandbox-id"}), - text=True, - capture_output=True, - check=True, - ) - assert (tmp_path / "sandbox-id").read_text().strip() == "actual-sandbox-id" - assert "bound" in result.stdout - - -def test_pi_dependencies_are_exact_upstream_packages() -> None: - package = json.loads((EXAMPLE / "pi-harness/package.json").read_text()) - lock = json.loads((EXAMPLE / "pi-harness/package-lock.json").read_text()) - for name, version in package["dependencies"].items(): - if name.startswith("@earendil-works/"): - assert version == "0.85.1" - resolved = lock["packages"][f"node_modules/{name}"] - assert resolved["version"] == version - assert resolved["resolved"].startswith("https://registry.npmjs.org/") - assert resolved["integrity"].startswith("sha512-") - - -def test_middleware_manifest_matches_upstream_protocol() -> None: - manifest = json.loads((PROJECT / ".openshell-middleware-manifest.json").read_text()) - assert manifest["openshell_version"] == "v0.0.116" - assert manifest["proto_source"] == ( - "https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.116" - "/proto/supervisor_middleware.proto" - ) - assert ( - manifest["proto_sha256"] - == hashlib.sha256( - (PROJECT / "proto/supervisor_middleware.proto").read_bytes() - ).hexdigest() - ) - script = (PROJECT / "scripts/generate-bindings.sh").read_text() - assert "--project ../openshell-middleware-manager omm update ." in script - assert "--openshell-version v0.0.116 --check-command 'make check'" in script - assert "grpc_tools.protoc" not in script and "curl" not in script