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..1c581a64 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/README.md @@ -0,0 +1,61 @@ +# Pi deny-or-redact example + +This example demonstrates two outcomes for a rendered Pi prompt: + +- **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. + +Run the credential-free demonstration from `projects/egress-gate/`: + +```shell +uv run python examples/pi-attested-admission/run_example.py +``` + +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]"] + } +} +``` + +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. + +## Managed Pi setup + +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) + +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 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. + +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 new file mode 100644 index 00000000..62d2a160 --- /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: 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/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..58fb373e --- /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_append", 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"; +} 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..91b75d89 --- /dev/null +++ b/projects/egress-gate/examples/pi-attested-admission/run_example.py @@ -0,0 +1,189 @@ +"""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/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 e919052f..3a0d0763 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -164,6 +164,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) @@ -206,6 +217,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 9fe191fe..9ddeea4f 100644 --- a/projects/egress-gate/src/egress_gate/request.py +++ b/projects/egress-gate/src/egress_gate/request.py @@ -23,6 +23,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.""" @@ -37,6 +53,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: @@ -49,8 +67,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 @@ -175,10 +208,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 c900fa95..a56501df 100644 --- a/projects/egress-gate/src/egress_gate/request_processor.py +++ b/projects/egress-gate/src/egress_gate/request_processor.py @@ -89,6 +89,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 6da9146c..dbde5928 100644 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ b/projects/egress-gate/src/egress_gate/service/server.py @@ -31,10 +31,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 78fdc3d9..ebe238a2 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -9,12 +9,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 @@ -62,6 +76,7 @@ DecisionSourceKind, EgressDecision, EgressResult, + GateDecisionSource, SourcedFinding, ) from egress_gate.string_validators import validate_bounded_metadata_string @@ -72,6 +87,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.""" @@ -80,6 +116,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 @@ -87,6 +124,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, @@ -122,7 +161,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 + ), ], ) @@ -148,6 +199,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, @@ -252,6 +398,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/admission/test_example.py b/projects/egress-gate/tests/admission/test_example.py new file mode 100644 index 00000000..cd35b9eb --- /dev/null +++ b/projects/egress-gate/tests/admission/test_example.py @@ -0,0 +1,31 @@ +"""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/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index 133de57d..81809168 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json from collections.abc import AsyncIterator from contextlib import asynccontextmanager @@ -10,6 +11,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 @@ -150,6 +155,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 aaeafad3..b7ca5ac4 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -71,8 +71,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: @@ -532,8 +533,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"