From faf3093d6bbdf7459a80532a2c6815436f45620e Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 12 Aug 2026 01:31:03 +0000 Subject: [PATCH 01/12] 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 056e45ab..107f995b 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>=6.33.5,<7", "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 f2ecd66e..0fa1e3e5 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 = ">=6.33.5,<7" }, { 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 021281fece490a0371887836229e24fdaddca6fc Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 12 Aug 2026 01:31:27 +0000 Subject: [PATCH 02/12] 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 c5601c067927833f3ff6e1f0d49bcb9a17cb816f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 12 Aug 2026 01:56:41 +0000 Subject: [PATCH 03/12] 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 355db91ccbb0f8222ed84cae3b0135f4af5cee23 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 12 Aug 2026 02:10:31 +0000 Subject: [PATCH 04/12] 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 9d5309513489f4f91ffce88cd5d2002d22fac4c2 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 12 Aug 2026 15:16:57 +0000 Subject: [PATCH 05/12] 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 22c93e6bd6898b914930e6cc230cd3a631f872b0 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 17 Aug 2026 21:35:24 +0000 Subject: [PATCH 06/12] 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 0540f544157993d8deccc9d78b78921f715e0a7e Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 17 Aug 2026 22:00:19 +0000 Subject: [PATCH 07/12] 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 35ec55c3c67a658bfa01f12de5f2b6f7eac77a20 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 20 Aug 2026 21:48:56 +0000 Subject: [PATCH 08/12] 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 de716d7d1446753537cb8b2ce4e6219257bb18d3 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 20 Aug 2026 21:50:33 +0000 Subject: [PATCH 09/12] 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 c8be825945c6f5fe22f7123698d0dd26ccf34a4f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 24 Aug 2026 03:01:03 +0000 Subject: [PATCH 10/12] chore: ignore local planning files --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 8e2e0357..f2abeaad 100644 --- a/.gitignore +++ b/.gitignore @@ -48,6 +48,7 @@ temp/ *.temp *.bak .scratch/ +plans/ # Python __pycache__/ From acebc5d4f1e3b0e6e8d05e466cd95220829e317b Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 26 Aug 2026 04:42:08 +0000 Subject: [PATCH 11/12] 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 7f71b164932f3604e360f4c43f9f48116d9a7674 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 26 Aug 2026 04:56:31 +0000 Subject: [PATCH 12/12] 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