From faf3093d6bbdf7459a80532a2c6815436f45620e Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 12 Aug 2026 01:31:03 +0000 Subject: [PATCH 01/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] 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/19] docs(egress-gate): streamline Pi example setup --- .../examples/pi-attested-admission/README.md | 31 +++---------------- .../examples/pi-attested-admission/demo.sh | 12 +++---- 2 files changed, 9 insertions(+), 34 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 8d5fdfe2..43617a42 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -51,23 +51,17 @@ You can inspect every command before running anything: ./demo.sh --print all ``` -Update both fork checkouts to the latest commits on those branches: - -```shell -./demo.sh sync -``` - -`sync` uses fast-forward-only pulls and stops instead of merging divergent local -work. - ## Try it -Build the Pi fork and register Egress Gate with OpenShell: +Update both fork branches, build Pi, and register Egress Gate with OpenShell: ```shell ./demo.sh prepare ``` +The updates use fast-forward-only pulls and stop instead of merging divergent +local work. + Keep Egress Gate running in one terminal: ```shell title="Terminal 1: Egress Gate" @@ -120,21 +114,6 @@ The output must contain `[REDACTED]` and must not contain `DENY_THIS` or 5. OpenShell checks the receipt before the model request leaves the sandbox and injects `OPENAI_API_KEY`; the key is never copied into the sandbox. -## Inspect individual commands - -The helper never requires you to trust hidden orchestration. Add `--print` to -any action to show its exact commands without executing them: - -```shell -./demo.sh --print prepare -./demo.sh --print launch -``` - -The actions are deliberately small: `sync` updates the two fork branches; -`prepare` builds and packages the Pi fork; `serve` runs Egress Gate; `gateway` -runs the matching OpenShell fork; and `launch` creates the credential provider -and sandbox. - ## Current scope This initial integration supports idle, text-only, direct OpenAI Chat @@ -153,4 +132,4 @@ sandbox and provider: ``` Then stop the OpenShell gateway and Egress Gate with `Ctrl-C`. To run the -example again, start from `./demo.sh sync`. +example again, start from `./demo.sh prepare`. diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index ef5d53f6..7d066dc8 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -81,7 +81,7 @@ require_branch() { fi } -sync() { +sync_forks() { if ! $print_only; then require_directory "$pi_repo" "Pi checkout" require_directory "$openshell_repo" "OpenShell checkout" @@ -100,9 +100,8 @@ pi_tarball() { } prepare() { + sync_forks if ! $print_only; then - require_directory "$pi_repo" "Pi checkout" - require_branch "$pi_repo" "$pi_branch" require_value "${EGRESS_GATE_HOST_IP:-}" EGRESS_GATE_HOST_IP fi local tarball @@ -199,8 +198,7 @@ usage() { Usage: ./demo.sh [--print] ACTION Actions: - sync Update the Pi and OpenShell fork branches with fast-forward pulls - prepare Build and package Pi, then register Egress Gate with OpenShell + prepare Update the forks, package Pi, and register Egress Gate with OpenShell serve Start Egress Gate gateway Start the forked OpenShell gateway launch Create the OpenAI provider and launch Pi in a managed sandbox @@ -209,14 +207,12 @@ Actions: all Print every action in order (requires --print) Use --print to show exact commands without running them: - ./demo.sh --print sync ./demo.sh --print prepare ./demo.sh --print all EOF } case "$action" in - sync) sync ;; prepare) prepare ;; serve) serve ;; gateway) gateway ;; @@ -228,7 +224,7 @@ case "$action" in printf 'The all action is print-only. Run: ./demo.sh --print all\n' >&2 exit 1 fi - for step in sync prepare serve gateway launch verify cleanup; do + for step in prepare serve gateway launch verify cleanup; do printf '\n# %s\n' "$step" "$step" done From 324d744dfd8183f16cd8453fef7adde0f1e5cd54 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 22:47:55 +0000 Subject: [PATCH 13/19] fix(egress-gate): isolate nested OpenShell checkout --- projects/egress-gate/examples/pi-attested-admission/demo.sh | 6 ++++-- projects/egress-gate/tests/test_pi_example_commands.py | 2 +- 2 files changed, 5 insertions(+), 3 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 7d066dc8..a669b5b2 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -126,8 +126,10 @@ gateway() { require_directory "$openshell_repo" "OpenShell checkout" require_branch "$openshell_repo" "$openshell_branch" fi - run_in "$openshell_repo" mise trust - run_in "$openshell_repo" mise run gateway + # A custom checkout may be nested below this uv project. Keep OpenShell's + # mise-pinned uv from inheriting Egress Gate's uv configuration. + run_in "$openshell_repo" env UV_NO_CONFIG=1 mise trust + run_in "$openshell_repo" env UV_NO_CONFIG=1 mise run gateway } launch() { diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 033558fa..47da0ac7 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -43,7 +43,7 @@ def test_pi_example_can_print_every_command_without_running_it( assert "git pull --ff-only origin openshell/pi-egress-admission" in result.stdout assert "add-gateway-registration" in result.stdout assert "egress-gate --debug serve" in result.stdout - assert "mise run gateway" in result.stdout + assert "env UV_NO_CONFIG=1 mise run gateway" in result.stdout assert "provider create" in result.stdout assert "sandbox create" in result.stdout assert "sandbox exec" in result.stdout From f512f94731bf7cd0a8c5d77028dcac319d0b59f2 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 23:34:10 -0400 Subject: [PATCH 14/19] feat(egress-gate): complete Pi attested admission example --- projects/egress-gate/.gitignore | 1 + .../pi-attested-admission/.env.example | 4 + .../examples/pi-attested-admission/README.md | 124 +++-- .../examples/pi-attested-admission/demo.sh | 459 ++++++++++++++++-- .../pi-attested-admission/models.json | 25 - .../openshell-input-admission.test.mjs | 109 +++++ .../openshell-input-admission.ts | 183 +++++-- .../pi-attested-admission/policy.yaml | 8 +- .../render-runtime-config.mjs | 162 +++++++ .../proto/supervisor_middleware.proto | 203 +++++++- .../src/egress_gate/admission/receipts.py | 16 - .../bindings/supervisor_middleware_pb2.py | 126 +++-- .../bindings/supervisor_middleware_pb2.pyi | 182 ++++++- .../supervisor_middleware_pb2_grpc.py | 59 ++- .../src/egress_gate/service/servicer.py | 4 +- .../tests/admission/test_admission.py | 58 ++- .../tests/test_pi_admission_extension.py | 20 + .../tests/test_pi_example_commands.py | 280 ++++++++++- 18 files changed, 1727 insertions(+), 296 deletions(-) create mode 100644 projects/egress-gate/.gitignore create mode 100644 projects/egress-gate/examples/pi-attested-admission/.env.example delete mode 100644 projects/egress-gate/examples/pi-attested-admission/models.json create mode 100644 projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs create mode 100644 projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs create mode 100644 projects/egress-gate/tests/test_pi_admission_extension.py diff --git a/projects/egress-gate/.gitignore b/projects/egress-gate/.gitignore new file mode 100644 index 00000000..3b9932d3 --- /dev/null +++ b/projects/egress-gate/.gitignore @@ -0,0 +1 @@ +.workspaces/ diff --git a/projects/egress-gate/examples/pi-attested-admission/.env.example b/projects/egress-gate/examples/pi-attested-admission/.env.example new file mode 100644 index 00000000..981b7931 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/.env.example @@ -0,0 +1,4 @@ +EGRESS_GATE_HOST_IP=YOUR_HOST_IPV4 +PI_MODEL_BASE_URL=https://provider.example.com/v1 +PI_MODEL_ID=your-model-id +PI_MODEL_API_KEY=your-provider-key diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 43617a42..b7bc274c 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,12 +1,18 @@ -# Managed Pi deny-or-redact example +# Managed Pi attested-admission example -This example runs the forked Pi CLI inside OpenShell and sends its rendered -user submissions through Egress Gate. It makes real OpenAI API calls and may -incur provider charges. +This example runs the forked Pi CLI inside OpenShell and sends admitted user +submissions to a model endpoint you choose. The endpoint may be a hosted +provider, an internal gateway, or a local server. It must accept the OpenAI Chat +Completions request shape used by the current attestation adapter; it does not +need to be OpenAI. -- `DENY_THIS` is rejected before Pi writes it to session history or starts a - model turn. -- `REDACT_THIS` becomes `[REDACTED]` before Pi writes or sends it. +The example demonstrates two outcomes: + +- `DENY_THIS` is rejected before Pi records it or starts a model request. +- `REDACT_THIS` becomes `[REDACTED]` before Pi records or sends it. + +The redaction case makes one real request to your configured endpoint and may +incur charges from that provider. ## Before you start @@ -16,44 +22,66 @@ Use these matching fork branches: - [OpenShell `openshell/pi-egress-admission`](https://github.com/johnnygreco/OpenShell/tree/openshell/pi-egress-admission) - [OpenShell Research integration branch](https://github.com/NVIDIA/OpenShell-Research/tree/johnny/pi-attested-admission) -Install each repository's development prerequisites and export: - -```shell -export OPENAI_API_KEY=your-key -export EGRESS_GATE_HOST_IP=192.168.1.20 -``` - -`EGRESS_GATE_HOST_IP` must be a non-loopback IPv4 address reachable by the -gateway and sandbox supervisors. `hostname -I` usually shows the available -addresses; choose the address for the host network shared with OpenShell. +You do not need to clone the Pi or OpenShell forks manually. The first +`./demo.sh prepare` clones both into the ignored local workspace +`projects/egress-gate/.workspaces/pi-attested-admission/`. Later runs update +them with fast-forward-only pulls, so the fork contents never appear as +OpenShell Research changes. To reuse a checkout elsewhere, set `PI_REPO` or +`OPENSHELL_REPO` to its absolute path. -The helper expects sibling checkouts named `pi`, `OpenShell`, and -`OpenShell-Research`. For another layout, set `PI_REPO` and `OPENSHELL_REPO` to -absolute paths. +The OpenShell gateway needs a running compute backend. On macOS, start Docker +Desktop and wait until `docker info` succeeds before running the gateway; +Podman is also supported. Building the gateway also requires Z3 (`brew install +z3` on macOS or `libz3-dev` on Debian and Ubuntu). The fork recommends `mise` +2026.4.25 or newer. -If you do not already have the fork checkouts, clone them beside this repository: +From the `OpenShell-Research` checkout, change to the example directory. Run +all remaining commands there: ```shell -git clone --branch johnny/before-user-message-commit https://github.com/johnnygreco/pi.git ../pi -git clone --branch openshell/pi-egress-admission https://github.com/johnnygreco/OpenShell.git ../OpenShell +cd projects/egress-gate/examples/pi-attested-admission ``` -From the `OpenShell-Research` checkout, change to the example directory. Run -all remaining commands there: +Create the local configuration file, replace every example value, and load it +into the current shell: ```shell -cd projects/egress-gate/examples/pi-attested-admission +cp .env.example .env +# Edit .env before continuing. +set -a +source .env +set +a ``` -You can inspect every command before running anything: +If the model endpoint does not require authentication, set +`PI_MODEL_API_KEY=unused`. Source `.env` again in each new terminal that runs +`demo.sh`. + +`EGRESS_GATE_HOST_IP` is the address OpenShell uses to reach Egress Gate on this +machine. It must be a reachable, non-loopback IPv4 address; do not use +`127.0.0.1`. `PI_MODEL_BASE_URL` is separate: it is the model endpoint Pi will +call. A model server running on this machine must likewise use a hostname or +address reachable from the sandbox rather than `localhost`. + +`demo.sh prepare` derives the endpoint policy and Pi model configuration from +these values. You do not need to edit `policy.yaml`. If required values are +missing or still contain placeholders, the script prints the configuration +steps and stops before performing any work. + +Preview the complete workflow before running anything: ```shell ./demo.sh --print all ``` -## Try it +The walkthrough lists the terminal sequence and configuration visible to the +current shell. To inspect the exact commands for one action, use its name—for +example, `./demo.sh --print prepare` or `./demo.sh --print launch`. -Update both fork branches, build Pi, and register Egress Gate with OpenShell: +## Run the example + +Prepare the forks, build Pi, generate the endpoint-specific runtime +configuration, and generate the Egress Gate registration used by Terminal 2: ```shell ./demo.sh prepare @@ -74,13 +102,24 @@ Start the matching OpenShell gateway in a second terminal: ./demo.sh gateway ``` -After the gateway reports that it is ready, launch the real Pi CLI from a -third terminal: +The example uses its own gateway name and passes it explicitly to every +OpenShell command. It does not depend on or change your globally selected +OpenShell gateway. + +After the gateway reports that it is ready, launch Pi from a third terminal: ```shell title="Terminal 3: managed Pi" ./demo.sh launch ``` +Each launch replaces the example's `pi-egress-demo` sandbox so the current Pi +runtime, extension, policy, and OpenShell supervisor are used together. + +The example registers an endpoint-specific provider profile and stores +`PI_MODEL_API_KEY` as its credential. Pi sees only an opaque placeholder; +OpenShell resolves it only when the admitted request is sent to the configured +model host and port. + At the Pi prompt, submit both of these in the same session: ```text @@ -91,8 +130,8 @@ Reply with exactly: DENY_THIS Reply with exactly: REDACT_THIS ``` -The first submission is denied without starting a model turn. The second makes -a real model call using `[REDACTED]`. Exit Pi, then inspect its persisted +The first submission is denied without starting a model request. The second +makes a request containing `[REDACTED]`. Exit Pi, then inspect its persisted session: ```shell @@ -110,17 +149,20 @@ The output must contain `[REDACTED]` and must not contain `DENY_THIS` or bridge. 3. Egress Gate applies `policy.yaml`: it either denies the submission or returns replacement text plus a short-lived receipt. -4. Pi appends only admitted or replacement text to session history. -5. OpenShell checks the receipt before the model request leaves the sandbox and - injects `OPENAI_API_KEY`; the key is never copied into the sandbox. +4. Pi records only admitted or replacement text. +5. Before each model request in that turn, including automatic requests after + tool calls, the extension obtains a fresh receipt for the active admitted + text. +6. As each request leaves the sandbox, Egress Gate verifies that its final user + text matches the receipt and OpenShell resolves the credential. ## Current scope -This initial integration supports idle, text-only, direct OpenAI Chat -Completions submissions. Images, queued input, retries, compaction, and -automatic continuations after tool calls are unsupported and fail closed. The -next comprehensive boundary is one receipt per provider request; it does not -require one Pi hook per message role. +The attestation adapter supports normal text turns, including tools, queued +steering and follow-up messages, and the automatic model continuations they +produce, using the OpenAI Chat Completions wire format. Providers with a +different native protocol and image inputs are not covered by this example and +fail closed. ## Cleanup diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index a669b5b2..dc11d777 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -14,23 +14,70 @@ fi action=${1:-help} script_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) egress_gate_dir=$(cd -- "$script_dir/../.." && pwd) -workspace_dir=$(cd -- "$egress_gate_dir/../../.." && pwd) -pi_repo=${PI_REPO:-$workspace_dir/pi} -openshell_repo=${OPENSHELL_REPO:-$workspace_dir/OpenShell} +forks_dir=${PI_EGRESS_FORKS_DIR:-$egress_gate_dir/.workspaces/pi-attested-admission} +pi_repo=${PI_REPO:-$forks_dir/pi} +openshell_repo=${OPENSHELL_REPO:-$forks_dir/OpenShell} pi_branch=johnny/before-user-message-commit openshell_branch=openshell/pi-egress-admission +pi_remote=https://github.com/johnnygreco/pi.git +openshell_remote=https://github.com/johnnygreco/OpenShell.git host_ip=${EGRESS_GATE_HOST_IP:-YOUR_HOST_IPV4} +model_base_url=${PI_MODEL_BASE_URL:-YOUR_MODEL_BASE_URL} +model_id=${PI_MODEL_ID:-YOUR_MODEL_ID} pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} openshell_cli=$openshell_repo/scripts/bin/openshell +gateway_name=${PI_EGRESS_GATEWAY_NAME:-pi-egress-demo-gateway} +runtime_models=$runtime_dir/models.json +runtime_policy=$runtime_dir/policy.yaml +runtime_provider_profile=$runtime_dir/provider-profile.yaml +runtime_gateway_fragment=$runtime_dir/gateway-middleware.toml +z3_library_path_override=${Z3_LIBRARY_PATH_OVERRIDE:-} + +bold="" +green="" +yellow="" +blue="" +cyan="" +reset="" +if [[ ${NO_COLOR+x} != x && (${FORCE_COLOR:-0} == 1 || (-t 1 && ${TERM:-} != dumb)) ]]; then + bold=$'\033[1m' + green=$'\033[32m' + yellow=$'\033[33m' + blue=$'\033[34m' + cyan=$'\033[36m' + reset=$'\033[0m' +fi print_command() { local directory=$1 shift - printf '(cd %q &&' "$directory" - printf ' %q' "$@" - printf ')\n' + local argument + local column=2 + local token + printf ' %bworking directory%b: %s\n' "$cyan" "$reset" "$directory" + printf ' %bcommand%b:\n ' "$green" "$reset" + for argument in "$@"; do + printf -v token '%q' "$argument" + if ((column > 2 && column + ${#token} + 1 > 96)); then + printf ' \\\n ' + column=6 + fi + if ((column > 2)); then + printf ' ' + ((column += 1)) + fi + printf '%s' "$token" + ((column += ${#token})) + done + printf '\n' +} + +describe_printed_commands() { + if $print_only; then + printf '\n%b%s%b\n' "$bold$blue" "$1" "$reset" + fi } run_in() { @@ -61,15 +108,125 @@ require_directory() { fi } -require_value() { - local value=$1 - local name=$2 - if [[ -z $value ]]; then - printf 'Set %s before running this action.\n' "$name" >&2 +require_compute_backend() { + local requested_driver=${OPENSHELL_DRIVERS:-} + if [[ -n ${KUBERNETES_SERVICE_HOST:-} ]]; then + return + fi + if [[ -z $requested_driver || $requested_driver == podman ]]; then + if command -v podman >/dev/null 2>&1 && podman info >/dev/null 2>&1; then + return + fi + fi + if [[ -z $requested_driver || $requested_driver == docker ]]; then + if command -v docker >/dev/null 2>&1 && docker info >/dev/null 2>&1; then + return + fi + fi + if [[ -n $requested_driver && $requested_driver != podman && $requested_driver != docker ]]; then + return + fi + + printf 'No running OpenShell compute backend was detected.\n' >&2 + printf 'Start Docker Desktop or Podman, wait until its info command succeeds, then retry:\n' >&2 + printf ' docker info\n' >&2 + printf ' # or: podman info\n' >&2 + printf 'For another supported backend, set OPENSHELL_DRIVERS before running gateway.\n' >&2 + exit 1 +} + +raise_gateway_open_file_limit() { + local target=10240 + local hard_limit + local soft_limit + hard_limit=$(ulimit -Hn) + soft_limit=$(ulimit -Sn) + if [[ $soft_limit == unlimited ]]; then + return + fi + if [[ $hard_limit != unlimited && $hard_limit -lt $target ]]; then + target=$hard_limit + fi + if ((soft_limit >= target)); then + return + fi + if ! ulimit -Sn "$target"; then + printf 'Could not raise the open-file limit from %s to %s for the OpenShell build.\n' \ + "$soft_limit" "$target" >&2 + printf 'Run `ulimit -n %s` in this terminal, then retry.\n' "$target" >&2 exit 1 fi } +require_gateway_z3() { + local z3_prefix + if [[ -n $z3_library_path_override ]]; then + return + fi + if command -v pkg-config >/dev/null 2>&1 && pkg-config --exists z3; then + return + fi + case $(uname -s) in + Darwin) + if command -v brew >/dev/null 2>&1; then + z3_prefix=$(brew --prefix z3 2>/dev/null || true) + if [[ -f $z3_prefix/lib/libz3.dylib ]]; then + z3_library_path_override=$z3_prefix/lib + return + fi + fi + printf 'The OpenShell gateway build requires Z3. Install it, then retry:\n' >&2 + printf ' brew install z3\n' >&2 + ;; + Linux) + if command -v ldconfig >/dev/null 2>&1 && ldconfig -p 2>/dev/null | grep -q 'libz3\.so'; then + return + fi + printf 'The OpenShell gateway build requires the Z3 development library.\n' >&2 + printf 'On Debian or Ubuntu, install it with: sudo apt-get install libz3-dev\n' >&2 + ;; + *) + printf 'The OpenShell gateway build requires the Z3 native library.\n' >&2 + printf 'Install Z3 or set Z3_LIBRARY_PATH_OVERRIDE to its library directory.\n' >&2 + ;; + esac + exit 1 +} + +require_example_configuration() { + local missing=() + if [[ -z ${EGRESS_GATE_HOST_IP:-} || ${EGRESS_GATE_HOST_IP:-} == YOUR_HOST_IPV4 ]]; then + missing+=(EGRESS_GATE_HOST_IP) + fi + if [[ -z ${PI_MODEL_BASE_URL:-} || ${PI_MODEL_BASE_URL:-} == https://provider.example.com/v1 ]]; then + missing+=(PI_MODEL_BASE_URL) + fi + if [[ -z ${PI_MODEL_ID:-} || ${PI_MODEL_ID:-} == your-model-id ]]; then + missing+=(PI_MODEL_ID) + fi + if [[ -z ${PI_MODEL_API_KEY:-} || ${PI_MODEL_API_KEY:-} == your-provider-key ]]; then + missing+=(PI_MODEL_API_KEY) + fi + if ((${#missing[@]} == 0)); then + return + fi + + printf 'The Pi attested-admission example is not configured.\n' >&2 + printf 'Set these environment variables:\n' >&2 + printf ' %s\n' "${missing[@]}" >&2 + printf '\n' >&2 + printf 'Configure and load %s:\n' "$script_dir/.env" >&2 + printf ' cd %s\n' "$script_dir" >&2 + if [[ ! -f $script_dir/.env ]]; then + printf ' cp .env.example .env\n' >&2 + fi + printf ' # Edit .env and replace every example value.\n' >&2 + printf ' set -a\n' >&2 + printf ' source .env\n' >&2 + printf ' set +a\n' >&2 + exit 1 +} + require_branch() { local repository=$1 local expected=$2 @@ -81,78 +238,196 @@ require_branch() { fi } +ensure_checkout() { + local repository=$1 + local description=$2 + local remote=$3 + local branch=$4 + local parent + parent=$(dirname -- "$repository") + if $print_only; then + describe_printed_commands "$description (only when missing):" + print_command "$parent" git clone --branch "$branch" "$remote" "$repository" + return + fi + if [[ -e $repository && ! -d $repository/.git ]]; then + printf '%s path exists but is not a Git checkout: %s\n' "$description" "$repository" >&2 + exit 1 + fi + if [[ ! -d $repository/.git ]]; then + mkdir -p "$parent" + run_in "$parent" git clone --branch "$branch" "$remote" "$repository" + fi +} + sync_forks() { + ensure_checkout "$pi_repo" "Pi checkout" "$pi_remote" "$pi_branch" + ensure_checkout "$openshell_repo" "OpenShell checkout" "$openshell_remote" "$openshell_branch" if ! $print_only; then - require_directory "$pi_repo" "Pi checkout" - require_directory "$openshell_repo" "OpenShell checkout" require_branch "$pi_repo" "$pi_branch" require_branch "$openshell_repo" "$openshell_branch" fi - run_in "$pi_repo" git pull --ff-only origin "$pi_branch" - run_in "$openshell_repo" git pull --ff-only origin "$openshell_branch" + describe_printed_commands "Update the Pi fork:" + run_in "$pi_repo" git pull --no-rebase --ff-only origin "$pi_branch" + describe_printed_commands "Update the OpenShell fork:" + run_in "$openshell_repo" git pull --no-rebase --ff-only origin "$openshell_branch" } pi_tarball() { + if $print_only; then + printf '%s/earendil-works-pi-coding-agent-VERSION.tgz' "$pack_dir" + return + fi require_file "$pi_repo/packages/coding-agent/package.json" "Pi coding-agent package" local version version=$(node -p "require(process.argv[1]).version" "$pi_repo/packages/coding-agent/package.json") printf '%s/earendil-works-pi-coding-agent-%s.tgz' "$pack_dir" "$version" } +render_runtime_configuration() { + run_in "$script_dir" mkdir -p "$runtime_dir" + run_in "$script_dir" node render-runtime-config.mjs \ + --base-url "$model_base_url" \ + --model-id "$model_id" \ + --models-output "$runtime_models" \ + --policy-output "$runtime_policy" \ + --provider-profile-output "$runtime_provider_profile" \ + --middleware-endpoint "http://$host_ip:50051" \ + --gateway-output "$runtime_gateway_fragment" +} + prepare() { - sync_forks if ! $print_only; then - require_value "${EGRESS_GATE_HOST_IP:-}" EGRESS_GATE_HOST_IP + require_example_configuration fi + sync_forks local tarball tarball=$(pi_tarball) + describe_printed_commands "Build and package Pi:" run_in "$pi_repo" npm install --ignore-scripts run_in "$pi_repo" npm run build run_in "$pi_repo" mkdir -p "$pack_dir" "$runtime_dir" run_in "$pi_repo" npm pack --workspace @earendil-works/pi-coding-agent --pack-destination "$pack_dir" run_in "$pi_repo" npm install --prefix "$runtime_dir" --ignore-scripts "$tarball" - run_in "$egress_gate_dir" uv run egress-gate add-gateway-registration \ - --host-ip "$host_ip" --name pi-egress --port 50051 + describe_printed_commands "Generate the Pi model configuration and endpoint policy:" + render_runtime_configuration } serve() { + describe_printed_commands "Run Egress Gate and keep it open:" run_in "$egress_gate_dir" uv run egress-gate --debug serve \ --listen 0.0.0.0:50051 --timeout 4s --require-pi-receipt } gateway() { if ! $print_only; then + require_example_configuration + require_compute_backend + raise_gateway_open_file_limit + require_gateway_z3 require_directory "$openshell_repo" "OpenShell checkout" require_branch "$openshell_repo" "$openshell_branch" fi + describe_printed_commands "Refresh the gateway middleware registration fragment:" + render_runtime_configuration # A custom checkout may be nested below this uv project. Keep OpenShell's # mise-pinned uv from inheriting Egress Gate's uv configuration. + describe_printed_commands "Start the matching OpenShell gateway and keep it open:" run_in "$openshell_repo" env UV_NO_CONFIG=1 mise trust - run_in "$openshell_repo" env UV_NO_CONFIG=1 mise run gateway + local gateway_environment=( + env + UV_NO_CONFIG=1 + CARGO_BUILD_JOBS="${CARGO_BUILD_JOBS:-4}" + OPENSHELL_GATEWAY_NAME="$gateway_name" + OPENSHELL_GATEWAY_CONFIG_FRAGMENT="$runtime_gateway_fragment" + ) + if [[ -n $z3_library_path_override ]]; then + gateway_environment+=(Z3_LIBRARY_PATH_OVERRIDE="$z3_library_path_override") + fi + run_in "$openshell_repo" "${gateway_environment[@]}" mise run gateway +} + +ensure_model_provider() { + if $print_only; then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile import --file "$runtime_provider_profile" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ + --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY + return + fi + if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + provider profile export pi-attested-model >/dev/null 2>&1); then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile update pi-attested-model --file "$runtime_provider_profile" + else + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile import --file "$runtime_provider_profile" + fi + if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + provider get pi-model >/dev/null 2>&1); then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider delete pi-model + fi + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ + --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY +} + +delete_demo_sandbox_if_present() { + if ! $print_only && (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ + sandbox list --names | grep -Fxq pi-egress-demo); then + printf 'Replacing existing sandbox pi-egress-demo with the current example runtime.\n' + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox delete pi-egress-demo + fi +} + +create_demo_sandbox() { + run_in "$script_dir" "$openshell_cli" --gateway "$gateway_name" sandbox create \ + --name pi-egress-demo \ + --from base \ + --provider pi-model \ + --policy "$runtime_policy" \ + --upload "$runtime_dir/node_modules:/sandbox/pi-runtime" \ + --upload "$script_dir/openshell-input-admission.ts:/sandbox/openshell-input-admission.ts" \ + --upload "$runtime_models:/sandbox/pi-agent/models.json" \ + --no-git-ignore \ + --detach } launch() { if ! $print_only; then + require_example_configuration require_file "$openshell_cli" "OpenShell CLI wrapper" require_file "$(pi_tarball)" "packed Pi coding-agent" - require_value "${OPENAI_API_KEY:-}" OPENAI_API_KEY + require_file "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" \ + "installed Pi CLI" fi - run_in "$openshell_repo" "$openshell_cli" provider create \ - --name pi-openai --type openai --credential OPENAI_API_KEY - run_in "$script_dir" "$openshell_cli" sandbox create \ - --name pi-egress-demo \ - --from base \ - --provider pi-openai \ - --policy policy.yaml \ - --upload "$runtime_dir:/sandbox/pi-runtime" \ - --upload "$script_dir/openshell-input-admission.ts:/sandbox/openshell-input-admission.ts" \ - --upload "$script_dir/models.json:/sandbox/pi-agent/models.json" \ - -- env PI_CODING_AGENT_DIR=/sandbox/pi-agent \ + describe_printed_commands "Refresh the endpoint-specific model, policy, and provider profile:" + render_runtime_configuration + if ! $print_only; then + require_file "$runtime_models" "generated Pi model configuration" + require_file "$runtime_policy" "generated OpenShell policy" + require_file "$runtime_provider_profile" "generated OpenShell provider profile" + fi + + describe_printed_commands "Remove an earlier example sandbox, if present:" + delete_demo_sandbox_if_present + describe_printed_commands "Register the endpoint-scoped model credential in OpenShell:" + ensure_model_provider + describe_printed_commands "Create a fresh sandbox and upload the Pi runtime:" + create_demo_sandbox + describe_printed_commands "Launch Pi interactively in the prepared sandbox:" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec --tty -n pi-egress-demo -- \ + env \ + PI_CODING_AGENT_DIR=/sandbox/pi-agent \ + PI_OFFLINE=1 \ + OPENSHELL_AGENT_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation \ node /sandbox/pi-runtime/node_modules/@earendil-works/pi-coding-agent/dist/cli.js \ - --provider openai-chat-completions \ - --model gpt-4o-mini \ + --provider attested-provider \ + --model "$model_id" \ --extension /sandbox/openshell-input-admission.ts \ --session-dir /sandbox/pi-sessions } @@ -165,20 +440,25 @@ verify() { local forbidden='DENY_THIS|REDACT_THIS' if $print_only; then - print_command "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + describe_printed_commands "Confirm that Pi saved the redacted text:" + print_command "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec -n pi-egress-demo -- \ grep -R -n -E "$redacted" /sandbox/pi-sessions - printf '! ' - print_command "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + describe_printed_commands "Confirm that Pi did not save either original marker (this command must find no matches):" + print_command "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec -n pi-egress-demo -- \ grep -R -n -E "$forbidden" /sandbox/pi-sessions return fi - if ! run_in "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + if ! run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec -n pi-egress-demo -- \ grep -R -n -E "$redacted" /sandbox/pi-sessions; then printf 'Verification failed: [REDACTED] was not found in Pi session history.\n' >&2 exit 1 fi - if run_in "$openshell_repo" "$openshell_cli" sandbox exec -n pi-egress-demo -- \ + if run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox exec -n pi-egress-demo -- \ grep -R -n -E "$forbidden" /sandbox/pi-sessions; then printf 'Verification failed: denied or unredacted input was found in Pi session history.\n' >&2 exit 1 @@ -190,30 +470,106 @@ cleanup() { if ! $print_only; then require_file "$openshell_cli" "OpenShell CLI wrapper" fi - run_in "$openshell_repo" "$openshell_cli" sandbox delete pi-egress-demo - run_in "$openshell_repo" "$openshell_cli" provider delete pi-openai - run_in "$egress_gate_dir" uv run egress-gate remove-gateway-registration --name pi-egress + describe_printed_commands "Delete the example sandbox:" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + sandbox delete pi-egress-demo + describe_printed_commands "Delete the example credential provider:" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider delete pi-model + describe_printed_commands "Delete the example provider profile:" + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile delete pi-attested-model } usage() { + printf '%bUsage%b: ./demo.sh [--print] ACTION\n\n' "$bold$cyan" "$reset" + printf '%bActions%b:\n' "$bold$blue" "$reset" cat <<'EOF' -Usage: ./demo.sh [--print] ACTION -Actions: - prepare Update the forks, package Pi, and register Egress Gate with OpenShell + prepare Update the forks, package Pi, and generate the runtime configuration serve Start Egress Gate gateway Start the forked OpenShell gateway - launch Create the OpenAI provider and launch Pi in a managed sandbox + launch Attach the configured model credential and launch managed Pi verify Confirm redaction and absence of original text in Pi session history - cleanup Delete the sandbox and provider, then remove the registration - all Print every action in order (requires --print) + cleanup Delete the example sandbox and credential provider + all Show the concise workflow walkthrough (requires --print) +EOF -Use --print to show exact commands without running them: + printf '\n%bPreview before running%b:\n' "$bold$blue" "$reset" + cat <<'EOF' ./demo.sh --print prepare ./demo.sh --print all EOF } +print_plan() { + local configuration_status="ready" + local status_color="$green" + local credential_status="not set" + local displayed_host="$host_ip" + local displayed_model_base_url="$model_base_url" + local displayed_model_id="$model_id" + if [[ $displayed_host == YOUR_HOST_IPV4 ]]; then + displayed_host="not set" + configuration_status="incomplete — edit and source .env" + fi + if [[ $displayed_model_base_url == YOUR_MODEL_BASE_URL ]]; then + displayed_model_base_url="not set" + configuration_status="incomplete — edit and source .env" + fi + if [[ $displayed_model_id == YOUR_MODEL_ID ]]; then + displayed_model_id="not set" + configuration_status="incomplete — edit and source .env" + fi + if [[ -n ${PI_MODEL_API_KEY:-} && ${PI_MODEL_API_KEY:-} != your-provider-key ]]; then + credential_status="set (value hidden)" + else + configuration_status="incomplete — edit and source .env" + fi + if [[ $configuration_status != ready ]]; then + status_color="$yellow" + fi + cat <&2 exit 1 fi - for step in prepare serve gateway launch verify cleanup; do - printf '\n# %s\n' "$step" - "$step" - done + print_plan ;; help | --help | -h) usage ;; *) diff --git a/projects/egress-gate/examples/pi-attested-admission/models.json b/projects/egress-gate/examples/pi-attested-admission/models.json deleted file mode 100644 index 69c6f911..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/models.json +++ /dev/null @@ -1,25 +0,0 @@ -{ - "providers": { - "openai-chat-completions": { - "baseUrl": "https://api.openai.com/v1", - "api": "openai-completions", - "apiKey": "$OPENAI_API_KEY", - "models": [ - { - "id": "gpt-4o-mini", - "name": "GPT-4o mini (Chat Completions)", - "reasoning": false, - "input": ["text"], - "contextWindow": 128000, - "maxTokens": 16384, - "cost": { - "input": 0.15, - "output": 0.6, - "cacheRead": 0.075, - "cacheWrite": 0 - } - } - ] - } - } -} diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs new file mode 100644 index 00000000..4032541e --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs @@ -0,0 +1,109 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import registerAdmission from "./openshell-input-admission.ts"; + +const BRIDGE_URL_ENV = "OPENSHELL_AGENT_CONVERSATION_URL"; +const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; + +function createHarness() { + const handlers = new Map(); + registerAdmission({ + on(event, handler) { + handlers.set(event, handler); + }, + }); + return handlers; +} + +function createContext(isIdle = true) { + return { + isIdle: () => isIdle, + sessionManager: { getSessionId: () => "session-1" }, + signal: new AbortController().signal, + ui: { notify: () => {} }, + }; +} + +function allowResponse(receipt) { + return new Response( + JSON.stringify({ + decision: "allow", + receipt: Array.from(new TextEncoder().encode(receipt)), + }), + { status: 200 }, + ); +} + +test("uses a fresh receipt for every provider request in one admitted turn", async () => { + const originalBridgeUrl = process.env[BRIDGE_URL_ENV]; + const originalFetch = globalThis.fetch; + const bridgeRequests = []; + process.env[BRIDGE_URL_ENV] = "http://bridge.test/admit"; + globalThis.fetch = async (_url, init) => { + bridgeRequests.push(JSON.parse(init.body)); + return allowResponse(`receipt-${bridgeRequests.length}`); + }; + + try { + const handlers = createHarness(); + const ctx = createContext(); + const append = await handlers.get("before_user_message_append")( + { text: "inspect the repository" }, + ctx, + ); + assert.equal(append, undefined); + + const firstHeaders = {}; + await handlers.get("before_provider_headers")({ headers: firstHeaders }, ctx); + assert.equal(firstHeaders[RECEIPT_HEADER], "receipt-1"); + + const continuationHeaders = {}; + await handlers.get("before_provider_headers")({ headers: continuationHeaders }, ctx); + assert.equal(continuationHeaders[RECEIPT_HEADER], "receipt-2"); + + assert.equal(bridgeRequests.length, 2); + assert.notEqual(bridgeRequests[0].submission_id, bridgeRequests[1].submission_id); + assert.deepEqual(bridgeRequests[0].request_body, bridgeRequests[1].request_body); + } finally { + globalThis.fetch = originalFetch; + if (originalBridgeUrl === undefined) delete process.env[BRIDGE_URL_ENV]; + else process.env[BRIDGE_URL_ENV] = originalBridgeUrl; + } +}); + +test("activates queued prompts only when Pi delivers them", async () => { + const originalBridgeUrl = process.env[BRIDGE_URL_ENV]; + const originalFetch = globalThis.fetch; + let receiptNumber = 0; + process.env[BRIDGE_URL_ENV] = "http://bridge.test/admit"; + globalThis.fetch = async () => allowResponse(`receipt-${++receiptNumber}`); + + try { + const handlers = createHarness(); + const idleContext = createContext(); + await handlers.get("before_user_message_append")({ text: "current turn" }, idleContext); + + const initialHeaders = {}; + await handlers.get("before_provider_headers")({ headers: initialHeaders }, idleContext); + assert.equal(initialHeaders[RECEIPT_HEADER], "receipt-1"); + + const streamingContext = createContext(false); + await handlers.get("before_user_message_append")({ text: "queued turn" }, streamingContext); + + const currentContinuationHeaders = {}; + await handlers.get("before_provider_headers")({ headers: currentContinuationHeaders }, idleContext); + assert.equal(currentContinuationHeaders[RECEIPT_HEADER], "receipt-3"); + + await handlers.get("message_start")({ + message: { role: "user", content: [{ type: "text", text: "queued turn" }] }, + }); + const queuedHeaders = {}; + await handlers.get("before_provider_headers")({ headers: queuedHeaders }, idleContext); + assert.equal(queuedHeaders[RECEIPT_HEADER], "receipt-2"); + } finally { + globalThis.fetch = originalFetch; + if (originalBridgeUrl === undefined) delete process.env[BRIDGE_URL_ENV]; + else process.env[BRIDGE_URL_ENV] = originalBridgeUrl; + } +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts index 58fb373e..1b5f120f 100644 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts @@ -2,86 +2,174 @@ * OpenShell direct-input admission for Pi. * * Load this extension explicitly with Pi's standard --extension option. It - * admits one idle, text-only user submission after rendering and before Pi - * persists it, then attaches the returned receipt to the first provider - * request. Steering, follow-ups, images, compaction, and post-tool - * continuations are unsupported and fail closed. + * admits each text-only user submission after rendering and before Pi + * persists it. Every provider request in the admitted turn receives a fresh + * receipt, including automatic continuations after tool calls. */ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; -const BRIDGE_URL_ENV = "OPENSHELL_PI_CONVERSATION_URL"; +const BRIDGE_URL_ENV = "OPENSHELL_AGENT_CONVERSATION_URL"; +const LEGACY_BRIDGE_URL_ENV = "OPENSHELL_PI_CONVERSATION_URL"; const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; const SCHEMA_VERSION = "openshell.pi-input.v1"; const MAX_RESPONSE_BYTES = 256 * 1024; const MAX_RECEIPT_BYTES = 8 * 1024; -interface BridgeResponse { - decision: "allow" | "deny"; - replacement_body?: number[]; - receipt?: number[]; - reason_code?: string; -} +type BridgeResponse = + | { decision: "allow"; replacement_body?: number[]; receipt: number[] } + | { decision: "deny"; reason_code?: string }; interface CandidateEnvelope { schema_version: typeof SCHEMA_VERSION; text: string; } +interface ActiveAdmission { + bridgeUrl: string; + sessionId: string; + envelope: CandidateEnvelope; +} + +interface PendingAdmission extends ActiveAdmission { + receipt: string; +} + +interface AdmissionResult { + envelope: CandidateEnvelope; + receipt: string; +} + export default function (pi: ExtensionAPI) { + let activeAdmission: ActiveAdmission | undefined; let pendingReceipt: string | undefined; + const queuedAdmissions: PendingAdmission[] = []; pi.on("before_user_message_append", async (event, ctx) => { + const isIdle = ctx.isIdle(); try { - pendingReceipt = undefined; - if (!ctx.isIdle() || event.images?.length) { - notifySafely(ctx, "OpenShell admission currently supports only idle, text-only prompts"); + if (isIdle) { + activeAdmission = undefined; + pendingReceipt = undefined; + } + if (event.images?.length) { + notifySafely(ctx, "OpenShell admission currently supports only text prompts"); return { action: "cancel" }; } - const bridgeUrl = process.env[BRIDGE_URL_ENV]; + const bridgeUrl = process.env[BRIDGE_URL_ENV] ?? process.env[LEGACY_BRIDGE_URL_ENV]; if (!bridgeUrl) throw new Error(`${BRIDGE_URL_ENV} is required for OpenShell admission`); const envelope: CandidateEnvelope = { schema_version: SCHEMA_VERSION, text: event.text }; - const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); - const response = await fetch(bridgeUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - harness_version: "extension-v1", - session_id: ctx.sessionManager.getSessionId(), - submission_id: crypto.randomUUID(), - request_body: Array.from(requestBody), - }), - signal: ctx.signal, - }); - if (!response.ok) throw new Error("OpenShell admission is unavailable"); - const encoded = new Uint8Array(await response.arrayBuffer()); - if (encoded.byteLength > MAX_RESPONSE_BYTES) throw new Error("OpenShell admission response is too large"); - const result = parseBridgeResponse(JSON.parse(new TextDecoder().decode(encoded))); - if (result.decision === "deny") { - notifySafely(ctx, `OpenShell denied the prompt (${result.reason_code ?? "policy_denied"})`); + const sessionId = ctx.sessionManager.getSessionId(); + const result = await requestAdmission(bridgeUrl, sessionId, envelope, ctx.signal); + if (result.response.decision === "deny") { + notifySafely(ctx, `OpenShell denied the prompt (${result.response.reason_code ?? "policy_denied"})`); return { action: "cancel" }; } - pendingReceipt = decodeReceipt(result.receipt); - if (!result.replacement_body) return; - const replacement = parseEnvelope(new Uint8Array(result.replacement_body)); - return { action: "transform", text: replacement.text }; + const admission = { bridgeUrl, sessionId, ...result.admission }; + if (isIdle) { + activeAdmission = admission; + pendingReceipt = admission.receipt; + } else { + queuedAdmissions.push(admission); + } + if (result.admission.envelope.text === event.text) return; + return { action: "transform", text: result.admission.envelope.text }; } catch { - pendingReceipt = undefined; + if (isIdle) { + activeAdmission = undefined; + pendingReceipt = undefined; + } notifySafely(ctx, "OpenShell admission is unavailable"); return { action: "cancel" }; } }); - pi.on("before_provider_headers", (event) => { - if (!pendingReceipt) throw new Error("OpenShell candidate admission receipt is missing"); + pi.on("message_start", (event) => { + const text = userMessageText(event.message); + if (text === undefined) return; + const index = queuedAdmissions.findIndex((admission) => admission.envelope.text === text); + if (index === -1) return; + const [admission] = queuedAdmissions.splice(index, 1); + activeAdmission = admission; + pendingReceipt = admission.receipt; + }); + + pi.on("before_provider_headers", async (event, ctx) => { if (Object.keys(event.headers).some((name) => name.toLowerCase() === RECEIPT_HEADER)) { throw new Error("OpenShell receipt header is reserved"); } - event.headers[RECEIPT_HEADER] = pendingReceipt; + if (!activeAdmission) throw new Error("OpenShell candidate admission context is missing"); + + let receipt = pendingReceipt; + if (!receipt) { + const result = await requestAdmission( + activeAdmission.bridgeUrl, + activeAdmission.sessionId, + activeAdmission.envelope, + ctx.signal, + ); + if (result.response.decision === "deny") { + throw new Error(`OpenShell denied the active prompt (${result.response.reason_code ?? "policy_denied"})`); + } + if (result.admission.envelope.text !== activeAdmission.envelope.text) { + throw new Error("OpenShell changed a prompt after Pi persisted it"); + } + receipt = result.admission.receipt; + } + + event.headers[RECEIPT_HEADER] = receipt; pendingReceipt = undefined; }); } +async function requestAdmission( + bridgeUrl: string, + sessionId: string, + envelope: CandidateEnvelope, + signal: AbortSignal, +): Promise< + | { response: Extract; admission?: never } + | { response: Extract; admission: AdmissionResult } +> { + const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); + const response = await fetch(bridgeUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + harness_version: "extension-v1", + session_id: sessionId, + submission_id: crypto.randomUUID(), + request_body: Array.from(requestBody), + }), + signal, + }); + if (!response.ok) throw new Error("OpenShell admission is unavailable"); + const encoded = new Uint8Array(await response.arrayBuffer()); + if (encoded.byteLength > MAX_RESPONSE_BYTES) throw new Error("OpenShell admission response is too large"); + const result = parseBridgeResponse(JSON.parse(new TextDecoder().decode(encoded))); + if (result.decision === "deny") return { response: result }; + + return { + response: result, + admission: { + receipt: decodeReceipt(result.receipt), + envelope: result.replacement_body ? parseEnvelope(new Uint8Array(result.replacement_body)) : envelope, + }, + }; +} + +function userMessageText(message: unknown): string | undefined { + if (!isRecord(message) || message.role !== "user" || !Array.isArray(message.content)) return undefined; + const text = message.content + .filter( + (part): part is { type: "text"; text: string } => + isRecord(part) && part.type === "text" && typeof part.text === "string", + ) + .map((part) => part.text) + .join("\n"); + return text || undefined; +} + function notifySafely(ctx: ExtensionContext, message: string): void { try { ctx.ui.notify(message, "warning"); @@ -103,10 +191,19 @@ function parseBridgeResponse(value: unknown): BridgeResponse { reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined, }; } - if (!isByteArray(value.receipt) || (value.replacement_body !== undefined && !isByteArray(value.replacement_body))) { + const receipt = value.receipt; + const replacementBody = value.replacement_body; + if (!isByteArray(receipt)) { throw new Error("OpenShell admission returned an invalid allow response"); } - return { decision: "allow", receipt: value.receipt, replacement_body: value.replacement_body }; + let replacement: number[] | undefined; + if (replacementBody !== undefined) { + if (!isByteArray(replacementBody)) { + throw new Error("OpenShell admission returned an invalid allow response"); + } + replacement = replacementBody; + } + return { decision: "allow", receipt, replacement_body: replacement }; } function parseEnvelope(body: Uint8Array): CandidateEnvelope { diff --git a/projects/egress-gate/examples/pi-attested-admission/policy.yaml b/projects/egress-gate/examples/pi-attested-admission/policy.yaml index 82e8fec5..8766f30a 100644 --- a/projects/egress-gate/examples/pi-attested-admission/policy.yaml +++ b/projects/egress-gate/examples/pi-attested-admission/policy.yaml @@ -11,10 +11,10 @@ process: run_as_group: sandbox network_policies: - openai: - name: OpenAI Chat Completions + model_provider: + name: Configured model endpoint endpoints: - - host: api.openai.com + - host: provider.example.com port: 443 protocol: rest enforcement: enforce @@ -61,4 +61,4 @@ network_middlewares: on_error: fail_closed endpoints: include: - - api.openai.com + - provider.example.com diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs new file mode 100644 index 00000000..cd424fb4 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -0,0 +1,162 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +import { readFileSync, writeFileSync } from "node:fs"; + +const options = parseOptions(process.argv.slice(2)); +const baseUrl = parseBaseUrl(options.get("base-url")); +const modelId = requireOption(options, "model-id"); +const modelsOutput = requireOption(options, "models-output"); +const policyOutput = requireOption(options, "policy-output"); +const providerProfileOutput = requireOption(options, "provider-profile-output"); +const gatewayOutput = requireOption(options, "gateway-output"); +const middlewareEndpoint = parseMiddlewareEndpoint( + options.get("middleware-endpoint"), +); +const endpointPort = baseUrl.port || (baseUrl.protocol === "https:" ? "443" : "80"); + +const models = { + providers: { + "attested-provider": { + baseUrl: baseUrl.toString().replace(/\/$/, ""), + api: "openai-completions", + apiKey: "$PI_MODEL_API_KEY", + models: [ + { + id: modelId, + name: modelId, + reasoning: false, + input: ["text"], + contextWindow: 128000, + maxTokens: 16384, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 }, + }, + ], + }, + }, +}; +writeFileSync(modelsOutput, `${JSON.stringify(models, null, 2)}\n`); + +const policyTemplate = readFileSync(new URL("policy.yaml", import.meta.url), "utf8"); +const policy = replaceExpected( + replaceExpected(policyTemplate, "provider.example.com", baseUrl.hostname, 2), + " port: 443", + ` port: ${endpointPort}`, + 1, +); +writeFileSync(policyOutput, policy); + +writeFileSync( + providerProfileOutput, + `id: pi-attested-model +display_name: Pi attested-admission model +description: Endpoint-scoped model credential for the Pi attested-admission example +category: inference +inference_capable: true +credentials: + - name: api_key + description: Model provider API key + env_vars: [PI_MODEL_API_KEY] + required: true + auth_style: bearer + header_name: authorization +discovery: + credentials: [api_key] +endpoints: + - host: ${JSON.stringify(baseUrl.hostname)} + port: ${endpointPort} + protocol: rest + access: read-write + enforcement: enforce +binaries: [/usr/bin/node, /usr/local/bin/node] +`, +); + +writeFileSync( + gatewayOutput, + `[[openshell.supervisor.middleware]] +name = "pi-egress" +grpc_endpoint = "${middlewareEndpoint}" +allow_insecure_transport = true +max_payload_bytes = 32768 +timeout = "30s" +`, +); + +function parseOptions(argumentsList) { + if (argumentsList.length % 2 !== 0) { + fail("Options must be passed as --name value pairs."); + } + const parsed = new Map(); + for (let index = 0; index < argumentsList.length; index += 2) { + const name = argumentsList[index]; + if (!name.startsWith("--")) { + fail(`Expected an option name, received: ${name}`); + } + parsed.set(name.slice(2), argumentsList[index + 1]); + } + return parsed; +} + +function requireOption(parsed, name) { + const value = parsed.get(name); + if (!value) { + fail(`Missing --${name}.`); + } + return value; +} + +function parseBaseUrl(value) { + const raw = value || fail("Missing --base-url."); + let parsed; + try { + parsed = new URL(raw); + } catch { + fail("--base-url must be an absolute HTTP or HTTPS URL."); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + fail("--base-url must use HTTP or HTTPS."); + } + if (parsed.username || parsed.password || parsed.search || parsed.hash) { + fail("--base-url must not contain credentials, a query, or a fragment."); + } + return parsed; +} + +function parseMiddlewareEndpoint(value) { + const raw = value || fail("Missing --middleware-endpoint."); + let parsed; + try { + parsed = new URL(raw); + } catch { + fail("--middleware-endpoint must be an absolute HTTP or HTTPS URL."); + } + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + fail("--middleware-endpoint must use HTTP or HTTPS."); + } + if ( + parsed.username || + parsed.password || + parsed.pathname !== "/" || + parsed.search || + parsed.hash + ) { + fail("--middleware-endpoint must contain only a scheme, host, and port."); + } + return parsed.toString().replace(/\/$/, ""); +} + +function replaceExpected(value, marker, replacement, expectedOccurrences) { + const occurrences = value.split(marker).length - 1; + if (occurrences !== expectedOccurrences) { + fail( + `Expected policy marker ${marker} ${expectedOccurrences} times; found ${occurrences}.`, + ); + } + return value.replaceAll(marker, replacement); +} + +function fail(message) { + console.error(message); + process.exit(1); +} diff --git a/projects/egress-gate/proto/supervisor_middleware.proto b/projects/egress-gate/proto/supervisor_middleware.proto index b30cb233..b388eec8 100644 --- a/projects/egress-gate/proto/supervisor_middleware.proto +++ b/projects/egress-gate/proto/supervisor_middleware.proto @@ -9,7 +9,8 @@ import "google/protobuf/empty.proto"; import "google/protobuf/struct.proto"; // SupervisorMiddleware lets an operator-run service inspect and transform -// sandbox HTTP egress or evaluate a supported agent-harness request. +// sandbox HTTP requests and client WebSocket text messages before OpenShell +// injects credentials, or evaluate a supported agent-harness request. service SupervisorMiddleware { // Describe returns the service manifest and declared bindings. rpc Describe(google.protobuf.Empty) returns (MiddlewareManifest); @@ -24,6 +25,16 @@ service SupervisorMiddleware { // EvaluateAgentConversation returns an allow, deny, or replacement decision for // one versioned, harness-native request before the harness commits or sends it. rpc EvaluateAgentConversation(AgentConversationEvaluation) returns (AgentConversationResult); + + // EvaluateWebSocketSession opens one ordered, phase-specific stream for a + // single middleware stage and WebSocket upgrade attempt. The current + // implementation supports client-to-upstream text messages at + // PRE_CREDENTIALS; PRE_RETURN is reserved for upstream-to-client messages. + // A request may go unanswered when the session terminates. For every opened + // stage stream, OpenShell attempts at most one session_end before closing the + // stream when its transport is still writable. + rpc EvaluateWebSocketSession(stream WebSocketSessionEvent) + returns (stream WebSocketSessionEventResult); } // MiddlewareManifest describes one middleware service and the bindings it @@ -38,27 +49,38 @@ message MiddlewareManifest { string service_version = 2; // Bindings exposed by this middleware service. repeated MiddlewareBinding bindings = 3; + // Exact JWT audience this service verifies on inbound OpenShell calls. + // After authenticated Describe succeeds, OpenShell rejects the registration + // unless this matches the operator-configured audience. A strict verifier may + // reject an incorrect audience before returning this manifest. Empty skips + // this post-authentication consistency check. + string expected_audience = 4; } // MiddlewareBinding declares one operation and phase supported by a service. message MiddlewareBinding { // Supported operation. SupervisorMiddlewareOperation operation = 1; - // Supported evaluation phase. + // Supported evaluation phase. PR 1 supports PRE_CREDENTIALS. PRE_RETURN is + // reserved for the return-path follow-up and is rejected by current + // manifest validation. SupervisorMiddlewarePhase phase = 2; - // Maximum request or replacement body this binding can process. - uint64 max_body_bytes = 3; + // Maximum logical payload or replacement this binding can process. For + // HTTP_REQUEST and AGENT_CONVERSATION this is the request body; for + // WEBSOCKET_MESSAGE this is one complete message. Required for every + // payload-bearing operation. + uint64 max_payload_bytes = 3; // Optional binding-specific RPC timeout. Empty uses the operator-configured // service timeout, or the 500ms platform default when that is also omitted. // A non-empty value may shorten but cannot extend the operator timeout. // Values use an integer with an `ms` or `s` suffix and must be between // 10ms and 30s. string timeout = 4; - // Agent harness supported by an AGENT_CONVERSATION binding. Empty for HTTP_REQUEST. + // Agent harness supported by an AGENT_CONVERSATION binding. Empty otherwise. string harness = 5; - // Harness hook supported by an AGENT_CONVERSATION binding. Empty for HTTP_REQUEST. + // Harness hook supported by an AGENT_CONVERSATION binding. Empty otherwise. string hook = 6; - // Version of the harness-native request schema. Empty for HTTP_REQUEST. + // Version of the harness-native request schema. Empty otherwise. string schema_version = 7; } @@ -114,14 +136,153 @@ message HttpHeader { enum SupervisorMiddlewareOperation { SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST = 1; - SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION = 2; + SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE = 2; + SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION = 3; } // Ordered phase within a supervisor operation. enum SupervisorMiddlewarePhase { SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED = 0; SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS = 1; - SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT = 2; + SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN = 2; + SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT = 3; +} + +// Why OpenShell is ending a middleware stream. +enum WebSocketSessionEndReason { + WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED = 0; + WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE = 1; + WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT = 2; + WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD = 3; + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL = 4; + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE = 5; + WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR = 6; + WEB_SOCKET_SESSION_END_REASON_CANCELLATION = 7; + WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED = 8; + WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL = 9; + // The middleware stage voluntarily declined inspection during preflight. + // This is a successful stage-local outcome, not a cancellation or denial of + // the WebSocket upgrade. + WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED = 10; +} + +// WebSocketSessionEvent is one ordered event in a stage-local stream. +// Message sequence numbers identify logical messages session-wide. A stage +// receives a strictly increasing subset of those numbers; gaps are valid when +// session messages are not delivered to that stage. +message WebSocketSessionEvent { + oneof event { + WebSocketPreflight preflight = 1; + WebSocketSessionStart session_start = 2; + WebSocketMessage message = 3; + WebSocketSessionEnd session_end = 4; + } +} + +// WebSocketPreflight lets a service decline this upgrade before OpenShell +// contacts upstream. It deliberately excludes query data, arbitrary request +// headers, and message payloads. +message WebSocketPreflight { + string session_id = 1; + SupervisorMiddlewarePhase phase = 2; + RequestContext context = 3; + // Admitted HTTP WebSocket-upgrade target. The method is GET, query is always + // empty, and path never includes a query string. + HttpRequestTarget target = 4; + repeated string requested_subprotocols = 5; + // Built-in middleware name or operator-owned registration name. + string middleware_name = 6; + google.protobuf.Struct config = 7; +} + +// WebSocketSessionStart reports bounded metadata known only after the +// upstream 101 response validates. Empty selected_subprotocol means none. +message WebSocketSessionStart { + string selected_subprotocol = 1; +} + +// WebSocketMessage contains one complete reconstructed logical message. +message WebSocketMessage { + // Session-global sequence starting at 1. Values delivered to one stage must + // strictly increase but need not be contiguous. Reject zero, duplicates, and + // regressions; accept gaps. + uint64 sequence = 1; + // One complete logical payload. Protobuf string decoding enforces UTF-8 for + // text messages. Raw frame mechanics are never exposed. Limited to 4 MiB by + // the platform and the binding-specific cap. + oneof payload { + string text = 2; + bytes binary = 3; + } +} + +// WebSocketSessionEnd is OpenShell's best-effort terminal notification for one +// opened stage stream. A stage receives at most one such notification. +message WebSocketSessionEnd { + WebSocketSessionEndReason reason = 1; +} + +// WebSocketPreflightAction is the service's one-time scoping decision. +enum WebSocketPreflightAction { + // Invalid response value handled according to the policy failure mode. + WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED = 0; + // Inspect this session after the upstream accepts the upgrade. + WEB_SOCKET_PREFLIGHT_ACTION_INSPECT = 1; + // Voluntarily decline inspection without denying the upgrade. This is a + // successful decision and does not engage on_error. + WEB_SOCKET_PREFLIGHT_ACTION_SKIP = 2; + // Authoritatively deny the upgrade before upstream contact. This is a + // successful decision and is enforced regardless of on_error. + WEB_SOCKET_PREFLIGHT_ACTION_DENY = 3; +} + +message WebSocketPreflightDecision { + WebSocketPreflightAction action = 1; + // Free-form service diagnostic. OpenShell never exposes this to the + // workload or security logs. Limited to 4 KiB before discarding. + string reason = 2; + // Optional stable machine-readable code for a deny decision. Because + // preflight runs before the HTTP upgrade completes, OpenShell may return + // this code to the requester. Codes follow the same format and 64-byte + // maximum as HttpRequestResult.reason_code. + string reason_code = 3; + // Audit-safe findings produced during preflight. At most 32 findings of at + // most 4 KiB encoded each are accepted. + repeated Finding findings = 4; + // Non-secret service-defined metadata included in diagnostics. At most 64 + // entries and 32 KiB of combined key/value data are accepted. + map metadata = 5; +} + +// WebSocketMessageResult contains the decision and optional replacement for +// one message. A replacement must use the same variant as the input payload. +message WebSocketMessageResult { + // Must exactly match the sequence of the corresponding WebSocketMessage. + uint64 sequence = 1; + Decision decision = 2; + // Absence preserves the input unchanged. Oneof presence distinguishes an + // empty replacement from no replacement, and string decoding enforces UTF-8. + oneof replacement { + string text = 3; + bytes binary = 4; + } + // Free-form service diagnostic. OpenShell never exposes this to the + // workload or security logs. Limited to 4 KiB before discarding. + string reason = 5; + // Optional stable machine-readable code for OCSF only. Unlike the HTTP + // reason_code, this value is never put in a WebSocket close frame. + string reason_code = 6; + repeated Finding findings = 7; + map metadata = 8; +} + +// WebSocketSessionEventResult is an evaluation result for a preflight or message +// event. Session start and end events do not produce results. +message WebSocketSessionEventResult { + oneof result { + WebSocketPreflightDecision preflight_decision = 1; + WebSocketMessageResult message_result = 2; + } } // RequestContext identifies the sandbox request being evaluated. @@ -132,11 +293,19 @@ message RequestContext { string sandbox_id = 2; // Workload process that originated the request, when available. Process originating_process = 3; + // Sandbox name that originated the request. For display and logging only. + // Names are workspace-scoped and may be reused for different sandbox + // instances, so consumers must use sandbox_id for authorization, persistence, + // durable correlation, and identity. + string sandbox_name = 4; + // Workspace the sandbox belongs to. For display and logging only; see the + // sandbox_name guidance above. + string workspace = 5; } // HttpRequestTarget describes the admitted HTTP destination and request target. message HttpRequestTarget { - // Request scheme, such as "http" or "https". + // Request scheme, such as "http", "https", "ws", or "wss". string scheme = 1; // Destination hostname selected by network policy. string host = 2; @@ -185,10 +354,7 @@ message AgentConversationEvaluation { string session_id = 7; string turn_id = 8; bytes request_body = 9; - string source = 10; - string delivery = 11; - string request_kind = 12; - optional uint32 candidate_index = 13; + reserved 10 to 13; } // AgentConversationResult carries the authority decision, an optional complete @@ -205,13 +371,16 @@ message AgentConversationResult { bool has_replacement_body = 10; } -// Decision controls whether OpenShell continues processing the request. +// Decision controls whether OpenShell continues processing the current +// evaluation unit. enum Decision { // Invalid response value handled according to the policy failure mode. DECISION_UNSPECIFIED = 0; - // Continue processing the request and apply any returned mutations. + // Continue processing the current request or message and apply any returned + // mutations. DECISION_ALLOW = 1; - // Deny the request before credentials are injected or data is sent upstream. + // Reject the current request or message. The operation-specific result + // defines the enclosing protocol behavior. DECISION_DENY = 2; } diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py index 6442fcbf..fc1d7c43 100644 --- a/projects/egress-gate/src/egress_gate/admission/receipts.py +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -46,12 +46,8 @@ class ReceiptClaimsV1(StrictDomainModel): submission_id: BoundedMetadataString receipt_id: str = Field(pattern=r"^[0-9a-f]{32}$") provider_adapter_schema: Literal["openai.chat-completions.v1"] - scheme: ScalarString host: ScalarString port: int = Field(ge=0, le=2**32 - 1) - method: ScalarString - path: ScalarString - query: ScalarString rendered_prompt_hash: str = Field(pattern=r"^[0-9a-f]{64}$") issued_at: int = Field(ge=0) expires_at: int = Field(ge=0) @@ -123,12 +119,8 @@ def issue( submission_id=provenance.submission_id, receipt_id=secrets.token_hex(16), provider_adapter_schema=context.provider_adapter_schema, - scheme=target.scheme, host=target.host, port=target.port, - method=target.method, - path=target.path, - query=target.query, rendered_prompt_hash=_prompt_hash(rendered_prompt), issued_at=issued_at, expires_at=issued_at + self._lifetime_seconds, @@ -178,12 +170,8 @@ def verify( policy_fingerprint, context.sandbox_id, context.provider_adapter_schema, - target.scheme, target.host, target.port, - target.method, - target.path, - target.query, _prompt_hash(rendered_prompt), ) actual = ( @@ -195,12 +183,8 @@ def verify( claims.policy_fingerprint, claims.sandbox_id, claims.provider_adapter_schema, - claims.scheme, claims.host, claims.port, - claims.method, - claims.path, - claims.query, claims.rendered_prompt_hash, ) if actual != expected: diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py index d0e51411..93d50eaf 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py @@ -26,63 +26,91 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"y\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\"\x81\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x16\n\x0emax_body_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"w\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xc9\x03\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0c\x12\x0e\n\x06source\x18\n \x01(\t\x12\x10\n\x08\x64\x65livery\x18\x0b \x01(\t\x12\x14\n\x0crequest_kind\x18\x0c \x01(\t\x12\x1c\n\x0f\x63\x61ndidate_index\x18\r \x01(\rH\x00\x88\x01\x01\x42\x12\n\x10_candidate_indexJ\x04\x08\x05\x10\x06\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xba\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x02*\xa8\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x02*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xd3\x03\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResultb\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\x84\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xe5\x02\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0cJ\x04\x08\x05\x10\x06J\x04\x08\n\x10\x0e\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xf1\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x03*\xd4\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x03*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xda\x04\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) _builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'supervisor_middleware_pb2', _globals) if not _descriptor._USE_C_DESCRIPTORS: DESCRIPTOR._loaded_options = None + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._loaded_options = None + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_options = b'8\001' + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._loaded_options = None + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._loaded_options = None _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_HTTPREQUESTRESULT_METADATAENTRY']._loaded_options = None _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=3108 - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=3294 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=3297 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=3465 - _globals['_DECISION']._serialized_start=3467 - _globals['_DECISION']._serialized_end=3542 - _globals['_EXISTINGHEADERACTION']._serialized_start=3545 - _globals['_EXISTINGHEADERACTION']._serialized_end=3713 - _globals['_MIDDLEWAREMANIFEST']._serialized_start=115 - _globals['_MIDDLEWAREMANIFEST']._serialized_end=236 - _globals['_MIDDLEWAREBINDING']._serialized_start=239 - _globals['_MIDDLEWAREBINDING']._serialized_end=496 - _globals['_VALIDATECONFIGREQUEST']._serialized_start=498 - _globals['_VALIDATECONFIGREQUEST']._serialized_end=587 - _globals['_VALIDATECONFIGRESPONSE']._serialized_start=589 - _globals['_VALIDATECONFIGRESPONSE']._serialized_end=644 - _globals['_HTTPREQUESTEVALUATION']._serialized_start=647 - _globals['_HTTPREQUESTEVALUATION']._serialized_end=989 - _globals['_HTTPHEADER']._serialized_start=991 - _globals['_HTTPHEADER']._serialized_end=1032 - _globals['_REQUESTCONTEXT']._serialized_start=1034 - _globals['_REQUESTCONTEXT']._serialized_end=1153 - _globals['_HTTPREQUESTTARGET']._serialized_start=1155 - _globals['_HTTPREQUESTTARGET']._serialized_end=1263 - _globals['_PROCESS']._serialized_start=1265 - _globals['_PROCESS']._serialized_end=1322 - _globals['_AGENTCONVERSATIONTARGET']._serialized_start=1325 - _globals['_AGENTCONVERSATIONTARGET']._serialized_end=1488 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=1491 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=1948 - _globals['_AGENTCONVERSATIONRESULT']._serialized_start=1951 - _globals['_AGENTCONVERSATIONRESULT']._serialized_end=2338 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2279 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2326 - _globals['_FINDING']._serialized_start=2340 - _globals['_FINDING']._serialized_end=2431 - _globals['_WRITEHEADER']._serialized_start=2433 - _globals['_WRITEHEADER']._serialized_end=2543 - _globals['_REMOVEHEADER']._serialized_start=2545 - _globals['_REMOVEHEADER']._serialized_end=2573 - _globals['_HEADERMUTATION']._serialized_start=2576 - _globals['_HEADERMUTATION']._serialized_end=2717 - _globals['_HTTPREQUESTRESULT']._serialized_start=2720 - _globals['_HTTPREQUESTRESULT']._serialized_end=3105 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2279 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2326 - _globals['_SUPERVISORMIDDLEWARE']._serialized_start=3716 - _globals['_SUPERVISORMIDDLEWARE']._serialized_end=4183 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=4828 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=5069 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=5072 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=5284 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=5287 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=5865 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=5868 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=6056 + _globals['_DECISION']._serialized_start=6058 + _globals['_DECISION']._serialized_end=6133 + _globals['_EXISTINGHEADERACTION']._serialized_start=6136 + _globals['_EXISTINGHEADERACTION']._serialized_end=6304 + _globals['_MIDDLEWAREMANIFEST']._serialized_start=116 + _globals['_MIDDLEWAREMANIFEST']._serialized_end=264 + _globals['_MIDDLEWAREBINDING']._serialized_start=267 + _globals['_MIDDLEWAREBINDING']._serialized_end=527 + _globals['_VALIDATECONFIGREQUEST']._serialized_start=529 + _globals['_VALIDATECONFIGREQUEST']._serialized_end=618 + _globals['_VALIDATECONFIGRESPONSE']._serialized_start=620 + _globals['_VALIDATECONFIGRESPONSE']._serialized_end=675 + _globals['_HTTPREQUESTEVALUATION']._serialized_start=678 + _globals['_HTTPREQUESTEVALUATION']._serialized_end=1020 + _globals['_HTTPHEADER']._serialized_start=1022 + _globals['_HTTPHEADER']._serialized_end=1063 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1066 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1368 + _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1371 + _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1694 + _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1696 + _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1749 + _globals['_WEBSOCKETMESSAGE']._serialized_start=1751 + _globals['_WEBSOCKETMESSAGE']._serialized_end=1832 + _globals['_WEBSOCKETSESSIONEND']._serialized_start=1834 + _globals['_WEBSOCKETSESSIONEND']._serialized_end=1923 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1926 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2244 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2197 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2244 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2247 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2610 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2197 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2244 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2613 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2810 + _globals['_REQUESTCONTEXT']._serialized_start=2813 + _globals['_REQUESTCONTEXT']._serialized_end=2973 + _globals['_HTTPREQUESTTARGET']._serialized_start=2975 + _globals['_HTTPREQUESTTARGET']._serialized_end=3083 + _globals['_PROCESS']._serialized_start=3085 + _globals['_PROCESS']._serialized_end=3142 + _globals['_AGENTCONVERSATIONTARGET']._serialized_start=3145 + _globals['_AGENTCONVERSATIONTARGET']._serialized_end=3308 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=3311 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=3668 + _globals['_AGENTCONVERSATIONRESULT']._serialized_start=3671 + _globals['_AGENTCONVERSATIONRESULT']._serialized_end=4058 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2197 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2244 + _globals['_FINDING']._serialized_start=4060 + _globals['_FINDING']._serialized_end=4151 + _globals['_WRITEHEADER']._serialized_start=4153 + _globals['_WRITEHEADER']._serialized_end=4263 + _globals['_REMOVEHEADER']._serialized_start=4265 + _globals['_REMOVEHEADER']._serialized_end=4293 + _globals['_HEADERMUTATION']._serialized_start=4296 + _globals['_HEADERMUTATION']._serialized_end=4437 + _globals['_HTTPREQUESTRESULT']._serialized_start=4440 + _globals['_HTTPREQUESTRESULT']._serialized_end=4825 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2197 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2244 + _globals['_SUPERVISORMIDDLEWARE']._serialized_start=6307 + _globals['_SUPERVISORMIDDLEWARE']._serialized_end=6909 # @@protoc_insertion_point(module_scope) diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi index accf5f19..aeea0f4f 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi @@ -13,14 +13,37 @@ class SupervisorMiddlewareOperation(int, metaclass=_enum_type_wrapper.EnumTypeWr __slots__ = () SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: _ClassVar[SupervisorMiddlewareOperation] + SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: _ClassVar[SupervisorMiddlewareOperation] SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: _ClassVar[SupervisorMiddlewareOperation] class SupervisorMiddlewarePhase(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: _ClassVar[SupervisorMiddlewarePhase] SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: _ClassVar[SupervisorMiddlewarePhase] + SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: _ClassVar[SupervisorMiddlewarePhase] SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: _ClassVar[SupervisorMiddlewarePhase] +class WebSocketSessionEndReason(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_CANCELLATION: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: _ClassVar[WebSocketSessionEndReason] + WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: _ClassVar[WebSocketSessionEndReason] + +class WebSocketPreflightAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): + __slots__ = () + WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_INSPECT: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_SKIP: _ClassVar[WebSocketPreflightAction] + WEB_SOCKET_PREFLIGHT_ACTION_DENY: _ClassVar[WebSocketPreflightAction] + class Decision(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): __slots__ = () DECISION_UNSPECIFIED: _ClassVar[Decision] @@ -35,10 +58,27 @@ class ExistingHeaderAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): EXISTING_HEADER_ACTION_SKIP: _ClassVar[ExistingHeaderAction] SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: SupervisorMiddlewareOperation +SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION: SupervisorMiddlewareOperation SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: SupervisorMiddlewarePhase +SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN: SupervisorMiddlewarePhase SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: SupervisorMiddlewarePhase +WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_CANCELLATION: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL: WebSocketSessionEndReason +WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED: WebSocketSessionEndReason +WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_INSPECT: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_SKIP: WebSocketPreflightAction +WEB_SOCKET_PREFLIGHT_ACTION_DENY: WebSocketPreflightAction DECISION_UNSPECIFIED: Decision DECISION_ALLOW: Decision DECISION_DENY: Decision @@ -48,32 +88,34 @@ EXISTING_HEADER_ACTION_OVERWRITE: ExistingHeaderAction EXISTING_HEADER_ACTION_SKIP: ExistingHeaderAction class MiddlewareManifest(_message.Message): - __slots__ = ("name", "service_version", "bindings") + __slots__ = ("name", "service_version", "bindings", "expected_audience") NAME_FIELD_NUMBER: _ClassVar[int] SERVICE_VERSION_FIELD_NUMBER: _ClassVar[int] BINDINGS_FIELD_NUMBER: _ClassVar[int] + EXPECTED_AUDIENCE_FIELD_NUMBER: _ClassVar[int] name: str service_version: str bindings: _containers.RepeatedCompositeFieldContainer[MiddlewareBinding] - def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ...) -> None: ... + expected_audience: str + def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ..., expected_audience: _Optional[str] = ...) -> None: ... class MiddlewareBinding(_message.Message): - __slots__ = ("operation", "phase", "max_body_bytes", "timeout", "harness", "hook", "schema_version") + __slots__ = ("operation", "phase", "max_payload_bytes", "timeout", "harness", "hook", "schema_version") OPERATION_FIELD_NUMBER: _ClassVar[int] PHASE_FIELD_NUMBER: _ClassVar[int] - MAX_BODY_BYTES_FIELD_NUMBER: _ClassVar[int] + MAX_PAYLOAD_BYTES_FIELD_NUMBER: _ClassVar[int] TIMEOUT_FIELD_NUMBER: _ClassVar[int] HARNESS_FIELD_NUMBER: _ClassVar[int] HOOK_FIELD_NUMBER: _ClassVar[int] SCHEMA_VERSION_FIELD_NUMBER: _ClassVar[int] operation: SupervisorMiddlewareOperation phase: SupervisorMiddlewarePhase - max_body_bytes: int + max_payload_bytes: int timeout: str harness: str hook: str schema_version: str - def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_body_bytes: _Optional[int] = ..., timeout: _Optional[str] = ..., harness: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ...) -> None: ... + def __init__(self, operation: _Optional[_Union[SupervisorMiddlewareOperation, str]] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., max_payload_bytes: _Optional[int] = ..., timeout: _Optional[str] = ..., harness: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ...) -> None: ... class ValidateConfigRequest(_message.Message): __slots__ = ("config", "middleware_name") @@ -117,15 +159,127 @@ class HttpHeader(_message.Message): value: str def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... +class WebSocketSessionEvent(_message.Message): + __slots__ = ("preflight", "session_start", "message", "session_end") + PREFLIGHT_FIELD_NUMBER: _ClassVar[int] + SESSION_START_FIELD_NUMBER: _ClassVar[int] + MESSAGE_FIELD_NUMBER: _ClassVar[int] + SESSION_END_FIELD_NUMBER: _ClassVar[int] + preflight: WebSocketPreflight + session_start: WebSocketSessionStart + message: WebSocketMessage + session_end: WebSocketSessionEnd + def __init__(self, preflight: _Optional[_Union[WebSocketPreflight, _Mapping]] = ..., session_start: _Optional[_Union[WebSocketSessionStart, _Mapping]] = ..., message: _Optional[_Union[WebSocketMessage, _Mapping]] = ..., session_end: _Optional[_Union[WebSocketSessionEnd, _Mapping]] = ...) -> None: ... + +class WebSocketPreflight(_message.Message): + __slots__ = ("session_id", "phase", "context", "target", "requested_subprotocols", "middleware_name", "config") + SESSION_ID_FIELD_NUMBER: _ClassVar[int] + PHASE_FIELD_NUMBER: _ClassVar[int] + CONTEXT_FIELD_NUMBER: _ClassVar[int] + TARGET_FIELD_NUMBER: _ClassVar[int] + REQUESTED_SUBPROTOCOLS_FIELD_NUMBER: _ClassVar[int] + MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + CONFIG_FIELD_NUMBER: _ClassVar[int] + session_id: str + phase: SupervisorMiddlewarePhase + context: RequestContext + target: HttpRequestTarget + requested_subprotocols: _containers.RepeatedScalarFieldContainer[str] + middleware_name: str + config: _struct_pb2.Struct + def __init__(self, session_id: _Optional[str] = ..., phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., requested_subprotocols: _Optional[_Iterable[str]] = ..., middleware_name: _Optional[str] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ...) -> None: ... + +class WebSocketSessionStart(_message.Message): + __slots__ = ("selected_subprotocol",) + SELECTED_SUBPROTOCOL_FIELD_NUMBER: _ClassVar[int] + selected_subprotocol: str + def __init__(self, selected_subprotocol: _Optional[str] = ...) -> None: ... + +class WebSocketMessage(_message.Message): + __slots__ = ("sequence", "text", "binary") + SEQUENCE_FIELD_NUMBER: _ClassVar[int] + TEXT_FIELD_NUMBER: _ClassVar[int] + BINARY_FIELD_NUMBER: _ClassVar[int] + sequence: int + text: str + binary: bytes + def __init__(self, sequence: _Optional[int] = ..., text: _Optional[str] = ..., binary: _Optional[bytes] = ...) -> None: ... + +class WebSocketSessionEnd(_message.Message): + __slots__ = ("reason",) + REASON_FIELD_NUMBER: _ClassVar[int] + reason: WebSocketSessionEndReason + def __init__(self, reason: _Optional[_Union[WebSocketSessionEndReason, str]] = ...) -> None: ... + +class WebSocketPreflightDecision(_message.Message): + __slots__ = ("action", "reason", "reason_code", "findings", "metadata") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + ACTION_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + action: WebSocketPreflightAction + reason: str + reason_code: str + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + def __init__(self, action: _Optional[_Union[WebSocketPreflightAction, str]] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class WebSocketMessageResult(_message.Message): + __slots__ = ("sequence", "decision", "text", "binary", "reason", "reason_code", "findings", "metadata") + class MetadataEntry(_message.Message): + __slots__ = ("key", "value") + KEY_FIELD_NUMBER: _ClassVar[int] + VALUE_FIELD_NUMBER: _ClassVar[int] + key: str + value: str + def __init__(self, key: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... + SEQUENCE_FIELD_NUMBER: _ClassVar[int] + DECISION_FIELD_NUMBER: _ClassVar[int] + TEXT_FIELD_NUMBER: _ClassVar[int] + BINARY_FIELD_NUMBER: _ClassVar[int] + REASON_FIELD_NUMBER: _ClassVar[int] + REASON_CODE_FIELD_NUMBER: _ClassVar[int] + FINDINGS_FIELD_NUMBER: _ClassVar[int] + METADATA_FIELD_NUMBER: _ClassVar[int] + sequence: int + decision: Decision + text: str + binary: bytes + reason: str + reason_code: str + findings: _containers.RepeatedCompositeFieldContainer[Finding] + metadata: _containers.ScalarMap[str, str] + def __init__(self, sequence: _Optional[int] = ..., decision: _Optional[_Union[Decision, str]] = ..., text: _Optional[str] = ..., binary: _Optional[bytes] = ..., reason: _Optional[str] = ..., reason_code: _Optional[str] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ...) -> None: ... + +class WebSocketSessionEventResult(_message.Message): + __slots__ = ("preflight_decision", "message_result") + PREFLIGHT_DECISION_FIELD_NUMBER: _ClassVar[int] + MESSAGE_RESULT_FIELD_NUMBER: _ClassVar[int] + preflight_decision: WebSocketPreflightDecision + message_result: WebSocketMessageResult + def __init__(self, preflight_decision: _Optional[_Union[WebSocketPreflightDecision, _Mapping]] = ..., message_result: _Optional[_Union[WebSocketMessageResult, _Mapping]] = ...) -> None: ... + class RequestContext(_message.Message): - __slots__ = ("request_id", "sandbox_id", "originating_process") + __slots__ = ("request_id", "sandbox_id", "originating_process", "sandbox_name", "workspace") REQUEST_ID_FIELD_NUMBER: _ClassVar[int] SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] ORIGINATING_PROCESS_FIELD_NUMBER: _ClassVar[int] + SANDBOX_NAME_FIELD_NUMBER: _ClassVar[int] + WORKSPACE_FIELD_NUMBER: _ClassVar[int] request_id: str sandbox_id: str originating_process: Process - def __init__(self, request_id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., originating_process: _Optional[_Union[Process, _Mapping]] = ...) -> None: ... + sandbox_name: str + workspace: str + def __init__(self, request_id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., originating_process: _Optional[_Union[Process, _Mapping]] = ..., sandbox_name: _Optional[str] = ..., workspace: _Optional[str] = ...) -> None: ... class HttpRequestTarget(_message.Message): __slots__ = ("scheme", "host", "port", "method", "path", "query") @@ -174,7 +328,7 @@ class AgentConversationTarget(_message.Message): def __init__(self, harness: _Optional[str] = ..., harness_version: _Optional[str] = ..., hook: _Optional[str] = ..., schema_version: _Optional[str] = ..., scheme: _Optional[str] = ..., host: _Optional[str] = ..., port: _Optional[int] = ..., path: _Optional[str] = ...) -> None: ... class AgentConversationEvaluation(_message.Message): - __slots__ = ("phase", "context", "config", "target", "middleware_name", "session_id", "turn_id", "request_body", "source", "delivery", "request_kind", "candidate_index") + __slots__ = ("phase", "context", "config", "target", "middleware_name", "session_id", "turn_id", "request_body") PHASE_FIELD_NUMBER: _ClassVar[int] CONTEXT_FIELD_NUMBER: _ClassVar[int] CONFIG_FIELD_NUMBER: _ClassVar[int] @@ -183,10 +337,6 @@ class AgentConversationEvaluation(_message.Message): SESSION_ID_FIELD_NUMBER: _ClassVar[int] TURN_ID_FIELD_NUMBER: _ClassVar[int] REQUEST_BODY_FIELD_NUMBER: _ClassVar[int] - SOURCE_FIELD_NUMBER: _ClassVar[int] - DELIVERY_FIELD_NUMBER: _ClassVar[int] - REQUEST_KIND_FIELD_NUMBER: _ClassVar[int] - CANDIDATE_INDEX_FIELD_NUMBER: _ClassVar[int] phase: SupervisorMiddlewarePhase context: RequestContext config: _struct_pb2.Struct @@ -195,11 +345,7 @@ class AgentConversationEvaluation(_message.Message): session_id: str turn_id: str request_body: bytes - source: str - delivery: str - request_kind: str - candidate_index: int - def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[AgentConversationTarget, _Mapping]] = ..., middleware_name: _Optional[str] = ..., session_id: _Optional[str] = ..., turn_id: _Optional[str] = ..., request_body: _Optional[bytes] = ..., source: _Optional[str] = ..., delivery: _Optional[str] = ..., request_kind: _Optional[str] = ..., candidate_index: _Optional[int] = ...) -> None: ... + def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[AgentConversationTarget, _Mapping]] = ..., middleware_name: _Optional[str] = ..., session_id: _Optional[str] = ..., turn_id: _Optional[str] = ..., request_body: _Optional[bytes] = ...) -> None: ... class AgentConversationResult(_message.Message): __slots__ = ("decision", "reason", "attestation", "findings", "metadata", "reason_code", "replacement_body", "has_replacement_body") diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py index aab704aa..e3421f1c 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py @@ -28,7 +28,8 @@ class SupervisorMiddlewareStub: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress or evaluate a supported agent-harness request. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials, or evaluate a supported agent-harness request. """ def __init__(self, channel): @@ -57,11 +58,17 @@ def __init__(self, channel): request_serializer=supervisor__middleware__pb2.AgentConversationEvaluation.SerializeToString, response_deserializer=supervisor__middleware__pb2.AgentConversationResult.FromString, _registered_method=True) + self.EvaluateWebSocketSession = channel.stream_stream( + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', + request_serializer=supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + response_deserializer=supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + _registered_method=True) class SupervisorMiddlewareServicer: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress or evaluate a supported agent-harness request. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials, or evaluate a supported agent-harness request. """ def Describe(self, request, context): @@ -94,6 +101,19 @@ def EvaluateAgentConversation(self, request, context): context.set_details('Method not implemented!') raise NotImplementedError('Method not implemented!') + def EvaluateWebSocketSession(self, request_iterator, context): + """EvaluateWebSocketSession opens one ordered, phase-specific stream for a + single middleware stage and WebSocket upgrade attempt. The current + implementation supports client-to-upstream text messages at + PRE_CREDENTIALS; PRE_RETURN is reserved for upstream-to-client messages. + A request may go unanswered when the session terminates. For every opened + stage stream, OpenShell attempts at most one session_end before closing the + stream when its transport is still writable. + """ + context.set_code(grpc.StatusCode.UNIMPLEMENTED) + context.set_details('Method not implemented!') + raise NotImplementedError('Method not implemented!') + def add_SupervisorMiddlewareServicer_to_server(servicer, server): rpc_method_handlers = { @@ -117,6 +137,11 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): request_deserializer=supervisor__middleware__pb2.AgentConversationEvaluation.FromString, response_serializer=supervisor__middleware__pb2.AgentConversationResult.SerializeToString, ), + 'EvaluateWebSocketSession': grpc.stream_stream_rpc_method_handler( + servicer.EvaluateWebSocketSession, + request_deserializer=supervisor__middleware__pb2.WebSocketSessionEvent.FromString, + response_serializer=supervisor__middleware__pb2.WebSocketSessionEventResult.SerializeToString, + ), } generic_handler = grpc.method_handlers_generic_handler( 'openshell.middleware.v1.SupervisorMiddleware', rpc_method_handlers) @@ -127,7 +152,8 @@ def add_SupervisorMiddlewareServicer_to_server(servicer, server): # This class is part of an EXPERIMENTAL API. class SupervisorMiddleware: """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress or evaluate a supported agent-harness request. + sandbox HTTP requests and client WebSocket text messages before OpenShell + injects credentials, or evaluate a supported agent-harness request. """ @staticmethod @@ -237,3 +263,30 @@ def EvaluateAgentConversation(request, timeout, metadata, _registered_method=True) + + @staticmethod + def EvaluateWebSocketSession(request_iterator, + target, + options=(), + channel_credentials=None, + call_credentials=None, + insecure=False, + compression=None, + wait_for_ready=None, + timeout=None, + metadata=None): + return grpc.experimental.stream_stream( + request_iterator, + target, + '/openshell.middleware.v1.SupervisorMiddleware/EvaluateWebSocketSession', + supervisor__middleware__pb2.WebSocketSessionEvent.SerializeToString, + supervisor__middleware__pb2.WebSocketSessionEventResult.FromString, + options, + channel_credentials, + insecure, + call_credentials, + compression, + wait_for_ready, + timeout, + metadata, + _registered_method=True) diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index 6a8ec786..a45fc9af 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -161,13 +161,13 @@ async def Describe( pb2.MiddlewareBinding( operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST, phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, - max_body_bytes=MAX_BODY_BYTES, + max_payload_bytes=MAX_BODY_BYTES, ), *( pb2.MiddlewareBinding( operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION, phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT, - max_body_bytes=MAX_ADMISSION_BODY_BYTES, + max_payload_bytes=MAX_ADMISSION_BODY_BYTES, harness="pi", hook=hook.value, schema_version="openshell.pi-input.v1", diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index 567fa7d2..f36f0f04 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -126,6 +126,7 @@ def _admit_body( body: bytes, *, timeout: Timeout | None = None, + provider_target: HttpTarget | None = None, ): result = processor.process( HarnessAdmissionRequest( @@ -144,7 +145,7 @@ def _admit_body( harness_version="extension-v1", hook=AdmissionHook.RENDERED_PROMPT, schema_version="openshell.pi-input.v1", - provider_target=_target(), + provider_target=provider_target or _target(), provider_adapter_schema="openai.chat-completions.v1", ), timeout=timeout or Timeout.from_seconds(1), @@ -152,7 +153,12 @@ def _admit_body( return result -def _provider_request(prompt: str, receipt: bytes | None) -> HttpRequest: +def _provider_request( + prompt: str, + receipt: bytes | None, + *, + target: HttpTarget | None = None, +) -> HttpRequest: body = json.dumps( { "model": "fixture-model", @@ -178,7 +184,7 @@ def _provider_request(prompt: str, receipt: bytes | None) -> HttpRequest: headers.append(HttpHeader(name=RECEIPT_HEADER, value=receipt.decode("ascii"))) return HttpRequest( context=RequestContext(request_id="network-1", sandbox_id="sandbox-1"), - target=_target(), + target=target or _target(), headers=tuple(headers), body=body, ) @@ -203,6 +209,52 @@ def test_safe_rendered_prompt_receipt_authorizes_first_request_and_is_stripped() ] == [RECEIPT_HEADER] +def test_receipt_uses_stable_destination_across_tls_proxy_normalization() -> None: + admission, egress = _processors() + body = canonical_json_bytes( + PiInputV1(schema_version="openshell.pi-input.v1", text="safe rendered prompt") + ) + admitted = _admit_body( + admission, + body, + provider_target=HttpTarget( + scheme="https", + host="provider.test", + port=443, + method="POST", + path="", + query="", + ), + ) + assert admitted.receipt is not None + + normalized_target = HttpTarget( + scheme="http", + host="provider.test", + port=443, + method="POST", + path="/v1/chat/completions", + query="", + ) + wrong_host = egress.process( + _provider_request( + "safe rendered prompt", + admitted.receipt, + target=normalized_target.model_copy(update={"host": "other.test"}), + ), + timeout=Timeout.from_seconds(1), + ) + result = egress.process( + _provider_request( + "safe rendered prompt", admitted.receipt, target=normalized_target + ), + timeout=Timeout.from_seconds(1), + ) + + assert wrong_host.reason_code == "receipt_context_mismatch" + assert result.decision.value == "allow" + + def test_rendered_prompt_receipt_is_consumed_after_first_request() -> None: admission, egress = _processors() _, admitted = _admit(admission, "safe rendered prompt") diff --git a/projects/egress-gate/tests/test_pi_admission_extension.py b/projects/egress-gate/tests/test_pi_admission_extension.py new file mode 100644 index 00000000..d2bd43f9 --- /dev/null +++ b/projects/egress-gate/tests/test_pi_admission_extension.py @@ -0,0 +1,20 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +from __future__ import annotations + +import subprocess +from pathlib import Path + + +def test_pi_admission_extension_renews_receipts_for_provider_continuations() -> None: + project_dir = Path(__file__).parents[1] + test_file = ( + project_dir + / "examples/pi-attested-admission/openshell-input-admission.test.mjs" + ) + + subprocess.run( + ["node", "--experimental-strip-types", "--test", str(test_file)], + check=True, + ) diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 47da0ac7..42941306 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -3,20 +3,21 @@ from __future__ import annotations +import json import os import subprocess +import tomllib from pathlib import Path +import yaml -def test_pi_example_can_print_every_command_without_running_it( + +def test_pi_example_can_print_each_action_without_running_it( tmp_path: Path, ) -> None: project_dir = Path(__file__).parents[1] script = project_dir / "examples/pi-attested-admission/demo.sh" pi_repo = tmp_path / "pi" - package_dir = pi_repo / "packages/coding-agent" - package_dir.mkdir(parents=True) - (package_dir / "package.json").write_text('{"version":"1.2.3"}') openshell_repo = tmp_path / "OpenShell" pack_dir = tmp_path / "pack" runtime_dir = tmp_path / "runtime" @@ -24,33 +25,268 @@ def test_pi_example_can_print_every_command_without_running_it( "PI_REPO": str(pi_repo), "OPENSHELL_REPO": str(openshell_repo), "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_MODEL_BASE_URL": "https://models.example.test/v1", + "PI_MODEL_ID": "example-model", "PI_EGRESS_PACK_DIR": str(pack_dir), "PI_EGRESS_RUNTIME_DIR": str(runtime_dir), } + results = [ + subprocess.run( + ["bash", str(script), "--print", action], + check=True, + capture_output=True, + env=environment, + text=True, + ) + for action in ("prepare", "serve", "gateway", "launch", "verify", "cleanup") + ] + output = "\n".join(result.stdout for result in results) + + assert "npm run build" in output + assert "earendil-works-pi-coding-agent-VERSION.tgz" in output + assert "git clone --branch johnny/before-user-message-commit" in output + assert "git clone --branch openshell/pi-egress-admission" in output + assert ( + "git pull --no-rebase --ff-only origin johnny/before-user-message-commit" + in output + ) + assert ( + "git pull --no-rebase --ff-only origin openshell/pi-egress-admission" in output + ) + assert "gateway-middleware.toml" in output + assert "OPENSHELL_GATEWAY_CONFIG_FRAGMENT=" in output + assert "render-runtime-config.mjs" in output + assert "https://models.example.test/v1" in output + assert "example-model" in output + assert "egress-gate --debug serve" in output + assert "CARGO_BUILD_JOBS=4" in output + assert "OPENSHELL_GATEWAY_NAME=pi-egress-demo-gateway" in output + assert "--gateway pi-egress-demo-gateway" in output + assert "provider create" in output + assert "provider profile import" in output + assert "--type pi-attested-model" in output + assert "PI_MODEL_API_KEY" in output + assert "OPENAI_API_KEY" not in output + assert "api.openai.com" not in output + assert "sandbox create" in output + assert "--detach" in output + assert "--no-git-ignore" in output + assert f"{runtime_dir}/node_modules:/sandbox/pi-runtime" in output + assert f"{runtime_dir}:/sandbox/pi-runtime" not in output + assert "sandbox exec" in output + assert "sandbox exec --tty" in output + assert "PI_OFFLINE=1" in output + assert "OPENSHELL_AGENT_CONVERSATION_URL=" in output + assert "REDACTED" in output + assert "DENY_THIS" in output + assert "REDACT_THIS" in output + assert "sandbox delete" in output + assert all(result.stderr == "" for result in results) + assert not pi_repo.exists() + assert not openshell_repo.exists() + assert not pack_dir.exists() + assert not runtime_dir.exists() + + +def test_pi_example_print_all_is_a_concise_walkthrough() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + result = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=os.environ + | { + "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_MODEL_BASE_URL": "https://models.example.test/v1", + "PI_MODEL_ID": "example-model", + "PI_MODEL_API_KEY": "secret-not-printed", + }, + text=True, + ) + + assert "Pi attested-admission walkthrough" in result.stdout + assert "Configuration visible to this shell" in result.stdout + assert "Model credential: set (value hidden)" in result.stdout + assert "1. prepare" in result.stdout + assert "7. cleanup" in result.stdout + assert "secret-not-printed" not in result.stdout + assert "working directory:" not in result.stdout + + +def test_pi_example_uses_terminal_colors_without_leaking_them_to_redirects() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + environment = { + name: value for name, value in os.environ.items() if name != "NO_COLOR" + } | {"FORCE_COLOR": "1"} + + colored = subprocess.run( ["bash", str(script), "--print", "all"], check=True, capture_output=True, env=environment, text=True, ) + uncolored = subprocess.run( + ["bash", str(script), "--print", "all"], + check=True, + capture_output=True, + env=environment | {"NO_COLOR": "1"}, + text=True, + ) + assert "\x1b[36m" in colored.stdout + assert "\x1b[" not in uncolored.stdout - assert "npm run build" in result.stdout - assert ( - "git pull --ff-only origin johnny/before-user-message-commit" in result.stdout - ) - assert "git pull --ff-only origin openshell/pi-egress-admission" in result.stdout - assert "add-gateway-registration" in result.stdout - assert "egress-gate --debug serve" in result.stdout - assert "env UV_NO_CONFIG=1 mise run gateway" in result.stdout - assert "provider create" in result.stdout - assert "sandbox create" in result.stdout - assert "sandbox exec" in result.stdout - assert "REDACTED" in result.stdout - assert "DENY_THIS" in result.stdout - assert "REDACT_THIS" in result.stdout - assert "sandbox delete" in result.stdout - assert result.stderr == "" - assert not pack_dir.exists() - assert not runtime_dir.exists() + +def test_pi_example_defaults_to_an_ignored_external_workspace() -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + + result = subprocess.run( + ["bash", str(script), "--print", "prepare"], + check=True, + capture_output=True, + env={ + name: value + for name, value in os.environ.items() + if name not in {"PI_REPO", "OPENSHELL_REPO", "PI_EGRESS_FORKS_DIR"} + }, + text=True, + ) + + workspace = project_dir / ".workspaces/pi-attested-admission" + assert str(workspace / "pi") in result.stdout + assert str(workspace / "OpenShell") in result.stdout + assert ".workspaces/" in (project_dir / ".gitignore").read_text().splitlines() + + +def test_pi_example_renders_provider_specific_runtime_configuration( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + example_dir = project_dir / "examples/pi-attested-admission" + models_output = tmp_path / "models.json" + policy_output = tmp_path / "policy.yaml" + provider_profile_output = tmp_path / "provider-profile.yaml" + gateway_output = tmp_path / "gateway-middleware.toml" + + subprocess.run( + [ + "node", + str(example_dir / "render-runtime-config.mjs"), + "--base-url", + "https://gateway.example.test:8443/models/v1", + "--model-id", + "custom-model", + "--models-output", + str(models_output), + "--policy-output", + str(policy_output), + "--provider-profile-output", + str(provider_profile_output), + "--middleware-endpoint", + "http://192.0.2.10:50051", + "--gateway-output", + str(gateway_output), + ], + check=True, + ) + + models = json.loads(models_output.read_text()) + provider = models["providers"]["attested-provider"] + assert provider["baseUrl"] == "https://gateway.example.test:8443/models/v1" + assert provider["api"] == "openai-completions" + assert provider["apiKey"] == "$PI_MODEL_API_KEY" + assert provider["models"][0]["id"] == "custom-model" + + provider_profile = yaml.safe_load(provider_profile_output.read_text()) + assert provider_profile["id"] == "pi-attested-model" + assert provider_profile["credentials"][0]["env_vars"] == ["PI_MODEL_API_KEY"] + assert provider_profile["endpoints"][0]["host"] == "gateway.example.test" + assert provider_profile["endpoints"][0]["port"] == 8443 + + policy = yaml.safe_load(policy_output.read_text()) + endpoint = policy["network_policies"]["model_provider"]["endpoints"][0] + assert endpoint["host"] == "gateway.example.test" + assert endpoint["port"] == 8443 + middleware = policy["network_middlewares"]["pi_egress_gate"] + assert middleware["endpoints"]["include"] == ["gateway.example.test"] + + gateway_fragment = tomllib.loads(gateway_output.read_text()) + registration = gateway_fragment["openshell"]["supervisor"]["middleware"][0] + assert registration["name"] == "pi-egress" + assert registration["grpc_endpoint"] == "http://192.0.2.10:50051" + assert registration["allow_insecure_transport"] is True + assert registration["max_payload_bytes"] == 32 * 1024 + + +def test_pi_example_reports_all_missing_configuration_before_work( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + environment = { + name: value + for name, value in os.environ.items() + if name + not in { + "EGRESS_GATE_HOST_IP", + "PI_MODEL_BASE_URL", + "PI_MODEL_ID", + "PI_MODEL_API_KEY", + } + } + + result = subprocess.run( + ["bash", str(script), "prepare"], + capture_output=True, + cwd=tmp_path, + env=environment, + text=True, + ) + + assert result.returncode == 1 + assert result.stdout == "" + assert "The Pi attested-admission example is not configured." in result.stderr + assert "EGRESS_GATE_HOST_IP" in result.stderr + assert "PI_MODEL_BASE_URL" in result.stderr + assert "PI_MODEL_ID" in result.stderr + assert "PI_MODEL_API_KEY" in result.stderr + assert "source .env" in result.stderr + assert "git pull" not in result.stderr + + +def test_pi_example_reports_a_missing_compute_backend_before_mise( + tmp_path: Path, +) -> None: + project_dir = Path(__file__).parents[1] + script = project_dir / "examples/pi-attested-admission/demo.sh" + for command in ("docker", "podman"): + stub = tmp_path / command + stub.write_text("#!/bin/sh\nexit 1\n") + stub.chmod(0o755) + + result = subprocess.run( + ["bash", str(script), "gateway"], + capture_output=True, + env=os.environ + | { + "PATH": f"{tmp_path}:{os.environ['PATH']}", + "OPENSHELL_DRIVERS": "", + "EGRESS_GATE_HOST_IP": "192.0.2.10", + "PI_MODEL_BASE_URL": "https://models.example.test/v1", + "PI_MODEL_ID": "example-model", + "PI_MODEL_API_KEY": "test-key", + }, + text=True, + ) + + assert result.returncode == 1 + assert result.stdout == "" + assert "No running OpenShell compute backend was detected." in result.stderr + assert "docker info" in result.stderr + assert "podman info" in result.stderr + assert "mise" not in result.stderr From 252a97770c9303d3c0d4bb1e5a0887fea5c81006 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 23:37:44 -0400 Subject: [PATCH 15/19] fix(egress-gate): recreate example provider profile on launch --- .../examples/pi-attested-admission/README.md | 5 +++-- .../examples/pi-attested-admission/demo.sh | 17 ++++++++++------- .../tests/test_pi_example_commands.py | 3 +++ 3 files changed, 16 insertions(+), 9 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index b7bc274c..4996eb49 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -112,8 +112,9 @@ After the gateway reports that it is ready, launch Pi from a third terminal: ./demo.sh launch ``` -Each launch replaces the example's `pi-egress-demo` sandbox so the current Pi -runtime, extension, policy, and OpenShell supervisor are used together. +Each launch replaces the example's `pi-egress-demo` sandbox, provider, and +custom provider profile so the current Pi runtime, extension, policy, endpoint, +and OpenShell supervisor are used together. The example registers an endpoint-specific provider profile and stores `PI_MODEL_API_KEY` as its credential. Pi sees only an opaque placeholder; diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index dc11d777..de40b532 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -350,6 +350,10 @@ gateway() { ensure_model_provider() { if $print_only; then + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider delete pi-model + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile delete pi-attested-model run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ provider profile import --file "$runtime_provider_profile" run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ @@ -357,18 +361,17 @@ ensure_model_provider() { return fi if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ - provider profile export pi-attested-model >/dev/null 2>&1); then - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider profile update pi-attested-model --file "$runtime_provider_profile" - else + provider get pi-model >/dev/null 2>&1); then run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider profile import --file "$runtime_provider_profile" + provider delete pi-model fi if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ - provider get pi-model >/dev/null 2>&1); then + provider profile export pi-attested-model >/dev/null 2>&1); then run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - provider delete pi-model + provider profile delete pi-attested-model fi + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ + provider profile import --file "$runtime_provider_profile" run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY } diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 42941306..92e6f775 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -65,6 +65,9 @@ def test_pi_example_can_print_each_action_without_running_it( assert "--gateway pi-egress-demo-gateway" in output assert "provider create" in output assert "provider profile import" in output + assert "provider profile delete pi-attested-model" in output + assert "provider delete pi-model" in output + assert "provider profile update" not in output assert "--type pi-attested-model" in output assert "PI_MODEL_API_KEY" in output assert "OPENAI_API_KEY" not in output From 18158b96b4f7f0681aae5d0e0c4a3986394f1cac Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 23:43:31 -0400 Subject: [PATCH 16/19] fix(egress-gate): redact model credentials from Pi tool output --- .../examples/pi-attested-admission/README.md | 9 ++++---- .../examples/pi-attested-admission/demo.sh | 10 +++++--- .../openshell-input-admission.test.mjs | 23 +++++++++++++++++++ .../openshell-input-admission.ts | 20 ++++++++++++++++ .../render-runtime-config.mjs | 4 ++-- .../tests/test_pi_example_commands.py | 6 ++--- 6 files changed, 60 insertions(+), 12 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 4996eb49..686d43a4 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -116,10 +116,11 @@ Each launch replaces the example's `pi-egress-demo` sandbox, provider, and custom provider profile so the current Pi runtime, extension, policy, endpoint, and OpenShell supervisor are used together. -The example registers an endpoint-specific provider profile and stores -`PI_MODEL_API_KEY` as its credential. Pi sees only an opaque placeholder; -OpenShell resolves it only when the admitted request is sent to the configured -model host and port. +The example registers an endpoint-specific provider profile using the host-side +`PI_MODEL_API_KEY`. Inside the sandbox it uses the distinct +`MODEL_PROVIDER_API_KEY` name so Pi's own `PI_*` diagnostics do not capture the +credential. The extension redacts accidental appearances in tool output, and +OpenShell blocks any credential-bearing request body from leaving the sandbox. At the Pi prompt, submit both of these in the same session: diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index de40b532..0c2a7a5f 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -29,6 +29,7 @@ pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} openshell_cli=$openshell_repo/scripts/bin/openshell gateway_name=${PI_EGRESS_GATEWAY_NAME:-pi-egress-demo-gateway} +model_credential_env=MODEL_PROVIDER_API_KEY runtime_models=$runtime_dir/models.json runtime_policy=$runtime_dir/policy.yaml runtime_provider_profile=$runtime_dir/provider-profile.yaml @@ -357,7 +358,7 @@ ensure_model_provider() { run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ provider profile import --file "$runtime_provider_profile" run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ - --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY + --name pi-model --type pi-attested-model --credential "$model_credential_env" return fi if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ @@ -372,8 +373,11 @@ ensure_model_provider() { fi run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ provider profile import --file "$runtime_provider_profile" - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ - --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY + ( + export MODEL_PROVIDER_API_KEY=$PI_MODEL_API_KEY + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ + --name pi-model --type pi-attested-model --credential "$model_credential_env" + ) } delete_demo_sandbox_if_present() { diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs index 4032541e..e9720c25 100644 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs @@ -107,3 +107,26 @@ test("activates queued prompts only when Pi delivers them", async () => { else process.env[BRIDGE_URL_ENV] = originalBridgeUrl; } }); + +test("redacts the model credential from tool output before Pi records it", async () => { + const credentialName = "MODEL_PROVIDER_API_KEY"; + const originalCredential = process.env[credentialName]; + const credential = "test-model-credential-123456"; + process.env[credentialName] = credential; + + try { + const handlers = createHarness(); + const result = await handlers.get("tool_result")({ + content: [{ type: "text", text: `MODEL_PROVIDER_API_KEY=${credential}\nPI_SESSION_ID=session-1` }], + }); + assert.deepEqual(result.content, [ + { + type: "text", + text: "MODEL_PROVIDER_API_KEY=[REDACTED_MODEL_CREDENTIAL]\nPI_SESSION_ID=session-1", + }, + ]); + } finally { + if (originalCredential === undefined) delete process.env[credentialName]; + else process.env[credentialName] = originalCredential; + } +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts index 1b5f120f..d153b95a 100644 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts @@ -14,6 +14,8 @@ const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; const SCHEMA_VERSION = "openshell.pi-input.v1"; const MAX_RESPONSE_BYTES = 256 * 1024; const MAX_RECEIPT_BYTES = 8 * 1024; +const REDACTED_CREDENTIAL = "[REDACTED_MODEL_CREDENTIAL]"; +const CREDENTIAL_ENV_NAMES = ["MODEL_PROVIDER_API_KEY", "PI_MODEL_API_KEY"]; type BridgeResponse = | { decision: "allow"; replacement_body?: number[]; receipt: number[] } @@ -43,6 +45,24 @@ export default function (pi: ExtensionAPI) { let activeAdmission: ActiveAdmission | undefined; let pendingReceipt: string | undefined; const queuedAdmissions: PendingAdmission[] = []; + const credentialValues = CREDENTIAL_ENV_NAMES.map((name) => process.env[name]).filter( + (value): value is string => typeof value === "string" && value.length >= 12, + ); + + pi.on("tool_result", (event) => { + let changed = false; + const content = event.content.map((part) => { + if (part.type !== "text") return part; + let text = part.text; + for (const credential of credentialValues) { + const redacted = text.replaceAll(credential, REDACTED_CREDENTIAL); + changed ||= redacted !== text; + text = redacted; + } + return text === part.text ? part : { ...part, text }; + }); + return changed ? { content } : undefined; + }); pi.on("before_user_message_append", async (event, ctx) => { const isIdle = ctx.isIdle(); diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs index cd424fb4..8d141840 100644 --- a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -20,7 +20,7 @@ const models = { "attested-provider": { baseUrl: baseUrl.toString().replace(/\/$/, ""), api: "openai-completions", - apiKey: "$PI_MODEL_API_KEY", + apiKey: "$MODEL_PROVIDER_API_KEY", models: [ { id: modelId, @@ -56,7 +56,7 @@ inference_capable: true credentials: - name: api_key description: Model provider API key - env_vars: [PI_MODEL_API_KEY] + env_vars: [MODEL_PROVIDER_API_KEY] required: true auth_style: bearer header_name: authorization diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 92e6f775..f2e9ac3d 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -69,7 +69,7 @@ def test_pi_example_can_print_each_action_without_running_it( assert "provider delete pi-model" in output assert "provider profile update" not in output assert "--type pi-attested-model" in output - assert "PI_MODEL_API_KEY" in output + assert "MODEL_PROVIDER_API_KEY" in output assert "OPENAI_API_KEY" not in output assert "api.openai.com" not in output assert "sandbox create" in output @@ -202,12 +202,12 @@ def test_pi_example_renders_provider_specific_runtime_configuration( provider = models["providers"]["attested-provider"] assert provider["baseUrl"] == "https://gateway.example.test:8443/models/v1" assert provider["api"] == "openai-completions" - assert provider["apiKey"] == "$PI_MODEL_API_KEY" + assert provider["apiKey"] == "$MODEL_PROVIDER_API_KEY" assert provider["models"][0]["id"] == "custom-model" provider_profile = yaml.safe_load(provider_profile_output.read_text()) assert provider_profile["id"] == "pi-attested-model" - assert provider_profile["credentials"][0]["env_vars"] == ["PI_MODEL_API_KEY"] + assert provider_profile["credentials"][0]["env_vars"] == ["MODEL_PROVIDER_API_KEY"] assert provider_profile["endpoints"][0]["host"] == "gateway.example.test" assert provider_profile["endpoints"][0]["port"] == 8443 From fb51e767fd3ed0d6779605baa6ccd8dcdd731e52 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 23:47:13 -0400 Subject: [PATCH 17/19] fix(egress-gate): distinguish credential placeholders from secrets --- .../examples/pi-attested-admission/README.md | 9 +++++---- .../examples/pi-attested-admission/demo.sh | 10 +++------- .../openshell-input-admission.test.mjs | 18 +++++++++--------- .../openshell-input-admission.ts | 10 +++++----- .../render-runtime-config.mjs | 4 ++-- .../tests/test_pi_example_commands.py | 6 +++--- 6 files changed, 27 insertions(+), 30 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 686d43a4..6cbac3b6 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -117,10 +117,11 @@ custom provider profile so the current Pi runtime, extension, policy, endpoint, and OpenShell supervisor are used together. The example registers an endpoint-specific provider profile using the host-side -`PI_MODEL_API_KEY`. Inside the sandbox it uses the distinct -`MODEL_PROVIDER_API_KEY` name so Pi's own `PI_*` diagnostics do not capture the -credential. The extension redacts accidental appearances in tool output, and -OpenShell blocks any credential-bearing request body from leaving the sandbox. +`PI_MODEL_API_KEY`. The real credential remains in OpenShell. Pi receives an +environment variable with the same name whose value is an opaque, +endpoint-bound OpenShell resolver placeholder. The extension redacts accidental +appearances of that placeholder in tool output; OpenShell resolves it in the +authorization header only for the configured model endpoint. At the Pi prompt, submit both of these in the same session: diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index 0c2a7a5f..de40b532 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -29,7 +29,6 @@ pack_dir=${PI_EGRESS_PACK_DIR:-/tmp/pi-egress-pack} runtime_dir=${PI_EGRESS_RUNTIME_DIR:-/tmp/pi-egress-runtime} openshell_cli=$openshell_repo/scripts/bin/openshell gateway_name=${PI_EGRESS_GATEWAY_NAME:-pi-egress-demo-gateway} -model_credential_env=MODEL_PROVIDER_API_KEY runtime_models=$runtime_dir/models.json runtime_policy=$runtime_dir/policy.yaml runtime_provider_profile=$runtime_dir/provider-profile.yaml @@ -358,7 +357,7 @@ ensure_model_provider() { run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ provider profile import --file "$runtime_provider_profile" run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ - --name pi-model --type pi-attested-model --credential "$model_credential_env" + --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY return fi if (cd -- "$openshell_repo" && "$openshell_cli" --gateway "$gateway_name" \ @@ -373,11 +372,8 @@ ensure_model_provider() { fi run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ provider profile import --file "$runtime_provider_profile" - ( - export MODEL_PROVIDER_API_KEY=$PI_MODEL_API_KEY - run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ - --name pi-model --type pi-attested-model --credential "$model_credential_env" - ) + run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" provider create \ + --name pi-model --type pi-attested-model --credential PI_MODEL_API_KEY } delete_demo_sandbox_if_present() { diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs index e9720c25..820f741f 100644 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs @@ -108,25 +108,25 @@ test("activates queued prompts only when Pi delivers them", async () => { } }); -test("redacts the model credential from tool output before Pi records it", async () => { - const credentialName = "MODEL_PROVIDER_API_KEY"; - const originalCredential = process.env[credentialName]; - const credential = "test-model-credential-123456"; - process.env[credentialName] = credential; +test("redacts the OpenShell credential placeholder before Pi records it", async () => { + const credentialName = "PI_MODEL_API_KEY"; + const originalPlaceholder = process.env[credentialName]; + const placeholder = "openshell:resolve:env:PI_MODEL_API_KEY:test-handle"; + process.env[credentialName] = placeholder; try { const handlers = createHarness(); const result = await handlers.get("tool_result")({ - content: [{ type: "text", text: `MODEL_PROVIDER_API_KEY=${credential}\nPI_SESSION_ID=session-1` }], + content: [{ type: "text", text: `PI_MODEL_API_KEY=${placeholder}\nPI_SESSION_ID=session-1` }], }); assert.deepEqual(result.content, [ { type: "text", - text: "MODEL_PROVIDER_API_KEY=[REDACTED_MODEL_CREDENTIAL]\nPI_SESSION_ID=session-1", + text: "PI_MODEL_API_KEY=[REDACTED_OPENSHELL_CREDENTIAL_PLACEHOLDER]\nPI_SESSION_ID=session-1", }, ]); } finally { - if (originalCredential === undefined) delete process.env[credentialName]; - else process.env[credentialName] = originalCredential; + if (originalPlaceholder === undefined) delete process.env[credentialName]; + else process.env[credentialName] = originalPlaceholder; } }); diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts index d153b95a..c47c29d6 100644 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts +++ b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts @@ -14,8 +14,8 @@ const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; const SCHEMA_VERSION = "openshell.pi-input.v1"; const MAX_RESPONSE_BYTES = 256 * 1024; const MAX_RECEIPT_BYTES = 8 * 1024; -const REDACTED_CREDENTIAL = "[REDACTED_MODEL_CREDENTIAL]"; -const CREDENTIAL_ENV_NAMES = ["MODEL_PROVIDER_API_KEY", "PI_MODEL_API_KEY"]; +const REDACTED_PLACEHOLDER = "[REDACTED_OPENSHELL_CREDENTIAL_PLACEHOLDER]"; +const PLACEHOLDER_ENV_NAMES = ["PI_MODEL_API_KEY", "MODEL_PROVIDER_API_KEY"]; type BridgeResponse = | { decision: "allow"; replacement_body?: number[]; receipt: number[] } @@ -45,7 +45,7 @@ export default function (pi: ExtensionAPI) { let activeAdmission: ActiveAdmission | undefined; let pendingReceipt: string | undefined; const queuedAdmissions: PendingAdmission[] = []; - const credentialValues = CREDENTIAL_ENV_NAMES.map((name) => process.env[name]).filter( + const credentialPlaceholders = PLACEHOLDER_ENV_NAMES.map((name) => process.env[name]).filter( (value): value is string => typeof value === "string" && value.length >= 12, ); @@ -54,8 +54,8 @@ export default function (pi: ExtensionAPI) { const content = event.content.map((part) => { if (part.type !== "text") return part; let text = part.text; - for (const credential of credentialValues) { - const redacted = text.replaceAll(credential, REDACTED_CREDENTIAL); + for (const placeholder of credentialPlaceholders) { + const redacted = text.replaceAll(placeholder, REDACTED_PLACEHOLDER); changed ||= redacted !== text; text = redacted; } diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs index 8d141840..cd424fb4 100644 --- a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -20,7 +20,7 @@ const models = { "attested-provider": { baseUrl: baseUrl.toString().replace(/\/$/, ""), api: "openai-completions", - apiKey: "$MODEL_PROVIDER_API_KEY", + apiKey: "$PI_MODEL_API_KEY", models: [ { id: modelId, @@ -56,7 +56,7 @@ inference_capable: true credentials: - name: api_key description: Model provider API key - env_vars: [MODEL_PROVIDER_API_KEY] + env_vars: [PI_MODEL_API_KEY] required: true auth_style: bearer header_name: authorization diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index f2e9ac3d..92e6f775 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -69,7 +69,7 @@ def test_pi_example_can_print_each_action_without_running_it( assert "provider delete pi-model" in output assert "provider profile update" not in output assert "--type pi-attested-model" in output - assert "MODEL_PROVIDER_API_KEY" in output + assert "PI_MODEL_API_KEY" in output assert "OPENAI_API_KEY" not in output assert "api.openai.com" not in output assert "sandbox create" in output @@ -202,12 +202,12 @@ def test_pi_example_renders_provider_specific_runtime_configuration( provider = models["providers"]["attested-provider"] assert provider["baseUrl"] == "https://gateway.example.test:8443/models/v1" assert provider["api"] == "openai-completions" - assert provider["apiKey"] == "$MODEL_PROVIDER_API_KEY" + assert provider["apiKey"] == "$PI_MODEL_API_KEY" assert provider["models"][0]["id"] == "custom-model" provider_profile = yaml.safe_load(provider_profile_output.read_text()) assert provider_profile["id"] == "pi-attested-model" - assert provider_profile["credentials"][0]["env_vars"] == ["MODEL_PROVIDER_API_KEY"] + assert provider_profile["credentials"][0]["env_vars"] == ["PI_MODEL_API_KEY"] assert provider_profile["endpoints"][0]["host"] == "gateway.example.test" assert provider_profile["endpoints"][0]["port"] == 8443 From 8d1b739295f8dd0f3dd8550243e7c113d18bfa8c Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 27 Aug 2026 23:59:56 -0400 Subject: [PATCH 18/19] fix(egress-gate): allow maximum Pi request payloads --- .../examples/pi-attested-admission/render-runtime-config.mjs | 2 +- projects/egress-gate/tests/test_pi_example_commands.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs index cd424fb4..773d1b7e 100644 --- a/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs +++ b/projects/egress-gate/examples/pi-attested-admission/render-runtime-config.mjs @@ -78,7 +78,7 @@ writeFileSync( name = "pi-egress" grpc_endpoint = "${middlewareEndpoint}" allow_insecure_transport = true -max_payload_bytes = 32768 +max_payload_bytes = 4194304 timeout = "30s" `, ); diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 92e6f775..68edc5f4 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -223,7 +223,7 @@ def test_pi_example_renders_provider_specific_runtime_configuration( assert registration["name"] == "pi-egress" assert registration["grpc_endpoint"] == "http://192.0.2.10:50051" assert registration["allow_insecure_transport"] is True - assert registration["max_payload_bytes"] == 32 * 1024 + assert registration["max_payload_bytes"] == 4 * 1024 * 1024 def test_pi_example_reports_all_missing_configuration_before_work( From 8d9f96b739af5e16c775a1456cd932748952aa5c Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Fri, 28 Aug 2026 12:54:49 -0400 Subject: [PATCH 19/19] feat(egress-gate): isolate managed Pi admission --- projects/egress-gate/README.md | 18 +- .../examples/pi-attested-admission/README.md | 83 ++-- .../examples/pi-attested-admission/demo.sh | 59 +-- .../managed-pi-admission.test.mjs | 101 ++++ .../managed-pi-admission.ts | 210 +++++++++ .../pi-attested-admission/managed-pi.ts | 65 +++ .../openshell-input-admission.test.mjs | 132 ------ .../openshell-input-admission.ts | 252 ---------- .../proto/supervisor_middleware.proto | 3 + .../src/egress_gate/admission/__init__.py | 23 +- .../src/egress_gate/admission/adapters.py | 143 +++++- .../src/egress_gate/admission/models.py | 30 +- .../src/egress_gate/admission/processor.py | 94 ++-- .../src/egress_gate/admission/receipts.py | 160 ++++++- .../bindings/supervisor_middleware_pb2.py | 128 ++--- .../bindings/supervisor_middleware_pb2.pyi | 6 +- projects/egress-gate/src/egress_gate/cli.py | 10 +- .../egress-gate/src/egress_gate/constants.py | 1 + .../src/egress_gate/service/server.py | 4 +- .../src/egress_gate/service/servicer.py | 45 +- .../tests/admission/test_admission.py | 443 +++++++++++------- .../tests/service/test_grpc_integration.py | 34 +- .../tests/service/test_servicer.py | 31 ++ projects/egress-gate/tests/test_cli.py | 8 +- ...ension.py => test_managed_pi_admission.py} | 5 +- .../tests/test_pi_example_commands.py | 12 +- 26 files changed, 1253 insertions(+), 847 deletions(-) create mode 100644 projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs create mode 100644 projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts create mode 100644 projects/egress-gate/examples/pi-attested-admission/managed-pi.ts delete mode 100644 projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs delete mode 100644 projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts rename projects/egress-gate/tests/{test_pi_admission_extension.py => test_managed_pi_admission.py} (68%) diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 940e1e28..a440e1b2 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -24,7 +24,7 @@ commands work from any directory and do not depend on repository-only files: egress-gate gates list egress-gate gates schema egress-gate validate --policy /absolute/path/to/your-policy.yaml -egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-receipt +egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-attestation ``` ## Source-checkout quickstart @@ -39,7 +39,7 @@ uv run egress-gate gates list uv run egress-gate gates schema uv run egress-gate validate \ --policy examples/regex-redaction/egress-gate-config.yaml -uv run egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-receipt +uv run egress-gate serve --listen 127.0.0.1:50051 --no-require-pi-attestation uv run egress-gate evaluate \ --policy examples/regex-redaction/egress-gate-config.yaml \ --cases examples/regex-redaction/cases.yaml @@ -49,10 +49,10 @@ Use `0.0.0.0` only when the OpenShell supervisor must reach the service across network namespaces. The development server uses plaintext gRPC. Restrict its listen port to trusted networks. -The CLI requires managed Pi admission receipts by default, coupling receipt -issuance to provider egress verification. The general Gate quickstarts opt out -explicitly. Keep the default, or pass `--require-pi-receipt`, for managed Pi; -use `--no-require-pi-receipt` only for an intentionally unmanaged deployment. +The CLI requires managed Pi context attestations by default, coupling admission +to provider egress verification. The general Gate quickstarts opt out +explicitly. Keep the default, or pass `--require-pi-attestation`, for managed +Pi; use `--no-require-pi-attestation` only for an intentionally unmanaged deployment. See the [managed Pi example](examples/pi-attested-admission/README.md) for the matching Pi and OpenShell fork branches, startup contract, and current limits. @@ -94,7 +94,7 @@ need initialization, helper bases, or typed resources use the full class-based ```bash uv run egress-gate --registry my_gates:registry gates list -uv run egress-gate --registry my_gates:registry serve --no-require-pi-receipt +uv run egress-gate --registry my_gates:registry serve --no-require-pi-attestation ``` OpenShell owns interception, routing, and credential attachment. Egress Gate @@ -110,12 +110,12 @@ from egress_gate.service import EgressGateServer server = EgressGateServer( create_builtin_registry(), timeout_middleware_processing=10, - require_pi_receipt=False, + require_pi_attestation=False, ) server.serve_sync("127.0.0.1:50051") ``` -Make the `require_pi_receipt` choice explicit in programmatic deployments; set +Make the `require_pi_attestation` choice explicit in programmatic deployments; set it to `True` for managed Pi. In this unmanaged example, `timeout_middleware_processing` gives each evaluation 10 seconds. Omitting it uses the one-second service default. The value is expressed diff --git a/projects/egress-gate/examples/pi-attested-admission/README.md b/projects/egress-gate/examples/pi-attested-admission/README.md index 6cbac3b6..a8ba3bac 100644 --- a/projects/egress-gate/examples/pi-attested-admission/README.md +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -1,15 +1,16 @@ # Managed Pi attested-admission example -This example runs the forked Pi CLI inside OpenShell and sends admitted user -submissions to a model endpoint you choose. The endpoint may be a hosted +This example runs a normal interactive Pi TUI inside OpenShell and sends +admitted conversation context to a model endpoint you choose. The endpoint may be a hosted provider, an internal gateway, or a local server. It must accept the OpenAI Chat Completions request shape used by the current attestation adapter; it does not need to be OpenAI. -The example demonstrates two outcomes: +The example demonstrates the same policy at both context boundaries: -- `DENY_THIS` is rejected before Pi records it or starts a model request. -- `REDACT_THIS` becomes `[REDACTED]` before Pi records or sends it. +- `DENY_THIS` is rejected before Pi adds a user message or tool result to its + live context. +- `REDACT_THIS` becomes `[REDACTED]` before Pi adds or sends it. The redaction case makes one real request to your configured endpoint and may incur charges from that provider. @@ -113,15 +114,13 @@ After the gateway reports that it is ready, launch Pi from a third terminal: ``` Each launch replaces the example's `pi-egress-demo` sandbox, provider, and -custom provider profile so the current Pi runtime, extension, policy, endpoint, -and OpenShell supervisor are used together. +custom provider profile so the current Pi runtime, managed harness, policy, +endpoint, and OpenShell supervisor are used together. The example registers an endpoint-specific provider profile using the host-side -`PI_MODEL_API_KEY`. The real credential remains in OpenShell. Pi receives an -environment variable with the same name whose value is an opaque, -endpoint-bound OpenShell resolver placeholder. The extension redacts accidental -appearances of that placeholder in tool output; OpenShell resolves it in the -authorization header only for the configured model endpoint. +`PI_MODEL_API_KEY`. The real credential remains in OpenShell. Pi receives only +an opaque, endpoint-bound resolver placeholder; OpenShell resolves it in the +authorization header for the configured model endpoint. At the Pi prompt, submit both of these in the same session: @@ -134,38 +133,52 @@ Reply with exactly: REDACT_THIS ``` The first submission is denied without starting a model request. The second -makes a request containing `[REDACTED]`. Exit Pi, then inspect its persisted -session: +makes a request containing `[REDACTED]`. -```shell -./demo.sh verify +To exercise tool-result admission without putting the marker in the user +message, ask Pi: + +```text +Use bash to print the concatenation of DENY_ and THIS, then tell me the output. ``` -The output must contain `[REDACTED]` and must not contain `DENY_THIS` or -`REDACT_THIS`. The command exits with an error if either check fails. +The tool runs, but its result is replaced by Pi's protocol-safe blocked result +before it enters live context. Repeat with `REDACT_` and `THIS` to see the tool +result admitted as `[REDACTED]`. + +This example deliberately uses Pi's in-memory session manager. The interactive +TUI, tools, queued messages, retries, and `/new` work normally during the run, +but the session is not written inside the sandbox and cannot be resumed after +Pi exits. That is the minimal isolation guarantee: unadmitted context cannot be +recovered from a workload-owned session file. ## How it works -1. Pi renders the user submission and calls its general-purpose - `before_user_message_append` extension hook. -2. The example extension sends that text to OpenShell's sandbox-local admission - bridge. -3. Egress Gate applies `policy.yaml`: it either denies the submission or - returns replacement text plus a short-lived receipt. -4. Pi records only admitted or replacement text. -5. Before each model request in that turn, including automatic requests after - tool calls, the extension obtains a fresh receipt for the active admitted - text. -6. As each request leaves the sandbox, Egress Gate verifies that its final user - text matches the receipt and OpenShell resolves the credential. +1. `managed-pi.ts` creates the regular Pi `InteractiveMode` with a mandatory SDK + `ContextAdmission` boundary and an in-memory session manager. It disables + dynamically loaded extensions, so project or user extensions cannot replace + this boundary. +2. Pi calls that boundary for each rendered user message and finalized tool + result before it queues, appends, or persists the value. +3. The adapter sends the exact context addition to OpenShell's sandbox-local + bridge. Egress Gate applies `policy.yaml` and returns allow, deny, or a + complete replacement. +4. OpenShell keeps the signed attestation and gives Pi only an opaque handle. + The adapter keeps handles in its private closure, outside Pi messages. +5. For each provider request or retry, Pi passes the exact outbound context to + the adapter. It selects the handle for the newest admitted user message or + tool result in that context. +6. OpenShell strips the handle, resolves the supervisor-held attestation, and + supplies it only to the configured Egress Gate middleware stage. Egress Gate + verifies the latest context addition and scans the complete provider request + before OpenShell resolves the model credential. ## Current scope -The attestation adapter supports normal text turns, including tools, queued -steering and follow-up messages, and the automatic model continuations they -produce, using the OpenAI Chat Completions wire format. Providers with a -different native protocol and image inputs are not covered by this example and -fail closed. +The attestation adapter supports normal text turns, text tool results, queued +steering and follow-up messages, retries, and automatic model continuations, +using the OpenAI Chat Completions wire format. Providers with a different native +protocol and image inputs are not covered by this example and fail closed. ## Cleanup diff --git a/projects/egress-gate/examples/pi-attested-admission/demo.sh b/projects/egress-gate/examples/pi-attested-admission/demo.sh index de40b532..576319f2 100755 --- a/projects/egress-gate/examples/pi-attested-admission/demo.sh +++ b/projects/egress-gate/examples/pi-attested-admission/demo.sh @@ -317,7 +317,7 @@ prepare() { serve() { describe_printed_commands "Run Egress Gate and keep it open:" run_in "$egress_gate_dir" uv run egress-gate --debug serve \ - --listen 0.0.0.0:50051 --timeout 4s --require-pi-receipt + --listen 0.0.0.0:50051 --timeout 4s --require-pi-attestation } gateway() { @@ -392,7 +392,8 @@ create_demo_sandbox() { --provider pi-model \ --policy "$runtime_policy" \ --upload "$runtime_dir/node_modules:/sandbox/pi-runtime" \ - --upload "$script_dir/openshell-input-admission.ts:/sandbox/openshell-input-admission.ts" \ + --upload "$script_dir/managed-pi.ts:/sandbox/pi-runtime/managed-pi.ts" \ + --upload "$script_dir/managed-pi-admission.ts:/sandbox/pi-runtime/managed-pi-admission.ts" \ --upload "$runtime_models:/sandbox/pi-agent/models.json" \ --no-git-ignore \ --detach @@ -403,8 +404,10 @@ launch() { require_example_configuration require_file "$openshell_cli" "OpenShell CLI wrapper" require_file "$(pi_tarball)" "packed Pi coding-agent" - require_file "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/cli.js" \ - "installed Pi CLI" + require_file "$runtime_dir/node_modules/@earendil-works/pi-coding-agent/dist/index.js" \ + "installed Pi SDK" + require_file "$script_dir/managed-pi.ts" "managed Pi harness" + require_file "$script_dir/managed-pi-admission.ts" "managed Pi admission adapter" fi describe_printed_commands "Refresh the endpoint-specific model, policy, and provider profile:" @@ -427,46 +430,10 @@ launch() { env \ PI_CODING_AGENT_DIR=/sandbox/pi-agent \ PI_OFFLINE=1 \ + PI_MANAGED_PROVIDER=attested-provider \ + PI_MANAGED_MODEL="$model_id" \ OPENSHELL_AGENT_CONVERSATION_URL=http://127.0.0.1:8193/v1/agent/conversation \ - node /sandbox/pi-runtime/node_modules/@earendil-works/pi-coding-agent/dist/cli.js \ - --provider attested-provider \ - --model "$model_id" \ - --extension /sandbox/openshell-input-admission.ts \ - --session-dir /sandbox/pi-sessions -} - -verify() { - if ! $print_only; then - require_file "$openshell_cli" "OpenShell CLI wrapper" - fi - local redacted='\[REDACTED\]' - local forbidden='DENY_THIS|REDACT_THIS' - - if $print_only; then - describe_printed_commands "Confirm that Pi saved the redacted text:" - print_command "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec -n pi-egress-demo -- \ - grep -R -n -E "$redacted" /sandbox/pi-sessions - describe_printed_commands "Confirm that Pi did not save either original marker (this command must find no matches):" - print_command "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec -n pi-egress-demo -- \ - grep -R -n -E "$forbidden" /sandbox/pi-sessions - return - fi - - if ! run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec -n pi-egress-demo -- \ - grep -R -n -E "$redacted" /sandbox/pi-sessions; then - printf 'Verification failed: [REDACTED] was not found in Pi session history.\n' >&2 - exit 1 - fi - if run_in "$openshell_repo" "$openshell_cli" --gateway "$gateway_name" \ - sandbox exec -n pi-egress-demo -- \ - grep -R -n -E "$forbidden" /sandbox/pi-sessions; then - printf 'Verification failed: denied or unredacted input was found in Pi session history.\n' >&2 - exit 1 - fi - printf 'Verified: session history contains [REDACTED] and no original test markers.\n' + node --experimental-strip-types /sandbox/pi-runtime/managed-pi.ts } cleanup() { @@ -492,7 +459,6 @@ usage() { serve Start Egress Gate gateway Start the forked OpenShell gateway launch Attach the configured model credential and launch managed Pi - verify Confirm redaction and absence of original text in Pi session history cleanup Delete the example sandbox and credential provider all Show the concise workflow walkthrough (requires --print) EOF @@ -558,15 +524,13 @@ ${bold}${blue}Workflow${reset} ${green}5. test${reset} At the Pi prompt, submit: Reply with exactly: DENY_THIS Reply with exactly: REDACT_THIS - ${green}6. verify${reset} After exiting Pi, confirm only [REDACTED] was persisted. - ${green}7. cleanup${reset} Delete the sandbox and credential provider. + ${green}6. cleanup${reset} Delete the sandbox and credential provider. ${bold}${blue}Inspect exact commands${reset} ./demo.sh --print prepare ./demo.sh --print serve ./demo.sh --print gateway ./demo.sh --print launch - ./demo.sh --print verify ./demo.sh --print cleanup Run an action without --print when you are ready. @@ -578,7 +542,6 @@ case "$action" in serve) serve ;; gateway) gateway ;; launch) launch ;; - verify) verify ;; cleanup) cleanup ;; all) if ! $print_only; then diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs new file mode 100644 index 00000000..d5b9d61a --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.test.mjs @@ -0,0 +1,101 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { createOpenShellContextAdmission } from "./managed-pi-admission.ts"; + +const HANDLE_HEADER = "x-openshell-agent-admission-handle"; + +function user(text, timestamp) { + return { role: "user", content: [{ type: "text", text }], timestamp }; +} + +test("selects the handle for the exact queued or retried provider context", async () => { + const bridgeRequests = []; + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { + const request = JSON.parse(init.body); + bridgeRequests.push(request); + const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.text}` })); + }); + const current = user("current turn", 1); + const queued = user("queued turn", 2); + + assert.deepEqual(await admission.admitUserMessage(current, { source: "interactive" }), { action: "allow" }); + assert.deepEqual(await admission.admitUserMessage(queued, { source: "interactive" }), { action: "allow" }); + + const currentHeaders = await admission.transformProviderHeaders({}, { messages: [current], tools: [] }); + const retryHeaders = await admission.transformProviderHeaders({}, { messages: [current], tools: [] }); + const queuedHeaders = await admission.transformProviderHeaders({}, { messages: [current, queued], tools: [] }); + + assert.equal(currentHeaders[HANDLE_HEADER], "handle:current turn"); + assert.equal(retryHeaders[HANDLE_HEADER], "handle:current turn"); + assert.equal(queuedHeaders[HANDLE_HEADER], "handle:queued turn"); + assert.deepEqual( + bridgeRequests.map((request) => [request.hook, request.schema_version]), + [ + ["rendered_prompt_admission", "openshell.pi-input.v1"], + ["rendered_prompt_admission", "openshell.pi-input.v1"], + ], + ); + assert.deepEqual(bridgeRequests.map((request) => request.session_id), ["session-123", "session-123"]); +}); + +test("uses an admitted replacement as the handle lookup key", async () => { + const replacement = new TextEncoder().encode( + JSON.stringify({ schema_version: "openshell.pi-input.v1", text: "[REDACTED]" }), + ); + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async () => + new Response( + JSON.stringify({ + decision: "allow", + handle: "replacement-handle", + replacement_body: Array.from(replacement), + }), + ), + ); + const original = user("secret", 1); + const admitted = await admission.admitUserMessage(original, { source: "interactive" }); + + assert.equal(admitted.action, "allow"); + assert.equal(admitted.message.content[0].text, "[REDACTED]"); + const headers = await admission.transformProviderHeaders( + {}, + { messages: [admitted.message], tools: [] }, + ); + assert.equal(headers[HANDLE_HEADER], "replacement-handle"); +}); + +test("bounds handles retained for a long-lived in-memory session", async () => { + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => "session-123", async (_url, init) => { + const request = JSON.parse(init.body); + const envelope = JSON.parse(new TextDecoder().decode(new Uint8Array(request.request_body))); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${envelope.text}` })); + }); + const messages = Array.from({ length: 1025 }, (_, index) => user(`turn ${index}`, index)); + for (const message of messages) { + assert.equal((await admission.admitUserMessage(message, { source: "interactive" })).action, "allow"); + } + + await assert.rejects( + admission.transformProviderHeaders({}, { messages: [messages[0]], tools: [] }), + /OpenShell admission handle is missing/, + ); + const headers = await admission.transformProviderHeaders({}, { messages: [messages.at(-1)], tools: [] }); + assert.equal(headers[HANDLE_HEADER], "handle:turn 1024"); +}); + +test("uses the current session ID after a new in-memory session starts", async () => { + let sessionId = "session-1"; + const observedSessionIds = []; + const admission = createOpenShellContextAdmission("http://bridge.test/admit", () => sessionId, async (_url, init) => { + const request = JSON.parse(init.body); + observedSessionIds.push(request.session_id); + return new Response(JSON.stringify({ decision: "allow", handle: `handle:${request.session_id}` })); + }); + + await admission.admitUserMessage(user("first", 1), { source: "interactive" }); + sessionId = "session-2"; + await admission.admitUserMessage(user("after new", 2), { source: "interactive" }); + + assert.deepEqual(observedSessionIds, ["session-1", "session-2"]); +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts new file mode 100644 index 00000000..6ac44b13 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/managed-pi-admission.ts @@ -0,0 +1,210 @@ +import type { + Context, + ImageContent, + ProviderHeaders, + TextContent, + ToolResultMessage, + UserMessage, +} from "@earendil-works/pi-ai/compat"; +import type { ContextAdmission } from "@earendil-works/pi-coding-agent"; + +const HANDLE_HEADER = "x-openshell-agent-admission-handle"; +const MAX_ADMISSION_BYTES = 4 * 1024 * 1024; +// A byte encoded as a JSON array item can occupy four characters including its comma. +const MAX_BRIDGE_RESPONSE_BYTES = MAX_ADMISSION_BYTES * 4 + 64 * 1024; +const MAX_HANDLE_ENTRIES = 1024; + +type ContentBlock = TextContent | ImageContent; +type UserEnvelope = { schema_version: "openshell.pi-input.v1"; text: string }; +type ToolResultEnvelope = { + schema_version: "openshell.pi-tool-result.v1"; + tool_call_id: string; + tool_name: string; + content: ContentBlock[]; + is_error: boolean; +}; +type AdmissionEnvelope = UserEnvelope | ToolResultEnvelope; +type BridgeResult = + | { decision: "deny"; reason_code?: string } + | { decision: "allow"; handle: string; replacement_body?: number[] }; + +export function createOpenShellContextAdmission( + bridgeUrl: string, + getSessionId: () => string, + fetchRequest: typeof fetch = fetch, +): ContextAdmission { + const handles = new Map(); + + async function requestAdmission( + hook: "rendered_prompt_admission" | "tool_result_admission", + envelope: AdmissionEnvelope, + ): Promise { + const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); + if (requestBody.byteLength > MAX_ADMISSION_BYTES) { + throw new Error("OpenShell admission request is too large"); + } + const response = await fetchRequest(bridgeUrl, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + harness_version: "sdk-v1", + hook, + schema_version: envelope.schema_version, + session_id: getSessionId(), + submission_id: crypto.randomUUID(), + request_body: Array.from(requestBody), + }), + }); + if (!response.ok) throw new Error("OpenShell admission is unavailable"); + const encoded = new Uint8Array(await response.arrayBuffer()); + if (encoded.byteLength > MAX_BRIDGE_RESPONSE_BYTES) throw new Error("OpenShell admission response is too large"); + return parseBridgeResult(JSON.parse(new TextDecoder().decode(encoded))); + } + + return { + async admitUserMessage(message) { + const envelope = userEnvelope(message); + if (!envelope) { + return { action: "deny", reason: "Image inputs are not supported by this managed Pi example" }; + } + const result = await requestAdmission("rendered_prompt_admission", envelope); + if (result.decision === "deny") return denied(result.reason_code); + const admittedEnvelope = result.replacement_body + ? parseUserEnvelope(new Uint8Array(result.replacement_body)) + : envelope; + const admittedMessage: UserMessage = { + ...message, + content: replaceUserText(message.content, admittedEnvelope.text), + }; + rememberHandle(handles, messageKey(admittedMessage), result.handle); + return admittedEnvelope.text === envelope.text + ? { action: "allow" } + : { action: "allow", message: admittedMessage }; + }, + + async admitToolResult(message) { + const envelope = toolResultEnvelope(message); + const result = await requestAdmission("tool_result_admission", envelope); + if (result.decision === "deny") return denied(result.reason_code); + const admittedEnvelope = result.replacement_body + ? parseToolResultEnvelope(new Uint8Array(result.replacement_body)) + : envelope; + const admittedMessage: ToolResultMessage = { + ...message, + content: admittedEnvelope.content, + }; + rememberHandle(handles, messageKey(admittedMessage), result.handle); + return result.replacement_body + ? { action: "allow", message: admittedMessage } + : { action: "allow" }; + }, + + async transformProviderHeaders(headers: ProviderHeaders, context: Context) { + if (Object.keys(headers).some((name) => name.toLowerCase() === HANDLE_HEADER)) { + throw new Error("OpenShell admission handle header is reserved"); + } + for (let index = context.messages.length - 1; index >= 0; index -= 1) { + const message = context.messages[index]; + if (message.role !== "user" && message.role !== "toolResult") continue; + const handle = handles.get(messageKey(message)); + if (handle) return { ...headers, [HANDLE_HEADER]: handle }; + } + throw new Error("OpenShell admission handle is missing for the outbound context"); + }, + }; +} + +function userEnvelope(message: UserMessage): UserEnvelope | undefined { + if (typeof message.content === "string") { + return { schema_version: "openshell.pi-input.v1", text: message.content }; + } + if (message.content.some((block) => block.type === "image")) return undefined; + return { + schema_version: "openshell.pi-input.v1", + text: message.content.map((block) => (block as TextContent).text).join("\n"), + }; +} + +function toolResultEnvelope(message: ToolResultMessage): ToolResultEnvelope { + return { + schema_version: "openshell.pi-tool-result.v1", + tool_call_id: message.toolCallId, + tool_name: message.toolName, + content: message.content, + is_error: message.isError, + }; +} + +function messageKey(message: UserMessage | ToolResultMessage): string { + return JSON.stringify(message.role === "user" ? userEnvelope(message) : toolResultEnvelope(message)); +} + +function rememberHandle(handles: Map, key: string, handle: string): void { + handles.delete(key); + handles.set(key, handle); + if (handles.size > MAX_HANDLE_ENTRIES) { + const oldest = handles.keys().next().value; + if (oldest !== undefined) handles.delete(oldest); + } +} + +function replaceUserText(content: UserMessage["content"], text: string): UserMessage["content"] { + return typeof content === "string" ? text : [{ type: "text", text }]; +} + +function denied(reasonCode?: string): { action: "deny"; reason: string } { + return { + action: "deny", + reason: reasonCode ? `OpenShell denied this context addition (${reasonCode})` : "OpenShell denied this context addition", + }; +} + +function parseBridgeResult(value: unknown): BridgeResult { + if (!isRecord(value) || (value.decision !== "allow" && value.decision !== "deny")) { + throw new Error("OpenShell admission returned an invalid response"); + } + if (value.decision === "deny") { + return { decision: "deny", reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined }; + } + if (typeof value.handle !== "string" || !value.handle || value.handle.length > 1024) { + throw new Error("OpenShell admission returned an invalid handle"); + } + if ( + value.replacement_body !== undefined && + (!isByteArray(value.replacement_body) || value.replacement_body.length > MAX_ADMISSION_BYTES) + ) { + throw new Error("OpenShell admission returned an invalid replacement"); + } + return { decision: "allow", handle: value.handle, replacement_body: value.replacement_body }; +} + +function parseUserEnvelope(body: Uint8Array): UserEnvelope { + const value: unknown = JSON.parse(new TextDecoder().decode(body)); + if (!isRecord(value) || value.schema_version !== "openshell.pi-input.v1" || typeof value.text !== "string") { + throw new Error("OpenShell admission returned an invalid user replacement"); + } + return { schema_version: "openshell.pi-input.v1", text: value.text }; +} + +function parseToolResultEnvelope(body: Uint8Array): ToolResultEnvelope { + const value: unknown = JSON.parse(new TextDecoder().decode(body)); + if ( + !isRecord(value) || + value.schema_version !== "openshell.pi-tool-result.v1" || + typeof value.tool_call_id !== "string" || + typeof value.tool_name !== "string" || + !Array.isArray(value.content) || + typeof value.is_error !== "boolean" + ) { + throw new Error("OpenShell admission returned an invalid tool-result replacement"); + } + return value as ToolResultEnvelope; +} + +function isByteArray(value: unknown): value is number[] { + return Array.isArray(value) && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255); +} + +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === "object"; +} diff --git a/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts b/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts new file mode 100644 index 00000000..c71988d9 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/managed-pi.ts @@ -0,0 +1,65 @@ +/** Normal interactive Pi with mandatory OpenShell context admission. */ +import { + type CreateAgentSessionRuntimeFactory, + InteractiveMode, + ModelRuntime, + SessionManager, + createAgentSessionFromServices, + createAgentSessionRuntime, + createAgentSessionServices, +} from "@earendil-works/pi-coding-agent"; +import { createOpenShellContextAdmission } from "./managed-pi-admission.ts"; + +async function main(): Promise { + const bridgeUrl = process.env.OPENSHELL_AGENT_CONVERSATION_URL; + const agentDir = process.env.PI_CODING_AGENT_DIR; + const provider = process.env.PI_MANAGED_PROVIDER; + const modelId = process.env.PI_MANAGED_MODEL; + if (!bridgeUrl || !agentDir || !provider || !modelId) { + throw new Error( + "OPENSHELL_AGENT_CONVERSATION_URL, PI_CODING_AGENT_DIR, PI_MANAGED_PROVIDER, and PI_MANAGED_MODEL are required", + ); + } + + const sessionManager = SessionManager.inMemory(process.cwd()); + const contextAdmission = createOpenShellContextAdmission(bridgeUrl, () => sessionManager.getSessionId()); + const modelRuntime = await ModelRuntime.create({ + authPath: `${agentDir}/auth.json`, + modelsPath: `${agentDir}/models.json`, + refreshOnCreate: false, + }); + const model = modelRuntime.getModel(provider, modelId); + if (!model) throw new Error(`Model ${provider}/${modelId} was not found`); + + const createRuntime: CreateAgentSessionRuntimeFactory = async ({ cwd, sessionManager, sessionStartEvent }) => { + const services = await createAgentSessionServices({ + cwd, + agentDir, + modelRuntime, + resourceLoaderOptions: { noExtensions: true }, + }); + return { + ...(await createAgentSessionFromServices({ + services, + sessionManager, + sessionStartEvent, + model, + thinkingLevel: "off", + contextAdmission, + })), + services, + diagnostics: services.diagnostics, + }; + }; + const runtime = await createAgentSessionRuntime(createRuntime, { + cwd: process.cwd(), + agentDir, + sessionManager, + }); + await new InteractiveMode(runtime, { startupDiagnostics: [...runtime.diagnostics] }).run(); +} + +main().catch((error: unknown) => { + console.error(error); + process.exitCode = 1; +}); diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs deleted file mode 100644 index 820f741f..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.test.mjs +++ /dev/null @@ -1,132 +0,0 @@ -import assert from "node:assert/strict"; -import test from "node:test"; - -import registerAdmission from "./openshell-input-admission.ts"; - -const BRIDGE_URL_ENV = "OPENSHELL_AGENT_CONVERSATION_URL"; -const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; - -function createHarness() { - const handlers = new Map(); - registerAdmission({ - on(event, handler) { - handlers.set(event, handler); - }, - }); - return handlers; -} - -function createContext(isIdle = true) { - return { - isIdle: () => isIdle, - sessionManager: { getSessionId: () => "session-1" }, - signal: new AbortController().signal, - ui: { notify: () => {} }, - }; -} - -function allowResponse(receipt) { - return new Response( - JSON.stringify({ - decision: "allow", - receipt: Array.from(new TextEncoder().encode(receipt)), - }), - { status: 200 }, - ); -} - -test("uses a fresh receipt for every provider request in one admitted turn", async () => { - const originalBridgeUrl = process.env[BRIDGE_URL_ENV]; - const originalFetch = globalThis.fetch; - const bridgeRequests = []; - process.env[BRIDGE_URL_ENV] = "http://bridge.test/admit"; - globalThis.fetch = async (_url, init) => { - bridgeRequests.push(JSON.parse(init.body)); - return allowResponse(`receipt-${bridgeRequests.length}`); - }; - - try { - const handlers = createHarness(); - const ctx = createContext(); - const append = await handlers.get("before_user_message_append")( - { text: "inspect the repository" }, - ctx, - ); - assert.equal(append, undefined); - - const firstHeaders = {}; - await handlers.get("before_provider_headers")({ headers: firstHeaders }, ctx); - assert.equal(firstHeaders[RECEIPT_HEADER], "receipt-1"); - - const continuationHeaders = {}; - await handlers.get("before_provider_headers")({ headers: continuationHeaders }, ctx); - assert.equal(continuationHeaders[RECEIPT_HEADER], "receipt-2"); - - assert.equal(bridgeRequests.length, 2); - assert.notEqual(bridgeRequests[0].submission_id, bridgeRequests[1].submission_id); - assert.deepEqual(bridgeRequests[0].request_body, bridgeRequests[1].request_body); - } finally { - globalThis.fetch = originalFetch; - if (originalBridgeUrl === undefined) delete process.env[BRIDGE_URL_ENV]; - else process.env[BRIDGE_URL_ENV] = originalBridgeUrl; - } -}); - -test("activates queued prompts only when Pi delivers them", async () => { - const originalBridgeUrl = process.env[BRIDGE_URL_ENV]; - const originalFetch = globalThis.fetch; - let receiptNumber = 0; - process.env[BRIDGE_URL_ENV] = "http://bridge.test/admit"; - globalThis.fetch = async () => allowResponse(`receipt-${++receiptNumber}`); - - try { - const handlers = createHarness(); - const idleContext = createContext(); - await handlers.get("before_user_message_append")({ text: "current turn" }, idleContext); - - const initialHeaders = {}; - await handlers.get("before_provider_headers")({ headers: initialHeaders }, idleContext); - assert.equal(initialHeaders[RECEIPT_HEADER], "receipt-1"); - - const streamingContext = createContext(false); - await handlers.get("before_user_message_append")({ text: "queued turn" }, streamingContext); - - const currentContinuationHeaders = {}; - await handlers.get("before_provider_headers")({ headers: currentContinuationHeaders }, idleContext); - assert.equal(currentContinuationHeaders[RECEIPT_HEADER], "receipt-3"); - - await handlers.get("message_start")({ - message: { role: "user", content: [{ type: "text", text: "queued turn" }] }, - }); - const queuedHeaders = {}; - await handlers.get("before_provider_headers")({ headers: queuedHeaders }, idleContext); - assert.equal(queuedHeaders[RECEIPT_HEADER], "receipt-2"); - } finally { - globalThis.fetch = originalFetch; - if (originalBridgeUrl === undefined) delete process.env[BRIDGE_URL_ENV]; - else process.env[BRIDGE_URL_ENV] = originalBridgeUrl; - } -}); - -test("redacts the OpenShell credential placeholder before Pi records it", async () => { - const credentialName = "PI_MODEL_API_KEY"; - const originalPlaceholder = process.env[credentialName]; - const placeholder = "openshell:resolve:env:PI_MODEL_API_KEY:test-handle"; - process.env[credentialName] = placeholder; - - try { - const handlers = createHarness(); - const result = await handlers.get("tool_result")({ - content: [{ type: "text", text: `PI_MODEL_API_KEY=${placeholder}\nPI_SESSION_ID=session-1` }], - }); - assert.deepEqual(result.content, [ - { - type: "text", - text: "PI_MODEL_API_KEY=[REDACTED_OPENSHELL_CREDENTIAL_PLACEHOLDER]\nPI_SESSION_ID=session-1", - }, - ]); - } finally { - if (originalPlaceholder === undefined) delete process.env[credentialName]; - else process.env[credentialName] = originalPlaceholder; - } -}); diff --git a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts b/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts deleted file mode 100644 index c47c29d6..00000000 --- a/projects/egress-gate/examples/pi-attested-admission/openshell-input-admission.ts +++ /dev/null @@ -1,252 +0,0 @@ -/** - * OpenShell direct-input admission for Pi. - * - * Load this extension explicitly with Pi's standard --extension option. It - * admits each text-only user submission after rendering and before Pi - * persists it. Every provider request in the admitted turn receives a fresh - * receipt, including automatic continuations after tool calls. - */ -import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; - -const BRIDGE_URL_ENV = "OPENSHELL_AGENT_CONVERSATION_URL"; -const LEGACY_BRIDGE_URL_ENV = "OPENSHELL_PI_CONVERSATION_URL"; -const RECEIPT_HEADER = "x-openshell-middleware-egress-receipt"; -const SCHEMA_VERSION = "openshell.pi-input.v1"; -const MAX_RESPONSE_BYTES = 256 * 1024; -const MAX_RECEIPT_BYTES = 8 * 1024; -const REDACTED_PLACEHOLDER = "[REDACTED_OPENSHELL_CREDENTIAL_PLACEHOLDER]"; -const PLACEHOLDER_ENV_NAMES = ["PI_MODEL_API_KEY", "MODEL_PROVIDER_API_KEY"]; - -type BridgeResponse = - | { decision: "allow"; replacement_body?: number[]; receipt: number[] } - | { decision: "deny"; reason_code?: string }; - -interface CandidateEnvelope { - schema_version: typeof SCHEMA_VERSION; - text: string; -} - -interface ActiveAdmission { - bridgeUrl: string; - sessionId: string; - envelope: CandidateEnvelope; -} - -interface PendingAdmission extends ActiveAdmission { - receipt: string; -} - -interface AdmissionResult { - envelope: CandidateEnvelope; - receipt: string; -} - -export default function (pi: ExtensionAPI) { - let activeAdmission: ActiveAdmission | undefined; - let pendingReceipt: string | undefined; - const queuedAdmissions: PendingAdmission[] = []; - const credentialPlaceholders = PLACEHOLDER_ENV_NAMES.map((name) => process.env[name]).filter( - (value): value is string => typeof value === "string" && value.length >= 12, - ); - - pi.on("tool_result", (event) => { - let changed = false; - const content = event.content.map((part) => { - if (part.type !== "text") return part; - let text = part.text; - for (const placeholder of credentialPlaceholders) { - const redacted = text.replaceAll(placeholder, REDACTED_PLACEHOLDER); - changed ||= redacted !== text; - text = redacted; - } - return text === part.text ? part : { ...part, text }; - }); - return changed ? { content } : undefined; - }); - - pi.on("before_user_message_append", async (event, ctx) => { - const isIdle = ctx.isIdle(); - try { - if (isIdle) { - activeAdmission = undefined; - pendingReceipt = undefined; - } - if (event.images?.length) { - notifySafely(ctx, "OpenShell admission currently supports only text prompts"); - return { action: "cancel" }; - } - const bridgeUrl = process.env[BRIDGE_URL_ENV] ?? process.env[LEGACY_BRIDGE_URL_ENV]; - if (!bridgeUrl) throw new Error(`${BRIDGE_URL_ENV} is required for OpenShell admission`); - const envelope: CandidateEnvelope = { schema_version: SCHEMA_VERSION, text: event.text }; - const sessionId = ctx.sessionManager.getSessionId(); - const result = await requestAdmission(bridgeUrl, sessionId, envelope, ctx.signal); - if (result.response.decision === "deny") { - notifySafely(ctx, `OpenShell denied the prompt (${result.response.reason_code ?? "policy_denied"})`); - return { action: "cancel" }; - } - - const admission = { bridgeUrl, sessionId, ...result.admission }; - if (isIdle) { - activeAdmission = admission; - pendingReceipt = admission.receipt; - } else { - queuedAdmissions.push(admission); - } - if (result.admission.envelope.text === event.text) return; - return { action: "transform", text: result.admission.envelope.text }; - } catch { - if (isIdle) { - activeAdmission = undefined; - pendingReceipt = undefined; - } - notifySafely(ctx, "OpenShell admission is unavailable"); - return { action: "cancel" }; - } - }); - - pi.on("message_start", (event) => { - const text = userMessageText(event.message); - if (text === undefined) return; - const index = queuedAdmissions.findIndex((admission) => admission.envelope.text === text); - if (index === -1) return; - const [admission] = queuedAdmissions.splice(index, 1); - activeAdmission = admission; - pendingReceipt = admission.receipt; - }); - - pi.on("before_provider_headers", async (event, ctx) => { - if (Object.keys(event.headers).some((name) => name.toLowerCase() === RECEIPT_HEADER)) { - throw new Error("OpenShell receipt header is reserved"); - } - if (!activeAdmission) throw new Error("OpenShell candidate admission context is missing"); - - let receipt = pendingReceipt; - if (!receipt) { - const result = await requestAdmission( - activeAdmission.bridgeUrl, - activeAdmission.sessionId, - activeAdmission.envelope, - ctx.signal, - ); - if (result.response.decision === "deny") { - throw new Error(`OpenShell denied the active prompt (${result.response.reason_code ?? "policy_denied"})`); - } - if (result.admission.envelope.text !== activeAdmission.envelope.text) { - throw new Error("OpenShell changed a prompt after Pi persisted it"); - } - receipt = result.admission.receipt; - } - - event.headers[RECEIPT_HEADER] = receipt; - pendingReceipt = undefined; - }); -} - -async function requestAdmission( - bridgeUrl: string, - sessionId: string, - envelope: CandidateEnvelope, - signal: AbortSignal, -): Promise< - | { response: Extract; admission?: never } - | { response: Extract; admission: AdmissionResult } -> { - const requestBody = new TextEncoder().encode(JSON.stringify(envelope)); - const response = await fetch(bridgeUrl, { - method: "POST", - headers: { "content-type": "application/json" }, - body: JSON.stringify({ - harness_version: "extension-v1", - session_id: sessionId, - submission_id: crypto.randomUUID(), - request_body: Array.from(requestBody), - }), - signal, - }); - if (!response.ok) throw new Error("OpenShell admission is unavailable"); - const encoded = new Uint8Array(await response.arrayBuffer()); - if (encoded.byteLength > MAX_RESPONSE_BYTES) throw new Error("OpenShell admission response is too large"); - const result = parseBridgeResponse(JSON.parse(new TextDecoder().decode(encoded))); - if (result.decision === "deny") return { response: result }; - - return { - response: result, - admission: { - receipt: decodeReceipt(result.receipt), - envelope: result.replacement_body ? parseEnvelope(new Uint8Array(result.replacement_body)) : envelope, - }, - }; -} - -function userMessageText(message: unknown): string | undefined { - if (!isRecord(message) || message.role !== "user" || !Array.isArray(message.content)) return undefined; - const text = message.content - .filter( - (part): part is { type: "text"; text: string } => - isRecord(part) && part.type === "text" && typeof part.text === "string", - ) - .map((part) => part.text) - .join("\n"); - return text || undefined; -} - -function notifySafely(ctx: ExtensionContext, message: string): void { - try { - ctx.ui.notify(message, "warning"); - } catch { - // Admission remains fail closed when a UI implementation cannot notify. - } -} - -function parseBridgeResponse(value: unknown): BridgeResponse { - if (!isRecord(value) || (value.decision !== "allow" && value.decision !== "deny")) { - throw new Error("OpenShell admission returned an invalid response"); - } - if (value.decision === "deny") { - if (value.receipt !== undefined || value.replacement_body !== undefined) { - throw new Error("OpenShell admission returned an invalid denial"); - } - return { - decision: "deny", - reason_code: typeof value.reason_code === "string" ? value.reason_code : undefined, - }; - } - const receipt = value.receipt; - const replacementBody = value.replacement_body; - if (!isByteArray(receipt)) { - throw new Error("OpenShell admission returned an invalid allow response"); - } - let replacement: number[] | undefined; - if (replacementBody !== undefined) { - if (!isByteArray(replacementBody)) { - throw new Error("OpenShell admission returned an invalid allow response"); - } - replacement = replacementBody; - } - return { decision: "allow", receipt, replacement_body: replacement }; -} - -function parseEnvelope(body: Uint8Array): CandidateEnvelope { - const value: unknown = JSON.parse(new TextDecoder().decode(body)); - if (!isRecord(value) || value.schema_version !== SCHEMA_VERSION || typeof value.text !== "string") { - throw new Error("OpenShell admission returned an invalid replacement"); - } - return { schema_version: SCHEMA_VERSION, text: value.text }; -} - -function decodeReceipt(value: number[] | undefined): string { - if (!value || value.length === 0 || value.length > MAX_RECEIPT_BYTES) { - throw new Error("OpenShell admission receipt is invalid"); - } - const receipt = new TextDecoder("ascii", { fatal: true }).decode(new Uint8Array(value)); - if (!/^[\x21-\x7e]+$/.test(receipt)) throw new Error("OpenShell admission receipt is invalid"); - return receipt; -} - -function isByteArray(value: unknown): value is number[] { - return Array.isArray(value) && value.every((byte) => Number.isInteger(byte) && byte >= 0 && byte <= 255); -} - -function isRecord(value: unknown): value is Record { - return value !== null && typeof value === "object"; -} diff --git a/projects/egress-gate/proto/supervisor_middleware.proto b/projects/egress-gate/proto/supervisor_middleware.proto index b388eec8..a51a57ba 100644 --- a/projects/egress-gate/proto/supervisor_middleware.proto +++ b/projects/egress-gate/proto/supervisor_middleware.proto @@ -122,6 +122,9 @@ message HttpRequestEvaluation { bytes body = 6; // Built-in middleware name or operator-owned registration name. string middleware_name = 7; + // Supervisor-resolved agent attestation for this middleware stage. The + // workload cannot set or observe these bytes. Limited to 8 KiB. + bytes agent_attestation = 8; } // HttpHeader is one request header line. diff --git a/projects/egress-gate/src/egress_gate/admission/__init__.py b/projects/egress-gate/src/egress_gate/admission/__init__.py index b60830a8..ec0004b5 100644 --- a/projects/egress-gate/src/egress_gate/admission/__init__.py +++ b/projects/egress-gate/src/egress_gate/admission/__init__.py @@ -4,10 +4,15 @@ """First-class harness admission and attested-egress APIs.""" from egress_gate.admission.adapters import ( + AttestedCandidate, HarnessAdapter, HarnessAdapterRegistry, OpenAIChatCompletionsV1Adapter, + PiImageContentV1, PiInputV1, + PiTextContentV1, + PiToolResultV1, + PiToolResultV1Adapter, PiV1Adapter, PreparedHarnessRequest, ProviderAdapterRegistry, @@ -30,21 +35,29 @@ PI_HARNESS_VERSION, AdmissionDecision, AdmissionHook, + AdmissionProvenance, HarnessAdmissionContext, HarnessAdmissionRequest, HarnessAdmissionResult, - PromptProvenance, ) from egress_gate.admission.processor import ( RECEIPT_HEADER, AttestedEgressProcessor, HarnessAdmissionProcessor, ) -from egress_gate.admission.receipts import ReceiptAuthority, ReceiptClaimsV1 +from egress_gate.admission.receipts import ( + AgentAttestationClaimsV1, + ReceiptAuthority, + ReceiptClaimsV1, + ReceiptVerificationError, +) __all__ = [ "AdmissionDecision", "AdmissionHook", + "AdmissionProvenance", + "AgentAttestationClaimsV1", + "AttestedCandidate", "AttestedEgressProcessor", "CanonicalFunctionCallV1", "CanonicalGenerationV1", @@ -59,11 +72,14 @@ "HarnessAdmissionRequest", "HarnessAdmissionResult", "MAX_ADMISSION_BODY_BYTES", - "PromptProvenance", "PI_HARNESS_VERSION", "ModelRequestV1", "OpenAIChatCompletionsV1Adapter", "PiInputV1", + "PiImageContentV1", + "PiTextContentV1", + "PiToolResultV1", + "PiToolResultV1Adapter", "PiV1Adapter", "PreparedHarnessRequest", "ProviderAdapterRegistry", @@ -71,6 +87,7 @@ "RECEIPT_HEADER", "ReceiptAuthority", "ReceiptClaimsV1", + "ReceiptVerificationError", "canonical_json_bytes", "create_pi_adapter_registry", "create_provider_adapter_registry", diff --git a/projects/egress-gate/src/egress_gate/admission/adapters.py b/projects/egress-gate/src/egress_gate/admission/adapters.py index 80af00eb..68634b10 100644 --- a/projects/egress-gate/src/egress_gate/admission/adapters.py +++ b/projects/egress-gate/src/egress_gate/admission/adapters.py @@ -6,7 +6,7 @@ from __future__ import annotations import json -from typing import Literal, Protocol +from typing import Literal, Protocol, TypeAlias from pydantic import ( Field, @@ -52,19 +52,52 @@ class ProviderShapeError(ValueError): class PiInputV1(StrictDomainModel): - """Rendered text submitted by the pinned Pi extension.""" + """Rendered text submitted by the managed Pi harness.""" schema_version: Literal["openshell.pi-input.v1"] text: ScalarString +class PiTextContentV1(StrictDomainModel): + """One Pi text content block.""" + + type: Literal["text"] + text: ScalarString + + +class PiImageContentV1(StrictDomainModel): + """One Pi image content block.""" + + type: Literal["image"] + data: ScalarString + mimeType: ScalarString + + +class PiToolResultV1(StrictDomainModel): + """Provider-relevant fields from one Pi tool-result message.""" + + schema_version: Literal["openshell.pi-tool-result.v1"] + tool_call_id: ScalarString + tool_name: ScalarString + content: tuple[PiTextContentV1 | PiImageContentV1, ...] + is_error: bool + + @field_validator("content", mode="before") + @classmethod + def _content_is_a_tuple(cls, value: object) -> object: + return tuple(value) if isinstance(value, list) else value + + +AttestedCandidate: TypeAlias = PiInputV1 | CanonicalMessageV1 + + class PreparedHarnessRequest: """Parsed Pi request plus its canonical Gate projection.""" def __init__( self, *, - native: PiInputV1, + native: PiInputV1 | PiToolResultV1, projected_body: bytes, original_body: bytes, ) -> None: @@ -89,7 +122,7 @@ def validate_result( projected_body: bytes, context: HarnessAdmissionContext, timeout: Timeout, - ) -> tuple[bytes | None, PiInputV1]: ... + ) -> tuple[bytes | None, AttestedCandidate]: ... class PiV1Adapter: @@ -125,6 +158,54 @@ def validate_result( return replacement, updated +class PiToolResultV1Adapter: + """Strict adapter for Pi tool-result content blocks.""" + + def prepare( + self, + request: HarnessAdmissionRequest, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> PreparedHarnessRequest: + native = _parse_pi_tool_result(request.request_body, timeout) + _tool_result_attested_candidate(native) + return PreparedHarnessRequest( + native=native, + projected_body=canonical_json_bytes(native), + original_body=request.request_body, + ) + + def validate_result( + self, + prepared: PreparedHarnessRequest, + projected_body: bytes, + context: HarnessAdmissionContext, + timeout: Timeout, + ) -> tuple[bytes | None, AttestedCandidate]: + updated = _parse_pi_tool_result(projected_body, timeout) + if not isinstance(prepared.native, PiToolResultV1): + raise AdmissionMutationError("tool-result admission state is invalid") + immutable_before = ( + prepared.native.schema_version, + prepared.native.tool_call_id, + prepared.native.tool_name, + prepared.native.is_error, + ) + immutable_after = ( + updated.schema_version, + updated.tool_call_id, + updated.tool_name, + updated.is_error, + ) + if immutable_after != immutable_before: + raise AdmissionMutationError("admission changed tool-result metadata") + encoded = canonical_json_bytes(updated) + replacement = ( + None if encoded == canonical_json_bytes(prepared.native) else encoded + ) + return replacement, _tool_result_attested_candidate(updated) + + class HarnessAdapterRegistry: """Small explicit registry for supported harness admission shapes.""" @@ -248,7 +329,9 @@ def canonicalize( self, request: HttpRequest, timeout: Timeout ) -> ModelRequestV1: ... - def rendered_prompt(self, request: HttpRequest, timeout: Timeout) -> PiInputV1: ... + def latest_attested_candidate( + self, request: HttpRequest, timeout: Timeout + ) -> AttestedCandidate: ... class OpenAIChatCompletionsV1Adapter: @@ -304,8 +387,10 @@ def canonicalize(self, request: HttpRequest, timeout: Timeout) -> ModelRequestV1 ), ) - def rendered_prompt(self, request: HttpRequest, timeout: Timeout) -> PiInputV1: - """Extract the last user text from the first provider request.""" + def latest_attested_candidate( + self, request: HttpRequest, timeout: Timeout + ) -> AttestedCandidate: + """Extract the latest user or tool context addition.""" canonical = self.canonicalize(request, timeout) for message in reversed(canonical.messages): if message.role is CanonicalRole.USER and message.content is not None: @@ -313,7 +398,11 @@ def rendered_prompt(self, request: HttpRequest, timeout: Timeout) -> PiInputV1: schema_version="openshell.pi-input.v1", text=message.content, ) - raise ProviderShapeError("provider request has no user prompt") + if message.role is CanonicalRole.TOOL and message.content is not None: + if message.tool_call_id is None: + raise ProviderShapeError("provider tool result has no call ID") + return message + raise ProviderShapeError("provider request has no attested context addition") class ProviderAdapterRegistry: @@ -343,6 +432,12 @@ def create_pi_adapter_registry() -> HarnessAdapterRegistry: "openshell.pi-input.v1", PiV1Adapter(), ) + registry.register( + "pi", + AdmissionHook.TOOL_RESULT, + "openshell.pi-tool-result.v1", + PiToolResultV1Adapter(), + ) return registry @@ -366,6 +461,32 @@ def _parse_pi_body(body: bytes, timeout: Timeout) -> PiInputV1: return parsed +def _parse_pi_tool_result(body: bytes, timeout: Timeout) -> PiToolResultV1: + value = _load_json(body, AdmissionShapeError, timeout) + try: + parsed = _PI_TOOL_RESULT_ADAPTER.validate_python(value, strict=True) + except ValidationError: + raise AdmissionShapeError("Pi tool-result body is unsupported") from None + if not isinstance(parsed, PiToolResultV1): + raise AdmissionShapeError("Pi tool-result body is unsupported") + return parsed + + +def _tool_result_attested_candidate( + result: PiToolResultV1, +) -> CanonicalMessageV1: + if any(block.type == "image" for block in result.content): + raise AdmissionShapeError("Pi tool-result images are unsupported") + text = "\n".join( + block.text for block in result.content if isinstance(block, PiTextContentV1) + ) + return CanonicalMessageV1( + role=CanonicalRole.TOOL, + content=text or "(no tool output)", + tool_call_id=result.tool_call_id, + ) + + def _load_json(body: bytes, error_type: type[ValueError], timeout: Timeout) -> object: try: JsonDocument.parse(body, timeout=timeout) @@ -411,16 +532,22 @@ def _provider_message_to_canonical(item: _ProviderMessage) -> CanonicalMessageV1 _PI_ADAPTER = TypeAdapter(PiInputV1) +_PI_TOOL_RESULT_ADAPTER = TypeAdapter(PiToolResultV1) _PROVIDER_ADAPTER = TypeAdapter(_ProviderRequest) __all__ = [ "AdmissionMutationError", "AdmissionShapeError", + "AttestedCandidate", "HarnessAdapter", "HarnessAdapterRegistry", "OpenAIChatCompletionsV1Adapter", "PiInputV1", + "PiImageContentV1", + "PiTextContentV1", + "PiToolResultV1", + "PiToolResultV1Adapter", "PiV1Adapter", "PreparedHarnessRequest", "ProviderAdapterRegistry", diff --git a/projects/egress-gate/src/egress_gate/admission/models.py b/projects/egress-gate/src/egress_gate/admission/models.py index 9cfbe944..2dc2743a 100644 --- a/projects/egress-gate/src/egress_gate/admission/models.py +++ b/projects/egress-gate/src/egress_gate/admission/models.py @@ -16,14 +16,15 @@ from egress_gate.result import ReasonCode, SourcedFinding from egress_gate.string_validators import BoundedMetadataString, ScalarString -MAX_ADMISSION_BODY_BYTES = 32 * 1024 -PI_HARNESS_VERSION = "extension-v1" +MAX_ADMISSION_BODY_BYTES = 4 * 1024 * 1024 +PI_HARNESS_VERSION = "sdk-v1" class AdmissionHook(StrEnum): """Supported Pi admission boundaries.""" RENDERED_PROMPT = "rendered_prompt_admission" + TOOL_RESULT = "tool_result_admission" class AdmissionDecision(StrEnum): @@ -34,19 +35,18 @@ class AdmissionDecision(StrEnum): DENY = "deny" -class PromptProvenance(StrictDomainModel): - """Request-local correlation assertions for one rendered submission.""" +class AdmissionProvenance(StrictDomainModel): + """Request-local correlation assertions for one context addition.""" - kind: Literal["rendered_prompt"] session_id: BoundedMetadataString submission_id: BoundedMetadataString class HarnessAdmissionRequest(StrictDomainModel): - """One complete harness-native rendered prompt.""" + """One complete harness-native context addition.""" request_body: bytes = Field(max_length=MAX_ADMISSION_BODY_BYTES, repr=False) - provenance: PromptProvenance + provenance: AdmissionProvenance class HarnessAdmissionContext(StrictDomainModel): @@ -56,9 +56,9 @@ class HarnessAdmissionContext(StrictDomainModel): sandbox_id: BoundedMetadataString middleware_name: BoundedMetadataString harness: Literal["pi"] - harness_version: Literal["extension-v1"] + harness_version: Literal["extension-v1", "sdk-v1"] hook: AdmissionHook - schema_version: Literal["openshell.pi-input.v1"] + schema_version: Literal["openshell.pi-input.v1", "openshell.pi-tool-result.v1"] provider_target: HttpTarget provider_adapter_schema: Literal["openai.chat-completions.v1"] @@ -73,7 +73,7 @@ class HarnessAdmissionResult(StrictDomainModel): max_length=MAX_ADMISSION_BODY_BYTES, repr=False, ) - receipt: bytes | None = Field( + attestation: bytes | None = Field( default=None, min_length=1, max_length=8 * 1024, @@ -90,8 +90,8 @@ def _decision_contract_is_consistent(self) -> HarnessAdmissionResult: if self.decision is AdmissionDecision.DENY: if self.reason_code is None: raise ValueError("denial requires a reason code") - if self.replacement_body is not None or self.receipt is not None: - raise ValueError("denial cannot carry a replacement or receipt") + if self.replacement_body is not None or self.attestation is not None: + raise ValueError("denial cannot carry a replacement or attestation") else: if self.reason_code is not None: raise ValueError("allow decisions cannot carry a reason code") @@ -105,18 +105,18 @@ def _decision_contract_is_consistent(self) -> HarnessAdmissionResult: and self.replacement_body is not None ): raise ValueError("allow decisions cannot carry a replacement body") - if self.receipt is None: - raise ValueError("admission requires a receipt") + if self.attestation is None: + raise ValueError("admission requires an attestation") return self __all__ = [ "AdmissionDecision", "AdmissionHook", + "AdmissionProvenance", "HarnessAdmissionContext", "HarnessAdmissionRequest", "HarnessAdmissionResult", "MAX_ADMISSION_BODY_BYTES", - "PromptProvenance", "PI_HARNESS_VERSION", ] diff --git a/projects/egress-gate/src/egress_gate/admission/processor.py b/projects/egress-gate/src/egress_gate/admission/processor.py index 7aec8c0c..3c40c691 100644 --- a/projects/egress-gate/src/egress_gate/admission/processor.py +++ b/projects/egress-gate/src/egress_gate/admission/processor.py @@ -13,10 +13,15 @@ AdmissionMutationError, AdmissionShapeError, HarnessAdapterRegistry, + PiInputV1, ProviderAdapterRegistry, ProviderShapeError, ) -from egress_gate.admission.canonical import canonical_json_bytes +from egress_gate.admission.canonical import ( + CanonicalMessageV1, + CanonicalRole, + canonical_json_bytes, +) from egress_gate.admission.models import ( MAX_ADMISSION_BODY_BYTES, AdmissionDecision, @@ -31,9 +36,7 @@ EnforcementPoint, HarnessAdmissionMetadata, HttpRequest, - RemoveHeaderMutation, RequestContext, - RequestMutations, ) from egress_gate.request_processor import RequestProcessor, apply_request_mutations from egress_gate.result import ( @@ -71,7 +74,7 @@ def readiness(self) -> dict[str, str]: "admission_schema": "openshell.pi-input.v1", "canonicalization": "canonical-json.v1", "provider_adapter": "openai.chat-completions.v1", - "receipt_version": "egress-receipt.v1", + "attestation_version": "agent-attestation.v1", "key_id": self._receipt_authority.key_id, "policy_fingerprint": self._policy_fingerprint, } @@ -124,7 +127,7 @@ def process( if replacement is not None and len(replacement) > MAX_ADMISSION_BODY_BYTES: raise AdmissionMutationError("admission replacement body is too large") timeout.raise_if_expired() - receipt = self._receipt_authority.issue( + attestation = self._receipt_authority.issue_attestation( rendered_prompt, context, request.provenance, @@ -139,7 +142,7 @@ def process( else AdmissionDecision.ALLOW ), replacement_body=replacement, - receipt=receipt, + attestation=attestation, findings=gate_result.findings, policy_fingerprint=self._policy_fingerprint, ) @@ -162,7 +165,7 @@ def _deny(self, reason_code: str, hook: AdmissionHook) -> HarnessAdmissionResult class AttestedEgressProcessor: - """Verify a receipt, run network Gates, and reject prompt divergence.""" + """Verify trusted agent attestation and reject context divergence.""" def __init__( self, @@ -171,7 +174,7 @@ def __init__( receipt_authority: ReceiptAuthority, *, middleware_name: str, - harness_version: Literal["extension-v1"], + harness_version: Literal["sdk-v1"], ) -> None: fingerprint = request_processor.policy_fingerprint if not fingerprint: @@ -184,70 +187,65 @@ def __init__( self._provider_adapter_schema = "openai.chat-completions.v1" self._policy_fingerprint = fingerprint - def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: + def process( + self, + request: HttpRequest, + *, + agent_attestation: bytes, + timeout: Timeout, + ) -> EgressResult: """Deny any unattested or semantically changed provider request.""" if request.context.enforcement_point is not EnforcementPoint.NETWORK_EGRESS: return self._deny("network_context_invalid") - receipt_headers = tuple( - header - for header in request.headers - if header.name.lower() == RECEIPT_HEADER - ) - if len(receipt_headers) != 1: - reason = "receipt_missing" if not receipt_headers else "receipt_duplicate" - return self._deny(reason) - stripped = request.model_copy( - update={ - "headers": tuple( - header - for header in request.headers - if header.name.lower() != RECEIPT_HEADER - ) - } - ) + if any(header.name.lower() == RECEIPT_HEADER for header in request.headers): + return self._deny("reserved_receipt_header") + if not agent_attestation: + return self._deny("attestation_missing") try: adapter = self._provider_adapters.resolve(self._provider_adapter_schema) - rendered_prompt = adapter.rendered_prompt(stripped, timeout) + candidate = adapter.latest_attested_candidate(request, timeout) timeout.raise_if_expired() + if isinstance(candidate, PiInputV1): + hook = AdmissionHook.RENDERED_PROMPT + schema_version = "openshell.pi-input.v1" + elif ( + isinstance(candidate, CanonicalMessageV1) + and candidate.role is CanonicalRole.TOOL + ): + hook = AdmissionHook.TOOL_RESULT + schema_version = "openshell.pi-tool-result.v1" + else: + raise ProviderShapeError("provider context addition is unsupported") context = HarnessAdmissionContext( request_id=request.context.request_id, sandbox_id=request.context.sandbox_id, middleware_name=self._middleware_name, harness="pi", harness_version=self._harness_version, - hook=AdmissionHook.RENDERED_PROMPT, - schema_version="openshell.pi-input.v1", + hook=hook, + schema_version=schema_version, provider_target=request.target, provider_adapter_schema="openai.chat-completions.v1", ) - self._receipt_authority.verify( - receipt_headers[0].value.encode("ascii"), - rendered_prompt, + self._receipt_authority.verify_attestation( + agent_attestation, + candidate, context, policy_fingerprint=self._policy_fingerprint, ) timeout.raise_if_expired() - gate_result = self._request_processor.process(stripped, timeout=timeout) + gate_result = self._request_processor.process(request, timeout=timeout) timeout.raise_if_expired() if gate_result.decision is EgressDecision.DENY: return gate_result final_request = apply_request_mutations( - stripped, gate_result.request_mutations + request, gate_result.request_mutations ) - final_prompt = adapter.rendered_prompt(final_request, timeout) - if canonical_json_bytes(final_prompt) != canonical_json_bytes( - rendered_prompt - ): + final_candidate = adapter.latest_attested_candidate(final_request, timeout) + if canonical_json_bytes(final_candidate) != canonical_json_bytes(candidate): return self._deny("semantic_mutation_denied") timeout.raise_if_expired() - mutations = RequestMutations( - replacement_body=gate_result.request_mutations.replacement_body, - header_mutations=gate_result.request_mutations.header_mutations - + (RemoveHeaderMutation(kind="remove", name=RECEIPT_HEADER),), - ) - return gate_result.model_copy(update={"request_mutations": mutations}) - except UnicodeEncodeError: - return self._deny("receipt_malformed") + return gate_result except ReceiptVerificationError as error: return self._deny(error.reason_code) except TimeoutExpiredError: @@ -264,8 +262,8 @@ def _deny(self, reason_code: str) -> EgressResult: decision=EgressDecision.DENY, decision_source=GateDecisionSource( kind=DecisionSourceKind.GATE, - gate_name="receipt-verifier", - gate_type="receipt-verifier", + gate_name="agent-attestation-verifier", + gate_type="agent-attestation-verifier", ), reason_code=reason_code, policy_fingerprint=self._policy_fingerprint, diff --git a/projects/egress-gate/src/egress_gate/admission/receipts.py b/projects/egress-gate/src/egress_gate/admission/receipts.py index fc1d7c43..4aa09aac 100644 --- a/projects/egress-gate/src/egress_gate/admission/receipts.py +++ b/projects/egress-gate/src/egress_gate/admission/receipts.py @@ -19,12 +19,12 @@ ) from pydantic import Field, ValidationError -from egress_gate.admission.adapters import PiInputV1 +from egress_gate.admission.adapters import AttestedCandidate, PiInputV1 from egress_gate.admission.canonical import canonical_json_bytes from egress_gate.admission.models import ( AdmissionHook, + AdmissionProvenance, HarnessAdmissionContext, - PromptProvenance, ) from egress_gate.base import StrictDomainModel from egress_gate.string_validators import BoundedMetadataString, ScalarString @@ -54,6 +54,30 @@ class ReceiptClaimsV1(StrictDomainModel): key_id: str = Field(pattern=r"^[0-9a-f]{16}$") +class AgentAttestationClaimsV1(StrictDomainModel): + """Supervisor-only proof that the latest context addition was admitted.""" + + attestation_version: Literal["agent-attestation.v1"] = "agent-attestation.v1" + canonicalization_version: Literal["canonical-json.v1"] = "canonical-json.v1" + harness: Literal["pi"] + harness_version: Literal["sdk-v1"] + harness_schema: Literal["openshell.pi-input.v1", "openshell.pi-tool-result.v1"] + hook: Literal["rendered_prompt_admission", "tool_result_admission"] + middleware_binding: BoundedMetadataString + policy_fingerprint: ScalarString + sandbox_id: BoundedMetadataString + session_id: BoundedMetadataString + submission_id: BoundedMetadataString + attestation_id: str = Field(pattern=r"^[0-9a-f]{32}$") + provider_adapter_schema: Literal["openai.chat-completions.v1"] + host: ScalarString + port: int = Field(ge=0, le=2**32 - 1) + candidate_hash: str = Field(pattern=r"^[0-9a-f]{64}$") + issued_at: int = Field(ge=0) + expires_at: int = Field(ge=0) + key_id: str = Field(pattern=r"^[0-9a-f]{16}$") + + class ReceiptVerificationError(ValueError): """A bounded receipt verification failure.""" @@ -87,6 +111,7 @@ def __init__( self._allowed_clock_skew_seconds = allowed_clock_skew_seconds self._consumed_receipts: dict[str, int] = {} self._consumed_receipts_lock = threading.Lock() + self._attestation_lifetime_seconds = 300 @property def key_id(self) -> str: @@ -97,7 +122,7 @@ def issue( self, rendered_prompt: PiInputV1, context: HarnessAdmissionContext, - provenance: PromptProvenance, + provenance: AdmissionProvenance, *, policy_fingerprint: str, now: int | None = None, @@ -105,6 +130,11 @@ def issue( """Issue one opaque receipt after final admission validation.""" if context.hook is not AdmissionHook.RENDERED_PROMPT: raise ValueError("receipts may be issued only for rendered prompts") + if ( + context.harness_version != "extension-v1" + or context.schema_version != "openshell.pi-input.v1" + ): + raise ValueError("receipt context is unsupported") issued_at = _now_seconds() if now is None else now target = context.provider_target claims = ReceiptClaimsV1( @@ -130,6 +160,43 @@ def issue( signature = self._private_key.sign(payload) return b"eg1." + _encode(payload) + b"." + _encode(signature) + def issue_attestation( + self, + candidate: AttestedCandidate, + context: HarnessAdmissionContext, + provenance: AdmissionProvenance, + *, + policy_fingerprint: str, + now: int | None = None, + ) -> bytes: + """Issue a retry-safe proof retained by the OpenShell supervisor.""" + if context.harness_version != "sdk-v1": + raise ValueError("agent attestation context is unsupported") + issued_at = _now_seconds() if now is None else now + target = context.provider_target + claims = AgentAttestationClaimsV1( + harness=context.harness, + harness_version=context.harness_version, + harness_schema=context.schema_version, + hook=context.hook.value, + middleware_binding=context.middleware_name, + policy_fingerprint=policy_fingerprint, + sandbox_id=context.sandbox_id, + session_id=provenance.session_id, + submission_id=provenance.submission_id, + attestation_id=secrets.token_hex(16), + provider_adapter_schema=context.provider_adapter_schema, + host=target.host, + port=target.port, + candidate_hash=_candidate_hash(candidate), + issued_at=issued_at, + expires_at=issued_at + self._attestation_lifetime_seconds, + key_id=self._key_id, + ) + payload = canonical_json_bytes(claims) + signature = self._private_key.sign(payload) + return b"ag1." + _encode(payload) + b"." + _encode(signature) + def verify( self, receipt: bytes, @@ -200,11 +267,76 @@ def verify( self._consumed_receipts[claims.receipt_id] = claims.expires_at return claims + def verify_attestation( + self, + attestation: bytes, + candidate: AttestedCandidate, + context: HarnessAdmissionContext, + *, + policy_fingerprint: str, + now: int | None = None, + ) -> AgentAttestationClaimsV1: + """Verify a supervisor-supplied context-addition attestation.""" + payload, signature = _decode_token( + attestation, prefix=b"ag1", malformed_reason="attestation_malformed" + ) + try: + self._public_key.verify(signature, payload) + except InvalidSignature: + raise ReceiptVerificationError("attestation_signature_invalid") from None + try: + claims = AgentAttestationClaimsV1.model_validate_json(payload, strict=True) + except ValidationError: + raise ReceiptVerificationError("attestation_malformed") from None + if canonical_json_bytes(claims) != payload: + raise ReceiptVerificationError("attestation_malformed") + current = _now_seconds() if now is None else now + if claims.key_id != self._key_id: + raise ReceiptVerificationError("attestation_key_mismatch") + if claims.issued_at > current + self._allowed_clock_skew_seconds: + raise ReceiptVerificationError("attestation_not_yet_valid") + if claims.expires_at <= current or claims.expires_at <= claims.issued_at: + raise ReceiptVerificationError("attestation_expired") + target = context.provider_target + expected = ( + context.harness, + context.harness_version, + context.schema_version, + context.hook.value, + context.middleware_name, + policy_fingerprint, + context.sandbox_id, + context.provider_adapter_schema, + target.host, + target.port, + _candidate_hash(candidate), + ) + actual = ( + claims.harness, + claims.harness_version, + claims.harness_schema, + claims.hook, + claims.middleware_binding, + claims.policy_fingerprint, + claims.sandbox_id, + claims.provider_adapter_schema, + claims.host, + claims.port, + claims.candidate_hash, + ) + if actual != expected: + raise ReceiptVerificationError("attestation_context_mismatch") + return claims + def _prompt_hash(rendered_prompt: PiInputV1) -> str: return hashlib.sha256(canonical_json_bytes(rendered_prompt)).hexdigest() +def _candidate_hash(candidate: AttestedCandidate) -> str: + return hashlib.sha256(canonical_json_bytes(candidate)).hexdigest() + + def _encode(value: bytes) -> bytes: return base64.urlsafe_b64encode(value).rstrip(b"=") @@ -218,12 +350,21 @@ def _decode(value: bytes) -> bytes: def _decode_receipt(receipt: bytes) -> tuple[bytes, bytes]: - if len(receipt) > 8 * 1024: - raise ReceiptVerificationError("receipt_malformed") - parts = receipt.split(b".") - if len(parts) != 3 or parts[0] != b"eg1" or not parts[1] or not parts[2]: - raise ReceiptVerificationError("receipt_malformed") - return _decode(parts[1]), _decode(parts[2]) + return _decode_token(receipt, prefix=b"eg1", malformed_reason="receipt_malformed") + + +def _decode_token( + value: bytes, *, prefix: bytes, malformed_reason: str +) -> tuple[bytes, bytes]: + if len(value) > 8 * 1024: + raise ReceiptVerificationError(malformed_reason) + parts = value.split(b".") + if len(parts) != 3 or parts[0] != prefix or not parts[1] or not parts[2]: + raise ReceiptVerificationError(malformed_reason) + try: + return _decode(parts[1]), _decode(parts[2]) + except ReceiptVerificationError: + raise ReceiptVerificationError(malformed_reason) from None def _now_seconds() -> int: @@ -231,6 +372,7 @@ def _now_seconds() -> int: __all__ = [ + "AgentAttestationClaimsV1", "ReceiptAuthority", "ReceiptClaimsV1", "ReceiptVerificationError", diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py index 93d50eaf..a611b35d 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py @@ -26,7 +26,7 @@ from google.protobuf import struct_pb2 as google_dot_protobuf_dot_struct__pb2 -DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\x84\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xd6\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xe5\x02\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0cJ\x04\x08\x05\x10\x06J\x04\x08\n\x10\x0e\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xf1\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x03*\xd4\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x03*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xda\x04\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\n\x1bsupervisor_middleware.proto\x12\x17openshell.middleware.v1\x1a\x1bgoogle/protobuf/empty.proto\x1a\x1cgoogle/protobuf/struct.proto\"\x94\x01\n\x12MiddlewareManifest\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\x17\n\x0fservice_version\x18\x02 \x01(\t\x12<\n\x08\x62indings\x18\x03 \x03(\x0b\x32*.openshell.middleware.v1.MiddlewareBinding\x12\x19\n\x11\x65xpected_audience\x18\x04 \x01(\t\"\x84\x02\n\x11MiddlewareBinding\x12I\n\toperation\x18\x01 \x01(\x0e\x32\x36.openshell.middleware.v1.SupervisorMiddlewareOperation\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x19\n\x11max_payload_bytes\x18\x03 \x01(\x04\x12\x0f\n\x07timeout\x18\x04 \x01(\t\x12\x0f\n\x07harness\x18\x05 \x01(\t\x12\x0c\n\x04hook\x18\x06 \x01(\t\x12\x16\n\x0eschema_version\x18\x07 \x01(\t\"Y\n\x15ValidateConfigRequest\x12\'\n\x06\x63onfig\x18\x01 \x01(\x0b\x32\x17.google.protobuf.Struct\x12\x17\n\x0fmiddleware_name\x18\x02 \x01(\t\"7\n\x16ValidateConfigResponse\x12\r\n\x05valid\x18\x01 \x01(\x08\x12\x0e\n\x06reason\x18\x02 \x01(\t\"\xf1\x02\n\x15HttpRequestEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x34\n\x07headers\x18\x05 \x03(\x0b\x32#.openshell.middleware.v1.HttpHeader\x12\x0c\n\x04\x62ody\x18\x06 \x01(\x0c\x12\x17\n\x0fmiddleware_name\x18\x07 \x01(\t\x12\x19\n\x11\x61gent_attestation\x18\x08 \x01(\x0c\")\n\nHttpHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\"\xae\x02\n\x15WebSocketSessionEvent\x12@\n\tpreflight\x18\x01 \x01(\x0b\x32+.openshell.middleware.v1.WebSocketPreflightH\x00\x12G\n\rsession_start\x18\x02 \x01(\x0b\x32..openshell.middleware.v1.WebSocketSessionStartH\x00\x12<\n\x07message\x18\x03 \x01(\x0b\x32).openshell.middleware.v1.WebSocketMessageH\x00\x12\x43\n\x0bsession_end\x18\x04 \x01(\x0b\x32,.openshell.middleware.v1.WebSocketSessionEndH\x00\x42\x07\n\x05\x65vent\"\xc3\x02\n\x12WebSocketPreflight\x12\x12\n\nsession_id\x18\x01 \x01(\t\x12\x41\n\x05phase\x18\x02 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x03 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12:\n\x06target\x18\x04 \x01(\x0b\x32*.openshell.middleware.v1.HttpRequestTarget\x12\x1e\n\x16requested_subprotocols\x18\x05 \x03(\t\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\'\n\x06\x63onfig\x18\x07 \x01(\x0b\x32\x17.google.protobuf.Struct\"5\n\x15WebSocketSessionStart\x12\x1c\n\x14selected_subprotocol\x18\x01 \x01(\t\"Q\n\x10WebSocketMessage\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x0e\n\x04text\x18\x02 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x03 \x01(\x0cH\x00\x42\t\n\x07payload\"Y\n\x13WebSocketSessionEnd\x12\x42\n\x06reason\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.WebSocketSessionEndReason\"\xbe\x02\n\x1aWebSocketPreflightDecision\x12\x41\n\x06\x61\x63tion\x18\x01 \x01(\x0e\x32\x31.openshell.middleware.v1.WebSocketPreflightAction\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0breason_code\x18\x03 \x01(\t\x12\x32\n\x08\x66indings\x18\x04 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12S\n\x08metadata\x18\x05 \x03(\x0b\x32\x41.openshell.middleware.v1.WebSocketPreflightDecision.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\"\xeb\x02\n\x16WebSocketMessageResult\x12\x10\n\x08sequence\x18\x01 \x01(\x04\x12\x33\n\x08\x64\x65\x63ision\x18\x02 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x04text\x18\x03 \x01(\tH\x00\x12\x10\n\x06\x62inary\x18\x04 \x01(\x0cH\x00\x12\x0e\n\x06reason\x18\x05 \x01(\t\x12\x13\n\x0breason_code\x18\x06 \x01(\t\x12\x32\n\x08\x66indings\x18\x07 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12O\n\x08metadata\x18\x08 \x03(\x0b\x32=.openshell.middleware.v1.WebSocketMessageResult.MetadataEntry\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01\x42\r\n\x0breplacement\"\xc5\x01\n\x1bWebSocketSessionEventResult\x12Q\n\x12preflight_decision\x18\x01 \x01(\x0b\x32\x33.openshell.middleware.v1.WebSocketPreflightDecisionH\x00\x12I\n\x0emessage_result\x18\x02 \x01(\x0b\x32/.openshell.middleware.v1.WebSocketMessageResultH\x00\x42\x08\n\x06result\"\xa0\x01\n\x0eRequestContext\x12\x12\n\nrequest_id\x18\x01 \x01(\t\x12\x12\n\nsandbox_id\x18\x02 \x01(\t\x12=\n\x13originating_process\x18\x03 \x01(\x0b\x32 .openshell.middleware.v1.Process\x12\x14\n\x0csandbox_name\x18\x04 \x01(\t\x12\x11\n\tworkspace\x18\x05 \x01(\t\"l\n\x11HttpRequestTarget\x12\x0e\n\x06scheme\x18\x01 \x01(\t\x12\x0c\n\x04host\x18\x02 \x01(\t\x12\x0c\n\x04port\x18\x03 \x01(\r\x12\x0e\n\x06method\x18\x04 \x01(\t\x12\x0c\n\x04path\x18\x05 \x01(\t\x12\r\n\x05query\x18\x06 \x01(\t\"9\n\x07Process\x12\x0e\n\x06\x62inary\x18\x01 \x01(\t\x12\x0b\n\x03pid\x18\x02 \x01(\r\x12\x11\n\tancestors\x18\x03 \x03(\t\"\xa3\x01\n\x17\x41gentConversationTarget\x12\x0f\n\x07harness\x18\x01 \x01(\t\x12\x17\n\x0fharness_version\x18\x02 \x01(\t\x12\x0c\n\x04hook\x18\x03 \x01(\t\x12\x16\n\x0eschema_version\x18\x04 \x01(\t\x12\x0e\n\x06scheme\x18\x05 \x01(\t\x12\x0c\n\x04host\x18\x06 \x01(\t\x12\x0c\n\x04port\x18\x07 \x01(\r\x12\x0c\n\x04path\x18\x08 \x01(\t\"\xe5\x02\n\x1b\x41gentConversationEvaluation\x12\x41\n\x05phase\x18\x01 \x01(\x0e\x32\x32.openshell.middleware.v1.SupervisorMiddlewarePhase\x12\x38\n\x07\x63ontext\x18\x02 \x01(\x0b\x32\'.openshell.middleware.v1.RequestContext\x12\'\n\x06\x63onfig\x18\x03 \x01(\x0b\x32\x17.google.protobuf.Struct\x12@\n\x06target\x18\x04 \x01(\x0b\x32\x30.openshell.middleware.v1.AgentConversationTarget\x12\x17\n\x0fmiddleware_name\x18\x06 \x01(\t\x12\x12\n\nsession_id\x18\x07 \x01(\t\x12\x0f\n\x07turn_id\x18\x08 \x01(\t\x12\x14\n\x0crequest_body\x18\t \x01(\x0cJ\x04\x08\x05\x10\x06J\x04\x08\n\x10\x0e\"\x83\x03\n\x17\x41gentConversationResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x13\n\x0b\x61ttestation\x18\x05 \x01(\x0c\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12P\n\x08metadata\x18\x07 \x03(\x0b\x32>.openshell.middleware.v1.AgentConversationResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x12\x18\n\x10replacement_body\x18\t \x01(\x0c\x12\x1c\n\x14has_replacement_body\x18\n \x01(\x08\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01J\x04\x08\x03\x10\x04J\x04\x08\x04\x10\x05\"[\n\x07\x46inding\x12\x0c\n\x04type\x18\x01 \x01(\t\x12\r\n\x05label\x18\x02 \x01(\t\x12\r\n\x05\x63ount\x18\x03 \x01(\r\x12\x12\n\nconfidence\x18\x04 \x01(\t\x12\x10\n\x08severity\x18\x05 \x01(\t\"n\n\x0bWriteHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t\x12\x42\n\x0bon_existing\x18\x03 \x01(\x0e\x32-.openshell.middleware.v1.ExistingHeaderAction\"\x1c\n\x0cRemoveHeader\x12\x0c\n\x04name\x18\x01 \x01(\t\"\x8d\x01\n\x0eHeaderMutation\x12\x35\n\x05write\x18\x01 \x01(\x0b\x32$.openshell.middleware.v1.WriteHeaderH\x00\x12\x37\n\x06remove\x18\x02 \x01(\x0b\x32%.openshell.middleware.v1.RemoveHeaderH\x00\x42\x0b\n\toperation\"\x81\x03\n\x11HttpRequestResult\x12\x33\n\x08\x64\x65\x63ision\x18\x01 \x01(\x0e\x32!.openshell.middleware.v1.Decision\x12\x0e\n\x06reason\x18\x02 \x01(\t\x12\x0c\n\x04\x62ody\x18\x03 \x01(\x0c\x12\x10\n\x08has_body\x18\x04 \x01(\x08\x12\x41\n\x10header_mutations\x18\x05 \x03(\x0b\x32\'.openshell.middleware.v1.HeaderMutation\x12\x32\n\x08\x66indings\x18\x06 \x03(\x0b\x32 .openshell.middleware.v1.Finding\x12J\n\x08metadata\x18\x07 \x03(\x0b\x32\x38.openshell.middleware.v1.HttpRequestResult.MetadataEntry\x12\x13\n\x0breason_code\x18\x08 \x01(\t\x1a/\n\rMetadataEntry\x12\x0b\n\x03key\x18\x01 \x01(\t\x12\r\n\x05value\x18\x02 \x01(\t:\x02\x38\x01*\xf1\x01\n\x1dSupervisorMiddlewareOperation\x12/\n+SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED\x10\x00\x12\x30\n,SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST\x10\x01\x12\x35\n1SUPERVISOR_MIDDLEWARE_OPERATION_WEBSOCKET_MESSAGE\x10\x02\x12\x36\n2SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION\x10\x03*\xd4\x01\n\x19SupervisorMiddlewarePhase\x12+\n\'SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED\x10\x00\x12/\n+SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS\x10\x01\x12*\n&SUPERVISOR_MIDDLEWARE_PHASE_PRE_RETURN\x10\x02\x12-\n)SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT\x10\x03*\xc2\x04\n\x19WebSocketSessionEndReason\x12-\n)WEB_SOCKET_SESSION_END_REASON_UNSPECIFIED\x10\x00\x12.\n*WEB_SOCKET_SESSION_END_REASON_NORMAL_CLOSE\x10\x01\x12\x31\n-WEB_SOCKET_SESSION_END_REASON_PEER_DISCONNECT\x10\x02\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_RELOAD\x10\x03\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_DENIAL\x10\x04\x12\x34\n0WEB_SOCKET_SESSION_END_REASON_MIDDLEWARE_FAILURE\x10\x05\x12\x30\n,WEB_SOCKET_SESSION_END_REASON_PROTOCOL_ERROR\x10\x06\x12.\n*WEB_SOCKET_SESSION_END_REASON_CANCELLATION\x10\x07\x12\x33\n/WEB_SOCKET_SESSION_END_REASON_UPSTREAM_REJECTED\x10\x08\x12/\n+WEB_SOCKET_SESSION_END_REASON_POLICY_DENIAL\x10\t\x12/\n+WEB_SOCKET_SESSION_END_REASON_STAGE_SKIPPED\x10\n*\xbc\x01\n\x18WebSocketPreflightAction\x12+\n\'WEB_SOCKET_PREFLIGHT_ACTION_UNSPECIFIED\x10\x00\x12\'\n#WEB_SOCKET_PREFLIGHT_ACTION_INSPECT\x10\x01\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_SKIP\x10\x02\x12$\n WEB_SOCKET_PREFLIGHT_ACTION_DENY\x10\x03*K\n\x08\x44\x65\x63ision\x12\x18\n\x14\x44\x45\x43ISION_UNSPECIFIED\x10\x00\x12\x12\n\x0e\x44\x45\x43ISION_ALLOW\x10\x01\x12\x11\n\rDECISION_DENY\x10\x02*\xa8\x01\n\x14\x45xistingHeaderAction\x12&\n\"EXISTING_HEADER_ACTION_UNSPECIFIED\x10\x00\x12!\n\x1d\x45XISTING_HEADER_ACTION_APPEND\x10\x01\x12$\n EXISTING_HEADER_ACTION_OVERWRITE\x10\x02\x12\x1f\n\x1b\x45XISTING_HEADER_ACTION_SKIP\x10\x03\x32\xda\x04\n\x14SupervisorMiddleware\x12O\n\x08\x44\x65scribe\x12\x16.google.protobuf.Empty\x1a+.openshell.middleware.v1.MiddlewareManifest\x12q\n\x0eValidateConfig\x12..openshell.middleware.v1.ValidateConfigRequest\x1a/.openshell.middleware.v1.ValidateConfigResponse\x12q\n\x13\x45valuateHttpRequest\x12..openshell.middleware.v1.HttpRequestEvaluation\x1a*.openshell.middleware.v1.HttpRequestResult\x12\x83\x01\n\x19\x45valuateAgentConversation\x12\x34.openshell.middleware.v1.AgentConversationEvaluation\x1a\x30.openshell.middleware.v1.AgentConversationResult\x12\x84\x01\n\x18\x45valuateWebSocketSession\x12..openshell.middleware.v1.WebSocketSessionEvent\x1a\x34.openshell.middleware.v1.WebSocketSessionEventResult(\x01\x30\x01\x62\x06proto3') _globals = globals() _builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) @@ -41,18 +41,18 @@ _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_options = b'8\001' _globals['_HTTPREQUESTRESULT_METADATAENTRY']._loaded_options = None _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_options = b'8\001' - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=4828 - _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=5069 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=5072 - _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=5284 - _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=5287 - _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=5865 - _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=5868 - _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=6056 - _globals['_DECISION']._serialized_start=6058 - _globals['_DECISION']._serialized_end=6133 - _globals['_EXISTINGHEADERACTION']._serialized_start=6136 - _globals['_EXISTINGHEADERACTION']._serialized_end=6304 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_start=4855 + _globals['_SUPERVISORMIDDLEWAREOPERATION']._serialized_end=5096 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_start=5099 + _globals['_SUPERVISORMIDDLEWAREPHASE']._serialized_end=5311 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_start=5314 + _globals['_WEBSOCKETSESSIONENDREASON']._serialized_end=5892 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_start=5895 + _globals['_WEBSOCKETPREFLIGHTACTION']._serialized_end=6083 + _globals['_DECISION']._serialized_start=6085 + _globals['_DECISION']._serialized_end=6160 + _globals['_EXISTINGHEADERACTION']._serialized_start=6163 + _globals['_EXISTINGHEADERACTION']._serialized_end=6331 _globals['_MIDDLEWAREMANIFEST']._serialized_start=116 _globals['_MIDDLEWAREMANIFEST']._serialized_end=264 _globals['_MIDDLEWAREBINDING']._serialized_start=267 @@ -62,55 +62,55 @@ _globals['_VALIDATECONFIGRESPONSE']._serialized_start=620 _globals['_VALIDATECONFIGRESPONSE']._serialized_end=675 _globals['_HTTPREQUESTEVALUATION']._serialized_start=678 - _globals['_HTTPREQUESTEVALUATION']._serialized_end=1020 - _globals['_HTTPHEADER']._serialized_start=1022 - _globals['_HTTPHEADER']._serialized_end=1063 - _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1066 - _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1368 - _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1371 - _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1694 - _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1696 - _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1749 - _globals['_WEBSOCKETMESSAGE']._serialized_start=1751 - _globals['_WEBSOCKETMESSAGE']._serialized_end=1832 - _globals['_WEBSOCKETSESSIONEND']._serialized_start=1834 - _globals['_WEBSOCKETSESSIONEND']._serialized_end=1923 - _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1926 - _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2244 - _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2197 - _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2244 - _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2247 - _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2610 - _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2197 - _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2244 - _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2613 - _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2810 - _globals['_REQUESTCONTEXT']._serialized_start=2813 - _globals['_REQUESTCONTEXT']._serialized_end=2973 - _globals['_HTTPREQUESTTARGET']._serialized_start=2975 - _globals['_HTTPREQUESTTARGET']._serialized_end=3083 - _globals['_PROCESS']._serialized_start=3085 - _globals['_PROCESS']._serialized_end=3142 - _globals['_AGENTCONVERSATIONTARGET']._serialized_start=3145 - _globals['_AGENTCONVERSATIONTARGET']._serialized_end=3308 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=3311 - _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=3668 - _globals['_AGENTCONVERSATIONRESULT']._serialized_start=3671 - _globals['_AGENTCONVERSATIONRESULT']._serialized_end=4058 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2197 - _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2244 - _globals['_FINDING']._serialized_start=4060 - _globals['_FINDING']._serialized_end=4151 - _globals['_WRITEHEADER']._serialized_start=4153 - _globals['_WRITEHEADER']._serialized_end=4263 - _globals['_REMOVEHEADER']._serialized_start=4265 - _globals['_REMOVEHEADER']._serialized_end=4293 - _globals['_HEADERMUTATION']._serialized_start=4296 - _globals['_HEADERMUTATION']._serialized_end=4437 - _globals['_HTTPREQUESTRESULT']._serialized_start=4440 - _globals['_HTTPREQUESTRESULT']._serialized_end=4825 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2197 - _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2244 - _globals['_SUPERVISORMIDDLEWARE']._serialized_start=6307 - _globals['_SUPERVISORMIDDLEWARE']._serialized_end=6909 + _globals['_HTTPREQUESTEVALUATION']._serialized_end=1047 + _globals['_HTTPHEADER']._serialized_start=1049 + _globals['_HTTPHEADER']._serialized_end=1090 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_start=1093 + _globals['_WEBSOCKETSESSIONEVENT']._serialized_end=1395 + _globals['_WEBSOCKETPREFLIGHT']._serialized_start=1398 + _globals['_WEBSOCKETPREFLIGHT']._serialized_end=1721 + _globals['_WEBSOCKETSESSIONSTART']._serialized_start=1723 + _globals['_WEBSOCKETSESSIONSTART']._serialized_end=1776 + _globals['_WEBSOCKETMESSAGE']._serialized_start=1778 + _globals['_WEBSOCKETMESSAGE']._serialized_end=1859 + _globals['_WEBSOCKETSESSIONEND']._serialized_start=1861 + _globals['_WEBSOCKETSESSIONEND']._serialized_end=1950 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_start=1953 + _globals['_WEBSOCKETPREFLIGHTDECISION']._serialized_end=2271 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_start=2224 + _globals['_WEBSOCKETPREFLIGHTDECISION_METADATAENTRY']._serialized_end=2271 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_start=2274 + _globals['_WEBSOCKETMESSAGERESULT']._serialized_end=2637 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_start=2224 + _globals['_WEBSOCKETMESSAGERESULT_METADATAENTRY']._serialized_end=2271 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_start=2640 + _globals['_WEBSOCKETSESSIONEVENTRESULT']._serialized_end=2837 + _globals['_REQUESTCONTEXT']._serialized_start=2840 + _globals['_REQUESTCONTEXT']._serialized_end=3000 + _globals['_HTTPREQUESTTARGET']._serialized_start=3002 + _globals['_HTTPREQUESTTARGET']._serialized_end=3110 + _globals['_PROCESS']._serialized_start=3112 + _globals['_PROCESS']._serialized_end=3169 + _globals['_AGENTCONVERSATIONTARGET']._serialized_start=3172 + _globals['_AGENTCONVERSATIONTARGET']._serialized_end=3335 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_start=3338 + _globals['_AGENTCONVERSATIONEVALUATION']._serialized_end=3695 + _globals['_AGENTCONVERSATIONRESULT']._serialized_start=3698 + _globals['_AGENTCONVERSATIONRESULT']._serialized_end=4085 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_start=2224 + _globals['_AGENTCONVERSATIONRESULT_METADATAENTRY']._serialized_end=2271 + _globals['_FINDING']._serialized_start=4087 + _globals['_FINDING']._serialized_end=4178 + _globals['_WRITEHEADER']._serialized_start=4180 + _globals['_WRITEHEADER']._serialized_end=4290 + _globals['_REMOVEHEADER']._serialized_start=4292 + _globals['_REMOVEHEADER']._serialized_end=4320 + _globals['_HEADERMUTATION']._serialized_start=4323 + _globals['_HEADERMUTATION']._serialized_end=4464 + _globals['_HTTPREQUESTRESULT']._serialized_start=4467 + _globals['_HTTPREQUESTRESULT']._serialized_end=4852 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_start=2224 + _globals['_HTTPREQUESTRESULT_METADATAENTRY']._serialized_end=2271 + _globals['_SUPERVISORMIDDLEWARE']._serialized_start=6334 + _globals['_SUPERVISORMIDDLEWARE']._serialized_end=6936 # @@protoc_insertion_point(module_scope) diff --git a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi index aeea0f4f..9549a7f1 100644 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi +++ b/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi @@ -134,7 +134,7 @@ class ValidateConfigResponse(_message.Message): def __init__(self, valid: _Optional[bool] = ..., reason: _Optional[str] = ...) -> None: ... class HttpRequestEvaluation(_message.Message): - __slots__ = ("phase", "context", "config", "target", "headers", "body", "middleware_name") + __slots__ = ("phase", "context", "config", "target", "headers", "body", "middleware_name", "agent_attestation") PHASE_FIELD_NUMBER: _ClassVar[int] CONTEXT_FIELD_NUMBER: _ClassVar[int] CONFIG_FIELD_NUMBER: _ClassVar[int] @@ -142,6 +142,7 @@ class HttpRequestEvaluation(_message.Message): HEADERS_FIELD_NUMBER: _ClassVar[int] BODY_FIELD_NUMBER: _ClassVar[int] MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] + AGENT_ATTESTATION_FIELD_NUMBER: _ClassVar[int] phase: SupervisorMiddlewarePhase context: RequestContext config: _struct_pb2.Struct @@ -149,7 +150,8 @@ class HttpRequestEvaluation(_message.Message): headers: _containers.RepeatedCompositeFieldContainer[HttpHeader] body: bytes middleware_name: str - def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., body: _Optional[bytes] = ..., middleware_name: _Optional[str] = ...) -> None: ... + agent_attestation: bytes + def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., body: _Optional[bytes] = ..., middleware_name: _Optional[str] = ..., agent_attestation: _Optional[bytes] = ...) -> None: ... class HttpHeader(_message.Message): __slots__ = ("name", "value") diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 1194d37e..50d06952 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -167,13 +167,13 @@ def serve( ), ), ] = f"{DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING:g}s", - require_pi_receipt: Annotated[ + require_pi_attestation: Annotated[ bool, typer.Option( - "--require-pi-receipt/--no-require-pi-receipt", + "--require-pi-attestation/--no-require-pi-attestation", help=( - "Require and verify a matching Pi rendered-prompt receipt " - "on HTTP egress. Enabled by default; disable only for an " + "Require a supervisor-held Pi context attestation on HTTP " + "egress. Enabled by default; disable only for an " "explicitly unmanaged deployment." ), ), @@ -220,7 +220,7 @@ def serve( EgressGateServer( options.registry, timeout_middleware_processing=timeout_middleware_processing, - require_pi_receipt=require_pi_receipt, + require_pi_attestation=require_pi_attestation, ).serve_sync(listen) except EgressGateError as error: _render_egress_error("Egress Gate could not start", error) diff --git a/projects/egress-gate/src/egress_gate/constants.py b/projects/egress-gate/src/egress_gate/constants.py index a854ef9b..3dde4144 100644 --- a/projects/egress-gate/src/egress_gate/constants.py +++ b/projects/egress-gate/src/egress_gate/constants.py @@ -79,6 +79,7 @@ MAX_PROTO_TARGET_BYTES = 32 * 1024 MAX_PROTO_HEADERS = 128 MAX_PROTO_HEADERS_BYTES = 64 * 1024 +MAX_AGENT_ATTESTATION_BYTES = 8 * 1024 PROTOBUF_ENVELOPE_ALLOWANCE_BYTES = 1024 * 1024 MAX_RECEIVE_MESSAGE_BYTES = MAX_BODY_BYTES + PROTOBUF_ENVELOPE_ALLOWANCE_BYTES diff --git a/projects/egress-gate/src/egress_gate/service/server.py b/projects/egress-gate/src/egress_gate/service/server.py index dca0f249..924b16bd 100644 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ b/projects/egress-gate/src/egress_gate/service/server.py @@ -34,12 +34,12 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - require_pi_receipt: bool = False, + require_pi_attestation: bool = False, ) -> None: self._middleware = EgressGateMiddleware( registry, timeout_middleware_processing=timeout_middleware_processing, - require_pi_receipt=require_pi_receipt, + require_pi_attestation=require_pi_attestation, ) def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index a45fc9af..645ab4b4 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -24,11 +24,11 @@ RECEIPT_HEADER, AdmissionDecision, AdmissionHook, + AdmissionProvenance, AttestedEgressProcessor, HarnessAdmissionContext, HarnessAdmissionProcessor, HarnessAdmissionRequest, - PromptProvenance, ReceiptAuthority, create_pi_adapter_registry, create_provider_adapter_registry, @@ -41,6 +41,7 @@ DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, LIMIT_REASON, LIMIT_REASON_CODE, + MAX_AGENT_ATTESTATION_BYTES, MAX_BODY_BYTES, MAX_CONCURRENT_PROCESSING, MAX_PROTO_CONFIG_BYTES, @@ -97,13 +98,17 @@ def _require_pi_harness(value: str) -> Literal["pi"]: raise ValueError("invalid admission harness") -def _require_pi_schema(value: str) -> Literal["openshell.pi-input.v1"]: - if value == "openshell.pi-input.v1": +def _require_pi_schema( + value: str, hook: AdmissionHook +) -> Literal["openshell.pi-input.v1", "openshell.pi-tool-result.v1"]: + if hook is AdmissionHook.RENDERED_PROMPT and value == "openshell.pi-input.v1": + return value + if hook is AdmissionHook.TOOL_RESULT and value == "openshell.pi-tool-result.v1": return value raise ValueError("invalid admission schema") -def _require_pi_harness_version(value: str) -> Literal["extension-v1"]: +def _require_pi_harness_version(value: str) -> Literal["sdk-v1"]: if value == PI_HARNESS_VERSION: return value raise ValueError("invalid Pi harness version") @@ -117,7 +122,7 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - require_pi_receipt: bool = False, + require_pi_attestation: bool = False, ) -> None: registry.configuration_json_schema() self._registry = registry @@ -126,7 +131,7 @@ def __init__( ) self._policy = _ActivePolicy(registry) self._receipt_authority = ReceiptAuthority() - self._require_pi_receipt = require_pi_receipt + self._require_pi_attestation = require_pi_attestation self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING) self._processing_executor = ThreadPoolExecutor( max_workers=MAX_CONCURRENT_PROCESSING, @@ -170,10 +175,14 @@ async def Describe( max_payload_bytes=MAX_ADMISSION_BODY_BYTES, harness="pi", hook=hook.value, - schema_version="openshell.pi-input.v1", + schema_version=( + "openshell.pi-input.v1" + if hook is AdmissionHook.RENDERED_PROMPT + else "openshell.pi-tool-result.v1" + ), ) for hook in AdmissionHook - if self._require_pi_receipt + if self._require_pi_attestation ), ], ) @@ -221,7 +230,7 @@ def _evaluate_agent_admission( timeout: Timeout, ) -> pb2.AgentConversationResult: try: - if not self._require_pi_receipt: + if not self._require_pi_attestation: raise ValueError("agent admission is disabled") if request.phase != pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT: raise ValueError("invalid admission phase") @@ -236,8 +245,7 @@ def _evaluate_agent_admission( path=request.target.path, query="", ) - provenance = PromptProvenance( - kind="rendered_prompt", + provenance = AdmissionProvenance( session_id=request.session_id, submission_id=request.turn_id, ) @@ -262,7 +270,9 @@ def _evaluate_agent_admission( request.target.harness_version ), hook=hook, - schema_version=_require_pi_schema(request.target.schema_version), + schema_version=_require_pi_schema( + request.target.schema_version, hook + ), provider_target=target, provider_adapter_schema="openai.chat-completions.v1", ), @@ -275,7 +285,7 @@ def _evaluate_agent_admission( else pb2.DECISION_ALLOW ), reason_code=result.reason_code or "", - attestation=result.receipt or b"", + attestation=result.attestation or b"", replacement_body=result.replacement_body or b"", has_replacement_body=result.replacement_body is not None, ) @@ -399,14 +409,18 @@ def _prepare_and_process( values, timeout=timeout, ) - if self._require_pi_receipt: + if self._require_pi_attestation: return AttestedEgressProcessor( processor, create_provider_adapter_registry(), self._receipt_authority, middleware_name=request.middleware_name, harness_version=PI_HARNESS_VERSION, - ).process(domain_request, timeout=timeout) + ).process( + domain_request, + agent_attestation=request.agent_attestation, + timeout=timeout, + ) if any( header.name.lower() == RECEIPT_HEADER for header in domain_request.headers ): @@ -622,6 +636,7 @@ def _validate_evaluation_envelope(request: pb2.HttpRequestEvaluation) -> None: or request.target.ByteSize() > MAX_PROTO_TARGET_BYTES or len(request.headers) > MAX_PROTO_HEADERS or _encoded_headers_size(request.headers) > MAX_PROTO_HEADERS_BYTES + or len(request.agent_attestation) > MAX_AGENT_ATTESTATION_BYTES ): raise EgressGateError(ErrorCode.REQUEST_ENVELOPE_INVALID) diff --git a/projects/egress-gate/tests/admission/test_admission.py b/projects/egress-gate/tests/admission/test_admission.py index f36f0f04..b8bc5da4 100644 --- a/projects/egress-gate/tests/admission/test_admission.py +++ b/projects/egress-gate/tests/admission/test_admission.py @@ -1,23 +1,30 @@ # SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. # SPDX-License-Identifier: Apache-2.0 -"""Conformance tests for rendered-prompt admission and attested egress.""" +"""Conformance tests for managed Pi context admission and attested egress.""" from __future__ import annotations import json +from typing import Literal + +import pytest from egress_gate.admission import ( + MAX_ADMISSION_BODY_BYTES, RECEIPT_HEADER, AdmissionDecision, AdmissionHook, + AdmissionProvenance, AttestedEgressProcessor, HarnessAdmissionContext, HarnessAdmissionProcessor, HarnessAdmissionRequest, PiInputV1, - PromptProvenance, + PiTextContentV1, + PiToolResultV1, ReceiptAuthority, + ReceiptVerificationError, canonical_json_bytes, create_pi_adapter_registry, create_provider_adapter_registry, @@ -32,7 +39,7 @@ def _processors( *, replacement_template: str = "[REDACTED]" -) -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor]: +) -> tuple[HarnessAdmissionProcessor, AttestedEgressProcessor, ReceiptAuthority]: registry = create_builtin_registry() config = registry.validate_config( { @@ -98,15 +105,16 @@ def _processors( create_provider_adapter_registry(), authority, middleware_name="pi-egress", - harness_version="extension-v1", + harness_version="sdk-v1", ), + authority, ) -def _target() -> HttpTarget: +def _target(*, host: str = "provider.test") -> HttpTarget: return HttpTarget( scheme="https", - host="provider.test", + host=host, port=443, method="POST", path="/v1/chat/completions", @@ -114,58 +122,112 @@ def _target() -> HttpTarget: ) -def _admit(processor: HarnessAdmissionProcessor, text: str): - body = canonical_json_bytes( - PiInputV1(schema_version="openshell.pi-input.v1", text=text) +def _context( + hook: AdmissionHook, + *, + harness_version: Literal["extension-v1", "sdk-v1"] = "sdk-v1", + target: HttpTarget | None = None, +) -> HarnessAdmissionContext: + schema = ( + "openshell.pi-input.v1" + if hook is AdmissionHook.RENDERED_PROMPT + else "openshell.pi-tool-result.v1" + ) + return HarnessAdmissionContext( + request_id="admission-1", + sandbox_id="sandbox-1", + middleware_name="pi-egress", + harness="pi", + harness_version=harness_version, + hook=hook, + schema_version=schema, + provider_target=target or _target(), + provider_adapter_schema="openai.chat-completions.v1", ) - return body, _admit_body(processor, body) -def _admit_body( +def _admit( processor: HarnessAdmissionProcessor, - body: bytes, + value: PiInputV1 | PiToolResultV1, *, + target: HttpTarget | None = None, timeout: Timeout | None = None, - provider_target: HttpTarget | None = None, ): - result = processor.process( + hook = ( + AdmissionHook.RENDERED_PROMPT + if isinstance(value, PiInputV1) + else AdmissionHook.TOOL_RESULT + ) + return processor.process( HarnessAdmissionRequest( - request_body=body, - provenance=PromptProvenance( - kind="rendered_prompt", - session_id="session-1", - submission_id="submission-1", + request_body=canonical_json_bytes(value), + provenance=AdmissionProvenance( + session_id="session-1", submission_id="submission-1" ), ), - HarnessAdmissionContext( - request_id="admission-1", - sandbox_id="sandbox-1", - middleware_name="pi-egress", - harness="pi", - harness_version="extension-v1", - hook=AdmissionHook.RENDERED_PROMPT, - schema_version="openshell.pi-input.v1", - provider_target=provider_target or _target(), - provider_adapter_schema="openai.chat-completions.v1", - ), + _context(hook, target=target), timeout=timeout or Timeout.from_seconds(1), ) - return result + + +def _user(text: str) -> PiInputV1: + return PiInputV1(schema_version="openshell.pi-input.v1", text=text) + + +def _tool_result(text: str, *, image: bool = False) -> PiToolResultV1: + content: list[dict[str, object]] = ( + [{"type": "image", "data": "AA==", "mimeType": "image/png"}] + if image + else [{"type": "text", "text": text}] + ) + return PiToolResultV1.model_validate( + { + "schema_version": "openshell.pi-tool-result.v1", + "tool_call_id": "call-1", + "tool_name": "read", + "content": content, + "is_error": False, + }, + strict=True, + ) def _provider_request( prompt: str, - receipt: bytes | None, *, + tool_result: str | None = None, + headers: tuple[HttpHeader, ...] = (), target: HttpTarget | None = None, ) -> HttpRequest: + messages: list[dict[str, object]] = [ + {"role": "system", "content": "fixture system prompt"}, + {"role": "user", "content": prompt}, + ] + if tool_result is not None: + messages.extend( + [ + { + "role": "assistant", + "content": None, + "tool_calls": [ + { + "id": "call-1", + "type": "function", + "function": {"name": "read", "arguments": "{}"}, + } + ], + }, + { + "role": "tool", + "content": tool_result, + "tool_call_id": "call-1", + }, + ] + ) body = json.dumps( { "model": "fixture-model", - "messages": [ - {"role": "system", "content": "fixture system prompt"}, - {"role": "user", "content": prompt}, - ], + "messages": messages, "tools": [], "tool_choice": "auto", "temperature": 0, @@ -179,45 +241,59 @@ def _provider_request( separators=(",", ":"), sort_keys=True, ).encode() - headers = [HttpHeader(name="content-type", value="application/json")] - if receipt is not None: - headers.append(HttpHeader(name=RECEIPT_HEADER, value=receipt.decode("ascii"))) return HttpRequest( context=RequestContext(request_id="network-1", sandbox_id="sandbox-1"), target=target or _target(), - headers=tuple(headers), + headers=(HttpHeader(name="content-type", value="application/json"),) + headers, body=body, ) -def test_safe_rendered_prompt_receipt_authorizes_first_request_and_is_stripped() -> ( - None +def _egress( + processor: AttestedEgressProcessor, + request: HttpRequest, + attestation: bytes | None, ): - admission, egress = _processors() - _, admitted = _admit(admission, "safe rendered prompt") - - assert admitted.decision is AdmissionDecision.ALLOW - assert admitted.receipt is not None - result = egress.process( - _provider_request("safe rendered prompt", admitted.receipt), + return processor.process( + request, + agent_attestation=attestation or b"", timeout=Timeout.from_seconds(1), ) - assert result.decision.value == "allow" - assert [ - mutation.name for mutation in result.request_mutations.header_mutations - ] == [RECEIPT_HEADER] +def test_user_attestation_authorizes_retries_without_entering_request_headers() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe rendered prompt")) -def test_receipt_uses_stable_destination_across_tls_proxy_normalization() -> None: - admission, egress = _processors() - body = canonical_json_bytes( - PiInputV1(schema_version="openshell.pi-input.v1", text="safe rendered prompt") - ) - admitted = _admit_body( + assert admitted.decision is AdmissionDecision.ALLOW + assert admitted.attestation is not None + request = _provider_request("safe rendered prompt") + first = _egress(egress, request, admitted.attestation) + retry = _egress(egress, request, admitted.attestation) + + assert first.decision.value == "allow" + assert retry.decision.value == "allow" + assert first.request_mutations.header_mutations == () + + +def test_changed_or_unattested_user_context_fails_closed() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe rendered prompt")) + assert admitted.attestation is not None + + changed = _egress(egress, _provider_request("changed prompt"), admitted.attestation) + missing = _egress(egress, _provider_request("safe rendered prompt"), None) + + assert changed.reason_code == "attestation_context_mismatch" + assert missing.reason_code == "attestation_missing" + + +def test_attestation_uses_stable_destination_across_tls_proxy_normalization() -> None: + admission, egress, _ = _processors() + admitted = _admit( admission, - body, - provider_target=HttpTarget( + _user("safe"), + target=HttpTarget( scheme="https", host="provider.test", port=443, @@ -226,9 +302,8 @@ def test_receipt_uses_stable_destination_across_tls_proxy_normalization() -> Non query="", ), ) - assert admitted.receipt is not None - - normalized_target = HttpTarget( + assert admitted.attestation is not None + normalized = HttpTarget( scheme="http", host="provider.test", port=443, @@ -236,150 +311,152 @@ def test_receipt_uses_stable_destination_across_tls_proxy_normalization() -> Non path="/v1/chat/completions", query="", ) - wrong_host = egress.process( - _provider_request( - "safe rendered prompt", - admitted.receipt, - target=normalized_target.model_copy(update={"host": "other.test"}), - ), - timeout=Timeout.from_seconds(1), + + allowed = _egress( + egress, + _provider_request("safe", target=normalized), + admitted.attestation, ) - result = egress.process( + wrong_host = _egress( + egress, _provider_request( - "safe rendered prompt", admitted.receipt, target=normalized_target + "safe", target=normalized.model_copy(update={"host": "other.test"}) ), - timeout=Timeout.from_seconds(1), + admitted.attestation, ) - assert wrong_host.reason_code == "receipt_context_mismatch" - assert result.decision.value == "allow" - - -def test_rendered_prompt_receipt_is_consumed_after_first_request() -> None: - admission, egress = _processors() - _, admitted = _admit(admission, "safe rendered prompt") - assert admitted.receipt is not None - request = _provider_request("safe rendered prompt", admitted.receipt) - - first = egress.process(request, timeout=Timeout.from_seconds(1)) - replay = egress.process(request, timeout=Timeout.from_seconds(1)) - - assert first.decision.value == "allow" - assert replay.decision.value == "deny" - assert replay.reason_code == "receipt_replayed" - - -def test_denial_returns_no_receipt_or_replacement() -> None: - admission, _ = _processors() - _, denied = _admit(admission, f"do not persist {DENY_TEXT}") - - assert denied.decision is AdmissionDecision.DENY - assert denied.receipt is None - assert denied.replacement_body is None + assert allowed.decision.value == "allow" + assert wrong_host.reason_code == "attestation_context_mismatch" -def test_redaction_receipt_binds_only_the_replacement() -> None: - admission, egress = _processors() - original = f"hide {REDACT_TEXT} please" - _, admitted = _admit(admission, original) +def test_user_redaction_attests_only_the_replacement() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user(f"hide {REDACT_TEXT} please")) assert admitted.decision is AdmissionDecision.REPLACE - assert admitted.receipt is not None + assert admitted.attestation is not None assert admitted.replacement_body is not None replacement = PiInputV1.model_validate_json( admitted.replacement_body, strict=True ).text + assert replacement == "hide [REDACTED] please" assert ( - egress.process( - _provider_request(original, admitted.receipt), - timeout=Timeout.from_seconds(1), - ).reason_code - == "receipt_context_mismatch" - ) - assert ( - egress.process( - _provider_request(replacement, admitted.receipt), - timeout=Timeout.from_seconds(1), + _egress( + egress, _provider_request(replacement), admitted.attestation ).decision.value == "allow" ) + assert ( + _egress( + egress, + _provider_request(f"hide {REDACT_TEXT} please"), + admitted.attestation, + ).reason_code + == "attestation_context_mismatch" + ) + +def test_tool_result_is_admitted_before_persistence_and_attested_at_egress() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _tool_result("safe tool output")) -def test_oversized_redaction_fails_before_receipt_issuance() -> None: - admission, _ = _processors(replacement_template="x" * 1024) + assert admitted.decision is AdmissionDecision.ALLOW + assert admitted.attestation is not None + matching = _egress( + egress, + _provider_request("inspect", tool_result="safe tool output"), + admitted.attestation, + ) + changed = _egress( + egress, + _provider_request("inspect", tool_result="changed tool output"), + admitted.attestation, + ) - _, denied = _admit(admission, REDACT_TEXT * 33) + assert matching.decision.value == "allow" + assert changed.reason_code == "attestation_context_mismatch" - assert denied.decision is AdmissionDecision.DENY - assert denied.reason_code == "admission_contract_invalid" - assert denied.receipt is None +def test_tool_result_denial_redaction_and_images_fail_closed() -> None: + admission, _, _ = _processors() -def test_changed_prompt_and_unattested_continuation_fail_closed() -> None: - admission, egress = _processors() - _, admitted = _admit(admission, "safe rendered prompt") - assert admitted.receipt is not None + denied = _admit(admission, _tool_result(DENY_TEXT)) + redacted = _admit(admission, _tool_result(REDACT_TEXT)) + image = _admit(admission, _tool_result("", image=True)) - changed = egress.process( - _provider_request("changed prompt", admitted.receipt), - timeout=Timeout.from_seconds(1), - ) - continuation = egress.process( - _provider_request("safe rendered prompt", None), - timeout=Timeout.from_seconds(1), + assert denied.decision is AdmissionDecision.DENY + assert denied.attestation is None + assert redacted.decision is AdmissionDecision.REPLACE + assert redacted.replacement_body is not None + redacted_tool_result = PiToolResultV1.model_validate_json( + redacted.replacement_body, strict=True ) - - assert changed.reason_code == "receipt_context_mismatch" - assert continuation.reason_code == "receipt_missing" + assert isinstance(redacted_tool_result.content[0], PiTextContentV1) + assert redacted_tool_result.content[0].text == "[REDACTED]" + assert image.decision is AdmissionDecision.DENY + assert image.reason_code == "admission_contract_invalid" -def test_malformed_and_duplicate_admission_json_are_contract_errors() -> None: - admission, _ = _processors() +def test_denial_returns_no_attestation_or_replacement() -> None: + admission, _, _ = _processors() - malformed = _admit_body(admission, b"{") - duplicate = _admit_body( - admission, - b'{"schema_version":"openshell.pi-input.v1",' - b'"schema_version":"openshell.pi-input.v1","text":"safe"}', - ) + denied = _admit(admission, _user(f"do not persist {DENY_TEXT}")) - assert malformed.reason_code == "admission_contract_invalid" - assert duplicate.reason_code == "admission_contract_invalid" + assert denied.decision is AdmissionDecision.DENY + assert denied.attestation is None + assert denied.replacement_body is None -def test_admission_json_limits_and_deadlines_remain_availability_errors() -> None: - admission, _ = _processors() - over_depth = b"[" * 129 + b"0" + b"]" * 129 +def test_oversized_redaction_attempt_fails_before_attestation_issuance() -> None: + admission, _, _ = _processors(replacement_template="x" * 1024) - limited = _admit_body(admission, over_depth) - expired = _admit_body(admission, b"{}", timeout=Timeout(deadline=0.0)) + denied = _admit( + admission, + _user(REDACT_TEXT * (MAX_ADMISSION_BODY_BYTES // 1024 + 1)), + ) - assert limited.reason_code == "admission_unavailable" - assert expired.reason_code == "admission_unavailable" + assert denied.decision is AdmissionDecision.DENY + assert denied.reason_code == "egress_gate_limit_exceeded" + assert denied.attestation is None -def test_provider_malformed_json_is_an_unsupported_shape() -> None: - admission, egress = _processors() - _, admitted = _admit(admission, "safe rendered prompt") - assert admitted.receipt is not None - malformed = _provider_request("safe rendered prompt", admitted.receipt).model_copy( - update={"body": b"{"} +def test_malformed_duplicate_and_expired_admission_fail_closed() -> None: + admission, _, _ = _processors() + provenance = AdmissionProvenance( + session_id="session-1", submission_id="submission-1" ) - result = egress.process(malformed, timeout=Timeout.from_seconds(1)) + def admit_body(body: bytes, timeout: Timeout | None = None): + return admission.process( + HarnessAdmissionRequest(request_body=body, provenance=provenance), + _context(AdmissionHook.RENDERED_PROMPT), + timeout=timeout or Timeout.from_seconds(1), + ) - assert result.reason_code == "provider_shape_unsupported" + malformed = admit_body(b"{") + duplicate = admit_body( + b'{"schema_version":"openshell.pi-input.v1",' + b'"schema_version":"openshell.pi-input.v1","text":"safe"}' + ) + over_depth = admit_body(b"[" * 129 + b"0" + b"]" * 129) + expired = admit_body(b"{}", Timeout(deadline=0.0)) + assert malformed.reason_code == "admission_contract_invalid" + assert duplicate.reason_code == "admission_contract_invalid" + assert over_depth.reason_code == "admission_unavailable" + assert expired.reason_code == "admission_unavailable" -def test_direct_openai_reasoning_effort_is_supported() -> None: - admission, egress = _processors() - _, admitted = _admit(admission, "safe rendered prompt") - assert admitted.receipt is not None - request = _provider_request("safe rendered prompt", admitted.receipt) + +def test_provider_shape_validation_and_optional_reasoning_field_are_preserved() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe")) + assert admitted.attestation is not None + request = _provider_request("safe") + malformed = request.model_copy(update={"body": b"{"}) provider_body = json.loads(request.body) provider_body["reasoning_effort"] = "medium" - request = request.model_copy( + with_reasoning = request.model_copy( update={ "body": json.dumps( provider_body, @@ -390,6 +467,38 @@ def test_direct_openai_reasoning_effort_is_supported() -> None: } ) - result = egress.process(request, timeout=Timeout.from_seconds(1)) + malformed_result = _egress(egress, malformed, admitted.attestation) + reasoning_result = _egress(egress, with_reasoning, admitted.attestation) + + assert malformed_result.reason_code == "provider_shape_unsupported" + assert reasoning_result.decision.value == "allow" + + +def test_workload_receipt_header_is_reserved_in_managed_flow() -> None: + admission, egress, _ = _processors() + admitted = _admit(admission, _user("safe")) + assert admitted.attestation is not None + request = _provider_request( + "safe", + headers=(HttpHeader(name=RECEIPT_HEADER, value="eg1.untrusted"),), + ) + + result = _egress(egress, request, admitted.attestation) + + assert result.reason_code == "reserved_receipt_header" + + +def test_legacy_workload_receipts_remain_one_use() -> None: + _, _, authority = _processors() + context = _context(AdmissionHook.RENDERED_PROMPT, harness_version="extension-v1") + provenance = AdmissionProvenance( + session_id="session-1", submission_id="submission-1" + ) + prompt = _user("safe") + receipt = authority.issue( + prompt, context, provenance, policy_fingerprint="policy", now=100 + ) - assert result.decision.value == "allow" + authority.verify(receipt, prompt, context, policy_fingerprint="policy", now=100) + with pytest.raises(ReceiptVerificationError, match="receipt_replayed"): + authority.verify(receipt, prompt, context, policy_fingerprint="policy", now=100) diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index 95714ef4..654f1888 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -159,7 +159,7 @@ async def test_generated_stub_round_trip_covers_manifest_and_gate_actions() -> N @pytest.mark.asyncio -async def test_generated_stub_issues_a_rendered_prompt_receipt() -> None: +async def test_generated_stub_issues_a_rendered_prompt_attestation() -> None: body = canonical_json_bytes( PiInputV1(schema_version="openshell.pi-input.v1", text="safe") ) @@ -169,7 +169,7 @@ async def test_generated_stub_issues_a_rendered_prompt_receipt() -> None: config=_config(action_kind="detect"), target=pb2.AgentConversationTarget( harness="pi", - harness_version="extension-v1", + harness_version="sdk-v1", hook="rendered_prompt_admission", schema_version="openshell.pi-input.v1", scheme="https", @@ -183,19 +183,19 @@ async def test_generated_stub_issues_a_rendered_prompt_receipt() -> None: request_body=body, ) middleware = EgressGateMiddleware( - create_builtin_registry(), require_pi_receipt=True + create_builtin_registry(), require_pi_attestation=True ) async with _running_stub(middleware) as (stub, _): response = await stub.EvaluateAgentConversation(request) assert response.decision == pb2.DECISION_ALLOW - assert response.attestation.startswith(b"eg1.") + assert response.attestation.startswith(b"ag1.") assert response.has_replacement_body is False assert response.metadata["admission_schema"] == "openshell.pi-input.v1" @pytest.mark.asyncio -async def test_agent_admission_is_unavailable_when_receipt_enforcement_is_off() -> None: +async def test_agent_admission_is_unavailable_when_managed_mode_is_off() -> None: request = pb2.AgentConversationEvaluation( phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_AGENT_CONTEXT ) @@ -208,7 +208,7 @@ async def test_agent_admission_is_unavailable_when_receipt_enforcement_is_off() @pytest.mark.asyncio -async def test_admission_receipt_is_verified_and_stripped_for_http_egress() -> None: +async def test_trusted_agent_attestation_is_verified_for_http_egress() -> None: pi_body = canonical_json_bytes( PiInputV1(schema_version="openshell.pi-input.v1", text="safe") ) @@ -218,7 +218,7 @@ async def test_admission_receipt_is_verified_and_stripped_for_http_egress() -> N config=_config(action_kind="detect"), target=pb2.AgentConversationTarget( harness="pi", - harness_version="extension-v1", + harness_version="sdk-v1", hook="rendered_prompt_admission", schema_version="openshell.pi-input.v1", scheme="https", @@ -249,7 +249,7 @@ async def test_admission_receipt_is_verified_and_stripped_for_http_egress() -> N separators=(",", ":"), ).encode() middleware = EgressGateMiddleware( - create_builtin_registry(), require_pi_receipt=True + create_builtin_registry(), require_pi_attestation=True ) async with _running_stub(middleware) as (stub, _): admitted = await stub.EvaluateAgentConversation(admission) @@ -258,15 +258,10 @@ async def test_admission_receipt_is_verified_and_stripped_for_http_egress() -> N network.target.host = "provider.invalid" network.target.path = "/v1/chat/completions" network.middleware_name = "pi-egress" - network.headers.extend( - [ - pb2.HttpHeader(name="content-type", value="application/json"), - pb2.HttpHeader( - name="x-openshell-middleware-egress-receipt", - value=admitted.attestation.decode("ascii"), - ), - ] + network.headers.append( + pb2.HttpHeader(name="content-type", value="application/json") ) + network.agent_attestation = admitted.attestation allowed = await stub.EvaluateHttpRequest(network) missing = _evaluation(provider_body, action_kind="detect") missing.target.host = "provider.invalid" @@ -278,12 +273,9 @@ async def test_admission_receipt_is_verified_and_stripped_for_http_egress() -> N denied = await stub.EvaluateHttpRequest(missing) assert allowed.decision == pb2.DECISION_ALLOW - assert ( - allowed.header_mutations[0].remove.name - == "x-openshell-middleware-egress-receipt" - ) + assert not allowed.header_mutations assert denied.decision == pb2.DECISION_DENY - assert denied.reason_code == "receipt_missing" + assert denied.reason_code == "attestation_missing" @pytest.mark.asyncio diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 22cfe86d..54b9ec85 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -24,6 +24,7 @@ DEFAULT_DENY_REASON_CODE, LIMIT_REASON, LIMIT_REASON_CODE, + MAX_AGENT_ATTESTATION_BYTES, MAX_BODY_BYTES, MAX_PROTO_CONFIG_BYTES, MAX_PROTO_CONTEXT_BYTES, @@ -130,6 +131,29 @@ def test_manifest_leaves_the_gateway_rpc_timeout_to_the_operator() -> None: assert manifest.bindings[0].timeout == "" +def test_managed_manifest_advertises_exact_user_and_tool_result_bindings() -> None: + middleware = EgressGateMiddleware( + create_builtin_registry(), require_pi_attestation=True + ) + try: + manifest = asyncio.run(middleware.Describe(object(), Mock())) + finally: + asyncio.run(middleware.close()) + + agent_bindings = [ + binding + for binding in manifest.bindings + if binding.operation == pb2.SUPERVISOR_MIDDLEWARE_OPERATION_AGENT_CONVERSATION + ] + assert [ + (binding.harness, binding.hook, binding.schema_version) + for binding in agent_bindings + ] == [ + ("pi", "rendered_prompt_admission", "openshell.pi-input.v1"), + ("pi", "tool_result_admission", "openshell.pi-tool-result.v1"), + ] + + def test_copied_proto_remains_the_current_five_field_finding_contract() -> None: evaluation = pb2.HttpRequestEvaluation() finding = pb2.Finding() @@ -220,6 +244,13 @@ def test_evaluation_enforces_exact_encoded_transport_boundaries() -> None: with pytest.raises(EgressGateError): servicer_module._validate_evaluation_envelope(request) + request = _request(body=b"") + request.agent_attestation = b"x" * MAX_AGENT_ATTESTATION_BYTES + servicer_module._validate_evaluation_envelope(request) + request.agent_attestation += b"x" + with pytest.raises(EgressGateError): + servicer_module._validate_evaluation_envelope(request) + def test_request_adapter_builds_the_full_domain_request() -> None: domain = servicer_module._request_from_proto(_request(b"bytes")) diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 9be29c16..4ac136e0 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -74,9 +74,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, - require_pi_receipt: bool = False, + require_pi_attestation: bool = False, ) -> None: - del registry, require_pi_receipt + del registry, require_pi_attestation self.timeout_middleware_processing = timeout_middleware_processing def serve_sync(self, listen: str) -> None: @@ -547,9 +547,9 @@ def __init__( registry: GateRegistry, *, timeout_middleware_processing: float, - require_pi_receipt: bool = False, + require_pi_attestation: bool = False, ) -> None: - del registry, timeout_middleware_processing, require_pi_receipt + del registry, timeout_middleware_processing, require_pi_attestation def serve_sync(self, listen: str) -> None: calls.append(listen) diff --git a/projects/egress-gate/tests/test_pi_admission_extension.py b/projects/egress-gate/tests/test_managed_pi_admission.py similarity index 68% rename from projects/egress-gate/tests/test_pi_admission_extension.py rename to projects/egress-gate/tests/test_managed_pi_admission.py index d2bd43f9..bf81fd9f 100644 --- a/projects/egress-gate/tests/test_pi_admission_extension.py +++ b/projects/egress-gate/tests/test_managed_pi_admission.py @@ -7,11 +7,10 @@ from pathlib import Path -def test_pi_admission_extension_renews_receipts_for_provider_continuations() -> None: +def test_managed_pi_admission_maps_handles_to_exact_provider_context() -> None: project_dir = Path(__file__).parents[1] test_file = ( - project_dir - / "examples/pi-attested-admission/openshell-input-admission.test.mjs" + project_dir / "examples/pi-attested-admission/managed-pi-admission.test.mjs" ) subprocess.run( diff --git a/projects/egress-gate/tests/test_pi_example_commands.py b/projects/egress-gate/tests/test_pi_example_commands.py index 68edc5f4..08f0e611 100644 --- a/projects/egress-gate/tests/test_pi_example_commands.py +++ b/projects/egress-gate/tests/test_pi_example_commands.py @@ -39,7 +39,7 @@ def test_pi_example_can_print_each_action_without_running_it( env=environment, text=True, ) - for action in ("prepare", "serve", "gateway", "launch", "verify", "cleanup") + for action in ("prepare", "serve", "gateway", "launch", "cleanup") ] output = "\n".join(result.stdout for result in results) @@ -81,9 +81,11 @@ def test_pi_example_can_print_each_action_without_running_it( assert "sandbox exec --tty" in output assert "PI_OFFLINE=1" in output assert "OPENSHELL_AGENT_CONVERSATION_URL=" in output - assert "REDACTED" in output - assert "DENY_THIS" in output - assert "REDACT_THIS" in output + assert "managed-pi.ts:/sandbox/pi-runtime/managed-pi.ts" in output + assert ( + "managed-pi-admission.ts:/sandbox/pi-runtime/managed-pi-admission.ts" in output + ) + assert "--extension" not in output assert "sandbox delete" in output assert all(result.stderr == "" for result in results) assert not pi_repo.exists() @@ -114,7 +116,7 @@ def test_pi_example_print_all_is_a_concise_walkthrough() -> None: assert "Configuration visible to this shell" in result.stdout assert "Model credential: set (value hidden)" in result.stdout assert "1. prepare" in result.stdout - assert "7. cleanup" in result.stdout + assert "6. cleanup" in result.stdout assert "secret-not-printed" not in result.stdout assert "working directory:" not in result.stdout