From 499f8e36c6b821fc49d8152fffedecca129097df Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 17 Sep 2026 18:39:35 +0000 Subject: [PATCH] chore: move egress gate to internal research --- .github/workflows/egress-gate.yml | 51 - .gitignore | 1 - docs/documentation/index.md | 2 - projects/README.md | 2 - .../.openshell-middleware-manifest.json | 13 - projects/egress-gate/AGENTS.md | 125 -- projects/egress-gate/LICENSE | 203 --- projects/egress-gate/Makefile | 63 - projects/egress-gate/README.md | 151 -- projects/egress-gate/analysis/README.md | 73 - .../analysis/egress-gate-latency.csv | 97 -- .../analysis/qa-reports/2026-08-05.html | 490 ------ .../analysis/render_latency_plot.py | 401 ----- .../egress-gate/docs/architecture/index.md | 74 - .../docs/architecture/request-lifecycle.md | 79 - .../docs/architecture/service-boundary.md | 82 - .../egress-gate-latency-vs-prompt-size.svg | 162 -- .../diagrams/component-architecture.svg | 82 - .../assets/diagrams/processing-pipeline.svg | 96 -- .../assets/diagrams/request-lifecycle.svg | 112 -- .../docs/assets/diagrams/request-path.svg | 88 -- projects/egress-gate/docs/configuration.md | 98 -- projects/egress-gate/docs/evaluation.md | 152 -- projects/egress-gate/docs/gates/custom.md | 171 --- projects/egress-gate/docs/gates/index.md | 25 - projects/egress-gate/docs/gates/regex.md | 199 --- projects/egress-gate/docs/index.md | 110 -- projects/egress-gate/docs/operations.md | 179 --- .../docs/reference/limits-and-failures.md | 65 - projects/egress-gate/docs/request-content.md | 187 --- .../examples/class-based-gate/README.md | 118 -- .../examples/class-based-gate/cases.yaml | 53 - .../class-based-gate/egress-gate-config.yaml | 5 - .../examples/class-based-gate/keyword_gate.py | 40 - .../examples/class-based-gate/policy.yaml | 47 - .../examples/custom-gate/README.md | 127 -- .../examples/custom-gate/cases.yaml | 53 - .../custom-gate/egress-gate-config.yaml | 5 - .../examples/custom-gate/keyword_gate.py | 42 - .../examples/custom-gate/policy.yaml | 47 - .../examples/regex-redaction/.gitignore | 1 - .../examples/regex-redaction/README.md | 103 -- .../examples/regex-redaction/cases.yaml | 50 - .../regex-redaction/egress-gate-config.yaml | 21 - .../examples/regex-redaction/patterns.yaml | 11 - .../examples/regex-redaction/policy.yaml | 52 - .../proto/supervisor_middleware.proto | 242 --- projects/egress-gate/pyproject.toml | 76 - projects/egress-gate/scripts/check.sh | 25 - .../egress-gate/src/egress_gate/__init__.py | 4 - projects/egress-gate/src/egress_gate/base.py | 21 - .../src/egress_gate/bindings/__init__.py | 1 - .../bindings/supervisor_middleware_pb2.py | 78 - .../bindings/supervisor_middleware_pb2.pyi | 209 --- .../supervisor_middleware_pb2_grpc.py | 194 --- projects/egress-gate/src/egress_gate/cli.py | 1321 ----------------- .../egress-gate/src/egress_gate/config.py | 71 - .../egress-gate/src/egress_gate/constants.py | 86 -- .../egress-gate/src/egress_gate/errors.py | 233 --- .../src/egress_gate/gates/__init__.py | 70 - .../egress-gate/src/egress_gate/gates/base.py | 375 ----- .../src/egress_gate/gates/regex.py | 793 ---------- .../src/egress_gate/gates/regex_scans.py | 203 --- .../src/egress_gate/gates/registry.py | 520 ------- .../src/egress_gate/gateway_config.py | 632 -------- .../egress-gate/src/egress_gate/logging.py | 135 -- .../egress-gate/src/egress_gate/request.py | 193 --- .../egress_gate/request_content/__init__.py | 59 - .../request_content/_json_parser.py | 230 --- .../src/egress_gate/request_content/json.py | 340 ----- .../request_content/json_values.py | 40 - .../egress_gate/request_content/messages.py | 222 --- .../egress_gate/request_content/parsers.py | 186 --- .../src/egress_gate/request_content/text.py | 25 - .../src/egress_gate/request_processor.py | 408 ----- .../egress-gate/src/egress_gate/result.py | 312 ---- .../src/egress_gate/service/__init__.py | 9 - .../src/egress_gate/service/server.py | 191 --- .../src/egress_gate/service/servicer.py | 587 -------- .../src/egress_gate/string_validators.py | 51 - .../egress-gate/src/egress_gate/timeout.py | 138 -- projects/egress-gate/tests/__init__.py | 4 - projects/egress-gate/tests/gates/__init__.py | 4 - projects/egress-gate/tests/gates/test_base.py | 284 ---- .../tests/gates/test_function_gate.py | 162 -- .../egress-gate/tests/gates/test_regex.py | 834 ----------- .../egress-gate/tests/gates/test_registry.py | 408 ----- .../tests/request_content/__init__.py | 4 - .../tests/request_content/test_json.py | 359 ----- .../tests/request_content/test_messages.py | 167 --- .../tests/request_content/test_parsers.py | 209 --- .../egress-gate/tests/service/__init__.py | 4 - .../tests/service/test_grpc_integration.py | 227 --- .../egress-gate/tests/service/test_server.py | 207 --- .../tests/service/test_servicer.py | 716 --------- projects/egress-gate/tests/test_cli.py | 700 --------- projects/egress-gate/tests/test_config.py | 130 -- projects/egress-gate/tests/test_errors.py | 60 - .../egress-gate/tests/test_gateway_config.py | 506 ------- projects/egress-gate/tests/test_logging.py | 180 --- projects/egress-gate/tests/test_request.py | 268 ---- .../tests/test_request_processor.py | 1135 -------------- projects/egress-gate/tests/test_result.py | 390 ----- projects/egress-gate/tests/test_timeout.py | 93 -- .../egress-gate/tests/test_typing_policy.py | 586 -------- projects/egress-gate/uv.lock | 1030 ------------- .../openshell-middleware-manager/README.md | 4 +- scripts/stage-project-docs.py | 1 - tests/test_page_navigation.py | 32 +- tests/test_stage_project_docs.py | 2 +- zensical.toml | 22 - 111 files changed, 20 insertions(+), 21396 deletions(-) delete mode 100644 .github/workflows/egress-gate.yml delete mode 100644 projects/egress-gate/.openshell-middleware-manifest.json delete mode 100644 projects/egress-gate/AGENTS.md delete mode 100644 projects/egress-gate/LICENSE delete mode 100644 projects/egress-gate/Makefile delete mode 100644 projects/egress-gate/README.md delete mode 100644 projects/egress-gate/analysis/README.md delete mode 100644 projects/egress-gate/analysis/egress-gate-latency.csv delete mode 100644 projects/egress-gate/analysis/qa-reports/2026-08-05.html delete mode 100644 projects/egress-gate/analysis/render_latency_plot.py delete mode 100644 projects/egress-gate/docs/architecture/index.md delete mode 100644 projects/egress-gate/docs/architecture/request-lifecycle.md delete mode 100644 projects/egress-gate/docs/architecture/service-boundary.md delete mode 100644 projects/egress-gate/docs/assets/analysis/egress-gate-latency-vs-prompt-size.svg delete mode 100644 projects/egress-gate/docs/assets/diagrams/component-architecture.svg delete mode 100644 projects/egress-gate/docs/assets/diagrams/processing-pipeline.svg delete mode 100644 projects/egress-gate/docs/assets/diagrams/request-lifecycle.svg delete mode 100644 projects/egress-gate/docs/assets/diagrams/request-path.svg delete mode 100644 projects/egress-gate/docs/configuration.md delete mode 100644 projects/egress-gate/docs/evaluation.md delete mode 100644 projects/egress-gate/docs/gates/custom.md delete mode 100644 projects/egress-gate/docs/gates/index.md delete mode 100644 projects/egress-gate/docs/gates/regex.md delete mode 100644 projects/egress-gate/docs/index.md delete mode 100644 projects/egress-gate/docs/operations.md delete mode 100644 projects/egress-gate/docs/reference/limits-and-failures.md delete mode 100644 projects/egress-gate/docs/request-content.md delete mode 100644 projects/egress-gate/examples/class-based-gate/README.md delete mode 100644 projects/egress-gate/examples/class-based-gate/cases.yaml delete mode 100644 projects/egress-gate/examples/class-based-gate/egress-gate-config.yaml delete mode 100644 projects/egress-gate/examples/class-based-gate/keyword_gate.py delete mode 100644 projects/egress-gate/examples/class-based-gate/policy.yaml delete mode 100644 projects/egress-gate/examples/custom-gate/README.md delete mode 100644 projects/egress-gate/examples/custom-gate/cases.yaml delete mode 100644 projects/egress-gate/examples/custom-gate/egress-gate-config.yaml delete mode 100644 projects/egress-gate/examples/custom-gate/keyword_gate.py delete mode 100644 projects/egress-gate/examples/custom-gate/policy.yaml delete mode 100644 projects/egress-gate/examples/regex-redaction/.gitignore delete mode 100644 projects/egress-gate/examples/regex-redaction/README.md delete mode 100644 projects/egress-gate/examples/regex-redaction/cases.yaml delete mode 100644 projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml delete mode 100644 projects/egress-gate/examples/regex-redaction/patterns.yaml delete mode 100644 projects/egress-gate/examples/regex-redaction/policy.yaml delete mode 100644 projects/egress-gate/proto/supervisor_middleware.proto delete mode 100644 projects/egress-gate/pyproject.toml delete mode 100755 projects/egress-gate/scripts/check.sh delete mode 100644 projects/egress-gate/src/egress_gate/__init__.py delete mode 100644 projects/egress-gate/src/egress_gate/base.py delete mode 100644 projects/egress-gate/src/egress_gate/bindings/__init__.py delete mode 100644 projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py delete mode 100644 projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi delete mode 100644 projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py delete mode 100644 projects/egress-gate/src/egress_gate/cli.py delete mode 100644 projects/egress-gate/src/egress_gate/config.py delete mode 100644 projects/egress-gate/src/egress_gate/constants.py delete mode 100644 projects/egress-gate/src/egress_gate/errors.py delete mode 100644 projects/egress-gate/src/egress_gate/gates/__init__.py delete mode 100644 projects/egress-gate/src/egress_gate/gates/base.py delete mode 100644 projects/egress-gate/src/egress_gate/gates/regex.py delete mode 100644 projects/egress-gate/src/egress_gate/gates/regex_scans.py delete mode 100644 projects/egress-gate/src/egress_gate/gates/registry.py delete mode 100644 projects/egress-gate/src/egress_gate/gateway_config.py delete mode 100644 projects/egress-gate/src/egress_gate/logging.py delete mode 100644 projects/egress-gate/src/egress_gate/request.py delete mode 100644 projects/egress-gate/src/egress_gate/request_content/__init__.py delete mode 100644 projects/egress-gate/src/egress_gate/request_content/_json_parser.py delete mode 100644 projects/egress-gate/src/egress_gate/request_content/json.py delete mode 100644 projects/egress-gate/src/egress_gate/request_content/json_values.py delete mode 100644 projects/egress-gate/src/egress_gate/request_content/messages.py delete mode 100644 projects/egress-gate/src/egress_gate/request_content/parsers.py delete mode 100644 projects/egress-gate/src/egress_gate/request_content/text.py delete mode 100644 projects/egress-gate/src/egress_gate/request_processor.py delete mode 100644 projects/egress-gate/src/egress_gate/result.py delete mode 100644 projects/egress-gate/src/egress_gate/service/__init__.py delete mode 100644 projects/egress-gate/src/egress_gate/service/server.py delete mode 100644 projects/egress-gate/src/egress_gate/service/servicer.py delete mode 100644 projects/egress-gate/src/egress_gate/string_validators.py delete mode 100644 projects/egress-gate/src/egress_gate/timeout.py delete mode 100644 projects/egress-gate/tests/__init__.py delete mode 100644 projects/egress-gate/tests/gates/__init__.py delete mode 100644 projects/egress-gate/tests/gates/test_base.py delete mode 100644 projects/egress-gate/tests/gates/test_function_gate.py delete mode 100644 projects/egress-gate/tests/gates/test_regex.py delete mode 100644 projects/egress-gate/tests/gates/test_registry.py delete mode 100644 projects/egress-gate/tests/request_content/__init__.py delete mode 100644 projects/egress-gate/tests/request_content/test_json.py delete mode 100644 projects/egress-gate/tests/request_content/test_messages.py delete mode 100644 projects/egress-gate/tests/request_content/test_parsers.py delete mode 100644 projects/egress-gate/tests/service/__init__.py delete mode 100644 projects/egress-gate/tests/service/test_grpc_integration.py delete mode 100644 projects/egress-gate/tests/service/test_server.py delete mode 100644 projects/egress-gate/tests/service/test_servicer.py delete mode 100644 projects/egress-gate/tests/test_cli.py delete mode 100644 projects/egress-gate/tests/test_config.py delete mode 100644 projects/egress-gate/tests/test_errors.py delete mode 100644 projects/egress-gate/tests/test_gateway_config.py delete mode 100644 projects/egress-gate/tests/test_logging.py delete mode 100644 projects/egress-gate/tests/test_request.py delete mode 100644 projects/egress-gate/tests/test_request_processor.py delete mode 100644 projects/egress-gate/tests/test_result.py delete mode 100644 projects/egress-gate/tests/test_timeout.py delete mode 100644 projects/egress-gate/tests/test_typing_policy.py delete mode 100644 projects/egress-gate/uv.lock diff --git a/.github/workflows/egress-gate.yml b/.github/workflows/egress-gate.yml deleted file mode 100644 index b16f6c91..00000000 --- a/.github/workflows/egress-gate.yml +++ /dev/null @@ -1,51 +0,0 @@ -name: Egress Gate - -"on": - pull_request: - push: - branches: - - main - workflow_dispatch: - -permissions: - contents: read - -concurrency: - group: egress-gate-${{ github.workflow }}-${{ github.ref }} - cancel-in-progress: true - -jobs: - check: - name: Check Egress Gate (Python ${{ matrix.python-version }}) - runs-on: ubuntu-latest - strategy: - fail-fast: false - matrix: - python-version: - - "3.11" - - "3.14" - defaults: - run: - working-directory: projects/egress-gate - steps: - - name: Checkout - uses: actions/checkout@v7 - with: - persist-credentials: false - - - name: Set up uv and Python - uses: astral-sh/setup-uv@08807647e7069bb48b6ef5acd8ec9567f424441b # v8.1.0 - with: - version: "0.11.31" - python-version: ${{ matrix.python-version }} - - - name: Configure isolated uv paths - run: | - echo "UV_CACHE_DIR=$RUNNER_TEMP/egress-gate-uv-cache" >> "$GITHUB_ENV" - echo "UV_PROJECT_ENVIRONMENT=$RUNNER_TEMP/egress-gate-venv" >> "$GITHUB_ENV" - - - name: Install locked dependencies - run: uv sync --frozen - - - name: Run project checks - run: make check diff --git a/.gitignore b/.gitignore index 87fb3d08..90b6c460 100644 --- a/.gitignore +++ b/.gitignore @@ -124,7 +124,6 @@ lib/ # Static site and docs output public/ site/ -docs/documentation/egress-gate/ docs/documentation/openshell-agent-runner/ .docusaurus/ .vitepress/cache/ diff --git a/docs/documentation/index.md b/docs/documentation/index.md index 0afff164..181c16cf 100644 --- a/docs/documentation/index.md +++ b/docs/documentation/index.md @@ -9,7 +9,5 @@ agent_markdown: true Technical documentation and references for installing, using, and extending OpenShell Research projects. -- [Egress Gate](egress-gate/index.md): extensible middleware for applying gates - to outgoing HTTP requests. - [OpenShell Agent Runner](openshell-agent-runner/index.md): launch ephemeral agents for single, bounded tasks in CI and automated workflows. diff --git a/projects/README.md b/projects/README.md index fe9c9ac5..686bb0bf 100644 --- a/projects/README.md +++ b/projects/README.md @@ -13,8 +13,6 @@ Current projects: horizons and repeated parallel attempts, starting with GitHub policy review. - `openshell-middleware-manager`: `omm` CLI that creates and updates version-matched Python and Rust OpenShell supervisor middleware projects. -- `egress-gate`: Extensible OpenShell middleware for provider-bound HTTP - requests. - `python-project-template`: Minimal, production-ready Python project scaffold managed with uv. - `reachy-mini-openshell`: Reachy Mini conversation demo for OpenShell. diff --git a/projects/egress-gate/.openshell-middleware-manifest.json b/projects/egress-gate/.openshell-middleware-manifest.json deleted file mode 100644 index 59d9d114..00000000 --- a/projects/egress-gate/.openshell-middleware-manifest.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "openshell_version": "v0.0.97", - "proto_source": "https://raw.githubusercontent.com/NVIDIA/OpenShell/v0.0.97/proto/supervisor_middleware.proto", - "proto_sha256": "e9d5a992ff5b50a33e9625176aaf6df8496d6774aa2ef3afe5cae7bc83c01105", - "languages": [ - "python" - ], - "python_package": "egress_gate", - "generator": { - "name": "openshell-middleware-manager", - "version": "0.1.0" - } -} diff --git a/projects/egress-gate/AGENTS.md b/projects/egress-gate/AGENTS.md deleted file mode 100644 index aaeb8db9..00000000 --- a/projects/egress-gate/AGENTS.md +++ /dev/null @@ -1,125 +0,0 @@ -# Egress Gate - -Egress Gate is OpenShell pre-credentials middleware. It receives one bounded, -immutable byte-oriented `HttpRequest`, runs an ordered pipeline of trusted -request-level gates, and returns an explicit allow or deny result with optional -request mutations. - -## Development commands - -Run commands from `projects/egress-gate/`. - -- List targets: `make help` -- Run all checks: `make check` -- Check Python 3.11: `make check-py311` -- Run focused tests: `make test PYTEST_ARGS=tests/test_request_processor.py` - -Run focused tests while working and `make check` before handoff. - -## Engineering approach - -- Backwards compatibility with the removed legacy policy API is not a concern. - Do not restore old schemas, imports, names, aliases, or obsolete pipeline terms. -- Gates are trusted application code. Capabilities enforce declared output - behavior and finding declarations; read capabilities are discovery metadata, - not Python isolation or a security sandbox. -- Keep request bytes and headers in immutable domain models. Only the service - package imports gRPC or generated protobuf bindings. -- Use the smallest owner for each validation. The service owns exact encoded - protobuf limits; domain models own bounded scalar and aggregate invariants. -- Put public declarations before private helpers, implementation types, and - module state when dependencies allow. Keep private implementation details at - the bottom of a module rather than interrupting its public surface. -- Do not add validators or runtime type checks that duplicate strict Pydantic - validation or an already validated caller boundary. -- Avoid speculative infrastructure, policy caches, observer interfaces, and - transport abstractions. - -## Project map - -- `src/egress_gate/gates/`: `Gate`, helper bases, registry, regex policy models, - and regex matching -- `src/egress_gate/config.py`: strict ordered `gates` and `default_decision` - policy models -- `src/egress_gate/request.py`: protobuf-free request and request-mutation models -- `src/egress_gate/request_content/`: reusable text parsers, strict JSON - selection, source-preserving edits, and normalized message blocks -- `src/egress_gate/result.py`: gate evaluations, five-field findings, provenance, - traces, metadata, and final results -- `src/egress_gate/request_processor.py`: shared deadline, current-request - mutation flow, terminal controls, aggregation, and default decisions -- `src/egress_gate/cli.py`: content-safe offline policy-corpus evaluation -- `src/egress_gate/service/`: gRPC lifecycle and the only protobuf adapter -- `src/egress_gate/timeout.py`: monotonic shared request deadline -- `src/egress_gate/errors.py`: stable content-safe error catalog -- `src/egress_gate/gateway_config.py`: safe OpenShell gateway TOML management -- `tests/`: domain, gate, processor, service, and boundary tests - -Before changing `request_processor.py`, `gates/`, or `service/`, read the -architecture overview and matching topic page under `docs/architecture/`. - -## Gate contract - -Every gate declares a strict `GateConfig` with a literal `kind` discriminator, -an optional typed `GateResources` bundle, an immutable set of `GateCapability` -values, and its -`FindingTypeDefinition` declarations. `GateRegistry` creates the exact -discriminated pipeline schema on first use and prepares validated gate -instances from trusted application-owned resources. - -`GateConfig` owns the required bounded `name` field. Concrete config classes -inherit it without redefining or aliasing it. Keep `kind` under its canonical -serialized field name. - -Use a required `kind` field for every serialized discriminated union. Each -variant must declare one string literal and its exact fields. Use an enum on a -single model when the selected value does not change the serialized shape; do -not create a union only to replace an enum. - -`Gate.evaluate()` receives the current `HttpRequest` and one shared `Timeout`. -It returns a validated `GateEvaluation` with explicit `proceed`, terminal -`allow`, or terminal `deny` control. Request mutations from a `proceed` result -are applied before the next gate; body replacement intent is preserved even -when replacement bytes are equal to the input. The pipeline processor adds -provenance through `RequestProcessor`, never through gate configuration or -gate-produced findings. - -Custom gates are trusted and must be safe for concurrent calls. Tests should -exercise concurrent evaluation, but the Python implementation is not claimed to -be deeply immutable. Resource bundles contain operator-owned, concurrency-safe -dependencies and no request state or policy behavior. - -`registry.gate` is a convenience helper for stateless, resource-free gates. It -must compile into the same `Gate` contract. It does not replace the class-based -API. Registries belong to application modules; do not add package-global -registration state. - -## Current built-ins and boundaries - -This slice ships exactly one built-in. `regex` selects one typed complete-body, -structured JSON, normalized message-block, path, query, or header scan and -preserves bounded catalog loading, matching, overlap resolution, and -detect/deny actions. Complete-body and structured scans also support bounded -replacement. Deterministic network request policy belongs to OpenShell. -Do not add more built-ins speculatively. - -The OpenShell wire `Finding` remains the released five-field contract: -`type`, `label`, `count`, `confidence`, and `severity`. Gate provenance stays -internal to the pipeline processor in `SourcedFinding` and `DecisionSource`; -do not serialize source or attributes or encode them into labels or result -metadata. - -The service adapts protobuf messages to `HttpRequest`, validates exact encoded -transport boundaries, and serializes `EgressResult`. Core domain and gate code -must not import gRPC or protobuf. The request model may contain body bytes and -visible pre-credentials headers, but provider credentials are outside this -middleware phase. - -## Plan boundaries - -The current implementation covers the gate contract, strict pipeline -configuration, automatically sealed registry, regex behavior, request processing, -single active-policy replacement, and offline evaluation. -Semantic or LLM judgment is deferred and must not be added as a built-in, -example implementation, or default dependency. Do not edit `plans/` as part of -implementation work. diff --git a/projects/egress-gate/LICENSE b/projects/egress-gate/LICENSE deleted file mode 100644 index 10a6d3d5..00000000 --- a/projects/egress-gate/LICENSE +++ /dev/null @@ -1,203 +0,0 @@ -Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. - - Apache License - Version 2.0, January 2004 - http://www.apache.org/licenses/ - - TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION - - 1. Definitions. - - "License" shall mean the terms and conditions for use, reproduction, - and distribution as defined by Sections 1 through 9 of this document. - - "Licensor" shall mean the copyright owner or entity authorized by - the copyright owner that is granting the License. - - "Legal Entity" shall mean the union of the acting entity and all - other entities that control, are controlled by, or are under common - control with that entity. For the purposes of this definition, - "control" means (i) the power, direct or indirect, to cause the - direction or management of such entity, whether by contract or - otherwise, or (ii) ownership of fifty percent (50%) or more of the - outstanding shares, or (iii) beneficial ownership of such entity. - - "You" (or "Your") shall mean an individual or Legal Entity - exercising permissions granted by this License. - - "Source" form shall mean the preferred form for making modifications, - including but not limited to software source code, documentation - source, and configuration files. - - "Object" form shall mean any form resulting from mechanical - transformation or translation of a Source form, including but - not limited to compiled object code, generated documentation, - and conversions to other media types. - - "Work" shall mean the work of authorship, whether in Source or - Object form, made available under the License, as indicated by a - copyright notice that is included in or attached to the work - (an example is provided in the Appendix below). - - "Derivative Works" shall mean any work, whether in Source or Object - form, that is based on (or derived from) the Work and for which the - editorial revisions, annotations, elaborations, or other modifications - represent, as a whole, an original work of authorship. For the purposes - of this License, Derivative Works shall not include works that remain - separable from, or merely link (or bind by name) to the interfaces of, - the Work and Derivative Works thereof. - - "Contribution" shall mean any work of authorship, including - the original version of the Work and any modifications or additions - to that Work or Derivative Works thereof, that is intentionally - submitted to Licensor for inclusion in the Work by the copyright owner - or by an individual or Legal Entity authorized to submit on behalf of - the copyright owner. For the purposes of this definition, "submitted" - means any form of electronic, verbal, or written communication sent - to the Licensor or its representatives, including but not limited to - communication on electronic mailing lists, source code control systems, - and issue tracking systems that are managed by, or on behalf of, the - Licensor for the purpose of discussing and improving the Work, but - excluding communication that is conspicuously marked or otherwise - designated in writing by the copyright owner as "Not a Contribution." - - "Contributor" shall mean Licensor and any individual or Legal Entity - on behalf of whom a Contribution has been received by Licensor and - subsequently incorporated within the Work. - - 2. Grant of Copyright License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - copyright license to reproduce, prepare Derivative Works of, - publicly display, publicly perform, sublicense, and distribute the - Work and such Derivative Works in Source or Object form. - - 3. Grant of Patent License. Subject to the terms and conditions of - this License, each Contributor hereby grants to You a perpetual, - worldwide, non-exclusive, no-charge, royalty-free, irrevocable - (except as stated in this section) patent license to make, have made, - use, offer to sell, sell, import, and otherwise transfer the Work, - where such license applies only to those patent claims licensable - by such Contributor that are necessarily infringed by their - Contribution(s) alone or by combination of their Contribution(s) - with the Work to which such Contribution(s) was submitted. If You - institute patent litigation against any entity (including a - cross-claim or counterclaim in a lawsuit) alleging that the Work - or a Contribution incorporated within the Work constitutes direct - or contributory patent infringement, then any patent licenses - granted to You under this License for that Work shall terminate - as of the date such litigation is filed. - - 4. Redistribution. You may reproduce and distribute copies of the - Work or Derivative Works thereof in any medium, with or without - modifications, and in Source or Object form, provided that You - meet the following conditions: - - (a) You must give any other recipients of the Work or - Derivative Works a copy of this License; and - - (b) You must cause any modified files to carry prominent notices - stating that You changed the files; and - - (c) You must retain, in the Source form of any Derivative Works - that You distribute, all copyright, patent, trademark, and - attribution notices from the Source form of the Work, - excluding those notices that do not pertain to any part of - the Derivative Works; and - - (d) If the Work includes a "NOTICE" text file as part of its - distribution, then any Derivative Works that You distribute must - include a readable copy of the attribution notices contained - within such NOTICE file, excluding those notices that do not - pertain to any part of the Derivative Works, in at least one - of the following places: within a NOTICE text file distributed - as part of the Derivative Works; within the Source form or - documentation, if provided along with the Derivative Works; or, - within a display generated by the Derivative Works, if and - wherever such third-party notices normally appear. The contents - of the NOTICE file are for informational purposes only and - do not modify the License. You may add Your own attribution - notices within Derivative Works that You distribute, alongside - or as an addendum to the NOTICE text from the Work, provided - that such additional attribution notices cannot be construed - as modifying the License. - - You may add Your own copyright statement to Your modifications and - may provide additional or different license terms and conditions - for use, reproduction, or distribution of Your modifications, or - for any such Derivative Works as a whole, provided Your use, - reproduction, and distribution of the Work otherwise complies with - the conditions stated in this License. - - 5. Submission of Contributions. Unless You explicitly state otherwise, - any Contribution intentionally submitted for inclusion in the Work - by You to the Licensor shall be under the terms and conditions of - this License, without any additional terms or conditions. - Notwithstanding the above, nothing herein shall supersede or modify - the terms of any separate license agreement you may have executed - with Licensor regarding such Contributions. - - 6. Trademarks. This License does not grant permission to use the trade - names, trademarks, service marks, or product names of the Licensor, - except as required for reasonable and customary use in describing the - origin of the Work and reproducing the content of the NOTICE file. - - 7. Disclaimer of Warranty. Unless required by applicable law or - agreed to in writing, Licensor provides the Work (and each - Contributor provides its Contributions) on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or - implied, including, without limitation, any warranties or conditions - of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A - PARTICULAR PURPOSE. You are solely responsible for determining the - appropriateness of using or redistributing the Work and assume any - risks associated with Your exercise of permissions under this License. - - 8. Limitation of Liability. In no event and under no legal theory, - whether in tort (including negligence), contract, or otherwise, - unless required by applicable law (such as deliberate and grossly - negligent acts) or agreed to in writing, shall any Contributor be - liable to You for damages, including any direct, indirect, special, - incidental, or consequential damages of any character arising as a - result of this License or out of the use or inability to use the - Work (including but not limited to damages for loss of goodwill, - work stoppage, computer failure or malfunction, or any and all - other commercial damages or losses), even if such Contributor - has been advised of the possibility of such damages. - - 9. Accepting Warranty or Additional Liability. While redistributing - the Work or Derivative Works thereof, You may choose to offer, - and charge a fee for, acceptance of support, warranty, indemnity, - or other liability obligations and/or rights consistent with this - License. However, in accepting such obligations, You may act only - on Your own behalf and on Your sole responsibility, not on behalf - of any other Contributor, and only if You agree to indemnify, - defend, and hold each Contributor harmless for any liability - incurred by, or claims asserted against, such Contributor by reason - of your accepting any such warranty or additional liability. - - END OF TERMS AND CONDITIONS - - APPENDIX: How to apply the Apache License to your work. - - To apply the Apache License to your work, attach the following - boilerplate notice, with the fields enclosed by brackets "[]" - replaced with your own identifying information. (Don't include - the brackets!) The text should be enclosed in the appropriate - comment syntax for the file format. We also recommend that a - file or class name and description of purpose be included on the - same "printed page" as the copyright notice for easier - identification within third-party archives. - - Copyright 2026 NVIDIA CORPORATION & AFFILIATES. - - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. diff --git a/projects/egress-gate/Makefile b/projects/egress-gate/Makefile deleted file mode 100644 index ebe240b3..00000000 --- a/projects/egress-gate/Makefile +++ /dev/null @@ -1,63 +0,0 @@ -.DEFAULT_GOAL := help - -UV ?= uv -UV_RUN := $(UV) run --frozen -PYTEST_ARGS ?= -.PHONY: \ - help sync lock lock-check \ - test \ - format format-check lint lint-fix typecheck fix \ - import-check build check check-py311 \ - clean - -help: ## Show available targets and configurable variables. - @awk 'BEGIN {FS = ":.*## "}; /^[a-zA-Z0-9_.-]+:.*## / {printf " %-28s %s\n", $$1, $$2}' $(MAKEFILE_LIST) - @printf "\nVariables:\n" - @printf " PYTEST_ARGS=... Extra pytest paths or flags\n" - -sync: ## Install locked runtime and development dependencies. - $(UV) sync --frozen - -lock: ## Refresh uv.lock from pyproject.toml. - $(UV) lock - -lock-check: ## Verify uv.lock matches pyproject.toml. - $(UV) lock --check - -test: ## Run tests; pass paths or flags with PYTEST_ARGS. - $(UV_RUN) pytest -q $(PYTEST_ARGS) - -format: ## Format handwritten Python. - $(UV_RUN) ruff format . - -format-check: ## Check formatting without changing files. - $(UV_RUN) ruff format --check . - -lint: ## Run Ruff lint checks. - $(UV_RUN) ruff check . - -lint-fix: ## Apply safe Ruff fixes. - $(UV_RUN) ruff check --fix . - -typecheck: ## Run curated ty checks. - $(UV_RUN) ty check - -fix: ## Apply Ruff fixes, then format. - $(UV_RUN) ruff check --fix . - $(UV_RUN) ruff format . - -import-check: ## Smoke-test the installed package import. - $(UV_RUN) python -c "import egress_gate" - -build: ## Build the source distribution and wheel. - $(UV) build - -check: ## Run the authoritative full project checks. - scripts/check.sh - -check-py311: ## Run full checks on the minimum Python version. - scripts/check.sh --python 3.11 - -clean: ## Remove build, test, lint, and Python cache artifacts. - rm -rf build dist .pytest_cache .ruff_cache - find src tests examples scripts -type d -name __pycache__ -prune -exec rm -rf {} + diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md deleted file mode 100644 index 1471f75e..00000000 --- a/projects/egress-gate/README.md +++ /dev/null @@ -1,151 +0,0 @@ -# Egress Gate - -Egress Gate is an extensible OpenShell supervisor middleware service. It -evaluates provider-bound HTTP requests during the pre-credentials phase. Each -request moves through an ordered pipeline of trusted gates. A gate can allow -the request, deny it, or propose validated request mutations. - -Gates do not modify request objects in place. The pipeline processor applies -proposed mutations to new local request snapshots. The service returns the -final mutations to the OpenShell supervisor, which applies them to the -intercepted request. - -The current released OpenShell `Finding` contract has five fields: -`type`, `label`, `count`, `confidence`, and `severity`. Gate provenance stays -inside the pipeline processor. Egress Gate does not add provenance to findings -or labels. - -## Installed quickstart - -After installing the package with your deployment's Python 3.11+ tooling, these -commands work from any directory and do not depend on repository-only files: - -```bash -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 -``` - -## Source-checkout quickstart - -The example policies, cases, and extended documentation are repository assets; -they are not installed with the Python distribution. From -`projects/egress-gate/` in a source checkout, `uv` 0.11+ prepares the project -environment before it starts each command: - -```bash -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 evaluate \ - --policy examples/regex-redaction/egress-gate-config.yaml \ - --cases examples/regex-redaction/cases.yaml -``` - -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. - -## Policy shape - -The registry builds an exact strict schema from installed gate types: - -```yaml -gates: - - name: identifiers - kind: regex - scan: - kind: body - action: - kind: replace - template: "[{entity}]" - pattern_catalog: patterns.yaml -default_decision: allow -``` - -The shipped registry contains exactly `regex`. Its `scan` selects the complete -body, selected JSON string fields, normalized JSON message blocks, the path, -query, or selected request headers. Each scan contains its `action`. Every scan -supports `detect` and `deny`. Complete-body and structured JSON scans also -support source-preserving `replace`. The typed configuration prevents -unsupported combinations. A replace action preserves an explicit -body-replacement intent even when the resulting bytes equal the input. Add -custom trusted gates through `--registry`. - -Structured scans are explicit policy choices. `json-fields` uses bounded typed -selectors. `message-blocks` applies a configurable JSON message mapping and can -filter normalized roles and block kinds. Replacement re-encodes only selected -JSON string tokens; all unrelated request-body bytes remain unchanged. Existing -`body` scans continue to inspect and optionally replace the complete UTF-8 body. - -Small stateless gates can use the optional `registry.gate` helper. Gates that -need initialization, helper bases, or typed resources use the full class-based -`Gate` API. - -```bash -uv run egress-gate --registry my_gates:registry gates list -uv run egress-gate --registry my_gates:registry serve -``` - -OpenShell owns interception, routing, and credential attachment. Egress Gate -does not act as an HTTP proxy, inspect responses, or protect data already -written by a harness to disk. - -## Python server API - -```python -from egress_gate.gates import create_builtin_registry -from egress_gate.service import EgressGateServer - -server = EgressGateServer( - create_builtin_registry(), - timeout_middleware_processing=10, -) -server.serve_sync("127.0.0.1:50051") -``` - -In this 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 -acquisition, policy preparation, and `RequestProcessor.process`. - -`Describe` leaves the optional binding RPC timeout empty, so OpenShell applies -the timeout configured on the gateway registration to the complete RPC. The -registration CLI defaults to 30 seconds and accepts `--timeout` to write a -different value. -The helper remembers the gateway file and registration name. On later CLI -starts, `serve` reads the current gateway timeout and requires the processing -timeout to be lower. If no registration has been added with the CLI, `serve` -starts without this check. If the gateway timeout expires, OpenShell applies -the policy's `on_error` behavior; use `on_error: fail_closed` when middleware -timeout failures must deny. - -## Documentation and examples - -- [Overview](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/index.md) -- [Configuration](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/configuration.md) -- [Request-content parsing](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/request-content.md) -- [Test policies offline](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/evaluation.md) -- [Operations](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/operations.md) -- [Gate authoring](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/gates/custom.md) -- [Regex gate](https://github.com/NVIDIA/OpenShell-Research/blob/main/projects/egress-gate/docs/gates/regex.md) -- [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) -- [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) - -## Development - -```bash -make help -make test PYTEST_ARGS="tests/gates tests/test_request_processor.py" -make check -``` - -Only `service/` imports generated protobuf/gRPC bindings. Do not edit -`plans/egress-gate-refactor.md` as part of implementation work. diff --git a/projects/egress-gate/analysis/README.md b/projects/egress-gate/analysis/README.md deleted file mode 100644 index be048d2c..00000000 --- a/projects/egress-gate/analysis/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# Egress Gate latency analysis - -This directory contains the source data, deterministic renderer, and generated -Egress Gate latency-versus-prompt-size figure. It is the reusable starting -point for future documentation and a Dev Note about the proof-of-concept stress -test. - -## Recreate the figure - -Run from `projects/egress-gate/`: - -```sh -uv run python analysis/render_latency_plot.py -``` - -The command writes the documentation asset: - -```text -docs/assets/analysis/egress-gate-latency-vs-prompt-size.svg -``` - -Verify that the committed figure matches the data and renderer: - -```sh -uv run python analysis/render_latency_plot.py --check -``` - -The renderer uses only the Python standard library and does not add a project -dependency. - -## Data - -`egress-gate-latency.csv` contains 96 joined observations from the synthetic -Claude Code context-growth experiment run on July 27–28, 2026. - -| Column | Meaning | -| --- | --- | -| `observed_at_utc` | Timestamp used to correlate the request across available logs | -| `prompt_tokens` | Claude Code session token accounting for the provider request | -| `egress_gate_latency_ms` | `duration_ms` emitted by the `egress_gate_evaluation` service log | -| `entity_count` | Aggregate Egress Gate finding count | -| `phase` | Baseline, pre-compaction, compaction-trigger, or context-rebuild phase | -| `openshell_observed_ms` | OpenShell L7 request event to middleware-result event | -| `first_output_elapsed_ms` | OpenShell L7 request event to the first Claude response output | -| `turn_elapsed_ms` | OpenShell L7 request event to the last Claude response output | - -The baseline observations have only the service-side measurement, token count, -and entity count. Thirteen large-context observations also have the -OpenShell-observed middleware interval. Twelve of those completed turns have -first- and last-output timing; the compaction-triggering request does not. - -The figure's 0.56% annotation is the arithmetic mean of -`egress_gate_latency_ms / turn_elapsed_ms` across those 12 completed turns. -It is not Egress Gate's share of the short OpenShell middleware interval. -Point color uses a continuous scale for `entity_count`; each SVG point also -contains an accessible tooltip with its token, latency, and entity values. The -SVG adapts its text, grid, threshold, and point-outline colors to the viewer's -light or dark color scheme. - -## Interpretation limits - -This is a proof-of-concept observation set, not a general benchmark: - -- the workload used synthetic, deliberately repeated text -- prompt size and entity count increased together -- the run used one host, sandbox, Egress Gate configuration, regex gate - policy, and Claude Code session -- baseline and large-context observations span a Egress Gate process restart -- the linear fit is descriptive and should not be treated as a performance - guarantee - -The CSV contains operational timing and aggregate counts only. It contains no -request text or detected entity values. diff --git a/projects/egress-gate/analysis/egress-gate-latency.csv b/projects/egress-gate/analysis/egress-gate-latency.csv deleted file mode 100644 index feee325c..00000000 --- a/projects/egress-gate/analysis/egress-gate-latency.csv +++ /dev/null @@ -1,97 +0,0 @@ -observed_at_utc,prompt_tokens,egress_gate_latency_ms,entity_count,phase,openshell_observed_ms,first_output_elapsed_ms,turn_elapsed_ms -2026-07-27T20:30:01.123Z,18291,5.500,3,baseline,,, -2026-07-27T20:30:16.777Z,18722,5.968,3,baseline,,, -2026-07-27T20:30:47.630Z,18923,6.032,4,baseline,,, -2026-07-27T20:33:01.353Z,19120,10.526,4,baseline,,, -2026-07-27T20:40:29.558Z,19378,12.501,6,baseline,,, -2026-07-27T20:52:52.194Z,19798,21.712,6,baseline,,, -2026-07-27T20:52:57.011Z,20335,7.766,6,baseline,,, -2026-07-27T20:53:09.407Z,21404,6.914,11,baseline,,, -2026-07-27T20:53:15.951Z,22377,8.772,11,baseline,,, -2026-07-27T20:53:59.529Z,22934,9.744,11,baseline,,, -2026-07-27T20:54:09.916Z,23528,7.848,15,baseline,,, -2026-07-27T20:54:50.861Z,23866,10.930,16,baseline,,, -2026-07-27T20:55:03.900Z,26472,10.931,22,baseline,,, -2026-07-27T20:57:46.380Z,26853,11.706,22,baseline,,, -2026-07-27T20:58:02.786Z,28021,12.020,22,baseline,,, -2026-07-27T20:58:13.857Z,28867,11.031,26,baseline,,, -2026-07-27T20:58:57.295Z,31351,12.766,26,baseline,,, -2026-07-27T21:00:20.727Z,33610,14.624,26,baseline,,, -2026-07-27T21:00:30.405Z,34193,14.459,26,baseline,,, -2026-07-27T22:10:44.672Z,20747,10.780,19,baseline,,, -2026-07-27T22:27:43.549Z,22310,18.206,20,baseline,,, -2026-07-27T22:39:51.865Z,23103,16.403,20,baseline,,, -2026-07-28T14:12:13.541Z,27015,28.333,22,baseline,,, -2026-07-28T14:15:00.603Z,31441,13.022,22,baseline,,, -2026-07-28T14:17:30.678Z,38362,12.441,23,baseline,,, -2026-07-28T14:18:12.741Z,39098,18.293,23,baseline,,, -2026-07-28T14:20:18.406Z,39471,16.974,23,baseline,,, -2026-07-28T14:21:57.261Z,40025,10.865,23,baseline,,, -2026-07-28T14:23:44.194Z,47124,14.840,23,baseline,,, -2026-07-28T14:24:40.109Z,47960,11.560,23,baseline,,, -2026-07-28T14:28:52.430Z,48251,17.624,23,baseline,,, -2026-07-28T14:29:37.005Z,51102,12.951,23,baseline,,, -2026-07-28T14:29:53.910Z,51552,11.305,23,baseline,,, -2026-07-28T14:30:03.599Z,52216,14.286,23,baseline,,, -2026-07-28T14:30:09.616Z,52634,11.082,23,baseline,,, -2026-07-28T14:32:44.808Z,53244,20.658,23,baseline,,, -2026-07-28T14:34:57.319Z,62423,13.714,23,baseline,,, -2026-07-28T14:35:06.628Z,63068,17.805,23,baseline,,, -2026-07-28T14:35:13.210Z,63486,17.526,23,baseline,,, -2026-07-28T14:36:05.558Z,64295,25.973,23,baseline,,, -2026-07-28T14:39:49.555Z,76372,18.898,23,baseline,,, -2026-07-28T14:43:07.287Z,77031,18.117,23,baseline,,, -2026-07-28T14:44:51.978Z,82339,26.136,24,baseline,,, -2026-07-28T14:55:59.291Z,83184,36.034,24,baseline,,, -2026-07-28T14:57:22.789Z,88677,20.867,24,baseline,,, -2026-07-28T14:57:37.436Z,89748,18.688,24,baseline,,, -2026-07-28T14:57:45.152Z,89970,20.018,24,baseline,,, -2026-07-28T14:58:06.951Z,90530,21.013,24,baseline,,, -2026-07-28T14:58:20.033Z,91420,21.663,24,baseline,,, -2026-07-28T15:00:36.270Z,92901,22.735,24,baseline,,, -2026-07-28T15:05:37.739Z,93678,35.987,24,baseline,,, -2026-07-28T15:06:38.443Z,95694,25.222,24,baseline,,, -2026-07-28T15:08:19.742Z,96704,30.232,24,baseline,,, -2026-07-28T15:08:33.441Z,96868,27.002,24,baseline,,, -2026-07-28T15:08:51.517Z,97886,30.335,24,baseline,,, -2026-07-28T15:09:14.278Z,98356,24.542,24,baseline,,, -2026-07-28T15:09:41.191Z,99265,35.327,24,baseline,,, -2026-07-28T15:10:11.704Z,101025,34.153,24,baseline,,, -2026-07-28T15:10:39.514Z,101193,23.337,24,baseline,,, -2026-07-28T15:10:55.787Z,102071,26.929,24,baseline,,, -2026-07-28T15:11:05.095Z,102481,23.703,24,baseline,,, -2026-07-28T15:11:52.844Z,104787,29.402,24,baseline,,, -2026-07-28T15:12:09.526Z,105313,31.890,24,baseline,,, -2026-07-28T15:12:15.243Z,105801,35.567,24,baseline,,, -2026-07-28T15:12:18.828Z,105958,39.620,24,baseline,,, -2026-07-28T15:12:21.998Z,106160,33.373,25,baseline,,, -2026-07-28T15:12:34.783Z,106428,24.077,25,baseline,,, -2026-07-28T15:12:40.464Z,106807,47.700,25,baseline,,, -2026-07-28T15:12:43.512Z,106981,43.296,25,baseline,,, -2026-07-28T15:13:14.543Z,107412,27.077,25,baseline,,, -2026-07-28T15:13:19.116Z,107647,32.041,25,baseline,,, -2026-07-28T15:13:30.257Z,108052,38.225,25,baseline,,, -2026-07-28T15:14:50.650Z,108800,36.868,25,baseline,,, -2026-07-28T15:16:10.735Z,114425,32.048,25,baseline,,, -2026-07-28T15:19:26.270Z,114584,32.318,25,baseline,,, -2026-07-28T15:19:40.244Z,115395,24.684,25,baseline,,, -2026-07-28T15:19:57.190Z,115852,39.067,25,baseline,,, -2026-07-28T15:20:22.819Z,117730,42.895,25,baseline,,, -2026-07-28T15:26:43.185Z,120732,36.043,25,baseline,,, -2026-07-28T15:28:43.793Z,122710,47.417,27,baseline,,, -2026-07-28T15:34:42.276Z,127634,38.612,59,baseline,,, -2026-07-28T15:37:03.144Z,136816,49.149,59,baseline,,, -2026-07-28T15:43:12.912Z,143349,41.177,59,baseline,,, -2026-07-28T16:46:47.621Z,323611,93.396,259,pre_compaction,120,13085.000,14496.000 -2026-07-28T16:47:02.185Z,324158,96.490,259,pre_compaction,110,26868.000,30972.000 -2026-07-28T16:47:33.245Z,325804,95.200,259,pre_compaction,110,13488.000,23734.000 -2026-07-28T16:50:37.017Z,329592,91.015,259,pre_compaction,110,46988.000,59311.000 -2026-07-28T16:51:36.588Z,656648,160.287,323,pre_compaction,189,60541.000,91668.000 -2026-07-28T16:54:02.960Z,665140,164.283,323,pre_compaction,198,22893.000,34883.000 -2026-07-28T16:54:38.109Z,963788,220.276,389,pre_compaction,264,13133.000,26286.000 -2026-07-28T16:56:39.208Z,965007,216.278,389,pre_compaction,263,20056.000,28635.000 -2026-07-28T16:57:08.108Z,1140979,268.666,429,compaction_trigger,344,, -2026-07-28T17:00:21.608Z,228409,54.221,43,context_rebuild,66,9571.000,17892.000 -2026-07-28T17:00:39.748Z,428405,103.414,83,context_rebuild,128,6836.000,14623.000 -2026-07-28T17:00:54.757Z,628418,196.250,123,context_rebuild,244,7270.000,14525.000 -2026-07-28T17:01:09.519Z,801456,176.012,163,context_rebuild,225,16508.000,31315.000 diff --git a/projects/egress-gate/analysis/qa-reports/2026-08-05.html b/projects/egress-gate/analysis/qa-reports/2026-08-05.html deleted file mode 100644 index acb2d604..00000000 --- a/projects/egress-gate/analysis/qa-reports/2026-08-05.html +++ /dev/null @@ -1,490 +0,0 @@ - - - - - - - EgressGate comprehensive QA and remediation report — 2026-08-05 - - - - -
-
-

Independent battle test

-

EgressGate comprehensive QA and remediation report

-

Functional, extension, service, resilience, content-safety, packaging, and operator-experience validation followed by verified remediation of every finding.

-
- 2026-08-05 UTC - commit aa74572 - branch johnny/egress-gate-refactor - EgressGate 0.1.0 - 13 of 13 findings addressed -
-
-
- -
- - -
-

Executive summary

-
-
Verified
Remediation assessment
-
217 / 217
Authoritative tests passed on 3.11 and 3.14
-
0
Critical or high findings
-
0 open
5 medium + 8 low findings addressed
-
-

Bottom line: EgressGate's core decision pipeline, regex behavior, custom-gate contract, content safety, limits, concurrency, policy replacement, packaging, and shutdown behavior remain strong. All 13 QA findings have now been addressed through code, tests, or explicit operator guidance, and the integrated project and documentation checks pass.

-

No request-content disclosure was found. Adversarial failures were generally fail-closed, bounded, and recoverable. The one implementation-detail disclosure contains Python/protobuf type information, not user request data.

- -

Release gate

-
- - - - - - - - - - -
AreaAssessmentRationale
Core correctnessPassAll 217 repository tests pass on Python 3.11 and 3.14; additional regex and service battle suites passed.
Custom gatesPassTwo novel gates and the bundled example worked through schema, validation, offline evaluation, downstream mutation, and live gRPC.
Security/content safetyPassSentinel request/config values stay secret; malformed protobuf now returns cataloged INVALID_ARGUMENT without implementation details.
ResiliencePassDeadlines, cancellation, worker and RPC saturation, invalid policy replacement, oversize input, and close/restart behavior recovered.
Operator UXPassValidation, preparation, and corpus failures now provide bounded actionable context; command discovery and narrow-terminal help are verified.
PackagingPasssdist and wheel build and install cleanly on Python 3.11; packaged guidance now separates installed and source-checkout workflows and uses durable links.
-
-
- -
-

Verified remediation status

-

The original QA session found five medium- and eight low-severity issues. Eleven new regression tests were added during remediation. Transport behaviors owned by grpcio were resolved with explicit bounded operational guidance instead of weakening EgressGate's admission controls or hiding HTTP/2 faults.

-
- - - - - - - - - - - - - - - - - -
FindingResolutionStatus
EG-QA-01A server interceptor now catches protobuf decode failures before dispatch and returns cataloged request_protobuf_invalid with gRPC INVALID_ARGUMENT. Raw-wire recovery is regression-tested.Verified
EG-QA-02Gate preparation failures map to config_preparation_failed with safe built-in regex remediation rather than custom-resource guidance.Verified
EG-QA-03Policy errors now report one trusted schema path and safe category while excluding submitted values, Pydantic inputs, context, and URLs.Verified
EG-QA-04The README separates installed and source-checkout workflows, states that examples/docs are repository assets, and uses durable absolute links. Wheel metadata was inspected after a clean build.Verified
EG-QA-05Automatic logging color honors the presence of NO_COLOR; explicit application-owned ALWAYS remains an override. Empty and non-empty values are tested.Verified
EG-QA-06Execution failures identify the validated case name and render safe results completed before the failure without exposing request content.Verified
EG-QA-07Bare invocation now renders help and exits 0.Verified
EG-QA-08Operations guidance now specifies short bounded 5/10/20 ms backoff within the middleware deadline. EgressGate retains grpcio's 16-RPC transport guard.Documented
EG-QA-09Operations guidance distinguishes expected HTTP/2 GOAWAY/cancellation during zero-grace planned shutdown from actionable out-of-window transport faults.Documented
EG-QA-10Generated schemas rewrite Pydantic generic definition names and references to stable ConfiguredGate and PipelineConfig names.Verified
EG-QA-11egress-gate --version reports the installed distribution version and exits 0.Verified
EG-QA-12Plain help preserves complete option identifiers at 40 columns; concise command summaries remain complete at standard widths.Verified
EG-QA-13Operations documentation now gives policy, transport, and controlled sandbox end-to-end readiness checks and explains the limits of each layer.Verified
-
-
- -
-

Original QA findings

- -
-
Medium

EG-QA-01 — Malformed protobuf returns UNKNOWN with implementation details

-
-
Observed
A malformed nested protobuf returned UNKNOWN and named google.protobuf.message.DecodeError plus the generated message type.
-
Expected
A stable, content-safe INVALID_ARGUMENT response consistent with the documented invalid-request contract.
-
Impact
Clients cannot classify all bad input consistently, and the response exposes runtime implementation detail. The server did recover immediately.
-
Likely seam
The failure occurs before the servicer method, so handling probably belongs at the gRPC deserialization or interceptor boundary.
-
-
Reproduction and evidence -
raw = channel.unary_unary(
-    "/openshell.middleware.v1.SupervisorMiddleware/EvaluateHttpRequest",
-    request_serializer=lambda value: value,
-    response_deserializer=lambda value: value,
-)
-await raw(b"\x12\x02\x0a\xff")
-
-status=UNKNOWN
-details="Unexpected <class 'google.protobuf.message.DecodeError'>:
-Error parsing message with type
-'openshell.middleware.v1.HttpRequestEvaluation'"
-
-
- -
-
Medium

EG-QA-02 — Built-in regex preparation failure gives custom-gate guidance

-
-
Observed
A structurally valid policy with a forbidden named capture group passes validate, then evaluate reports generic execution_failed and tells the user to inspect custom resources.
-
Expected
A cataloged configuration/preparation error that identifies the regex-policy remediation without echoing pattern content.
-
Impact
The error is safe but sends operators to the wrong subsystem, increasing time to diagnose a built-in configuration issue.
-
-
Reproduction and evidence -
$ egress-gate validate --policy qa_policy_named_group.yaml
-✓ Policy is valid
-
-$ egress-gate evaluate --policy qa_policy_named_group.yaml --cases qa_cases.yaml
-Evaluation failed [execution_failed]
-An unexpected error stopped the evaluation.
-Next: Check custom gate and application-owned resource setup, then retry.
-[exit 2]
-
-
- -
-
Medium

EG-QA-03 — Policy validation diagnostics lack a field path

-
-
Observed
A typo such as scna is reduced to a generic schema mismatch; the CLI does not identify the gate, YAML path, unknown key, or missing scan field. Missing and malformed files also share the same invalid_input text.
-
Expected
A bounded structural location and reason, while continuing to suppress submitted values and raw exception text.
-
Impact
Safe but slow troubleshooting, especially in a large multi-gate policy.
-
-
Observed output -
Policy validation failed [config_invalid]
-The policy does not match the schema for the installed gates.
-Next: Run egress-gate gates schema, then check the pipeline, gate kinds,
-required fields, and pattern catalog.
-[exit 1]
-
-
- -
-
Medium

EG-QA-04 — Packaged README quickstart depends on files that are not shipped

-
-
Observed
The wheel and sdist include sources, license, and README but omit examples/, docs/, and uv.lock. The embedded README tells users to validate examples/regex-redaction/egress-gate-config.yaml and links to relative documentation files.
-
Expected
An installed-package quickstart that works from a neutral directory, or an explicit “from a source checkout” label with absolute repository/documentation links.
-
Impact
A successful clean installation leads directly to a failing advertised first workflow and broken local documentation links.
-
-
Distribution evidence -
$ egress-gate validate \
-    --policy examples/regex-redaction/egress-gate-config.yaml
-Policy validation failed [invalid_input]
-[exit 1]
-
-
- -
-
Medium

EG-QA-05 — NO_COLOR is ignored by interactive service logging

-
-
Observed
In a pseudo-TTY, NO_COLOR=1 egress-gate --debug serve still emitted ANSI sequences for timestamp, level, and logger name.
-
Expected
The standard opt-out should disable styling in logging as well as command output.
-
Impact
Accessibility preferences are not honored and captured terminal logs may contain unwanted escape codes.
-
-
- -
-
Low

EG-QA-06 — Execution failure omits the failing corpus case

-

An invalid-UTF-8 case aborts with body_encoding_invalid but does not print its bounded case name or already completed results. In a large corpus this forces manual bisection. Include the validated case name without rendering request fields.

-
- -
-
Low

EG-QA-07 — Bare command prints help but exits 2

-

Running egress-gate with no arguments renders useful top-level help but returns usage-error status 2. This is common CLI-framework behavior, but exit 0 would better match a discovery-oriented first run.

-
- -
-
Low

EG-QA-08 — Immediate retry can briefly remain saturated

-

After 20 concurrent calls produced 16 allows and four expected RESOURCE_EXHAUSTED results, one immediate retry was also rejected. A retry 5 ms later succeeded. This may be grpcio accounting teardown rather than EgressGate logic; document retry/backoff or smooth the recovery if practical.

-
- -
-
Low

EG-QA-09 — Successful shutdown can emit confusing GOAWAY noise

-

Normal live-server teardown emitted grpcio core messages including Got goaway and Cancelling all calls. No work was lost. Consider logging guidance or filtering so expected shutdown does not resemble an incident.

-
- -
-
Low

EG-QA-10 — Generated schema definition names are unwieldy

-

The JSON is valid, but custom-gate definitions can receive long Pydantic-derived names such as ConfiguredGate_Annotated_Union_RegexConfig__PathPrefixDenyConfig.... Stable human-oriented titles or a concise YAML schema summary would make diagnostics and discussion easier.

-
- -
-
Low

EG-QA-11 — No --version command

-

egress-gate --version returns “No such option” with exit 2. Operators lack a direct way to correlate a running CLI with package and protocol versions.

-
- -
-
Low

EG-QA-12 — Very narrow help truncates option names

-

At a 40-column pseudo-TTY, required registration options render as --host… and --conf…. Prefer a stacked/plain layout at narrow widths so identifiers remain copyable.

-
- -
-
Low

EG-QA-13 — Readiness verification is under-documented

-

Operations guidance covers binding, registration, restart, and logs but no explicit health or end-to-end gateway reachability check. Add a concrete readiness verification workflow.

-
-
- -
-

Custom-gate release-critical track

-

QA did not rely only on the bundled keyword example. Two disposable gates were independently authored in isolated copies using the documented public API.

-
- - - - - - - - -
GatePurposePath exercisedResult
stamp-or-denyWrite a header unless a body token requires denial.--registry-factory, list, schema, validate, offline evaluate, downstream regex observation.Pass
qa-probeResource-backed keyword deny, controlled delay, counters, and deliberate exception.Real grpc.aio server, Describe, ValidateConfig, EvaluateHttpRequest, concurrency, policy replacement, failure redaction.Pass
Deliberately invalid gateReturn an undeclared terminal deny.Public capability enforcement and content-safe CLI failure.Rejected correctly
Bundled keyword-denyDocumentation smoke test.Discovery, policy validation, two-case offline corpus.Pass
-
- -

What the authored gates proved

-
    -
  • The exact registry schema includes custom discriminators and capabilities.
  • -
  • Custom configuration is strict; malformed configuration is rejected without echoing sentinel values.
  • -
  • Header mutations are applied to a new request snapshot and are visible to a downstream built-in regex gate.
  • -
  • Terminal custom decisions preserve gate name, gate type, and decision-source provenance.
  • -
  • Undeclared deny output fails closed as gate_output_invalid; the sensitive body sentinel is absent.
  • -
  • Application-owned resources are reused safely under live concurrency and never exceed four active workers.
  • -
  • A custom exception containing the request body becomes cataloged gate_execution_failed; the body is not returned.
  • -
  • An invalid policy candidate does not replace the last valid active processor.
  • -
-
$ egress-gate --registry-factory qa_custom_gate:create_registry evaluate \
-    --policy qa_custom_policy.yaml --cases qa_custom_cases.yaml
-2 passed · 0 failed · 2 total
-[exit 0]
-
-$ egress-gate --registry-factory qa_bad_gate:create_registry evaluate \
-    --policy qa_bad_policy.yaml --cases qa_bad_cases.yaml
-Evaluation failed [gate_output_invalid]
-A gate returned an invalid result.
-[exit 2]
-
- -
-

Battle-test matrix

-
- - - - - - - - - - - - - - - - - - -
AreaScenariosResult
Repository checksTests, formatting, Ruff, ty, import smoke test, dependency audit on Python 3.11.15 and 3.14.4.Pass
Regex gateBody, path, raw query, selected/repeated/case-insensitive headers, detect, deny, replace, overlap ranking, downstream mutation, ordering.Pass
Policy/corpus strictnessDuplicate keys/cases, YAML aliases, unknown fields, noncanonical base64, unsupported replacements, absolute/traversal/symlink catalogs, missing files.Rejected safely; diagnostics finding
CLIHelp, gate list/schema, validation, evaluation, stable exits 0/1/2, timeout bounds, registry reference errors, non-TTY and 40-column pseudo-TTY output.Pass; UX findings
PackagingLocked sync, sdist/wheel build, clean wheel install on Python 3.11, Python 3.10 rejection, installed console script, embedded README workflow.Runtime pass; packaged quickstart finding
Live gRPCDescribe, ValidateConfig, EvaluateHttpRequest, custom resource gate, active-policy changes, invalid candidate rollback.Pass
Deadlines/queueing12 calls, four workers, 90 ms work, 40 ms service timeout; atomic limit denials; post-drain recovery.Pass
CancellationEight calls with 15 ms client deadline and 100 ms work; slot retention, worker bound, recovery.Pass
Saturation20 calls against 16-RPC bound.16 allowed, 4 expected exhausted; one transient retry finding
Boundaries5,242,881-byte frame, 4 MiB + 1 body, NaN configuration, malformed protobuf.Limits enforced; malformed-wire finding
Content safetySentinels in bodies, policies, paths, templates, malformed configs, and raised exceptions.No request-content leak found
LifecycleClose during in-flight gate, active processor cleanup, repeated start/stop.Pass
Gateway registrationInput rejection, XDG creation, mode 0600, idempotent add/update, removal, operator next steps.Pass
AccessibilityNormal and narrow terminal rendering, piped schema, NO_COLOR service logging.Readable overall; color and narrow-help findings
-
-
- -
-

Evidence and environments

-

Each specialist copied the project to a unique temporary directory and selected a distinct UV_PROJECT_ENVIRONMENT. Disposable gates, policies, corpora, and battle tests existed only in those copies. The shared worktree was used read-only until this report was added.

- -

Authoritative validation

-
$ UV_PROJECT_ENVIRONMENT=/tmp/egress-gate-remediation-py311 make check-py311
-Using CPython 3.11.15
-217 passed in 1.68s
-41 files already formatted
-All checks passed!  # Ruff
-All checks passed!  # ty
-No known vulnerabilities found
-
-$ UV_PROJECT_ENVIRONMENT=/tmp/egress-gate-remediation-qa make check
-Using CPython 3.14.4
-217 passed in 1.61s
-41 files already formatted
-All checks passed!  # Ruff
-All checks passed!  # ty
-No known vulnerabilities found
-

The audit skipped only local unpublished egress-gate 0.1.0, as expected. Repeated cachecontrol cache-deserialization warnings were environment/tooling noise and did not affect the result.

- -

Additional suites and measurements

-
    -
  • Service/timeout/processor focus: 63 passed in 1.08s.
  • -
  • Disposable live-service battle suite: 9 passed in 1.72s.
  • -
  • Five-case adversarial regex corpus: 5 passed.
  • -
  • Authored stamp-or-deny corpus: 2 passed.
  • -
  • Bundled regex corpus: 2 passed.
  • -
  • Bundled custom-gate corpus: 2 passed.
  • -
  • Custom live concurrency: 16 RPCs × 25 ms work, 109.5 ms elapsed, max_active=4.
  • -
  • Saturation recovery: 7.4 ms to successful retry, with one transient rejection.
  • -
- -
Representative commands -
# Baselines
-UV_PROJECT_ENVIRONMENT=/tmp/egress-gate-root-qa-venv make check
-UV_PROJECT_ENVIRONMENT=/tmp/egress-gate-root-qa-py311 make check-py311
-
-# Built-in workflow
-uv run --frozen egress-gate gates list
-uv run --frozen egress-gate validate \
-  --policy examples/regex-redaction/egress-gate-config.yaml
-uv run --frozen egress-gate evaluate \
-  --policy examples/regex-redaction/egress-gate-config.yaml \
-  --cases examples/regex-redaction/cases.yaml
-
-# Bundled custom gate
-uv run --frozen egress-gate \
-  --registry-factory examples.custom-gate.keyword_gate:create_registry \
-  evaluate --policy examples/custom-gate/egress-gate-config.yaml \
-  --cases examples/custom-gate/cases.yaml
-
-# Packaging in a disposable source copy
-uv build
-uv venv /tmp/egress-gate-package-qa/install-venv --python 3.11
-uv pip install --python /tmp/egress-gate-package-qa/install-venv/bin/python \
-  dist/egress_gate-0.1.0-py3-none-any.whl
-/tmp/egress-gate-package-qa/install-venv/bin/egress-gate gates list
-
-
- -
-

Implemented actions and guardrails

-
    -
  1. Normalized malformed protobuf errors. Decode failures now return stable, content-safe INVALID_ARGUMENT; a raw-wire regression protects the boundary.
  2. -
  3. Repaired the installed-package journey. Source-checkout commands are labeled, the installed quickstart is standalone, and documentation links are absolute.
  4. -
  5. Translated preparation errors precisely. Built-in GateConfigurationError failures map to a cataloged preparation response with relevant next steps.
  6. -
  7. Added safe structural diagnostics. Bounded locations such as pipeline.gates[2].config.scan and safe categories replace generic schema errors.
  8. -
  9. Honored color preferences. NO_COLOR applies to automatic service logging and has regression coverage.
  10. -
  11. Added case context to evaluator failures. The validated case name and already completed safe projections are retained.
  12. -
  13. Documented overload retry and readiness behavior. Operations guidance defines bounded RESOURCE_EXHAUSTED backoff and layered readiness checks.
  14. -
  15. Polished command discovery. --version, exit-0 bare help, complete narrow-width option identifiers, concise command summaries, and stable schema titles are verified.
  16. -
-

Release stance after remediation: no open QA finding blocks controlled trusted-network deployment. Keep the new raw-wire, content-safety, CLI, schema, logging, and documentation checks in the release gate.

-
- -
-

Scope and limitations

-
    -
  • No upstream provider traffic or credential attachment was tested; those are outside EgressGate's documented pre-credentials boundary.
  • -
  • Live tests used a real grpc.aio server and generated stub, but did not launch the long-running CLI serve process on a production port.
  • -
  • TLS was not tested because this version intentionally documents plaintext gRPC on a restricted trusted network.
  • -
  • Cancellation cannot forcibly terminate already-running trusted synchronous Python code; QA verified bounded slot ownership and later recovery.
  • -
  • Offline evaluator mutation bodies are intentionally not directly assertable; downstream gates were used to prove mutation flow.
  • -
  • The existing latency CSV was not rerun as a performance benchmark; this session measured only targeted concurrency and recovery scenarios.
  • -
  • Dependency auditing covers published dependencies; the local unpublished EgressGate package cannot be resolved by pip-audit.
  • -
-
-
- - - - diff --git a/projects/egress-gate/analysis/render_latency_plot.py b/projects/egress-gate/analysis/render_latency_plot.py deleted file mode 100644 index 78fa78b2..00000000 --- a/projects/egress-gate/analysis/render_latency_plot.py +++ /dev/null @@ -1,401 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Render the Egress Gate latency proof-of-concept figure as deterministic SVG.""" - -from __future__ import annotations - -import argparse -import csv -import math -import statistics -from dataclasses import dataclass -from pathlib import Path - -_ANALYSIS_DIR = Path(__file__).resolve().parent -_DEFAULT_DATA = _ANALYSIS_DIR / "egress-gate-latency.csv" -_DEFAULT_OUTPUT = ( - _ANALYSIS_DIR.parent - / "docs" - / "assets" - / "analysis" - / "egress-gate-latency-vs-prompt-size.svg" -) - -_WIDTH = 1200 -_HEIGHT = 720 -_MARGIN_LEFT = 105 -_MARGIN_RIGHT = 65 -_MARGIN_TOP = 75 -_MARGIN_BOTTOM = 105 -_X_MIN = 10_000.0 -_X_MAX = 1_200_000.0 -_Y_MIN = 4.0 -_Y_MAX = 400.0 -_CONTEXT_THRESHOLD = 1_000_000.0 -_COLORBAR_X = 650 -_COLORBAR_WIDTH = 330 -_ENTITY_COLORS = ( - (0.00, (43, 10, 61)), - (0.25, (123, 47, 142)), - (0.50, (213, 82, 105)), - (0.75, (245, 137, 76)), - (1.00, (247, 209, 61)), -) - - -@dataclass(frozen=True) -class Measurement: - """One joined latency observation.""" - - observed_at_utc: str - prompt_tokens: int - egress_gate_latency_ms: float - entity_count: int - phase: str - openshell_observed_ms: float | None - first_output_elapsed_ms: float | None - turn_elapsed_ms: float | None - - -@dataclass(frozen=True) -class LinearFit: - """Ordinary least-squares fit of latency against tokens.""" - - intercept_ms: float - milliseconds_per_100k_tokens: float - r_squared: float - - def predict(self, prompt_tokens: float) -> float: - """Return the fitted latency for one prompt size.""" - return self.intercept_ms + self.milliseconds_per_100k_tokens * ( - prompt_tokens / 100_000.0 - ) - - -def main() -> None: - """Load observations and write or verify the generated SVG.""" - parser = argparse.ArgumentParser() - parser.add_argument("--data", type=Path, default=_DEFAULT_DATA) - parser.add_argument("--output", type=Path, default=_DEFAULT_OUTPUT) - parser.add_argument( - "--check", - action="store_true", - help="Fail when the committed SVG does not match a fresh render.", - ) - args = parser.parse_args() - - measurements = _load_measurements(args.data) - fit = _linear_fit(measurements) - svg = _render_svg(measurements, fit) - - if args.check: - if not args.output.exists() or args.output.read_text(encoding="utf-8") != svg: - raise SystemExit( - f"{args.output} is missing or stale; rerun without --check" - ) - else: - args.output.parent.mkdir(parents=True, exist_ok=True) - args.output.write_text(svg, encoding="utf-8") - - completed_turns = [row for row in measurements if row.turn_elapsed_ms is not None] - mean_turn_share = statistics.fmean( - row.egress_gate_latency_ms / row.turn_elapsed_ms - for row in completed_turns - if row.turn_elapsed_ms is not None - ) - print( - f"{len(measurements)} measurements; " - f"fit={fit.intercept_ms:.2f} + " - f"{fit.milliseconds_per_100k_tokens:.2f} ms/100k tokens; " - f"R²={fit.r_squared:.3f}; " - f"mean turn share={100.0 * mean_turn_share:.2f}%" - ) - - -def _load_measurements(path: Path) -> list[Measurement]: - rows: list[Measurement] = [] - with path.open(encoding="utf-8", newline="") as source: - for row in csv.DictReader(source): - rows.append( - Measurement( - observed_at_utc=row["observed_at_utc"], - prompt_tokens=int(row["prompt_tokens"]), - egress_gate_latency_ms=float(row["egress_gate_latency_ms"]), - entity_count=int(row["entity_count"]), - phase=row["phase"], - openshell_observed_ms=_optional_float(row["openshell_observed_ms"]), - first_output_elapsed_ms=_optional_float( - row["first_output_elapsed_ms"] - ), - turn_elapsed_ms=_optional_float(row["turn_elapsed_ms"]), - ) - ) - if len(rows) < 2: - raise ValueError("at least two measurements are required") - return rows - - -def _optional_float(value: str) -> float | None: - return float(value) if value else None - - -def _linear_fit(measurements: list[Measurement]) -> LinearFit: - x_values = [row.prompt_tokens / 100_000.0 for row in measurements] - y_values = [row.egress_gate_latency_ms for row in measurements] - x_mean = statistics.fmean(x_values) - y_mean = statistics.fmean(y_values) - x_variance = sum((value - x_mean) ** 2 for value in x_values) - if x_variance == 0.0: - raise ValueError("prompt-token observations must not all be equal") - slope = ( - sum( - (x_value - x_mean) * (y_value - y_mean) - for x_value, y_value in zip(x_values, y_values, strict=True) - ) - / x_variance - ) - intercept = y_mean - slope * x_mean - residual_sum = sum( - (y_value - (intercept + slope * x_value)) ** 2 - for x_value, y_value in zip(x_values, y_values, strict=True) - ) - total_sum = sum((value - y_mean) ** 2 for value in y_values) - r_squared = 1.0 - residual_sum / total_sum - return LinearFit( - intercept_ms=intercept, - milliseconds_per_100k_tokens=slope, - r_squared=r_squared, - ) - - -def _render_svg(measurements: list[Measurement], fit: LinearFit) -> str: - plot_right = _WIDTH - _MARGIN_RIGHT - plot_bottom = _HEIGHT - _MARGIN_BOTTOM - minimum_entities = min(row.entity_count for row in measurements) - maximum_entities = max(row.entity_count for row in measurements) - completed_turns = [row for row in measurements if row.turn_elapsed_ms is not None] - mean_turn_share = statistics.fmean( - row.egress_gate_latency_ms / row.turn_elapsed_ms - for row in completed_turns - if row.turn_elapsed_ms is not None - ) - - parts = [ - ( - f'' - ), - 'Egress Gate latency versus prompt size', - ( - 'Scatter plot of 96 Egress Gate service ' - "latency measurements from 18 thousand to 1.141 million prompt " - "tokens, with one linear fit and a one-million-token threshold. " - f"Egress Gate averaged {100.0 * mean_turn_share:.2f} percent of " - "end-to-end time across 12 completed turns." - ), - "", - '', - *[ - f'' - for position, color in _ENTITY_COLORS - ], - "", - "", - ( - f'' - "Egress Gate latency (ms) · log scale" - ), - ] - - for value in (10_000, 30_000, 100_000, 300_000, 1_000_000): - x = _x(value) - parts.append( - f'' - ) - anchor = "start" if value == 10_000 else "middle" - parts.append( - f'{_format_tokens(value)}' - ) - - for value in (5, 10, 20, 50, 100, 250): - y = _y(value) - parts.append( - f'' - ) - parts.append( - f'{value}' - ) - - threshold_x = _x(_CONTEXT_THRESHOLD) - parts.extend( - [ - ( - f'' - ), - ( - f'1M threshold' - ), - ] - ) - - fit_points = [] - token_value = _X_MIN - while token_value < _X_MAX: - fit_points.append(f"{_x(token_value):.2f},{_y(fit.predict(token_value)):.2f}") - token_value *= 1.06 - fit_points.append(f"{_x(_X_MAX):.2f},{_y(fit.predict(_X_MAX)):.2f}") - parts.append(f'') - - for row in measurements: - point_color = _entity_color( - row.entity_count, - minimum=minimum_entities, - maximum=maximum_entities, - ) - parts.append( - f'' - f"{row.prompt_tokens:,} tokens; " - f"{row.egress_gate_latency_ms:.1f} ms; " - f"{row.entity_count} entities detected" - ) - - share_x = _x(14_000) - share_y = _y(90) - parts.extend( - [ - ( - f'' - f"Egress Gate averaged {100.0 * mean_turn_share:.2f}%" - ), - ( - f'of end-to-end turn time' - ), - ( - f'across {len(completed_turns)} ' - "completed turns" - ), - ] - ) - - parts.extend( - [ - ( - f'' - "Entities detected" - ), - ( - f'' - ), - ( - f'Prompt tokens · log scale' - ), - ( - f'' - f"Single fit across {len(measurements)} measurements: " - f"{fit.intercept_ms:.2f} + " - f"{fit.milliseconds_per_100k_tokens:.2f} ms per 100k tokens · " - f"R² {fit.r_squared:.3f}" - ), - ] - ) - for tick in (minimum_entities, 100, 200, 300, maximum_entities): - tick_x = _COLORBAR_X + _COLORBAR_WIDTH * ( - (tick - minimum_entities) / (maximum_entities - minimum_entities) - ) - parts.append( - f'' - ) - parts.append( - f'{tick}' - ) - parts.append("") - return "\n".join(parts) + "\n" - - -def _x(value: float) -> float: - fraction = (math.log10(value) - math.log10(_X_MIN)) / ( - math.log10(_X_MAX) - math.log10(_X_MIN) - ) - return _MARGIN_LEFT + fraction * (_WIDTH - _MARGIN_LEFT - _MARGIN_RIGHT) - - -def _y(value: float) -> float: - fraction = (math.log10(value) - math.log10(_Y_MIN)) / ( - math.log10(_Y_MAX) - math.log10(_Y_MIN) - ) - return ( - _HEIGHT - _MARGIN_BOTTOM - fraction * (_HEIGHT - _MARGIN_TOP - _MARGIN_BOTTOM) - ) - - -def _format_tokens(value: int) -> str: - if value >= 1_000_000: - return f"{value / 1_000_000:.0f}M" - return f"{value // 1_000}k" - - -def _entity_color(value: int, *, minimum: int, maximum: int) -> str: - if maximum == minimum: - return _rgb_hex(_ENTITY_COLORS[len(_ENTITY_COLORS) // 2][1]) - fraction = (value - minimum) / (maximum - minimum) - for (left_position, left_color), ( - right_position, - right_color, - ) in zip(_ENTITY_COLORS, _ENTITY_COLORS[1:], strict=True): - if fraction <= right_position: - segment = (fraction - left_position) / (right_position - left_position) - color = ( - round(left_color[0] + segment * (right_color[0] - left_color[0])), - round(left_color[1] + segment * (right_color[1] - left_color[1])), - round(left_color[2] + segment * (right_color[2] - left_color[2])), - ) - return _rgb_hex(color) - return _rgb_hex(_ENTITY_COLORS[-1][1]) - - -def _rgb_hex(color: tuple[int, int, int]) -> str: - return "#" + "".join(f"{channel:02x}" for channel in color) - - -if __name__ == "__main__": - main() diff --git a/projects/egress-gate/docs/architecture/index.md b/projects/egress-gate/docs/architecture/index.md deleted file mode 100644 index 81ebd3ac..00000000 --- a/projects/egress-gate/docs/architecture/index.md +++ /dev/null @@ -1,74 +0,0 @@ ---- -title: System architecture -description: Egress Gate components, boundaries, state, and concurrency. -agent_markdown: true ---- - -# System architecture - -Egress Gate has one transport adapter and one protobuf-free pipeline processor. - -
- Inside Egress Gate, the gRPC service adapter is separate from the protobuf-free pipeline processor and request gates. -
The external OpenShell supervisor talks only to the Egress Gate service adapter. The pipeline processor and gates use local domain models.
-
- -## Component ownership - -| Module | Responsibility | -| --- | --- | -| `request.py` | Immutable request, headers, and `RequestMutations` | -| `request_content/` | Reusable text parsers, strict JSON documents, typed selection, source-preserving edits, and normalized message blocks | -| `result.py` | Gate evaluations, five-field findings, provenance, traces, and result invariants | -| `gates/base.py` | Gate lifecycle, capabilities, output validation, and UTF-8 helper | -| `gates/registry.py` | Trusted registration, exact pipeline schema, resources, discovery, and processor preparation | -| `gates/regex_scans.py` | Typed regex scan and action policy configuration | -| `gates/regex.py` | Content-parser composition, non-body text adaptation, pattern catalogs, bounded matching, finding aggregation, and gate evaluation | -| `config.py` | Strict ordered gates and required default decision | -| `request_processor.py` | Shared deadline, immutable snapshot construction, control flow, aggregation, and provenance | -| `service/` | Protobuf validation/conversion, worker slots, lifecycle, and wire serialization | - -The CLI's offline evaluator parses bounded YAML. It uses -`GateRegistry.prepare_processor()` and the production `RequestProcessor`. It -does not add a second execution path or import the transport adapter. - -Only `service/` imports generated protobuf/gRPC bindings. The pipeline processor -and gates receive domain values and can be tested offline. - -The request body remains canonical immutable bytes. A configured gate can -interpret the current body snapshot as strict JSON and select text nodes, then -optionally adapt those nodes to normalized message blocks. These views remain -local to one gate evaluation. The pipeline processor does not parse bodies or -cache request state across reusable gate instances. - -Regex scan models remain declarative Pydantic configuration. During gate -preparation, body-based variants compose a reusable `RequestContentParser`: -`Utf8TextParser`, `JsonFieldsParser`, or `MessageBlocksParser`. Each parser owns -text extraction and how immutable `TextReplacement` values become bounded body -bytes. `MessageBlocksParser` composes a `MessageBlockExtractor` to normalize a -parsed JSON document without conflating that semantic step with body parsing. -The regex gate only adapts path, query, and header values itself, then applies -matching and actions uniformly to the text targets it receives. - -## Pipeline execution - -
- A request moves through pipeline processor controls and an ordered gate pipeline before Egress Gate returns a result. -
Each gate proposes changes to its current snapshot. The pipeline processor builds the next snapshot, the service adapter maps the final mutations, and the OpenShell supervisor applies them.
-
- -## Trust and state - -Registry factories and custom gate modules are trusted deployment code. -Capabilities mechanically constrain outputs but do not sandbox Python reads. -Prepared gates can use application-owned resources that are safe for concurrent -use. Egress Gate does not close these resources. - -One validated policy and one prepared pipeline processor (`RequestProcessor`) -are active at a time. -Preparation is serialized and a complete candidate is published only after -the shared deadline checks. A failed candidate leaves the existing policy -unchanged. Gate instances are reused across worker threads, so per-request -state must remain local to `evaluate`. - -See [Request lifecycle](request-lifecycle.md) and [Service boundary](service-boundary.md). diff --git a/projects/egress-gate/docs/architecture/request-lifecycle.md b/projects/egress-gate/docs/architecture/request-lifecycle.md deleted file mode 100644 index f98fb6fd..00000000 --- a/projects/egress-gate/docs/architecture/request-lifecycle.md +++ /dev/null @@ -1,79 +0,0 @@ ---- -title: Request lifecycle -description: How one OpenShell evaluation becomes an EgressResult. -agent_markdown: true ---- - -# Request lifecycle - -
- The Egress Gate service validates an OpenShell request, prepares the policy, creates immutable snapshots for the gate pipeline, and maps the result back to OpenShell. -
The pipeline processor updates local snapshots. The OpenShell supervisor applies final mutations to the intercepted request.
-
- -## 1. Validate the transport - -The service checks the pre-credentials phase, exact protobuf configuration, -context, target, header, and body bounds. Domain models then enforce bounded -scalar and aggregate values. Invalid input produces a cataloged gRPC failure. - -## 2. Validate and prepare the policy - -The service converts the protobuf `Struct` to a mapping. The sealed -`GateRegistry` validates it as an exact `EgressGateConfig`. The registry then -prepares each configured gate and creates a `RequestProcessor`. Preparation -uses one replacement lock and the request `Timeout`. The service publishes the -candidate only after a final deadline check. - -## 3. Execute the pipeline - -For each configured gate, the Egress Gate pipeline processor: - -1. Check the shared deadline. -2. Pass the current read-only `HttpRequest` snapshot to the gate. -3. When configured by the gate, parse the current body as strict JSON, select - bounded string nodes, and optionally normalize those nodes as message - blocks. -4. Reconstruct and validate the returned `GateEvaluation`. -5. Add a content-safe `GateTrace` and `SourcedFinding` values owned by the - pipeline processor. -6. On `proceed`, validate the request mutations and construct the next request - snapshot. -7. On terminal `allow` or `deny`, stop without invoking later gates. - -The pipeline processor never changes a request object in place. It keeps the -first snapshot private, constructs a new snapshot after each validated mutation -set, and passes that snapshot to the next gate. The final allowed result -combines these mutations in order. A denied result always has an empty mutation -set. Body replacement `None` and `b""` remain distinct. Header mutation variants -use the required `kind` values `write` and `remove`. - -Structured JSON replacement works through the same complete-body mutation -contract. The JSON document renders selected string-token edits in one bounded -pass while preserving every byte outside those tokens. It returns one complete -replacement body. A later gate therefore parses the body snapshot produced by -earlier structured or raw replacements. - -If every gate proceeds, `default_decision` controls the result. Default deny -uses `egress_gate_default_deny`. Default allow has no reason code. - -## 4. Handle pipeline processor limits - -Deadline expiry, worker-slot exhaustion, mutation bounds, finding limits, and -encoded output limits return an atomic deny with source `runtime_limit` and -`egress_gate_limit_exceeded`. No partial mutations or findings are returned. -Gate contract and execution failures remain gRPC failures. - -The internal processing timeout can return this denial only while the RPC is -still active. The OpenShell gateway owns a separate outer RPC ceiling. If that -outer clock expires first, OpenShell applies the middleware entry's `on_error` -policy instead of receiving an Egress Gate result. Use `on_error: fail_closed` -when middleware timeout failures must deny the request. - -## 5. Serialize the result - -The Egress Gate service adapter maps the protobuf-free `EgressResult` to -OpenShell's `HttpRequestResult`. It serializes the final body and header -mutations, exactly five finding fields, and no internal provenance. An explicit -empty replacement sets `has_body=true` with an empty body. After an allow, the -OpenShell supervisor applies these mutations to the intercepted request. diff --git a/projects/egress-gate/docs/architecture/service-boundary.md b/projects/egress-gate/docs/architecture/service-boundary.md deleted file mode 100644 index 686cd493..00000000 --- a/projects/egress-gate/docs/architecture/service-boundary.md +++ /dev/null @@ -1,82 +0,0 @@ ---- -title: Service boundary -description: Protobuf conversion, validation, worker scheduling, and lifecycle. -agent_markdown: true ---- - -# Service boundary - -The `service/` package is the only handwritten package that imports OpenShell -protobuf/gRPC bindings. It owns exact encoded wire limits and transport status -mapping. Domain models own protobuf-free invariants. - -The OpenShell supervisor owns the intercepted request. Egress Gate receives its -request data over gRPC and works with local immutable `HttpRequest` snapshots. -The Egress Gate service adapter returns a decision and final mutations; the -supervisor applies allowed mutations to the intercepted request. - -## RPCs - -| RPC | Behavior | -| --- | --- | -| `Describe` | Return Egress Gate's pre-credentials HTTP binding | -| `ValidateConfig` | Validate a complete registry-backed pipeline without publishing it | -| `EvaluateHttpRequest` | Adapt one request, prepare/reuse policy, execute, and serialize | - -The configuration arrives as `google.protobuf.Struct`. The adapter normalizes -safe integral doubles before strict domain validation and rejects oversized -encoded configuration before registry parsing. - -## Shared deadline and workers - -`EvaluateHttpRequest` converts `timeout_middleware_processing` into one monotonic -`Timeout` used for semaphore acquisition, policy preparation, replacement-lock -waits, gate execution, and final result checks. `Describe` leaves the binding's -optional RPC timeout empty. OpenShell therefore applies the separately -configured gateway registration timeout to the complete RPC. - -The middleware protocol does not report the resolved gateway timeout back to -Egress Gate, and OpenShell does not propagate it as a gRPC deadline. For the -normal CLI-managed path, `add-gateway-registration` remembers the gateway TOML -path and registration name. `serve` reads the current timeout from that entry -at startup and requires it to be greater than -`timeout_middleware_processing`. Direct Python API use and manually managed -registrations do not have this startup check. If the gateway timeout expires -first, OpenShell applies the policy's `on_error` behavior. - -`RequestProcessor.process` accepts the caller-owned timeout and never creates or -stores one. - -Synchronous work runs in a bounded four-slot executor. The gRPC server permits -sixteen concurrent RPCs. Cancellation does not stop Python code that already -runs in a worker. The worker owns its slot until it exits. - -## Wire findings and mutations - -The current OpenShell `Finding` contains exactly `type`, `label`, `count`, -`confidence`, and `severity`. `SourcedFinding.source_gate`, decision sources, -and traces belong to the pipeline processor and are not serialized. Decision -sources use a strict `kind`-discriminated union. The adapter rechecks protobuf -finding and header sizes before returning a response. - -`RequestMutations` is Egress Gate's internal aggregate. A gate returns it with -`proceed` instead of modifying its input. The pipeline processor validates and -applies it to a new local `HttpRequest` snapshot for the next gate. - -At the service boundary, the adapter maps the accumulated -`RequestMutations.replacement_body` to `HttpRequestResult.body` and `has_body`. -It maps each ordered header operation to -`HttpRequestResult.header_mutations`. `None` means no body replacement, while -empty bytes are emitted with `has_body=true`. The OpenShell supervisor applies -these wire mutations after an allow. - -## Lifecycle and errors - -The active policy contains one validated configuration and one prepared -pipeline processor. An equal configuration reuses the active pipeline -processor. The service prepares a changed candidate before it publishes that -candidate. An invalid candidate does not replace the active policy. - -Invalid input maps to `INVALID_ARGUMENT`. Internal gate or service failures map -to `INTERNAL`. A pipeline processor limit denial is not a gRPC failure. It uses -`egress_gate_limit_exceeded`. diff --git a/projects/egress-gate/docs/assets/analysis/egress-gate-latency-vs-prompt-size.svg b/projects/egress-gate/docs/assets/analysis/egress-gate-latency-vs-prompt-size.svg deleted file mode 100644 index fb18bc60..00000000 --- a/projects/egress-gate/docs/assets/analysis/egress-gate-latency-vs-prompt-size.svg +++ /dev/null @@ -1,162 +0,0 @@ - -Egress Gate latency versus prompt size -Scatter plot of 96 Egress Gate service latency measurements from 18 thousand to 1.141 million prompt tokens, with one linear fit and a one-million-token threshold. Egress Gate averaged 0.56 percent of end-to-end time across 12 completed turns. - - - - - - - - - -Egress Gate latency (ms) · log scale - -10k - -30k - -100k - -300k - -1M - -5 - -10 - -20 - -50 - -100 - -250 - -1M threshold - -18,291 tokens; 5.5 ms; 3 entities detected -18,722 tokens; 6.0 ms; 3 entities detected -18,923 tokens; 6.0 ms; 4 entities detected -19,120 tokens; 10.5 ms; 4 entities detected -19,378 tokens; 12.5 ms; 6 entities detected -19,798 tokens; 21.7 ms; 6 entities detected -20,335 tokens; 7.8 ms; 6 entities detected -21,404 tokens; 6.9 ms; 11 entities detected -22,377 tokens; 8.8 ms; 11 entities detected -22,934 tokens; 9.7 ms; 11 entities detected -23,528 tokens; 7.8 ms; 15 entities detected -23,866 tokens; 10.9 ms; 16 entities detected -26,472 tokens; 10.9 ms; 22 entities detected -26,853 tokens; 11.7 ms; 22 entities detected -28,021 tokens; 12.0 ms; 22 entities detected -28,867 tokens; 11.0 ms; 26 entities detected -31,351 tokens; 12.8 ms; 26 entities detected -33,610 tokens; 14.6 ms; 26 entities detected -34,193 tokens; 14.5 ms; 26 entities detected -20,747 tokens; 10.8 ms; 19 entities detected -22,310 tokens; 18.2 ms; 20 entities detected -23,103 tokens; 16.4 ms; 20 entities detected -27,015 tokens; 28.3 ms; 22 entities detected -31,441 tokens; 13.0 ms; 22 entities detected -38,362 tokens; 12.4 ms; 23 entities detected -39,098 tokens; 18.3 ms; 23 entities detected -39,471 tokens; 17.0 ms; 23 entities detected -40,025 tokens; 10.9 ms; 23 entities detected -47,124 tokens; 14.8 ms; 23 entities detected -47,960 tokens; 11.6 ms; 23 entities detected -48,251 tokens; 17.6 ms; 23 entities detected -51,102 tokens; 13.0 ms; 23 entities detected -51,552 tokens; 11.3 ms; 23 entities detected -52,216 tokens; 14.3 ms; 23 entities detected -52,634 tokens; 11.1 ms; 23 entities detected -53,244 tokens; 20.7 ms; 23 entities detected -62,423 tokens; 13.7 ms; 23 entities detected -63,068 tokens; 17.8 ms; 23 entities detected -63,486 tokens; 17.5 ms; 23 entities detected -64,295 tokens; 26.0 ms; 23 entities detected -76,372 tokens; 18.9 ms; 23 entities detected -77,031 tokens; 18.1 ms; 23 entities detected -82,339 tokens; 26.1 ms; 24 entities detected -83,184 tokens; 36.0 ms; 24 entities detected -88,677 tokens; 20.9 ms; 24 entities detected -89,748 tokens; 18.7 ms; 24 entities detected -89,970 tokens; 20.0 ms; 24 entities detected -90,530 tokens; 21.0 ms; 24 entities detected -91,420 tokens; 21.7 ms; 24 entities detected -92,901 tokens; 22.7 ms; 24 entities detected -93,678 tokens; 36.0 ms; 24 entities detected -95,694 tokens; 25.2 ms; 24 entities detected -96,704 tokens; 30.2 ms; 24 entities detected -96,868 tokens; 27.0 ms; 24 entities detected -97,886 tokens; 30.3 ms; 24 entities detected -98,356 tokens; 24.5 ms; 24 entities detected -99,265 tokens; 35.3 ms; 24 entities detected -101,025 tokens; 34.2 ms; 24 entities detected -101,193 tokens; 23.3 ms; 24 entities detected -102,071 tokens; 26.9 ms; 24 entities detected -102,481 tokens; 23.7 ms; 24 entities detected -104,787 tokens; 29.4 ms; 24 entities detected -105,313 tokens; 31.9 ms; 24 entities detected -105,801 tokens; 35.6 ms; 24 entities detected -105,958 tokens; 39.6 ms; 24 entities detected -106,160 tokens; 33.4 ms; 25 entities detected -106,428 tokens; 24.1 ms; 25 entities detected -106,807 tokens; 47.7 ms; 25 entities detected -106,981 tokens; 43.3 ms; 25 entities detected -107,412 tokens; 27.1 ms; 25 entities detected -107,647 tokens; 32.0 ms; 25 entities detected -108,052 tokens; 38.2 ms; 25 entities detected -108,800 tokens; 36.9 ms; 25 entities detected -114,425 tokens; 32.0 ms; 25 entities detected -114,584 tokens; 32.3 ms; 25 entities detected -115,395 tokens; 24.7 ms; 25 entities detected -115,852 tokens; 39.1 ms; 25 entities detected -117,730 tokens; 42.9 ms; 25 entities detected -120,732 tokens; 36.0 ms; 25 entities detected -122,710 tokens; 47.4 ms; 27 entities detected -127,634 tokens; 38.6 ms; 59 entities detected -136,816 tokens; 49.1 ms; 59 entities detected -143,349 tokens; 41.2 ms; 59 entities detected -323,611 tokens; 93.4 ms; 259 entities detected -324,158 tokens; 96.5 ms; 259 entities detected -325,804 tokens; 95.2 ms; 259 entities detected -329,592 tokens; 91.0 ms; 259 entities detected -656,648 tokens; 160.3 ms; 323 entities detected -665,140 tokens; 164.3 ms; 323 entities detected -963,788 tokens; 220.3 ms; 389 entities detected -965,007 tokens; 216.3 ms; 389 entities detected -1,140,979 tokens; 268.7 ms; 429 entities detected -228,409 tokens; 54.2 ms; 43 entities detected -428,405 tokens; 103.4 ms; 83 entities detected -628,418 tokens; 196.2 ms; 123 entities detected -801,456 tokens; 176.0 ms; 163 entities detected -Egress Gate averaged 0.56% -of end-to-end turn time -across 12 completed turns -Entities detected - -Prompt tokens · log scale -Single fit across 96 measurements: 6.69 + 23.28 ms per 100k tokens · R² 0.976 - -3 - -100 - -200 - -300 - -429 - diff --git a/projects/egress-gate/docs/assets/diagrams/component-architecture.svg b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg deleted file mode 100644 index dc7ca530..00000000 --- a/projects/egress-gate/docs/assets/diagrams/component-architecture.svg +++ /dev/null @@ -1,82 +0,0 @@ - - Egress Gate component architecture - All four layers are inside Egress Gate. The Egress Gate gRPC adapter exchanges HttpRequestEvaluation and HttpRequestResult messages with the external OpenShell supervisor. The protobuf-free pipeline processor invokes request gates and builds immutable local snapshots. - - - - - - - - - TRANSPORT AND STARTUP - - CLI and gateway config - cli.py · gateway_config.py - - Egress Gate gRPC adapter - HttpRequestEvaluation · HttpRequestResult - - - TYPED POLICY AND GATE INVENTORY - - Policy configuration - config.py · ordered gates · default decision - - Gate registry - gate union · resources · gate construction - - - PIPELINE PROCESSOR - - RequestProcessor - gate order · one deadline · aggregation · allow or deny - - - REQUEST GATES - - Gate wrapper · contract and bounds - - regex - typed scan · explicit action - - Custom gates - trusted integrations - - - validate config - - validated policy - - construct gates - run gates - - - diff --git a/projects/egress-gate/docs/assets/diagrams/processing-pipeline.svg b/projects/egress-gate/docs/assets/diagrams/processing-pipeline.svg deleted file mode 100644 index 901f114c..00000000 --- a/projects/egress-gate/docs/assets/diagrams/processing-pipeline.svg +++ /dev/null @@ -1,96 +0,0 @@ - - Egress Gate processing pipeline - The Egress Gate pipeline processor passes an immutable request snapshot to each gate. A gate can propose RequestMutations. The pipeline processor validates them, builds the next snapshot, and accumulates final mutations. The service adapter maps them to HttpRequestResult, and the OpenShell supervisor applies them after an allow. - - - - - - - - - 1 - Input - OpenShell request + policy - - - - - 2 - Pipeline processor - One shared deadline + bounds - - - - - 3 - Evaluate gate - gate.evaluate(current snapshot) - - - - - 4 - Validate output - Control · mutations · findings - - - validated - - - 5 - Apply control - Build next snapshot or stop - - - - - 6 - Finalize - Findings · provenance · default - - - - - 7 - Return the explicit result - - - Decision - allow or deny - - Final mutations - for HttpRequestResult - - Findings - bounded + sourced - - - proceed · next gate - diff --git a/projects/egress-gate/docs/assets/diagrams/request-lifecycle.svg b/projects/egress-gate/docs/assets/diagrams/request-lifecycle.svg deleted file mode 100644 index 59918805..00000000 --- a/projects/egress-gate/docs/assets/diagrams/request-lifecycle.svg +++ /dev/null @@ -1,112 +0,0 @@ - - Egress Gate request lifecycle - The OpenShell supervisor sends a request to Egress Gate. The service validates the request and policy. The Egress Gate pipeline processor passes immutable local snapshots through the gates and accumulates final mutations. The service returns an HttpRequestResult, and the supervisor applies allowed mutations to the intercepted request. - - - - - - - - - 1 · TRANSPORT - - Receive request - OpenShell evaluation RPC - pre-credentials phase - - - Validate input - phase · request context - target · headers · body - size · encoding bounds - - - - - 2 · CONFIGURATION - - Validate policy - strict policy schema - gates · default decision - - - Prepare pipeline - reuse unchanged policy - prepare changed policy - activate when complete - - - - - 3 · PIPELINE PROCESSOR - - Create snapshot - from OpenShell request - immutable HttpRequest - - - Run gates - gate reads current snapshot - returns RequestMutations - build next snapshot - - - - - 4 · SERVICE BOUNDARY - - Finalize result - allow or deny - final mutations - bounded findings - - - Map to OpenShell - HttpRequestResult - mutations + findings - - - RPC FAILURE - Return a gRPC error - INVALID_ARGUMENT · invalid input - INTERNAL · gate or service failure - - - - - - SUCCESSFUL MIDDLEWARE RESULT - Return to OpenShell supervisor - Allow + final mutations, or deny - Supervisor applies mutations after allow - - diff --git a/projects/egress-gate/docs/assets/diagrams/request-path.svg b/projects/egress-gate/docs/assets/diagrams/request-path.svg deleted file mode 100644 index 60aa7957..00000000 --- a/projects/egress-gate/docs/assets/diagrams/request-path.svg +++ /dev/null @@ -1,88 +0,0 @@ - - Egress Gate request path - The OpenShell supervisor sends an intercepted request and policy to Egress Gate. Egress Gate runs gates that inspect the request and propose mutations, then returns an allow with final mutations or a deny. The supervisor applies allowed mutations, attaches credentials, and sends the request to the provider. - - - - - - - - - - - Sandbox application - Creates a provider-bound HTTP request - - - request - - - - - OpenShell supervisor - Routes the request before credentials are attached - - - request + policy over gRPC - - - - PRE-CREDENTIALS - - - - Egress Gate - Runs gates that inspect requests and propose mutations - - - allow + final mutations, or deny - - - - - OpenShell supervisor - Applies mutations · attaches credentials after allow - - - authorized request - - - - - Provider - diff --git a/projects/egress-gate/docs/configuration.md b/projects/egress-gate/docs/configuration.md deleted file mode 100644 index aa1ec106..00000000 --- a/projects/egress-gate/docs/configuration.md +++ /dev/null @@ -1,98 +0,0 @@ ---- -title: Configure policies -description: Configure Egress Gate pipelines and request-level gates. -agent_markdown: true ---- - -# Configure policies - -OpenShell embeds the Egress Gate policy in a `network_middlewares` entry. The -registry validates the complete strict configuration before preparing a -pipeline processor. - -```yaml title="OpenShell policy" -network_middlewares: - egress_gate: - name: Inspect provider requests - middleware: egress-gate - order: 0 - config: - gates: - - name: identifiers - kind: regex - scan: - kind: body - action: - kind: replace - template: '[{entity}]' - pattern_catalog: patterns.yaml - default_decision: allow - on_error: fail_closed - endpoints: - include: [api.anthropic.com] -``` - -Relative catalog paths resolve from the Egress Gate process working directory, -not from the policy file. Use an inline catalog when the process does not have a -stable working directory. - -The Egress Gate policy has two required fields: - -- `gates` contains one through ten named gate configurations. -- `default_decision` is `allow` or `deny`. - -Each gate entry has a unique, bounded `name`. Its literal `kind` field selects -the exact gate type and its remaining fields. The registry rejects unknown -fields, unknown gate types, missing defaults, and duplicate names. - -## Built-in gates - -The shipped registry contains only `regex`. See -[Regex gate](gates/regex.md) for scans, actions, catalogs, and replacement -templates. `scan.kind` selects the complete body, selected JSON string fields, -normalized message blocks, path, query, or named headers. Every scan supports -`detect` and `deny`. Complete-body, `json-fields`, and `message-blocks` scans -also support `replace`; path, query, and header schemas do not. A trusted -application registry supplies other behavior. - -Structured scans do not activate automatically based on headers or request -contents. Existing `kind: body` policies retain their complete-body UTF-8 -behavior. Choose `json-fields` or `message-blocks` explicitly when a policy -expects a strict JSON request body. - -## Inspect the installed registry - -Run these commands from the -[`projects/egress-gate/`](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate) -directory in a source checkout: - -```bash title="Inspect the default registry" -uv run egress-gate gates list -uv run egress-gate gates schema -uv run egress-gate validate --policy path/to/policy.yaml -``` - -Custom registries use the same module attribute for inspection and serving: - -```bash title="Inspect a custom registry" -uv run egress-gate \ - --registry my_gates:registry gates list -uv run egress-gate \ - --registry my_gates:registry gates schema -``` - -The attribute can contain a `GateRegistry` or a zero-argument factory that -returns one. A factory is useful when a deployment must construct typed -`GateResources` dynamically. Policy configuration cannot import Python, choose -a resource implementation, or provide credentials. - -`validate` checks the policy and registered resources. It also reads and checks -a file-backed pattern catalog. It does not construct gates, prepare a -pipeline processor, or change the active policy. Use `evaluate` to check -artifacts that the gate creates during preparation. The gRPC service checks the -exact encoded size of the OpenShell configuration. - -For repeatable request-level checks, the `evaluate` command accepts a pipeline -policy and a strict version-one corpus. It uses the registry's prepared -pipeline processor path and does not start the gRPC service. See -[Test policies offline](evaluation.md). diff --git a/projects/egress-gate/docs/evaluation.md b/projects/egress-gate/docs/evaluation.md deleted file mode 100644 index bd678a61..00000000 --- a/projects/egress-gate/docs/evaluation.md +++ /dev/null @@ -1,152 +0,0 @@ ---- -title: Test policies offline -description: Test policy decisions and findings before deployment. -agent_markdown: true ---- - -# Test policies offline - -A policy can be valid and still do the wrong thing. It might allow a request -that you meant to deny, invoke the wrong gate, or stop reporting a finding -after a rule changes. - -`egress-gate evaluate` lets you catch these problems before the policy handles -live traffic. You give it a policy and a set of request examples. Each example -states the result that you expect. Egress Gate runs every request through the -same prepared `RequestProcessor` that the service uses and reports any -difference. - -This is useful when you want to: - -- check a new policy before rollout -- turn a fixed bug into a permanent regression test -- test a custom gate without starting the gRPC service -- compare the behavior of two policy revisions -- build a repeatable request set for a separate performance benchmark - -The command tests correctness. It does not report latency or throughput. Use a -benchmark harness around the same request set when you need performance data. - -## Try the included example - -The repository includes a regex policy and two request cases. Run them from -`projects/egress-gate/`; `uv` prepares the project environment automatically: - -```bash title="Run the example policy tests" -uv run egress-gate evaluate \ - --policy examples/regex-redaction/egress-gate-config.yaml \ - --cases examples/regex-redaction/cases.yaml \ - --timeout 1s -``` - -The command prepares the policy once, runs each case with a fresh timeout, and -shows whether each request produced its expected result: - -```text title="Evaluation output" -Policy evaluation -Status Case Details -PASS email-is-detected-and-request-is-allowed All checks matched -PASS ordinary-body-is-allowed All checks matched -2 passed · 0 failed · 2 total -``` - -If a case fails, the Details column shows each field that differed and its -expected and actual values. The summary and exit status make the same result -easy to use in CI. - -No request goes to an upstream service. The command does not start gRPC, -attach credentials, or persist request data. - -## Write one test case - -The CLI calls the cases file a *corpus*. In plain terms, it is a versioned YAML -test suite. `version: 1` selects the current file format. You do not need to -manage multiple versions. - -This example checks that the regex policy reports an email finding and then -allows the request through its default decision: - -```yaml title="cases.yaml" -version: 1 -cases: - - name: email-is-detected - provenance: - kind: synthetic - redacted: false - request: - context: - request_id: test-email - sandbox_id: test-sandbox - target: - scheme: https - host: api.example.com - port: 443 - method: POST - path: /v1/messages - query: "" - headers: [] - body: - encoding: utf8 - value: "send alice@example.com" - expected: - decision: allow - finding_types: [regex_match] -``` - -Each case has three parts: - -- `provenance` records whether the request is synthetic or captured and - whether its content is redacted. -- `request` contains the first read-only HTTP request snapshot that the gates - will evaluate. -- `expected` contains the result fields that must match. - -Only `expected.decision` is required. Add more expected fields when they make -the test more useful: - -| Expected field | What it checks | -| --- | --- | -| `decision_source_kind` | Whether a gate, the pipeline default, or a pipeline processor limit made the decision | -| `gate_name` | Which configured gate made a terminal decision | -| `gate_type` | Which gate implementation made a terminal decision | -| `finding_types` | The ordered finding types returned by the pipeline | - -Gate name and gate type apply only when `decision_source_kind` is `gate`. -Omitted fields are not compared. The current evaluator does not compare the -contents of request mutations. - -## Grow the suite with the policy - -Start with one normal request and one request for each important deny or -finding rule. Add a case whenever you fix a policy bug. Keep captured requests -small, deliberate, and redacted when possible. - -Case names must be unique. Optional tags can group cases for external tooling. -The parser also rejects aliases, duplicate keys, unknown fields, invalid -base64, and values that exceed pipeline processor limits. These checks keep tests -repeatable and ensure that test requests follow the same bounds as service -requests. - -Use `--registry` when the policy contains application-owned custom gates: - -```bash title="Test a custom gate" -uv run egress-gate \ - --registry examples.custom-gate.keyword_gate:registry \ - evaluate \ - --policy examples/custom-gate/egress-gate-config.yaml \ - --cases examples/custom-gate/cases.yaml -``` - -## Use the result in automation - -The command uses stable exit statuses: - -| Status | Meaning | -| ---: | --- | -| `0` | Every case matched | -| `1` | One or more cases did not match | -| `2` | The policy, cases, preparation, or execution failed | - -Failure output contains decision metadata and finding types. It does not print -request bodies or raw exception text. This makes the command suitable for CI -logs while keeping request content out of normal output. diff --git a/projects/egress-gate/docs/gates/custom.md b/projects/egress-gate/docs/gates/custom.md deleted file mode 100644 index f3fecdcd..00000000 --- a/projects/egress-gate/docs/gates/custom.md +++ /dev/null @@ -1,171 +0,0 @@ ---- -title: Custom gates -description: Author and register trusted request-level gates. -agent_markdown: true ---- - -# Custom gates - -Custom gates are trusted application code. They target the protobuf-free -`egress_gate.request` and `egress_gate.result` models and do not import gRPC, -protobuf, or `RequestProcessor` internals. Use the function helper for a small, -stateless gate. Use the class-based API when a gate needs initialization, -helper-base behavior, or operational resources. - -Custom gates that inspect request-body text can compose the public -`egress_gate.request_content` surface. Prepared parsers are stateless and safe -to reuse; each parsed result and its text targets remain local to one -`evaluate` call. See [Parse request content](../request-content.md) for parser -selection, typed JSON paths, message mappings, replacement, and a custom-gate -example. - -The repository includes runnable examples for both extension styles: - -- [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) - -Each example pairs one implementation with a policy and two offline evaluation -cases. Run the function example from `projects/egress-gate/`: - -```bash title="Run the function-based example" -uv run egress-gate \ - --registry examples.custom-gate.keyword_gate:registry \ - evaluate \ - --policy examples/custom-gate/egress-gate-config.yaml \ - --cases examples/custom-gate/cases.yaml -``` - -The executable resolves the explicit `module:attribute` reference from the -working directory. The attribute can contain a registry or a zero-argument -registry factory. A packaged deployment can resolve the same reference from an -installed custom-gate package. - -```python title="examples/custom-gate/keyword_gate.py" -from typing import Literal - -from egress_gate.gates import ( - GateCapability, - GateConfig, - GateRegistry, -) -from egress_gate.request import HttpRequest -from egress_gate.result import GateEvaluation -from egress_gate.timeout import Timeout - - -class KeywordDenyConfig(GateConfig): - kind: Literal["keyword-deny"] - keyword: str - - -registry = GateRegistry(include_builtin_gates=True) - - -@registry.gate( - config=KeywordDenyConfig, - capabilities=frozenset({GateCapability.READ_BODY, GateCapability.DENY}), -) -def keyword_deny( - request: HttpRequest, - config: KeywordDenyConfig, - *, - timeout: Timeout, -) -> GateEvaluation: - timeout.raise_if_expired() - if config.keyword.encode("utf-8") in request.body: - return GateEvaluation.deny("keyword_denied") - return GateEvaluation.proceed() - - -``` - -`registry.gate` creates an ordinary resource-free `Gate` type and adds it to -the application-owned registry. The existing public wrapper still validates -configuration, capabilities, findings, mutations, timeouts, and errors. The -registry stays open while the module declares gates. The CLI or service seals -it automatically on first use. - -On first use, `GateRegistry` constructs the exact discriminated `gates` schema -from the registered config types. A registry factory remains available when a -deployment must construct typed `GateResources` dynamically. Policy -configuration cannot construct or replace those resources. - -`GateConfig` supplies the common required `name` field. Custom config classes -inherit it and do not redefine or alias it. Each config declares one required -literal `kind` and keeps that serialized field name. Nested unions follow the -same discriminator rule. This gives policy parsers and generated schemas one -consistent way to select an exact configuration shape. - -Declare capabilities and finding types accurately. The public wrapper -rejects undeclared body replacements, header mutations, terminal decisions, -and finding types. Read capabilities are discovery metadata. They do not limit -which request fields trusted Python code can read. Keep request state local so -the gate is safe for concurrent calls. - -Declare capabilities as a `frozenset` of `GateCapability` values. Read access, -body replacement, header mutation, terminal allow, and deny are explicit. -Resource use comes from the gate's `GateResources` type, and finding support -comes from `finding_types`, so a gate does not declare either fact twice. - -A custom gate must not edit its `HttpRequest` input. To propose a change, return -`GateEvaluation.proceed(request_mutations=RequestMutations(...))`. The pipeline -processor validates the mutations and creates the next immutable snapshot. - -## Class-based gates - -The function helper does not replace the class-based extension API. Implement -`Gate[ConfigType, ResourcesType]` directly when a gate needs `_initialize`, a -helper base such as `Utf8BodyGate`, or typed `GateResources`. Resource-free -class-based gates use `registry.register(GateType)`. - -```python title="examples/class-based-gate/keyword_gate.py" -from typing import Literal - -from egress_gate.gates import Gate, GateCapability, GateConfig, GateRegistry -from egress_gate.request import HttpRequest -from egress_gate.result import GateEvaluation -from egress_gate.timeout import Timeout - - -class KeywordDenyConfig(GateConfig): - kind: Literal["keyword-deny"] - keyword: str - - -class KeywordDenyGate(Gate[KeywordDenyConfig, None]): - capabilities = frozenset( - {GateCapability.READ_BODY, GateCapability.DENY} - ) - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - timeout.raise_if_expired() - if self.config.keyword.encode("utf-8") in request.body: - return GateEvaluation.deny("keyword_denied") - return GateEvaluation.proceed() - - -registry = GateRegistry(include_builtin_gates=True) -registry.register(KeywordDenyGate) -``` - -Run the complete class-based example with: - -```bash title="Run the class-based example" -uv run egress-gate \ - --registry examples.class-based-gate.keyword_gate:registry \ - evaluate \ - --policy examples/class-based-gate/egress-gate-config.yaml \ - --cases examples/class-based-gate/cases.yaml -``` - -For a resource-backed gate, define a typed `GateResources` bundle. Pass the -bundle to `registry.register(..., resources=resources)`. Resources are trusted, -application-owned dependencies that must be safe for concurrent use. Policy -configuration can select behavior. It cannot construct clients, provide -credentials, or replace the registered resource implementation. diff --git a/projects/egress-gate/docs/gates/index.md b/projects/egress-gate/docs/gates/index.md deleted file mode 100644 index 2db9fa65..00000000 --- a/projects/egress-gate/docs/gates/index.md +++ /dev/null @@ -1,25 +0,0 @@ ---- -title: Gates -description: Built-in request matching and trusted custom gates. -agent_markdown: true ---- - -# Gates - -A gate receives a read-only `HttpRequest` snapshot, one shared `Timeout`, and -its exact typed configuration. It cannot change that request object in place. -To change the request, the gate returns `proceed` with a `RequestMutations` -value. - -The pipeline processor validates the mutations and constructs a new read-only -snapshot for the next gate. It also accumulates the mutations that the Egress -Gate service adapter will map to `HttpRequestResult` if the pipeline allows the -request. The OpenShell supervisor then applies those final mutations to the -intercepted request. A gate can instead return terminal `allow` or terminal -`deny` to stop the pipeline. - -The default registry ships exactly `regex`. Application registries can add -trusted custom gates. Egress Gate does not isolate trusted Python gate code. - -- [Regex gate](regex.md) -- [Custom gates](custom.md) diff --git a/projects/egress-gate/docs/gates/regex.md b/projects/egress-gate/docs/gates/regex.md deleted file mode 100644 index 3eacfdb2..00000000 --- a/projects/egress-gate/docs/gates/regex.md +++ /dev/null @@ -1,199 +0,0 @@ ---- -title: Regex gate -description: Scan one configured part of a request with bounded regular expressions. -agent_markdown: true ---- - -# Regex gate - -The `regex` gate matches one configured part of the current request. It can -inspect the complete body, selected JSON string fields, normalized JSON message -blocks, path, query, or selected header values. It returns audit-safe findings -with type `regex_match`. - -Choose what to scan with `scan.kind`, then choose what to do with -`scan.action.kind`. This example replaces matches in the request body: - -```yaml title="Inline regex catalog" -name: customer-identifiers -kind: regex -scan: - kind: body - action: - kind: replace - template: '[{entity}]' -pattern_catalog: - entities: - - name: customer-id - rules: - - name: customer-id-rule - pattern: '\bCUST-[0-9]{8}\b' - confidence: high -``` - -The body is decoded as strict UTF-8. Path and query scans use the exact text in -the request model. A header scan matches each selected header value on its own; -a match cannot span two values. Header names are case-insensitive: - -```yaml title="Selected request headers" -name: labeled-headers -kind: regex -scan: - kind: header - names: [x-customer-note, x-request-label] - action: - kind: deny -pattern_catalog: patterns.yaml -``` - -The header scan sees the current request snapshot, including validated header -mutations from earlier gates. The regex gate does not return header mutations -itself. -OpenShell permits writes only in the `x-openshell-middleware-` namespace, so a -general regex replacement cannot rewrite arbitrary selected headers. A custom -gate can return supported header writes or removals when it declares the -`GateCapability.MUTATE_HEADERS` capability. - -## Structured JSON fields - -`json-fields` parses the current body as strict UTF-8 JSON and scans only string -values selected by typed paths. The selectors are part of the general -[`egress_gate.request_content` contract](../request-content.md), which is also -available to custom gates. A `json-fields` selector starts at the document -root: - -```yaml title="Selected JSON message content" -name: message-identifiers -kind: regex -scan: - kind: json-fields - selectors: - - segments: - - kind: key - value: messages - - kind: each - - kind: key - value: content - action: - kind: replace - template: '[{entity}]' -pattern_catalog: patterns.yaml -``` - -Missing paths and non-string terminal values produce no scan text. Overlapping -selectors select the same JSON string once. Matches cannot span two selected -string values. Structured replacement re-encodes each selected string token -and preserves every source byte outside selected tokens, including whitespace, -number spellings, key order, and escaping in unrelated strings. - -The JSON parser rejects invalid UTF-8, malformed JSON, duplicate object keys, -non-standard constants, invalid Unicode scalar values, and configured parsing -limits. Invalid UTF-8 produces `body_encoding_invalid`; invalid strict JSON -produces `body_format_invalid`. - -## Normalized message blocks - -`message-blocks` builds on the same JSON document. A `json-message-map` selects -one or more message arrays, reads each message role, and applies relative -`text_selectors`. Optional `tool_input_selectors` and `tool_output_selectors` -classify provider- or harness-specific fields explicitly. The mapping requires -at least one selector across those three groups. The scan can then filter -normalized roles and block kinds: - -```yaml title="Selected user and tool messages" -name: model-visible-identifiers -kind: regex -scan: - kind: message-blocks - message_mapping: - kind: json-message-map - messages: - segments: - - kind: key - value: request - - kind: key - value: messages - role_key: role - text_selectors: - - segments: - - kind: key - value: content - - segments: - - kind: key - value: content - - kind: each - - kind: key - value: text - roles: [system, developer, user, tool] - block_kinds: [text, tool_output] - action: - kind: deny -pattern_catalog: patterns.yaml -``` - -Known roles are `system`, `developer`, `user`, `assistant`, and `tool`; other or -missing roles normalize to `unknown`. Text selected from a `tool` message is a -`tool_output`; other selected text is `text`. Message mappings are policy -configuration, so harness-specific envelopes do not require changes to regex -matching. A custom gate can reuse the public `RequestContentParser`, -`MessageBlocksParser`, and `MessageBlockExtractor` surfaces when it needs the -same text-target and replacement contracts with a different semantic -extractor. - -A catalog can be inline or in a relative `.yaml` or `.yml` file. Relative paths -resolve from the Egress Gate process working directory, not from the policy -file. Use an inline catalog when the process does not have a stable working -directory. The gate rejects absolute paths, path traversal, symlinks, YAML -aliases, duplicate keys, invalid body UTF-8, unsafe patterns, and oversized -catalogs. - -Each entity has a stable, bounded name and one or more rules. Rule confidence -is `low`, `medium`, or `high`. Optional flags are `ignore_case`, `multiline`, -`dot_all`, and `ascii`. Do not use named capture groups or inline flags. -Patterns must produce non-empty matches. Findings include overlapping -detections. Replacement uses deterministic, non-overlapping matches. - -## Actions - -| `scan.action.kind` | Match result | -| --- | --- | -| `detect` | `proceed`, findings, no request mutation | -| `deny` | terminal `deny`, findings, `egress_gate_regex_denied` | -| `replace` | `proceed`, findings, explicit body replacement | - -`detect` and `deny` work with every scan kind. `replace` exists in the complete -body, `json-fields`, and `message-blocks` schemas. It cannot be configured for -a path, query, or header scan. -This structure keeps unsupported combinations out of generated schemas and -editor suggestions. OpenShell middleware results cannot rewrite a request path -or query. Header replacement is not part of the built-in gate. - -The replace action owns its template. It returns a body replacement even when -there is no match. This preserves the operator's explicit intent to replace the -current body. Invalid body UTF-8 is a stable `body_encoding_invalid` service -failure. A structured scan whose UTF-8 body is not strict JSON produces the -stable `body_format_invalid` failure. - -The regex gate does not edit the body in place. It returns the replacement in -`RequestMutations`. The pipeline processor uses it to build the next immutable -`HttpRequest` snapshot. If the pipeline allows the request, Egress Gate includes -the replacement in the final mutations returned to the OpenShell supervisor. - -Replacement templates contain literal text and the `{entity}` field only. -Output size is projected before rendering and is bounded by the advertised -OpenShell body limit. - -## Scan reference - -| `scan.kind` | Additional fields | Supported `action.kind` values | -| --- | --- | --- | -| `body` | none | `detect`, `deny`, `replace` | -| `json-fields` | non-empty typed `selectors` | `detect`, `deny`, `replace` | -| `message-blocks` | `message_mapping`; optional `roles` and `block_kinds` | `detect`, `deny`, `replace` | -| `path` | none | `detect`, `deny` | -| `query` | none | `detect`, `deny` | -| `header` | non-empty `names` list | `detect`, `deny` | - -Configure another regex gate when different request parts need different -catalogs or actions. Keeping one scan per gate makes matches, findings, and -replacement offsets unambiguous. diff --git a/projects/egress-gate/docs/index.md b/projects/egress-gate/docs/index.md deleted file mode 100644 index fe98ed26..00000000 --- a/projects/egress-gate/docs/index.md +++ /dev/null @@ -1,110 +0,0 @@ ---- -title: Egress Gate -description: Overview and quickstart for request-level OpenShell middleware. -agent_markdown: true ---- - -# Egress Gate - -Egress Gate is extensible OpenShell middleware for applying an ordered pipeline -of gates to outgoing HTTP requests from sandboxes. A gate can inspect a -request, report findings, rewrite supported content, allow it, or deny it. Use -the built-in regex gate or install trusted custom gates for application-specific -checks. - -Egress Gate provides one typed, configurable place for request-level controls. -You can combine gates, test policies offline, and add new gate types without -changing OpenShell. - -OpenShell still owns interception, routing, network policy, and credential -attachment. Egress Gate is not a forward proxy, TLS interceptor, response -filter, or storage control. - -## Quickstart - -From the -[`projects/egress-gate/`](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate) -directory in a source checkout: - -First, inspect the installed gates and their configuration schema: - -```bash title="Explore available gates" -uv run egress-gate gates list -uv run egress-gate gates schema -``` - -Then validate a policy before you use it: - -```bash title="Validate a policy" -uv run egress-gate validate \ - --policy examples/regex-redaction/egress-gate-config.yaml -``` - -Start Egress Gate in the foreground when the policy is ready: - -```bash title="Start the server" -uv run egress-gate serve --listen 127.0.0.1:50051 -``` - -Use the [regex guide](gates/regex.md) for an OpenShell policy and a -regex-pattern "catalog". - -Use [offline policy tests](evaluation.md) to check saved request examples with -the same prepared `RequestProcessor` used by the service. No request goes to an -upstream provider. - -## How a request moves through the pipeline - -1. The OpenShell supervisor sends the intercepted request to Egress Gate. -2. Each gate reads the current read-only `HttpRequest` and can propose - `RequestMutations`. A gate does not modify its input in place. -3. The Egress Gate pipeline processor validates and applies the requested - mutations by creating a new local `HttpRequest`. -4. The next gate receives the updated request. -5. If the pipeline allows the request, the service adapter maps the accumulated - mutations to OpenShell's `HttpRequestResult`. -6. The OpenShell supervisor applies the returned mutations to the intercepted - request before it attaches credentials. - -A denial returns no request mutations. - -
- The OpenShell supervisor sends an intercepted request to Egress Gate, receives the final decision and mutations, and applies allowed mutations before it attaches credentials. -
Egress Gate evaluates the request. The OpenShell supervisor owns and updates the intercepted request.
-
- -Within Egress Gate, the -[gRPC service adapter](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/src/egress_gate/service) -is the only code that imports OpenShell's generated protobuf bindings. Gates -and the pipeline processor do not depend on protobuf or gRPC, so the same -policy pipeline can run in offline tests. - -## Core rules - -- A policy has one through ten named gates and a required `default_decision`. -- Each gate receives the current read-only request. -- Each gate returns one control result. `proceed` continues to the next gate; - `allow` and `deny` stop the pipeline. Only `proceed` can include request - mutations. -- `None` body replacement means no replacement. `b""` is an explicit empty - replacement. -- When the pipeline processor reaches a safety limit, Egress Gate denies the - request. The result uses source `runtime_limit` and code - `egress_gate_limit_exceeded`. -- Pipeline default deny uses source `pipeline_default` and code - `egress_gate_default_deny`. -- The released Finding wire contract has only five fields. Gate source and - decision provenance remain internal to the pipeline processor. - -## Further reading - -- [Configuration](configuration.md) -- [Parse request content](request-content.md) -- [Test policies offline](evaluation.md) -- [Operations](operations.md) -- [Gate authoring](gates/custom.md) -- [Regex gate](gates/regex.md) -- [Architecture](architecture/index.md) -- [Request lifecycle](architecture/request-lifecycle.md) -- [Service boundary](architecture/service-boundary.md) -- [Limits and failures](reference/limits-and-failures.md) diff --git a/projects/egress-gate/docs/operations.md b/projects/egress-gate/docs/operations.md deleted file mode 100644 index 82d9fbd8..00000000 --- a/projects/egress-gate/docs/operations.md +++ /dev/null @@ -1,179 +0,0 @@ ---- -title: Run and operate Egress Gate -description: Start, register, observe, and troubleshoot Egress Gate. -agent_markdown: true ---- - -# Run and operate Egress Gate - -The OpenShell gateway and sandbox supervisors call Egress Gate through gRPC. -Run the service from `projects/egress-gate`. `uv run` prepares the project -environment as needed. - -```bash title="Start Egress Gate" -uv run egress-gate gates list -uv run egress-gate gates schema -uv run egress-gate serve --listen 0.0.0.0:50051 --timeout 4s -``` - -Use a reachable non-loopback address only when the supervisor is outside the -host network namespace of the service. Plaintext gRPC is for a restricted, -trusted network. Do not expose the port to an untrusted network. - -## OpenShell registration - -Before you change the gateway configuration, stop any running OpenShell -gateways that use it. A running gateway does not reload middleware -registrations. - -```bash title="Register Egress Gate" -uv run egress-gate add-gateway-registration \ - --host-ip YOUR_HOST_IPV4 --name egress-gate --port 50051 --timeout 30s -``` - -The command updates `OPENSHELL_GATEWAY_CONFIG`, then -`$XDG_CONFIG_HOME/openshell/gateway.toml`, then -`~/.config/openshell/gateway.toml`. Use `--config PATH` for another file. -It remembers the absolute gateway file path and registration name in -`$XDG_CONFIG_HOME/openshell-egress-gate/registration.toml`, or under -`~/.config` when `XDG_CONFIG_HOME` is unset. -Start the gateways again with the same commands or service managers that you -normally use. - -The optional registration `--timeout` sets the gateway RPC timeout written to -the TOML file and defaults to 30 seconds. It accepts whole seconds or -milliseconds, such as `45s` or `500ms`, and must be greater than 10ms so the -internal processing budget can remain lower. Rerunning the command writes the -value passed on that invocation. Set Egress Gate's internal processing budget with -`egress-gate serve --timeout DURATION`; the Python API calls that setting -`timeout_middleware_processing`. It must be at least 10ms and resolve to whole -milliseconds. When a remembered registration exists, `serve` reads its current -gateway timeout and refuses to start unless the processing timeout is lower. A -manually managed setup with no remembered registration starts without this -check. - -To remove a registration, stop any running gateways that use the configuration -again. List the available names with: - -```bash title="List middleware registrations" -uv run egress-gate list-gateway-registrations -``` - -The gateway config does not identify which service owns a registration. The -command therefore lists all external middleware. Use its exact name to remove -the registration you no longer need: - -```bash title="Remove the registration" -uv run egress-gate remove-gateway-registration --name egress-gate -``` - -Start the gateways again after the command completes. - -`serve --timeout` covers queueing, policy preparation, and every configured -gate. If the gateway timeout expires first despite the startup check, OpenShell -applies the policy's `on_error` setting: `fail_closed` denies the request, while -`fail_open` allows it to continue. - -If the middleware RPC returns gRPC `RESOURCE_EXHAUSTED`, capacity may remain -accounted for briefly while completed RPCs are torn down. The OpenShell gateway -or supervisor should retry the middleware RPC with short, bounded exponential -backoff, for example 5, 10, then 20 milliseconds, while staying inside its -middleware deadline. Do not turn this into an unbounded application-level -retry or replay an outbound request unless its request semantics permit that. - -## Verify readiness - -Egress Gate does not expose a separate gRPC health service. Verify readiness at -the policy, transport, and end-to-end layers instead. The commands below use an -installed `egress-gate` executable; prefix them with `uv run` in a source -checkout. - -1. Validate and evaluate the exact deployment policy before starting the - service: - - ```bash - egress-gate validate --policy /absolute/path/to/policy.yaml - egress-gate evaluate \ - --policy /absolute/path/to/policy.yaml \ - --cases /absolute/path/to/cases.yaml - ``` - -2. Start Egress Gate and wait for the content-safe - `egress_gate_server_bound` log entry. From the OpenShell gateway host or - network namespace, confirm the registered address accepts a TCP connection: - - ```bash - python3 -c 'import socket; socket.create_connection(("EGRESS_GATE_HOST", 50051), timeout=2).close()' - ``` - - This proves transport reachability only; it does not exercise the gRPC - contract or a policy. - -3. After restarting the OpenShell gateway, send one harmless request from a - sandbox whose policy uses the registration. Choose an endpoint explicitly - allowed by that policy: - - ```bash - openshell sandbox exec --name SANDBOX_NAME --no-tty -- \ - curl --fail --silent --show-error https://ALLOWED_TEST_ENDPOINT/health - openshell logs SANDBOX_NAME -n 100 --source sandbox - ``` - - Readiness requires the request to receive its expected allow or deny result - without a middleware connection, timeout, or configuration error. A TCP - check alone is not sufficient. - -## Logging and decisions - -`--debug` enables content-safe diagnostics. Egress Gate does not log request or -replacement bodies. Set `NO_COLOR` to any value to suppress ANSI styling when -default logging writes to an interactive terminal. Application code can still -request colors explicitly with `LoggingConfig(color_mode=ColorMode.ALWAYS)`. - -Successful policy outcomes are distinct from gRPC failures: - -| Outcome | Wire result | -| --- | --- | -| Gate deny | deny, gate-owned reason code | -| Pipeline default deny | deny, `egress_gate_default_deny` | -| Pipeline processor reaches a safety limit | deny, `egress_gate_limit_exceeded` | -| Invalid request or config | gRPC `INVALID_ARGUMENT` | -| Gate or service failure | gRPC `INTERNAL` | - -Results caused by pipeline processor limits contain no partial mutations or -findings. A failed candidate does not replace the active policy. See -[Limits and failures](reference/limits-and-failures.md). - -## Policy rollout - -Each Egress Gate service keeps one active prepared policy. To change the -policy, first stop requests that use the old configuration. Let all admitted -requests finish. Then, send a request that uses the new configuration. Use -separate service instances when different policies must be active at the same -time. - -## Shutdown - -Use Ctrl-C for an interactive process or send `SIGINT` through the service -manager, then wait for the process to exit before replacing it. Egress Gate -stops the gRPC server with zero transport grace and closes its worker resources; -callers with an active RPC may observe cancellation or unavailability and -should follow the bounded retry guidance above. grpcio messages such as -`Got goaway` or `Cancelling all calls` are expected during a planned shutdown -when the process exits normally. Investigate them when they occur outside a -deployment or shutdown window, accompany lost work, or the process does not -exit. - -## Troubleshooting - -Inspect a finite OpenShell log window: - -```bash title="Inspect recent sandbox logs" -openshell status -openshell logs SANDBOX_NAME -n 100 --source sandbox -``` - -Check the request ID and stable error code in content-safe Egress Gate logs. -Reduce request, header, finding, metadata, or regex catalog size when the -limit reason is returned. Check the exact schema with `gates schema` -when validation fails. diff --git a/projects/egress-gate/docs/reference/limits-and-failures.md b/projects/egress-gate/docs/reference/limits-and-failures.md deleted file mode 100644 index 9bb6926f..00000000 --- a/projects/egress-gate/docs/reference/limits-and-failures.md +++ /dev/null @@ -1,65 +0,0 @@ ---- -title: Limits and failure behavior -description: Bounded Egress Gate domain and service behavior. -agent_markdown: true ---- - -# Limits and failure behavior - -Egress Gate-owned limits are fail-closed and content-safe. The `service/` -package checks exact encoded protobuf sizes. Domain models check scalar, -aggregate, and result limits. OpenShell owns the separate outer RPC ceiling and -applies its configured `on_error` behavior when that ceiling expires first. - -| Area | Limit | -| --- | ---: | -| Request body | 4 MiB | -| JSON nesting depth | 128 | -| JSON value nodes | 100,000 | -| JSON field selectors per scan | 32 | -| Message content selectors per mapping | 32, plus the required messages selector | -| JSON path segments per selector | 32 | -| Selected JSON nodes or normalized message blocks | 4,096 | -| Pipeline gates | 10 | -| Finding groups per gate/result | 32 | -| Estimated finding wire size | 4 KiB | -| Result metadata entries | 64 | -| Result metadata aggregate strings | 32 KiB | -| Gate traces per result | 10 | -| Header mutations per gate evaluation | 64 | -| Offline `--timeout` | 10 milliseconds minimum; whole milliseconds | -| `timeout_middleware_processing` | 10 milliseconds minimum; whole milliseconds | -| Gateway registration timeout | Operator-configurable; helper default 30 seconds | -| Concurrent processing slots | 4 | - -Request context and target aggregates, headers, replacement bodies, regex -catalogs, individual patterns, and diagnostic strings have additional bounded -limits in `constants.py`. Tests cover exact accepted boundaries and the first -rejected value. - -## Outcomes - -| Condition | Outcome | -| --- | --- | -| Invalid phase, envelope, policy, input encoding, or configured JSON format | gRPC `INVALID_ARGUMENT` | -| Gate contract or unexpected execution failure | gRPC `INTERNAL` | -| Internal processing deadline or pipeline processor limit | deny, source `runtime_limit`, code `egress_gate_limit_exceeded` | -| Gate terminal deny | deny, source `gate`, gate-owned reason code | -| Pipeline default deny | deny, source `pipeline_default`, code `egress_gate_default_deny` | -| Pipeline default allow | allow, source `pipeline_default`, no reason code | - -Pipeline processor limit results contain no partial mutations, findings, or -trace details. Failed policy preparation leaves the active policy unchanged. -Stable error catalogs and reason codes never include request content or -arbitrary exception text. - -An internal processing timeout returns the runtime-limit denial only while the -RPC remains active. If the gateway's independent outer RPC ceiling expires -first, OpenShell applies the middleware entry's `on_error` policy. - -## Finding contract - -The released OpenShell wire contract has five fields. The pipeline processor's -`SourcedFinding` and `DecisionSource` preserve provenance for internal tests, -traces, and logging only. Do not encode source or attributes into labels or -metadata while the canonical protocol remains five-field. diff --git a/projects/egress-gate/docs/request-content.md b/projects/egress-gate/docs/request-content.md deleted file mode 100644 index 53edb5e8..00000000 --- a/projects/egress-gate/docs/request-content.md +++ /dev/null @@ -1,187 +0,0 @@ ---- -title: Parse request content -description: Select and replace bounded text in complete bodies, JSON fields, and normalized message blocks. -agent_markdown: true ---- - -# Parse request content - -The public `egress_gate.request_content` package lets built-in and custom gates -interpret the current request body as independently inspectable text targets. -It supports a complete UTF-8 body, selected strings in a strict JSON document, -and normalized message blocks derived from harness-specific JSON envelopes. - -`Utf8TextParser`, `JsonFieldsParser`, and `MessageBlocksParser` implement the -same `RequestContentParser` contract. Each parser owns both text extraction and -rendering immutable `TextReplacement` values back into a complete replacement -body. The regex gate composes these parsers, but they are not regex-specific; -trusted custom gates can use the same public API. - -Here, *bounded* means that parsing and replacement enforce explicit limits on -body size, JSON depth, node and selector counts, selected text, and message -blocks. All work also uses the request's shared deadline. See -[Limits and failures](reference/limits-and-failures.md) for the failure -contract. - -## Use a parser from a custom gate - -Prepare a parser once with the gate, then parse the current body inside each -evaluation. The returned view contains request-local targets and owns rendering -their replacements back into complete body bytes: - -```python title="Parse and replace selected JSON text" -from egress_gate.request_content import ( - JsonFieldsParser, - JsonKeySegment, - JsonSelector, - TextReplacement, -) - -parser = JsonFieldsParser( - selectors=( - JsonSelector( - segments=(JsonKeySegment(kind="key", value="prompt"),), - ), - ), -) - -parsed = parser.parse(request.body, timeout=timeout) -replacements = tuple( - TextReplacement(target_id=target.id, text=transform(target.text)) - for target in parsed.targets -) -replacement_body = parsed.replace_text(replacements, timeout=timeout) -``` - -The custom gate can return `replacement_body` through `RequestMutations` after -declaring `GateCapability.REPLACE_BODY`. Keeping parsing and rendering on the -same request-local view prevents a replacement from targeting text that the -parser did not expose. - -## Select JSON values with typed paths - -A `JsonSelector` is an ordered sequence of typed path segments. It is not a -JSONPath string. Egress Gate starts with one JSON value and applies each segment -to the values selected by the preceding segment. - -| Segment | Required input | Selection | -| --- | --- | --- | -| `key` | JSON object | The value of the member whose key exactly equals `value` | -| `index` | JSON array | The item at position `value`; `0` is the first item, `1` is the second, and so on | -| `each` | JSON object or array | Every immediate object-member value or array item | - -For example, this selector chooses the `content` value from every item in the -top-level `messages` array: - -```yaml title="Select every message's content" -segments: - - kind: key - value: messages - - kind: each - - kind: key - value: content -``` - -Given this document: - -```json -{ - "messages": [ - {"content": "first"}, - {"content": "second"} - ] -} -``` - -the selector produces the two string values `first` and `second`. - -Use `index` when a policy needs one specific array position. The numeric -`value` is the position in the array: - -```yaml title="Select the first message's content" -segments: - - kind: key - value: messages - - kind: index - value: 0 - - kind: key - value: content -``` - -This selector produces only `first`. An index equal to or greater than the -array length selects nothing; it is not an input error. - -A segment applied to the wrong JSON kind also selects nothing. For example, -`key` does not select from an array, and `index` does not select from an object. -Missing paths therefore produce no selected values. When a parser requests -text, selected non-string terminal values also produce no text targets. - -Multiple selectors are evaluated in configuration order. When selectors reach -the same JSON node, Egress Gate returns that node once. Results produced by one -selector follow their order in the document. - -## Select JSON string fields - -`JsonFieldsParser` evaluates selectors from the JSON document root and exposes -each selected string as an independent `TextTarget`. A match or other custom -gate operation on one target cannot span another selected string. - -The regex gate exposes this parser through `scan.kind: json-fields`: - -```yaml title="Scan selected JSON strings" -scan: - kind: json-fields - selectors: - - segments: - - kind: key - value: messages - - kind: each - - kind: key - value: content - action: - kind: replace - template: '[{entity}]' -``` - -## Map normalized message blocks - -`MessageBlocksParser` parses the request body as the same strict JSON document, -then delegates message normalization to a `MessageBlockExtractor`. The built-in -`JsonMessageBlockExtractor` applies a `JsonMessageMapConfig` for ordinary JSON -harness envelopes. - -The mapping's `messages` selector starts at the document root. Its -`text_selectors`, `tool_input_selectors`, and `tool_output_selectors` start at -each selected message object. This distinction is important: relative text -selectors do not repeat the path to the message array. - -Message roles normalize to `system`, `developer`, `user`, `assistant`, `tool`, -or `unknown`. Blocks normalize to `text`, `tool_input`, or `tool_output`. -Provider- or harness-specific adapters can implement the public -`MessageBlockExtractor` protocol without changing request-content consumers -such as the regex gate. - -When selector groups reach the same JSON string, the extractor retains each -distinct block classification. `MessageBlocksParser` applies role and block -kind filters first, then exposes the shared string once. A broad -`text_selectors` entry therefore cannot hide the same node from an explicit -tool-input or tool-output filter. - -See the [regex gate](gates/regex.md#normalized-message-blocks) for a complete -policy example and [custom gates](gates/custom.md) for the trusted extension -contract. - -## Replacement and parsing behavior - -JSON parsing is strict and bounded. It rejects invalid UTF-8, malformed JSON, -duplicate object keys, non-standard constants, invalid Unicode scalar values, -and configured parsing limits. - -Structured replacement re-encodes only selected JSON string tokens. It -preserves every source byte outside those tokens, including whitespace, number -spellings, key order, and escaping in unrelated strings. A parsed-content view -rejects replacement of targets it did not expose. - -Request-content parsers are prepared stateless objects. Each parsed result and -its document-local target identities belong to one request evaluation and must -not be reused with another request body. diff --git a/projects/egress-gate/examples/class-based-gate/README.md b/projects/egress-gate/examples/class-based-gate/README.md deleted file mode 100644 index 48763139..00000000 --- a/projects/egress-gate/examples/class-based-gate/README.md +++ /dev/null @@ -1,118 +0,0 @@ -# Class-based custom gate - -This example implements the same `keyword-deny` behavior as the -function-based example, but uses the full `Gate` API. Use this form when a gate -needs initialization, a helper base, or typed operational resources. - -The implementation has three pieces: - -1. `KeywordDenyConfig` defines the policy fields and `kind` discriminator. -2. `KeywordDenyGate._evaluate` implements the request decision. -3. The module creates a registry and registers the gate class. - -Run the example from `projects/egress-gate/`. First inspect the registry: - -```bash -uv run egress-gate \ - --registry examples.class-based-gate.keyword_gate:registry \ - gates list -``` - -Then test the policy against two saved requests: - -```bash -uv run egress-gate \ - --registry examples.class-based-gate.keyword_gate:registry \ - evaluate \ - --policy examples/class-based-gate/egress-gate-config.yaml \ - --cases examples/class-based-gate/cases.yaml -``` - -The first case contains the configured keyword and is denied. The second gate -evaluation proceeds, so `default_decision: allow` determines its result. - -## Run it with OpenShell - -Start Egress Gate with this example registry and content-safe debug diagnostics: - -```bash -uv run egress-gate \ - --debug \ - --registry examples.class-based-gate.keyword_gate:registry \ - serve --listen 0.0.0.0:50051 --timeout 4s -``` - -Before you change the gateway configuration, stop any running OpenShell -gateways that use it. A running gateway does not reload middleware -registrations. - -In another terminal, register the service in your default gateway -configuration. Replace `YOUR_HOST_IPV4` with a non-loopback address that the -gateway and sandbox supervisors can reach. - -```bash -uv run egress-gate add-gateway-registration \ - --host-ip YOUR_HOST_IPV4 \ - --name egress-class \ - --port 50051 -``` - -Start the OpenShell gateway again with the same command or service manager that -you normally use. Then create a sandbox and launch Claude Code: - -```bash -openshell sandbox create \ - --name egress-class \ - --from base \ - --no-auto-providers \ - --policy examples/class-based-gate/policy.yaml \ - -- env CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude -``` - -This command uses the base sandbox image, prevents OpenShell from creating or -attaching a provider, and starts Claude Code with nonessential traffic disabled. -The policy can therefore omit telemetry and error-reporting endpoints. - -On the first run, complete Claude Code's browser sign-in from inside the -sandbox. The session uses your Claude subscription directly; OpenShell does not -attach an Anthropic API-key provider. - -At the Claude prompt, enter a normal request: - -```text -Reply with only the word OK. -``` - -Claude should reply normally, and the Egress Gate terminal should record an -allow decision. Then enter a request that contains the configured keyword: - -```text -Reply with only the word SECRET. -``` - -The request must fail before Claude answers. The Egress Gate terminal must -record `action=deny` and `decision_source_kind=gate`. Together, the normal -response and denied request confirm that the class-based gate is active. - -Exit Claude Code and delete the sandbox. Stop any running OpenShell gateways -before you remove the registration. Then start the gateways again: - -```bash -openshell sandbox delete egress-class -uv run egress-gate remove-gateway-registration --name egress-class -``` - -OpenShell names used by this example have a 19-character limit. The chosen -names stay within that limit. - -The base class owns construction and the public `evaluate` wrapper. A custom -class implements `_evaluate` and reads its validated configuration from -`self.config`. Do not override `__init__` or `evaluate`. Use `_initialize` for -reusable derived state. - -This teaching gate searches the body bytes for the UTF-8 encoding of the -configured keyword. It is not a robust content classifier. A production gate -must define its encoding, normalization, and matching behavior. Add limits only -for work that belongs to the gate. Do not put request content in errors or -findings. Check the shared timeout during expensive work, and keep request state -local. diff --git a/projects/egress-gate/examples/class-based-gate/cases.yaml b/projects/egress-gate/examples/class-based-gate/cases.yaml deleted file mode 100644 index 4a911910..00000000 --- a/projects/egress-gate/examples/class-based-gate/cases.yaml +++ /dev/null @@ -1,53 +0,0 @@ -version: 1 -cases: - - name: configured-keyword-is-denied - tags: [class-based-gate] - provenance: - kind: synthetic - redacted: true - request: - context: - request_id: class-gate-deny - sandbox_id: sandbox-example - target: - scheme: https - host: api.example.com - port: 443 - method: POST - path: /messages - query: "" - headers: [] - body: - encoding: utf8 - value: "do not send this SECRET" - expected: - decision: deny - decision_source_kind: gate - gate_name: block-secret-keyword - gate_type: keyword-deny - finding_types: [] - - - name: other-bodies-proceed-to-the-default - tags: [class-based-gate] - provenance: - kind: synthetic - redacted: true - request: - context: - request_id: class-gate-allow - sandbox_id: sandbox-example - target: - scheme: https - host: api.example.com - port: 443 - method: POST - path: /messages - query: "" - headers: [] - body: - encoding: utf8 - value: "ordinary text" - expected: - decision: allow - decision_source_kind: pipeline_default - finding_types: [] diff --git a/projects/egress-gate/examples/class-based-gate/egress-gate-config.yaml b/projects/egress-gate/examples/class-based-gate/egress-gate-config.yaml deleted file mode 100644 index 05186e04..00000000 --- a/projects/egress-gate/examples/class-based-gate/egress-gate-config.yaml +++ /dev/null @@ -1,5 +0,0 @@ -gates: - - name: block-secret-keyword - kind: keyword-deny - keyword: SECRET -default_decision: allow diff --git a/projects/egress-gate/examples/class-based-gate/keyword_gate.py b/projects/egress-gate/examples/class-based-gate/keyword_gate.py deleted file mode 100644 index 17e4a2e7..00000000 --- a/projects/egress-gate/examples/class-based-gate/keyword_gate.py +++ /dev/null @@ -1,40 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""A minimal class-based Egress Gate implementation.""" - -from typing import Literal - -from egress_gate.gates import Gate, GateCapability, GateConfig, GateRegistry -from egress_gate.request import HttpRequest -from egress_gate.result import GateEvaluation -from egress_gate.timeout import Timeout - - -class KeywordDenyConfig(GateConfig): - """Policy fields accepted by the custom gate.""" - - kind: Literal["keyword-deny"] - keyword: str - - -class KeywordDenyGate(Gate[KeywordDenyConfig, None]): - """Deny requests whose body contains the configured UTF-8 keyword.""" - - capabilities = frozenset({GateCapability.READ_BODY, GateCapability.DENY}) - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - timeout.raise_if_expired() - if self.config.keyword.encode("utf-8") in request.body: - return GateEvaluation.deny("keyword_denied") - return GateEvaluation.proceed() - - -registry = GateRegistry(include_builtin_gates=True) -registry.register(KeywordDenyGate) diff --git a/projects/egress-gate/examples/class-based-gate/policy.yaml b/projects/egress-gate/examples/class-based-gate/policy.yaml deleted file mode 100644 index b82b4064..00000000 --- a/projects/egress-gate/examples/class-based-gate/policy.yaml +++ /dev/null @@ -1,47 +0,0 @@ -version: 1 - -filesystem_policy: - include_workdir: true - read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] - read_write: [/sandbox, /tmp, /dev/null] -landlock: - compatibility: best_effort -process: - run_as_user: sandbox - run_as_group: sandbox - -network_policies: - claude_code: - name: Claude Code access - endpoints: - - host: api.anthropic.com - port: 443 - protocol: rest - enforcement: enforce - access: full - - host: platform.claude.com - port: 443 - protocol: rest - enforcement: enforce - access: full - - host: claude.ai - port: 443 - binaries: - - { path: /usr/local/bin/claude } - - { path: /usr/bin/node } - -network_middlewares: - egress_gate_class: - name: Deny the configured keyword - middleware: egress-class - order: 0 - config: - gates: - - name: block-secret-keyword - kind: keyword-deny - keyword: SECRET - default_decision: allow - on_error: fail_closed - endpoints: - include: - - api.anthropic.com diff --git a/projects/egress-gate/examples/custom-gate/README.md b/projects/egress-gate/examples/custom-gate/README.md deleted file mode 100644 index 2905f8c2..00000000 --- a/projects/egress-gate/examples/custom-gate/README.md +++ /dev/null @@ -1,127 +0,0 @@ -# Function-based custom gate - -This example adds a `keyword-deny` gate in one Python file. If the configured -keyword occurs in the request body, the gate denies the request. Otherwise, it -returns `proceed`, and the pipeline continues. - -The implementation has three pieces: - -1. `KeywordDenyConfig` defines the exact policy fields and the stable - `kind: keyword-deny` discriminator. -2. `registry.gate` turns the typed `keyword_deny` function into a standard - resource-free gate type and adds it to the application registry. -3. The CLI loads that module-owned registry directly. - -Run the example from `projects/egress-gate/`. First confirm that the custom -gate is installed in this registry: - -```bash -uv run egress-gate \ - --registry examples.custom-gate.keyword_gate:registry \ - gates list -``` - -Then test the policy against two saved requests: - -```bash -uv run egress-gate \ - --registry examples.custom-gate.keyword_gate:registry \ - evaluate \ - --policy examples/custom-gate/egress-gate-config.yaml \ - --cases examples/custom-gate/cases.yaml -``` - -The executable resolves the explicit `module:attribute` reference from the -working directory. The attribute can contain a registry or a zero-argument -registry factory. An installed custom-gate package works the same way. - -The first case contains the configured keyword and is denied. The second gate -evaluation proceeds, so `default_decision: allow` determines its result. - -## Run it with OpenShell - -Start Egress Gate with this example registry and content-safe debug diagnostics: - -```bash -uv run egress-gate \ - --debug \ - --registry examples.custom-gate.keyword_gate:registry \ - serve --listen 0.0.0.0:50051 --timeout 4s -``` - -Before you change the gateway configuration, stop any running OpenShell -gateways that use it. A running gateway does not reload middleware -registrations. - -In another terminal, register the service in your default gateway -configuration. Replace `YOUR_HOST_IPV4` with a non-loopback address that the -gateway and sandbox supervisors can reach. - -```bash -uv run egress-gate add-gateway-registration \ - --host-ip YOUR_HOST_IPV4 \ - --name egress-function \ - --port 50051 -``` - -Start the OpenShell gateway again with the same command or service manager that -you normally use. Then create a sandbox and launch Claude Code: - -```bash -openshell sandbox create \ - --name egress-function \ - --from base \ - --no-auto-providers \ - --policy examples/custom-gate/policy.yaml \ - -- env CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude -``` - -This command uses the base sandbox image, prevents OpenShell from creating or -attaching a provider, and starts Claude Code with nonessential traffic disabled. -The policy can therefore omit telemetry and error-reporting endpoints. - -On the first run, complete Claude Code's browser sign-in from inside the -sandbox. The session uses your Claude subscription directly; OpenShell does not -attach an Anthropic API-key provider. - -At the Claude prompt, enter a normal request: - -```text -Reply with only the word OK. -``` - -Claude should reply normally, and the Egress Gate terminal should record an -allow decision. Then enter a request that contains the configured keyword: - -```text -Reply with only the word SECRET. -``` - -The request must fail before Claude answers. The Egress Gate terminal must -record `action=deny` and `decision_source_kind=gate`. Together, the normal -response and denied request confirm that the custom gate is active. - -Exit Claude Code and delete the sandbox. Stop any running OpenShell gateways -before you remove the registration. Then start the gateways again: - -```bash -openshell sandbox delete egress-function -uv run egress-gate remove-gateway-registration --name egress-function -``` - -OpenShell names used by this example have a 19-character limit. The chosen -names stay within that limit. - -This teaching gate searches the body bytes for the UTF-8 encoding of the -configured keyword. It is not a robust content classifier. The pipeline -processor already checks the `HttpRequest` limits; the gate does not repeat -those checks. - -The decorator is a helper for small, stateless gates. See the runnable -[`class-based-gate`](../class-based-gate/) example when a gate needs reusable -initialization, a helper base, or typed operational resources. - -A production gate must define its encoding, normalization, and matching -behavior. Add limits only for work that belongs to the gate. Do not put request -content in errors or findings. Check the shared timeout during expensive work, -and keep request state local. diff --git a/projects/egress-gate/examples/custom-gate/cases.yaml b/projects/egress-gate/examples/custom-gate/cases.yaml deleted file mode 100644 index 53dc2467..00000000 --- a/projects/egress-gate/examples/custom-gate/cases.yaml +++ /dev/null @@ -1,53 +0,0 @@ -version: 1 -cases: - - name: configured-keyword-is-denied - tags: [custom-gate] - provenance: - kind: synthetic - redacted: true - request: - context: - request_id: custom-gate-deny - sandbox_id: sandbox-example - target: - scheme: https - host: api.example.com - port: 443 - method: POST - path: /messages - query: "" - headers: [] - body: - encoding: utf8 - value: "do not send this SECRET" - expected: - decision: deny - decision_source_kind: gate - gate_name: block-secret-keyword - gate_type: keyword-deny - finding_types: [] - - - name: other-bodies-proceed-to-the-default - tags: [custom-gate] - provenance: - kind: synthetic - redacted: true - request: - context: - request_id: custom-gate-allow - sandbox_id: sandbox-example - target: - scheme: https - host: api.example.com - port: 443 - method: POST - path: /messages - query: "" - headers: [] - body: - encoding: utf8 - value: "ordinary text" - expected: - decision: allow - decision_source_kind: pipeline_default - finding_types: [] diff --git a/projects/egress-gate/examples/custom-gate/egress-gate-config.yaml b/projects/egress-gate/examples/custom-gate/egress-gate-config.yaml deleted file mode 100644 index 05186e04..00000000 --- a/projects/egress-gate/examples/custom-gate/egress-gate-config.yaml +++ /dev/null @@ -1,5 +0,0 @@ -gates: - - name: block-secret-keyword - kind: keyword-deny - keyword: SECRET -default_decision: allow diff --git a/projects/egress-gate/examples/custom-gate/keyword_gate.py b/projects/egress-gate/examples/custom-gate/keyword_gate.py deleted file mode 100644 index f20b0d4d..00000000 --- a/projects/egress-gate/examples/custom-gate/keyword_gate.py +++ /dev/null @@ -1,42 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""A minimal custom Egress Gate implementation.""" - -from typing import Literal - -from egress_gate.gates import ( - GateCapability, - GateConfig, - GateRegistry, -) -from egress_gate.request import HttpRequest -from egress_gate.result import GateEvaluation -from egress_gate.timeout import Timeout - - -class KeywordDenyConfig(GateConfig): - """Policy fields accepted by the custom gate.""" - - kind: Literal["keyword-deny"] - keyword: str - - -registry = GateRegistry(include_builtin_gates=True) - - -@registry.gate( - config=KeywordDenyConfig, - capabilities=frozenset({GateCapability.READ_BODY, GateCapability.DENY}), -) -def keyword_deny( - request: HttpRequest, - config: KeywordDenyConfig, - *, - timeout: Timeout, -) -> GateEvaluation: - """Deny requests whose body contains the configured UTF-8 keyword.""" - timeout.raise_if_expired() - if config.keyword.encode("utf-8") in request.body: - return GateEvaluation.deny("keyword_denied") - return GateEvaluation.proceed() diff --git a/projects/egress-gate/examples/custom-gate/policy.yaml b/projects/egress-gate/examples/custom-gate/policy.yaml deleted file mode 100644 index 796ca0c9..00000000 --- a/projects/egress-gate/examples/custom-gate/policy.yaml +++ /dev/null @@ -1,47 +0,0 @@ -version: 1 - -filesystem_policy: - include_workdir: true - read_only: [/usr, /lib, /proc, /dev/urandom, /app, /etc, /var/log] - read_write: [/sandbox, /tmp, /dev/null] -landlock: - compatibility: best_effort -process: - run_as_user: sandbox - run_as_group: sandbox - -network_policies: - claude_code: - name: Claude Code access - endpoints: - - host: api.anthropic.com - port: 443 - protocol: rest - enforcement: enforce - access: full - - host: platform.claude.com - port: 443 - protocol: rest - enforcement: enforce - access: full - - host: claude.ai - port: 443 - binaries: - - { path: /usr/local/bin/claude } - - { path: /usr/bin/node } - -network_middlewares: - egress_gate_fn: - name: Deny the configured keyword - middleware: egress-function - order: 0 - config: - gates: - - name: block-secret-keyword - kind: keyword-deny - keyword: SECRET - default_decision: allow - on_error: fail_closed - endpoints: - include: - - api.anthropic.com diff --git a/projects/egress-gate/examples/regex-redaction/.gitignore b/projects/egress-gate/examples/regex-redaction/.gitignore deleted file mode 100644 index b223234c..00000000 --- a/projects/egress-gate/examples/regex-redaction/.gitignore +++ /dev/null @@ -1 +0,0 @@ -gateway.local.toml diff --git a/projects/egress-gate/examples/regex-redaction/README.md b/projects/egress-gate/examples/regex-redaction/README.md deleted file mode 100644 index 7df9a3d1..00000000 --- a/projects/egress-gate/examples/regex-redaction/README.md +++ /dev/null @@ -1,103 +0,0 @@ -# Regex redaction - -This example replaces email addresses and customer IDs in request bodies. The -OpenShell policy applies the built-in `regex` gate to requests for one provider -endpoint. - -Run these commands from `projects/egress-gate/examples/regex-redaction/`. - -## Test the gate - -Inspect the installed gates, then test the standalone policy against two saved -requests: - -```bash -uv run egress-gate gates list -uv run egress-gate evaluate \ - --policy egress-gate-config.yaml \ - --cases cases.yaml -``` - -## Run it with OpenShell - -Start Egress Gate with content-safe debug diagnostics in one terminal. The -working directory contains the pattern catalog referenced by `policy.yaml`. - -```bash -uv run egress-gate --debug serve \ - --listen 0.0.0.0:50051 \ - --timeout 4s -``` - -Before you change the gateway configuration, stop any running OpenShell -gateways that use it. A running gateway does not reload middleware -registrations. - -In another terminal, add the registration to your default gateway -configuration. Replace `YOUR_HOST_IPV4` with a non-loopback address that the -gateway and sandbox supervisors can reach. - -```bash -uv run egress-gate add-gateway-registration \ - --host-ip YOUR_HOST_IPV4 \ - --name eg-regex \ - --port 50051 -``` - -Start the OpenShell gateway again with the same command or service manager that -you normally use. Then create a sandbox and launch Claude Code: - -```bash -openshell sandbox create \ - --name eg-regex \ - --from base \ - --no-auto-providers \ - --policy policy.yaml \ - -- env CLAUDE_CODE_DISABLE_NONESSENTIAL_TRAFFIC=1 claude -``` - -This command uses the base sandbox image, prevents OpenShell from creating or -attaching a provider, and starts Claude Code with nonessential traffic disabled. -The policy can therefore omit telemetry and error-reporting endpoints. - -On the first run, complete Claude Code's browser sign-in from inside the -sandbox. The session uses your Claude subscription directly; OpenShell does not -attach an Anthropic API-key provider. - -At the Claude prompt, enter: - -```text -Reply with exactly this text: alice@example.com CUST-12345678 -``` - -Claude must not receive the original identifiers. Its response should contain -`[email]` and `[customer-id]` instead. The Egress Gate terminal also records an -allow decision with `finding_count=2`, without logging request content. These -two observations confirm that OpenShell called Egress Gate and applied the -replacement before it sent the request to Claude. - -Exit Claude Code, then delete the sandbox when the test is complete: - -```bash -openshell sandbox delete eg-regex -``` - -To remove the example registration, first stop any running OpenShell gateways -that use the configuration. Run this command, then start the gateways again: - -```bash -uv run egress-gate remove-gateway-registration \ - --name eg-regex -``` - -OpenShell names used by this example have a 19-character limit. The chosen -names stay within that limit. - -## What the policy does - -The gate uses `scan.kind: body` with `action.kind: replace`. It strictly -decodes the body as UTF-8, finds catalog matches, and requests a body -replacement. Egress Gate applies that mutation before the request continues. - -Body scans also support `detect` and `deny`. The same gate can detect or deny -matches in the path, query, or selected header values. diff --git a/projects/egress-gate/examples/regex-redaction/cases.yaml b/projects/egress-gate/examples/regex-redaction/cases.yaml deleted file mode 100644 index e9e568c7..00000000 --- a/projects/egress-gate/examples/regex-redaction/cases.yaml +++ /dev/null @@ -1,50 +0,0 @@ -version: 1 -cases: - - name: email-is-detected-and-request-is-allowed - tags: [regex, redaction] - provenance: - kind: synthetic - redacted: false - request: - context: - request_id: corpus-email - sandbox_id: sandbox-example - target: - scheme: https - host: api.example.com - port: 443 - method: POST - path: /v1/messages - query: "" - headers: [] - body: - encoding: utf8 - value: "send alice@example.com" - expected: - decision: allow - decision_source_kind: pipeline_default - finding_types: [regex_match] - - name: ordinary-body-is-allowed - tags: [regex] - provenance: - kind: synthetic - redacted: true - request: - context: - request_id: corpus-ordinary - sandbox_id: sandbox-example - target: - scheme: https - host: api.example.com - port: 443 - method: POST - path: /v1/messages - query: "" - headers: [] - body: - encoding: utf8 - value: "ordinary text" - expected: - decision: allow - decision_source_kind: pipeline_default - finding_types: [] diff --git a/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml b/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml deleted file mode 100644 index bec57150..00000000 --- a/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml +++ /dev/null @@ -1,21 +0,0 @@ -gates: - - name: identifiers - kind: regex - scan: - kind: body - action: - kind: replace - template: "[{entity}]" - pattern_catalog: - entities: - - name: email - rules: - - name: conventional-email - pattern: '(? metadata = 7; - // Optional stable machine-readable code for a deny decision. Codes must - // start with a lowercase ASCII letter and contain only lowercase ASCII - // letters, digits, and underscores, with a maximum length of 64 bytes. - // OpenShell may return this code to the requester, unlike free-form reason. - string reason_code = 8; -} diff --git a/projects/egress-gate/pyproject.toml b/projects/egress-gate/pyproject.toml deleted file mode 100644 index 99254472..00000000 --- a/projects/egress-gate/pyproject.toml +++ /dev/null @@ -1,76 +0,0 @@ -[project] -name = "egress-gate" -version = "0.1.0" -description = "Run extensible gates on provider-bound OpenShell HTTP requests." -readme = "README.md" -requires-python = ">=3.11" -license = "Apache-2.0" -license-files = ["LICENSE"] -authors = [ - { name = "NVIDIA CORPORATION & AFFILIATES" }, -] -dependencies = [ - "grpcio>=1.81.1,<2", - "protobuf>=7.36,<8", # 7.36.0 fixes protobuf security advisories. - "pydantic>=2.11,<3", - "pyyaml>=6,<7", - "regex>=2026.7.19,<2027", - "rich>=14,<16", - "typer>=0.16,<1", - "typing-extensions>=4.12,<5", -] - -[project.scripts] -egress-gate = "egress_gate.cli:app" - -[project.urls] -Repository = "https://github.com/NVIDIA/OpenShell-Research" - -[dependency-groups] -dev = [ - "pip-audit==2.10.1", - "pytest>=9.0.3,<10", - "pytest-asyncio>=0.25,<2", - "ruff>=0.12,<0.13", - "ty>=0.0.1a16,<0.1", -] - -[build-system] -requires = ["uv_build>=0.11.8,<0.12.0"] -build-backend = "uv_build" - -[tool.pytest.ini_options] -testpaths = ["tests"] - -[tool.ruff] -target-version = "py311" -extend-exclude = ["src/egress_gate/bindings"] - -[tool.ruff.lint] -select = ["E", "F", "I", "UP"] - -[tool.ty.src] -# Generated protobuf/gRPC bindings are checked at their handwritten adapter seam; -# their generator-owned implementations and incomplete annotations stay excluded. -exclude = ["src/egress_gate/bindings"] - -[tool.ty.rules] -blanket-ignore-comment = "error" -missing-override-decorator = "ignore" -missing-type-argument = "error" -redundant-cast = "error" -unresolved-attribute = "error" -unresolved-global = "error" -unresolved-import = "error" -unresolved-reference = "error" -unused-ignore-comment = "error" -unused-type-ignore-comment = "error" - -[[tool.ty.overrides]] -include = ["examples/**"] - -[tool.ty.overrides.rules] -missing-override-decorator = "ignore" - -[tool.uv] -required-version = ">=0.11.0" diff --git a/projects/egress-gate/scripts/check.sh b/projects/egress-gate/scripts/check.sh deleted file mode 100755 index 0a02d5fb..00000000 --- a/projects/egress-gate/scripts/check.sh +++ /dev/null @@ -1,25 +0,0 @@ -#!/usr/bin/env bash -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -set -euo pipefail - -cd "$(dirname "${BASH_SOURCE[0]}")/.." - -uv_run=(uv run --frozen) -if [[ $# -gt 0 ]]; then - if [[ $1 != "--python" || $# -ne 2 ]]; then - echo "usage: scripts/check.sh [--python VERSION]" >&2 - exit 2 - fi - uv_run+=(--python "$2") -fi - -"${uv_run[@]}" pytest -q -"${uv_run[@]}" ruff format --check . -"${uv_run[@]}" ruff check . -"${uv_run[@]}" ty check -"${uv_run[@]}" python -c "import egress_gate" -"${uv_run[@]}" pip-audit \ - --progress-spinner off \ - --local diff --git a/projects/egress-gate/src/egress_gate/__init__.py b/projects/egress-gate/src/egress_gate/__init__.py deleted file mode 100644 index b3e1977a..00000000 --- a/projects/egress-gate/src/egress_gate/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Egress Gate: an OpenShell supervisor middleware. See the package README.""" diff --git a/projects/egress-gate/src/egress_gate/base.py b/projects/egress-gate/src/egress_gate/base.py deleted file mode 100644 index b69274bc..00000000 --- a/projects/egress-gate/src/egress_gate/base.py +++ /dev/null @@ -1,21 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Package-wide base class for validated, immutable domain objects.""" - -from pydantic import BaseModel, ConfigDict - - -class StrictDomainModel(BaseModel): - """Base for immutable domain values parsed without implicit coercion.""" - - model_config = ConfigDict( - strict=True, - frozen=True, - extra="forbid", - hide_input_in_errors=True, - validate_default=True, - ) - - -__all__ = ["StrictDomainModel"] diff --git a/projects/egress-gate/src/egress_gate/bindings/__init__.py b/projects/egress-gate/src/egress_gate/bindings/__init__.py deleted file mode 100644 index d206506f..00000000 --- a/projects/egress-gate/src/egress_gate/bindings/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Generated OpenShell supervisor middleware bindings. Do not edit.""" 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 deleted file mode 100644 index c254b0f3..00000000 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.py +++ /dev/null @@ -1,78 +0,0 @@ -# -*- coding: utf-8 -*- -# Generated by the protocol buffer compiler. DO NOT EDIT! -# NO CHECKED-IN PROTOBUF GENCODE -# source: supervisor_middleware.proto -# Protobuf Python Version: 6.33.5 -"""Generated protocol buffer code.""" -from google.protobuf import descriptor as _descriptor -from google.protobuf import descriptor_pool as _descriptor_pool -from google.protobuf import runtime_version as _runtime_version -from google.protobuf import symbol_database as _symbol_database -from google.protobuf.internal import builder as _builder -_runtime_version.ValidateProtobufRuntimeVersion( - _runtime_version.Domain.PUBLIC, - 6, - 33, - 5, - '', - 'supervisor_middleware.proto' -) -# @@protoc_insertion_point(imports) - -_sym_db = _symbol_database.Default() - - -from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 -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') - -_globals = globals() -_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, _globals) -_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'supervisor_middleware_pb2', _globals) -if not _descriptor._USE_C_DESCRIPTORS: - DESCRIPTOR._loaded_options = None - _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['_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 -# @@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 deleted file mode 100644 index 10eac7f5..00000000 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2.pyi +++ /dev/null @@ -1,209 +0,0 @@ -from google.protobuf import empty_pb2 as _empty_pb2 -from google.protobuf import struct_pb2 as _struct_pb2 -from google.protobuf.internal import containers as _containers -from google.protobuf.internal import enum_type_wrapper as _enum_type_wrapper -from google.protobuf import descriptor as _descriptor -from google.protobuf import message as _message -from collections.abc import Iterable as _Iterable, Mapping as _Mapping -from typing import ClassVar as _ClassVar, Optional as _Optional, Union as _Union - -DESCRIPTOR: _descriptor.FileDescriptor - -class SupervisorMiddlewareOperation(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: _ClassVar[SupervisorMiddlewareOperation] - SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: _ClassVar[SupervisorMiddlewareOperation] - -class SupervisorMiddlewarePhase(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: _ClassVar[SupervisorMiddlewarePhase] - SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: _ClassVar[SupervisorMiddlewarePhase] - -class Decision(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - DECISION_UNSPECIFIED: _ClassVar[Decision] - DECISION_ALLOW: _ClassVar[Decision] - DECISION_DENY: _ClassVar[Decision] - -class ExistingHeaderAction(int, metaclass=_enum_type_wrapper.EnumTypeWrapper): - __slots__ = () - EXISTING_HEADER_ACTION_UNSPECIFIED: _ClassVar[ExistingHeaderAction] - EXISTING_HEADER_ACTION_APPEND: _ClassVar[ExistingHeaderAction] - EXISTING_HEADER_ACTION_OVERWRITE: _ClassVar[ExistingHeaderAction] - EXISTING_HEADER_ACTION_SKIP: _ClassVar[ExistingHeaderAction] -SUPERVISOR_MIDDLEWARE_OPERATION_UNSPECIFIED: SupervisorMiddlewareOperation -SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST: SupervisorMiddlewareOperation -SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED: SupervisorMiddlewarePhase -SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: SupervisorMiddlewarePhase -DECISION_UNSPECIFIED: Decision -DECISION_ALLOW: Decision -DECISION_DENY: Decision -EXISTING_HEADER_ACTION_UNSPECIFIED: ExistingHeaderAction -EXISTING_HEADER_ACTION_APPEND: ExistingHeaderAction -EXISTING_HEADER_ACTION_OVERWRITE: ExistingHeaderAction -EXISTING_HEADER_ACTION_SKIP: ExistingHeaderAction - -class MiddlewareManifest(_message.Message): - __slots__ = ("name", "service_version", "bindings") - NAME_FIELD_NUMBER: _ClassVar[int] - SERVICE_VERSION_FIELD_NUMBER: _ClassVar[int] - BINDINGS_FIELD_NUMBER: _ClassVar[int] - name: str - service_version: str - bindings: _containers.RepeatedCompositeFieldContainer[MiddlewareBinding] - def __init__(self, name: _Optional[str] = ..., service_version: _Optional[str] = ..., bindings: _Optional[_Iterable[_Union[MiddlewareBinding, _Mapping]]] = ...) -> None: ... - -class MiddlewareBinding(_message.Message): - __slots__ = ("operation", "phase", "max_body_bytes", "timeout") - OPERATION_FIELD_NUMBER: _ClassVar[int] - PHASE_FIELD_NUMBER: _ClassVar[int] - MAX_BODY_BYTES_FIELD_NUMBER: _ClassVar[int] - TIMEOUT_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: ... - -class ValidateConfigRequest(_message.Message): - __slots__ = ("config", "middleware_name") - CONFIG_FIELD_NUMBER: _ClassVar[int] - MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] - config: _struct_pb2.Struct - middleware_name: str - def __init__(self, config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., middleware_name: _Optional[str] = ...) -> None: ... - -class ValidateConfigResponse(_message.Message): - __slots__ = ("valid", "reason") - VALID_FIELD_NUMBER: _ClassVar[int] - REASON_FIELD_NUMBER: _ClassVar[int] - valid: bool - reason: str - def __init__(self, valid: _Optional[bool] = ..., reason: _Optional[str] = ...) -> None: ... - -class HttpRequestEvaluation(_message.Message): - __slots__ = ("phase", "context", "config", "target", "headers", "body", "middleware_name") - PHASE_FIELD_NUMBER: _ClassVar[int] - CONTEXT_FIELD_NUMBER: _ClassVar[int] - CONFIG_FIELD_NUMBER: _ClassVar[int] - TARGET_FIELD_NUMBER: _ClassVar[int] - HEADERS_FIELD_NUMBER: _ClassVar[int] - BODY_FIELD_NUMBER: _ClassVar[int] - MIDDLEWARE_NAME_FIELD_NUMBER: _ClassVar[int] - phase: SupervisorMiddlewarePhase - context: RequestContext - config: _struct_pb2.Struct - target: HttpRequestTarget - headers: _containers.RepeatedCompositeFieldContainer[HttpHeader] - body: bytes - middleware_name: str - def __init__(self, phase: _Optional[_Union[SupervisorMiddlewarePhase, str]] = ..., context: _Optional[_Union[RequestContext, _Mapping]] = ..., config: _Optional[_Union[_struct_pb2.Struct, _Mapping]] = ..., target: _Optional[_Union[HttpRequestTarget, _Mapping]] = ..., headers: _Optional[_Iterable[_Union[HttpHeader, _Mapping]]] = ..., body: _Optional[bytes] = ..., middleware_name: _Optional[str] = ...) -> None: ... - -class HttpHeader(_message.Message): - __slots__ = ("name", "value") - NAME_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - name: str - value: str - def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ...) -> None: ... - -class RequestContext(_message.Message): - __slots__ = ("request_id", "sandbox_id", "originating_process") - REQUEST_ID_FIELD_NUMBER: _ClassVar[int] - SANDBOX_ID_FIELD_NUMBER: _ClassVar[int] - ORIGINATING_PROCESS_FIELD_NUMBER: _ClassVar[int] - request_id: str - sandbox_id: str - originating_process: Process - def __init__(self, request_id: _Optional[str] = ..., sandbox_id: _Optional[str] = ..., originating_process: _Optional[_Union[Process, _Mapping]] = ...) -> None: ... - -class HttpRequestTarget(_message.Message): - __slots__ = ("scheme", "host", "port", "method", "path", "query") - SCHEME_FIELD_NUMBER: _ClassVar[int] - HOST_FIELD_NUMBER: _ClassVar[int] - PORT_FIELD_NUMBER: _ClassVar[int] - METHOD_FIELD_NUMBER: _ClassVar[int] - PATH_FIELD_NUMBER: _ClassVar[int] - QUERY_FIELD_NUMBER: _ClassVar[int] - scheme: str - host: str - port: int - method: str - path: str - query: str - def __init__(self, scheme: _Optional[str] = ..., host: _Optional[str] = ..., port: _Optional[int] = ..., method: _Optional[str] = ..., path: _Optional[str] = ..., query: _Optional[str] = ...) -> None: ... - -class Process(_message.Message): - __slots__ = ("binary", "pid", "ancestors") - BINARY_FIELD_NUMBER: _ClassVar[int] - PID_FIELD_NUMBER: _ClassVar[int] - ANCESTORS_FIELD_NUMBER: _ClassVar[int] - binary: str - pid: int - ancestors: _containers.RepeatedScalarFieldContainer[str] - def __init__(self, binary: _Optional[str] = ..., pid: _Optional[int] = ..., ancestors: _Optional[_Iterable[str]] = ...) -> None: ... - -class Finding(_message.Message): - __slots__ = ("type", "label", "count", "confidence", "severity") - TYPE_FIELD_NUMBER: _ClassVar[int] - LABEL_FIELD_NUMBER: _ClassVar[int] - COUNT_FIELD_NUMBER: _ClassVar[int] - CONFIDENCE_FIELD_NUMBER: _ClassVar[int] - SEVERITY_FIELD_NUMBER: _ClassVar[int] - type: str - label: str - count: int - confidence: str - severity: str - def __init__(self, type: _Optional[str] = ..., label: _Optional[str] = ..., count: _Optional[int] = ..., confidence: _Optional[str] = ..., severity: _Optional[str] = ...) -> None: ... - -class WriteHeader(_message.Message): - __slots__ = ("name", "value", "on_existing") - NAME_FIELD_NUMBER: _ClassVar[int] - VALUE_FIELD_NUMBER: _ClassVar[int] - ON_EXISTING_FIELD_NUMBER: _ClassVar[int] - name: str - value: str - on_existing: ExistingHeaderAction - def __init__(self, name: _Optional[str] = ..., value: _Optional[str] = ..., on_existing: _Optional[_Union[ExistingHeaderAction, str]] = ...) -> None: ... - -class RemoveHeader(_message.Message): - __slots__ = ("name",) - NAME_FIELD_NUMBER: _ClassVar[int] - name: str - def __init__(self, name: _Optional[str] = ...) -> None: ... - -class HeaderMutation(_message.Message): - __slots__ = ("write", "remove") - WRITE_FIELD_NUMBER: _ClassVar[int] - REMOVE_FIELD_NUMBER: _ClassVar[int] - write: WriteHeader - remove: RemoveHeader - def __init__(self, write: _Optional[_Union[WriteHeader, _Mapping]] = ..., remove: _Optional[_Union[RemoveHeader, _Mapping]] = ...) -> None: ... - -class HttpRequestResult(_message.Message): - __slots__ = ("decision", "reason", "body", "has_body", "header_mutations", "findings", "metadata", "reason_code") - 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] - BODY_FIELD_NUMBER: _ClassVar[int] - HAS_BODY_FIELD_NUMBER: _ClassVar[int] - HEADER_MUTATIONS_FIELD_NUMBER: _ClassVar[int] - FINDINGS_FIELD_NUMBER: _ClassVar[int] - METADATA_FIELD_NUMBER: _ClassVar[int] - REASON_CODE_FIELD_NUMBER: _ClassVar[int] - decision: Decision - reason: str - body: bytes - has_body: bool - header_mutations: _containers.RepeatedCompositeFieldContainer[HeaderMutation] - findings: _containers.RepeatedCompositeFieldContainer[Finding] - metadata: _containers.ScalarMap[str, str] - reason_code: str - def __init__(self, decision: _Optional[_Union[Decision, str]] = ..., reason: _Optional[str] = ..., body: _Optional[bytes] = ..., has_body: _Optional[bool] = ..., header_mutations: _Optional[_Iterable[_Union[HeaderMutation, _Mapping]]] = ..., findings: _Optional[_Iterable[_Union[Finding, _Mapping]]] = ..., metadata: _Optional[_Mapping[str, str]] = ..., reason_code: _Optional[str] = ...) -> None: ... 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 deleted file mode 100644 index a4914b37..00000000 --- a/projects/egress-gate/src/egress_gate/bindings/supervisor_middleware_pb2_grpc.py +++ /dev/null @@ -1,194 +0,0 @@ -# Generated by the gRPC Python protocol compiler plugin. DO NOT EDIT! -"""Client and server classes corresponding to protobuf-defined services.""" -import grpc -import warnings - -from google.protobuf import empty_pb2 as google_dot_protobuf_dot_empty__pb2 -from . import supervisor_middleware_pb2 as supervisor__middleware__pb2 - -GRPC_GENERATED_VERSION = '1.81.1' -GRPC_VERSION = grpc.__version__ -_version_not_supported = False - -try: - from grpc._utilities import first_version_is_lower - _version_not_supported = first_version_is_lower(GRPC_VERSION, GRPC_GENERATED_VERSION) -except ImportError: - _version_not_supported = True - -if _version_not_supported: - raise RuntimeError( - f'The grpc package installed is at version {GRPC_VERSION},' - + ' but the generated code in supervisor_middleware_pb2_grpc.py depends on' - + f' grpcio>={GRPC_GENERATED_VERSION}.' - + f' Please upgrade your grpc module to grpcio>={GRPC_GENERATED_VERSION}' - + f' or downgrade your generated code using grpcio-tools<={GRPC_VERSION}.' - ) - - -class SupervisorMiddlewareStub: - """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress before OpenShell injects credentials. - """ - - def __init__(self, channel): - """Constructor. - - Args: - channel: A grpc.Channel. - """ - self.Describe = channel.unary_unary( - '/openshell.middleware.v1.SupervisorMiddleware/Describe', - request_serializer=google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, - response_deserializer=supervisor__middleware__pb2.MiddlewareManifest.FromString, - _registered_method=True) - self.ValidateConfig = channel.unary_unary( - '/openshell.middleware.v1.SupervisorMiddleware/ValidateConfig', - request_serializer=supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, - response_deserializer=supervisor__middleware__pb2.ValidateConfigResponse.FromString, - _registered_method=True) - self.EvaluateHttpRequest = channel.unary_unary( - '/openshell.middleware.v1.SupervisorMiddleware/EvaluateHttpRequest', - request_serializer=supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, - response_deserializer=supervisor__middleware__pb2.HttpRequestResult.FromString, - _registered_method=True) - - -class SupervisorMiddlewareServicer: - """SupervisorMiddleware lets an operator-run service inspect and transform - sandbox HTTP egress before OpenShell injects credentials. - """ - - def Describe(self, request, context): - """Describe returns the service manifest and declared bindings. - """ - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - def ValidateConfig(self, request, context): - """ValidateConfig checks service-specific configuration for one binding. - """ - context.set_code(grpc.StatusCode.UNIMPLEMENTED) - context.set_details('Method not implemented!') - raise NotImplementedError('Method not implemented!') - - def EvaluateHttpRequest(self, request, context): - """EvaluateHttpRequest returns an allow, deny, or mutation decision for one - buffered HTTP request. - """ - 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 = { - 'Describe': grpc.unary_unary_rpc_method_handler( - servicer.Describe, - request_deserializer=google_dot_protobuf_dot_empty__pb2.Empty.FromString, - response_serializer=supervisor__middleware__pb2.MiddlewareManifest.SerializeToString, - ), - 'ValidateConfig': grpc.unary_unary_rpc_method_handler( - servicer.ValidateConfig, - request_deserializer=supervisor__middleware__pb2.ValidateConfigRequest.FromString, - response_serializer=supervisor__middleware__pb2.ValidateConfigResponse.SerializeToString, - ), - 'EvaluateHttpRequest': grpc.unary_unary_rpc_method_handler( - servicer.EvaluateHttpRequest, - request_deserializer=supervisor__middleware__pb2.HttpRequestEvaluation.FromString, - response_serializer=supervisor__middleware__pb2.HttpRequestResult.SerializeToString, - ), - } - generic_handler = grpc.method_handlers_generic_handler( - 'openshell.middleware.v1.SupervisorMiddleware', rpc_method_handlers) - server.add_generic_rpc_handlers((generic_handler,)) - server.add_registered_method_handlers('openshell.middleware.v1.SupervisorMiddleware', rpc_method_handlers) - - - # 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. - """ - - @staticmethod - def Describe(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/Describe', - google_dot_protobuf_dot_empty__pb2.Empty.SerializeToString, - supervisor__middleware__pb2.MiddlewareManifest.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) - - @staticmethod - def ValidateConfig(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/ValidateConfig', - supervisor__middleware__pb2.ValidateConfigRequest.SerializeToString, - supervisor__middleware__pb2.ValidateConfigResponse.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) - - @staticmethod - def EvaluateHttpRequest(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/EvaluateHttpRequest', - supervisor__middleware__pb2.HttpRequestEvaluation.SerializeToString, - supervisor__middleware__pb2.HttpRequestResult.FromString, - options, - channel_credentials, - insecure, - call_credentials, - compression, - wait_for_ready, - timeout, - metadata, - _registered_method=True) diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py deleted file mode 100644 index 6f789c29..00000000 --- a/projects/egress-gate/src/egress_gate/cli.py +++ /dev/null @@ -1,1321 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Egress Gate command-line application.""" - -from __future__ import annotations - -import base64 -import binascii -import importlib -import ipaddress -import json -import sys -from collections.abc import Mapping -from dataclasses import dataclass -from importlib.metadata import PackageNotFoundError, version -from pathlib import Path -from typing import Annotated, Literal, Self - -import typer -import yaml -from pydantic import ValidationError, field_validator, model_validator -from rich.console import Console -from rich.panel import Panel -from rich.syntax import Syntax -from rich.table import Table -from rich.text import Text -from yaml.constructor import ConstructorError -from yaml.events import AliasEvent -from yaml.nodes import MappingNode -from yaml.resolver import BaseResolver - -from egress_gate.base import StrictDomainModel -from egress_gate.constants import ( - DEFAULT_GATEWAY_REGISTRATION_TIMEOUT, - DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - MAX_BODY_BYTES, - MAX_EVALUATION_CASE_NAME_BYTES, - MAX_EVALUATION_CASES, - MAX_EVALUATION_FILE_BYTES, - MAX_EVALUATION_TAGS, - MAX_PROTO_FINDING_GROUPS, -) -from egress_gate.errors import EgressGateError, GateRegistryError -from egress_gate.gates.base import GateCapability -from egress_gate.gates.registry import ( - GateRegistry, - PolicyValidationError, - create_builtin_registry, -) -from egress_gate.gateway_config import ( - MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES, - GatewayConfigError, - GatewayConfigRemoval, - GatewayConfigUpdate, - GatewayMiddlewareRegistration, - default_gateway_config_path, - forget_gateway_registration, - list_gateway_registrations, - read_remembered_gateway_timeout, - remember_gateway_registration, - remove_gateway_config, - update_gateway_config, - validate_gateway_timeout, - validate_middleware_name, -) -from egress_gate.logging import LoggingConfig, configure_logging, get_logger -from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext -from egress_gate.result import EgressResult, GateDecisionSource -from egress_gate.string_validators import BoundedMetadataString -from egress_gate.timeout import ( - Timeout, - parse_timeout_duration, - validate_timeout_middleware_processing, -) - -_DURATION_FORMAT_HELP = ( - "Use an integer followed by s for seconds or ms for milliseconds, such as " - "10s or 500ms." -) -_TIMEOUT_DURATION_HELP = f"{_DURATION_FORMAT_HELP} Minimum 10ms." -_LOG = get_logger(__name__) - -app = typer.Typer( - name="egress-gate", - help=( - "Run the OpenShell middleware, test policies offline, manage the OpenShell " - "gateway registration, and inspect installed gates." - ), - invoke_without_command=True, - no_args_is_help=False, - add_completion=False, - rich_markup_mode=None, -) -gates_app = typer.Typer( - help="Inspect installed gates and the policy schema they accept.", - no_args_is_help=True, - rich_markup_mode=None, -) -app.add_typer( - gates_app, - name="gates", - short_help="Inspect installed gates and policy schema.", -) - - -@app.callback() -def configure_cli( - context: typer.Context, - version_requested: Annotated[ - bool, - typer.Option( - "--version", - help="Show the installed Egress Gate version and exit.", - is_eager=True, - ), - ] = False, - registry: Annotated[ - str | None, - typer.Option( - "--registry", - help=( - "Load a trusted MODULE:ATTRIBUTE containing a GateRegistry or a " - "zero-argument registry factory. This option applies to every command." - ), - ), - ] = None, - debug: Annotated[ - bool, - typer.Option( - "--debug", - help="Log content-safe startup and request diagnostics.", - ), - ] = False, -) -> None: - """Configure the command application and its gate inventory.""" - if version_requested: - _CONSOLE.print(f"egress-gate {_package_version()}") - raise typer.Exit - configure_logging(LoggingConfig(level="DEBUG" if debug else "INFO")) - context.obj = _CommandOptions(registry=_load_registry(registry)) - if context.invoked_subcommand is None: - _CONSOLE.print(context.get_help()) - raise typer.Exit - - -@app.command("serve", short_help="Start the Egress Gate gRPC service.") -def serve( - context: typer.Context, - listen: Annotated[ - str, - typer.Option( - help=( - "Listen address in HOST:PORT form. Use 0.0.0.0 only when sandbox " - "supervisors must connect across a network namespace." - ), - ), - ] = "127.0.0.1:50051", - timeout: Annotated[ - str, - typer.Option( - "--timeout", - help=( - "Internal processing budget for one request. " - f"{_TIMEOUT_DURATION_HELP} The OpenShell gateway applies its " - "separately configured RPC timeout." - ), - ), - ] = f"{DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING:g}s", -) -> None: - """Start the Egress Gate gRPC service and run until shutdown.""" - options = _command_options(context) - from egress_gate.service.server import EgressGateServer - - try: - timeout_middleware_processing = parse_timeout_duration(timeout) - except ValueError as error: - raise typer.BadParameter( - str(error), - param_hint="--timeout", - ) from None - try: - remembered_timeout = read_remembered_gateway_timeout() - except GatewayConfigError as error: - _render_cli_error( - "Gateway timeout could not be validated", - code="gateway_config_error", - message=str(error), - ) - raise typer.Exit(code=1) from None - if remembered_timeout is not None: - remembered, timeout_gateway_ceiling = remembered_timeout - if timeout_middleware_processing >= timeout_gateway_ceiling: - raise typer.BadParameter( - "The middleware processing timeout must be less than the " - f"{timeout_gateway_ceiling:g}s gateway timeout configured for " - f"{remembered.middleware_name!r} in {remembered.config_path}.", - param_hint="--timeout", - ) - _LOG.info( - "Validated timeout_middleware_processing=%ss against " - "timeout_gateway_ceiling=%ss registration=%s gateway_config=%s", - timeout_middleware_processing, - timeout_gateway_ceiling, - remembered.middleware_name, - remembered.config_path, - ) - try: - EgressGateServer( - options.registry, - timeout_middleware_processing=timeout_middleware_processing, - ).serve_sync(listen) - except EgressGateError as error: - _render_egress_error("Egress Gate could not start", error) - raise typer.Exit(code=1) from None - - -@app.command( - "add-gateway-registration", - short_help="Register Egress Gate with OpenShell.", -) -def add_gateway_registration( - host_ip: Annotated[ - str, - typer.Option( - help=( - "Non-loopback IPv4 address that the OpenShell gateway and sandbox " - "supervisors can use to reach this Egress Gate service." - ), - ), - ], - config: Annotated[ - Path | None, - typer.Option( - help=( - "OpenShell gateway TOML file to update. By default, use " - "OPENSHELL_GATEWAY_CONFIG, then the standard per-user file." - ), - ), - ] = None, - name: Annotated[ - str, - typer.Option( - help=( - "Registration name used by an OpenShell policy's middleware field. " - "OpenShell allows " - f"1-{MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES} ASCII bytes." - ), - ), - ] = "egress-gate", - port: Annotated[ - int, - typer.Option( - min=1, - max=65535, - help=( - "Port to advertise to OpenShell. It must match the port in the " - "egress-gate serve --listen address." - ), - ), - ] = 50051, - timeout: Annotated[ - str, - typer.Option( - "--timeout", - help=( - "Gateway RPC timeout to write in the registration. " - f"{_DURATION_FORMAT_HELP}" - ), - ), - ] = DEFAULT_GATEWAY_REGISTRATION_TIMEOUT, -) -> None: - """Add or update Egress Gate with a configurable gateway RPC timeout.""" - try: - address = ipaddress.IPv4Address(host_ip) - except ipaddress.AddressValueError: - raise typer.BadParameter( - "Pass one IPv4 address, for example --host-ip 192.168.1.20.", - param_hint="--host-ip", - ) from None - if address.is_loopback or address.is_unspecified: - raise typer.BadParameter( - "Pass a non-loopback host IPv4 address reachable by sandbox " - "supervisors; do not use 127.0.0.1 or 0.0.0.0.", - param_hint="--host-ip", - ) - try: - validated_name = validate_middleware_name(name) - except GatewayConfigError as error: - raise typer.BadParameter( - str(error), - param_hint="--name", - ) from None - try: - validate_gateway_timeout(timeout) - except GatewayConfigError as error: - raise typer.BadParameter( - str(error), - param_hint="--timeout", - ) from None - config_path = config or default_gateway_config_path() - try: - result = update_gateway_config( - config_path, - middleware_name=validated_name, - host_ip=str(address), - port=port, - timeout_gateway_ceiling=timeout, - ) - remember_gateway_registration( - config_path, - middleware_name=validated_name, - ) - except GatewayConfigError as error: - _render_cli_error( - "Gateway registration could not be saved", - code="gateway_config_error", - message=str(error), - ) - raise typer.Exit(code=1) from None - - change = { - GatewayConfigUpdate.CREATED: "Created the gateway configuration file", - GatewayConfigUpdate.ADDED: "Added the registration", - GatewayConfigUpdate.UPDATED: "Updated the registration", - GatewayConfigUpdate.UNCHANGED: "Registration was already current", - }[result] - _render_registration( - title="Gateway registration is ready", - config_path=config_path, - name=validated_name, - endpoint=f"http://{address}:{port}", - timeout_gateway_ceiling=timeout, - change=change, - next_step=( - "Start Egress Gate, then restart the OpenShell gateway to load this " - "registration." - ), - ) - - -@app.command( - "list-gateway-registrations", - short_help="List OpenShell middleware registrations.", -) -def list_gateway_registrations_command( - config: Annotated[ - Path | None, - typer.Option( - help=( - "OpenShell gateway TOML file to inspect. By default, use " - "OPENSHELL_GATEWAY_CONFIG, then the standard per-user file." - ), - ), - ] = None, -) -> None: - """List the names and endpoints of registered OpenShell middleware.""" - config_path = config or default_gateway_config_path() - try: - registrations = list_gateway_registrations(config_path) - except GatewayConfigError as error: - _render_cli_error( - "Gateway registrations could not be listed", - code="gateway_config_error", - message=str(error), - ) - raise typer.Exit(code=1) from None - - _render_gateway_registrations(config_path, registrations) - - -@app.command( - "remove-gateway-registration", - short_help="Remove an OpenShell registration.", -) -def remove_gateway_registration( - name: Annotated[ - str, - typer.Option( - help="Exact registration name to remove from the gateway config.", - ), - ], - config: Annotated[ - Path | None, - typer.Option( - help=( - "OpenShell gateway TOML file to update. By default, use " - "OPENSHELL_GATEWAY_CONFIG, then the standard per-user file." - ), - ), - ] = None, -) -> None: - """Remove a named registration from an OpenShell gateway TOML file.""" - config_path = config or default_gateway_config_path() - try: - result = remove_gateway_config( - config_path, - middleware_name=name, - ) - forget_gateway_registration( - config_path, - middleware_name=name, - ) - except GatewayConfigError as error: - _render_cli_error( - "Gateway registration could not be removed", - code="gateway_config_error", - message=str(error), - ) - raise typer.Exit(code=1) from None - - if result is GatewayConfigRemoval.REMOVED: - _render_registration( - title="Gateway registration was removed", - config_path=config_path, - name=name, - next_step=("Restart the OpenShell gateway to unload this registration."), - ) - else: - _render_registration( - title="Gateway registration was not found", - config_path=config_path, - name=name, - status_style="bold yellow", - ) - - -@gates_app.command("list") -def list_gates(context: typer.Context) -> None: - """Show what each installed gate can read, change, decide, and report.""" - _render_gates(_command_options(context).registry) - - -@gates_app.command("schema") -def gate_schema(context: typer.Context) -> None: - """Print the complete policy JSON Schema for the installed gates.""" - schema = json.dumps( - _command_options(context).registry.configuration_json_schema(), - indent=2, - ensure_ascii=False, - sort_keys=True, - ) - _CONSOLE.print( - Syntax( - schema, - "json", - theme="ansi_dark", - word_wrap=False, - ), - soft_wrap=True, - ) - - -@app.command("validate", short_help="Check a policy against installed gates.") -def validate_policy( - context: typer.Context, - policy: Annotated[ - Path, - typer.Option( - "--policy", - help="Path to the YAML policy to check.", - ), - ], -) -> None: - """Check a policy without preparing gates or activating the policy.""" - options = _command_options(context) - try: - values = _load_policy(policy) - options.registry.validate_config(values) - except _EvaluationCorpusError: - _render_cli_error( - "Policy validation failed", - code="invalid_input", - message="The policy file could not be read as a supported YAML policy.", - ) - raise typer.Exit(code=1) from None - except PolicyValidationError as error: - _render_cli_error( - "Policy validation failed", - code=error.code.value, - message=(f"Policy field {error.formatted_path}: {error.category.value}."), - hint="Run egress-gate gates schema and correct that field.", - ) - raise typer.Exit(code=1) from None - except EgressGateError: - _render_cli_error( - "Policy validation failed", - code="config_invalid", - message="The policy does not match the schema for the installed gates.", - hint=( - "Run egress-gate gates schema, then check the gate names, kinds, " - "required fields, and pattern catalog." - ), - ) - raise typer.Exit(code=1) from None - _CONSOLE.print("[bold green]✓[/bold green] Policy is valid") - - -@app.command("evaluate", short_help="Test policy cases without starting the service.") -def evaluate( - context: typer.Context, - policy: Annotated[ - Path, - typer.Option( - "--policy", - help="Path to the YAML policy to test.", - ), - ], - cases: Annotated[ - Path, - typer.Option( - "--cases", - help="Path to the YAML file of saved request cases and expected results.", - ), - ], - timeout: Annotated[ - str, - typer.Option( - "--timeout", - help=( - "Timeout for policy preparation and, separately, each case. " - f"{_TIMEOUT_DURATION_HELP}" - ), - ), - ] = f"{DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING:g}s", -) -> None: - """Test saved requests against a policy without starting the service.""" - options = _command_options(context) - try: - timeout_seconds = parse_timeout_duration(timeout) - except ValueError as error: - raise typer.BadParameter( - str(error), - param_hint="--timeout", - ) from None - try: - policy_values = _load_policy(policy) - except _EvaluationCorpusError: - _render_cli_error( - "Evaluation could not start", - code="invalid_policy_file", - message="The policy file could not be read as a supported YAML policy.", - ) - raise typer.Exit(code=2) from None - try: - corpus = _load_corpus(cases) - except _EvaluationCorpusError: - _render_cli_error( - "Evaluation could not start", - code="invalid_cases_file", - message=( - "The cases file could not be read as a valid version 1 YAML test suite." - ), - ) - raise typer.Exit(code=2) from None - try: - summary = _run_corpus( - options.registry, - policy_values, - corpus, - timeout_seconds=timeout_seconds, - ) - except _CaseExecutionError as error: - if error.completed: - _render_evaluation( - _EvaluationSummary(cases=error.completed), - title="Completed before failure", - ) - failure_title = f"Evaluation failed for case {error.case_name}" - if isinstance(error.cause, EgressGateError): - _render_egress_error(failure_title, error.cause) - else: - _render_cli_error( - failure_title, - code="execution_failed", - message="An unexpected error stopped the evaluation.", - hint="Check the configured gate and its resources, then retry.", - ) - raise typer.Exit(code=2) from None - except EgressGateError as error: - _render_egress_error("Evaluation failed", error) - raise typer.Exit(code=2) from None - except Exception: - _render_cli_error( - "Evaluation failed", - code="execution_failed", - message="An unexpected error stopped the evaluation.", - hint=( - "Check custom gate and application-owned resource setup, then retry." - ), - ) - raise typer.Exit(code=2) from None - - _render_evaluation(summary) - if summary.failed: - raise typer.Exit(code=1) - - -_CONSOLE = Console() -_ERROR_CONSOLE = Console(stderr=True) - - -@dataclass(frozen=True) -class _CommandOptions: - registry: GateRegistry - - -class _EvaluationCorpusError(Exception): - """A content-safe offline policy or corpus input failure.""" - - -class _CaseExecutionError(Exception): - """One case failure plus safe results completed before it.""" - - def __init__( - self, - *, - case_name: str, - completed: tuple[_CaseEvaluation, ...], - cause: Exception, - ) -> None: - self.case_name = case_name - self.completed = completed - self.cause = cause - super().__init__("corpus case execution failed") - - -class _CorpusProvenance(StrictDomainModel): - """Required origin and redaction declaration for one corpus case.""" - - kind: Literal["synthetic", "captured"] - redacted: bool - - -class _CorpusBody(StrictDomainModel): - """One bounded UTF-8 or standard base64 body representation.""" - - encoding: Literal["utf8", "base64"] - value: str - - @model_validator(mode="after") - def _value_is_bounded_and_decodable(self) -> Self: - if self.encoding == "utf8": - try: - if len(self.value.encode("utf-8", errors="strict")) > MAX_BODY_BYTES: - raise ValueError - except UnicodeEncodeError: - raise ValueError("body value is not valid UTF-8") from None - return self - - try: - encoded = self.value.encode("ascii") - decoded = base64.b64decode(encoded, validate=True) - except (UnicodeEncodeError, binascii.Error, ValueError, OverflowError): - raise ValueError("body value is not valid base64") from None - if len(decoded) > MAX_BODY_BYTES: - raise ValueError("decoded body exceeds the size limit") - if base64.b64encode(decoded) != encoded: - raise ValueError("body base64 is not canonical") - return self - - def decode(self) -> bytes: - """Decode the bounded body without exposing input in an error.""" - if self.encoding == "utf8": - return self.value.encode("utf-8") - return base64.b64decode(self.value.encode("ascii"), validate=True) - - -class _CorpusRequest(StrictDomainModel): - """The exact protobuf-free request fields accepted by a corpus case.""" - - context: RequestContext - target: HttpTarget - headers: tuple[HttpHeader, ...] - body: _CorpusBody - - @field_validator("context", mode="before") - @classmethod - def _normalize_context_sequences(cls, value: object) -> object: - if not isinstance(value, Mapping): - return value - context = dict(value) - process = context.get("originating_process") - if isinstance(process, Mapping): - process_values = dict(process) - ancestors = process_values.get("ancestors") - if isinstance(ancestors, list): - process_values["ancestors"] = tuple(ancestors) - context["originating_process"] = process_values - return context - - @field_validator("headers", mode="before") - @classmethod - def _headers_are_a_tuple(cls, value: object) -> object: - if isinstance(value, list): - return tuple(value) - return value - - def to_http_request(self) -> HttpRequest: - """Build the same immutable request value used by the service.""" - return HttpRequest( - context=self.context, - target=self.target, - headers=self.headers, - body=self.body.decode(), - ) - - -class _CorpusExpected(StrictDomainModel): - """Required decision and optional content-safe result projections.""" - - decision: Literal["allow", "deny"] - decision_source_kind: ( - Literal["gate", "pipeline_default", "runtime_limit"] | None - ) = None - gate_name: BoundedMetadataString | None = None - gate_type: BoundedMetadataString | None = None - finding_types: tuple[BoundedMetadataString, ...] | None = None - - @field_validator("finding_types", mode="before") - @classmethod - def _finding_types_are_a_tuple(cls, value: object) -> object: - if isinstance(value, list): - return tuple(value) - return value - - @model_validator(mode="after") - def _optional_fields_are_omitted_or_present(self) -> Self: - for field_name in ( - "decision_source_kind", - "gate_name", - "gate_type", - "finding_types", - ): - if ( - field_name in self.model_fields_set - and getattr(self, field_name) is None - ): - raise ValueError(f"{field_name} must be omitted when not expected") - if ( - self.finding_types is not None - and len(self.finding_types) > MAX_PROTO_FINDING_GROUPS - ): - raise ValueError("too many expected finding types") - gate_fields_present = any( - field_name in self.model_fields_set - for field_name in ("gate_name", "gate_type") - ) - if gate_fields_present and self.decision_source_kind != "gate": - raise ValueError("gate source fields require a gate decision source") - return self - - -class _EvaluationCase(StrictDomainModel): - """One named request, provenance declaration, and expected projection.""" - - name: BoundedMetadataString - tags: tuple[BoundedMetadataString, ...] = () - provenance: _CorpusProvenance - request: _CorpusRequest - expected: _CorpusExpected - - @field_validator("name") - @classmethod - def _name_is_bounded_for_reporting(cls, value: str) -> str: - if len(value.encode("utf-8")) > MAX_EVALUATION_CASE_NAME_BYTES: - raise ValueError("case name exceeds the size limit") - return value - - @field_validator("tags", mode="before") - @classmethod - def _tags_are_a_tuple(cls, value: object) -> object: - if isinstance(value, list): - return tuple(value) - return value - - @model_validator(mode="after") - def _tags_are_bounded(self) -> Self: - if len(self.tags) > MAX_EVALUATION_TAGS: - raise ValueError("case has too many tags") - return self - - -class _EvaluationCorpus(StrictDomainModel): - """Version-one strict bounded corpus document.""" - - version: Literal[1] - cases: tuple[_EvaluationCase, ...] - - @field_validator("cases", mode="before") - @classmethod - def _cases_are_bounded_tuple(cls, value: object) -> object: - if not isinstance(value, list | tuple) or not value: - raise ValueError("corpus cases must be a non-empty list") - if len(value) > MAX_EVALUATION_CASES: - raise ValueError("corpus has too many cases") - return tuple(value) - - @model_validator(mode="after") - def _case_names_are_unique(self) -> Self: - names = tuple(case.name for case in self.cases) - if len(names) != len(set(names)): - raise ValueError("corpus case names must be unique") - return self - - -@dataclass(frozen=True) -class _FieldDifference: - """One stable expected-versus-actual projection difference.""" - - field: str - expected: object - actual: object - - -@dataclass(frozen=True) -class _CaseEvaluation: - """Content-safe result for one corpus case.""" - - name: str - differences: tuple[_FieldDifference, ...] - - @property - def matched(self) -> bool: - return not self.differences - - -@dataclass(frozen=True) -class _EvaluationSummary: - """Stable aggregate for an offline corpus run.""" - - cases: tuple[_CaseEvaluation, ...] - - @property - def total(self) -> int: - return len(self.cases) - - @property - def passed(self) -> int: - return sum(case.matched for case in self.cases) - - @property - def failed(self) -> int: - return self.total - self.passed - - -class _StrictEvaluationLoader(yaml.SafeLoader): - """Safe YAML loader that rejects aliases and duplicate mapping keys.""" - - def compose_node( - self, - parent: object, - index: object, - ) -> yaml.Node: - if self.check_event(AliasEvent): - raise ConstructorError( - None, - None, - "YAML aliases are not supported", - self.peek_event().start_mark, - ) - return super().compose_node(parent, index) - - -def _construct_unique_mapping( - loader: _StrictEvaluationLoader, - node: MappingNode, - deep: bool = False, -) -> dict[object, object]: - loader.flatten_mapping(node) - mapping: dict[object, object] = {} - for key_node, value_node in node.value: - key = loader.construct_object(key_node, deep=deep) - try: - duplicate = key in mapping - except TypeError: - raise ConstructorError( - "while constructing a mapping", - node.start_mark, - "found an unhashable key", - key_node.start_mark, - ) from None - if duplicate: - raise ConstructorError( - "while constructing a mapping", - node.start_mark, - "found a duplicate key", - key_node.start_mark, - ) - mapping[key] = loader.construct_object(value_node, deep=deep) - return mapping - - -_StrictEvaluationLoader.add_constructor( - BaseResolver.DEFAULT_MAPPING_TAG, - _construct_unique_mapping, -) - - -def _load_policy(path: Path) -> Mapping[str, object]: - """Load one bounded strict YAML policy.""" - values = _load_yaml(path) - if not isinstance(values, Mapping): - raise _EvaluationCorpusError - policy_values: dict[str, object] = {} - for key, value in values.items(): - if not isinstance(key, str): - raise _EvaluationCorpusError - policy_values[key] = value - return policy_values - - -def _load_corpus(path: Path) -> _EvaluationCorpus: - """Load and validate one version-one strict YAML corpus.""" - values = _load_yaml(path) - try: - return _EvaluationCorpus.model_validate(values) - except (TypeError, ValueError, ValidationError): - raise _EvaluationCorpusError from None - - -def _run_corpus( - registry: GateRegistry, - policy_values: Mapping[str, object], - corpus: _EvaluationCorpus, - *, - timeout_seconds: float, -) -> _EvaluationSummary: - """Prepare once, then evaluate every case with a fresh shared timeout.""" - validated_timeout = validate_timeout_middleware_processing(timeout_seconds) - validated_config = registry.validate_config(policy_values) - processor = registry.prepare_processor( - validated_config, - timeout=Timeout.from_seconds(validated_timeout), - ) - evaluations: list[_CaseEvaluation] = [] - for case in corpus.cases: - try: - result = processor.process( - case.request.to_http_request(), - timeout=Timeout.from_seconds(validated_timeout), - ) - except Exception as error: - raise _CaseExecutionError( - case_name=case.name, - completed=tuple(evaluations), - cause=error, - ) from None - evaluations.append( - _CaseEvaluation( - name=case.name, - differences=_compare_result(case.expected, result), - ) - ) - return _EvaluationSummary(cases=tuple(evaluations)) - - -def _render_evaluation( - summary: _EvaluationSummary, - *, - title: str = "Policy evaluation", -) -> None: - """Render content-safe case results and their aggregate.""" - table = Table( - title=title, - box=None, - pad_edge=False, - padding=(0, 2), - header_style="bold cyan", - title_style="bold", - title_justify="left", - ) - table.add_column("Status", no_wrap=True) - table.add_column("Case", ratio=2) - table.add_column("Details", ratio=3) - - for evaluation in summary.cases: - if evaluation.matched: - status = Text("PASS", style="bold green") - details = Text("All checks matched", style="dim") - else: - status = Text("FAIL", style="bold red") - details = Text() - for index, difference in enumerate(evaluation.differences): - if index: - details.append("\n") - details.append(f"{difference.field}: ", style="bold") - details.append("expected ", style="dim") - details.append(_format_value(difference.expected)) - details.append(" · actual ", style="dim") - details.append(_format_value(difference.actual)) - table.add_row(status, Text(evaluation.name), details) - - _CONSOLE.print(table) - _CONSOLE.print( - Text.assemble( - (f"{summary.passed} passed", "bold green"), - " · ", - ( - f"{summary.failed} failed", - "bold red" if summary.failed else "dim", - ), - " · ", - (f"{summary.total} total", "dim"), - ) - ) - - -def _render_gates(registry: GateRegistry) -> None: - """Render the installed gate inventory for a person.""" - _CONSOLE.print("[bold]Installed gates[/bold]") - for description in registry.describe_gates(): - finding_types = ( - ", ".join(item.type for item in description.finding_types) - or "None declared" - ) - request_access = ", ".join( - label - for capability, label in _REQUEST_ACCESS_LABELS.items() - if capability in description.capabilities - ) - possible_result_labels = [ - label - for capability, label in _MUTATION_CAPABILITY_LABELS.items() - if capability in description.capabilities - ] - if description.finding_types: - possible_result_labels.append("findings") - possible_result_labels.extend( - label - for capability, label in _DECISION_CAPABILITY_LABELS.items() - if capability in description.capabilities - ) - possible_results = ", ".join(possible_result_labels) - details = Table.grid(padding=(0, 2)) - details.add_column(style="bold cyan", no_wrap=True) - details.add_column() - details.add_row("Description", Text(description.description)) - details.add_row("Request access", Text(request_access or "None declared")) - details.add_row( - "Possible results", - Text(possible_results or "None declared"), - ) - details.add_row("Finding types", Text(finding_types)) - details.add_row("Python config", Text(description.config_type)) - if description.resource_type is not None: - details.add_row("Python resources", Text(description.resource_type)) - _CONSOLE.print( - Panel( - details, - title=Text(description.gate_type, style="bold green"), - title_align="left", - border_style="bright_blue", - ) - ) - - -def _render_registration( - *, - title: str, - config_path: Path, - name: str, - endpoint: str | None = None, - timeout_gateway_ceiling: str | None = None, - change: str | None = None, - next_step: str | None = None, - status_style: str = "bold green", -) -> None: - """Render one gateway registration outcome and its relevant values.""" - _CONSOLE.print(Text(title, style=status_style)) - details = Table.grid(padding=(0, 2)) - details.add_column(style="bold cyan", no_wrap=True) - details.add_column(overflow="fold") - details.add_row("Gateway file", Text(str(config_path))) - details.add_row("Registration", Text(name)) - if endpoint is not None: - details.add_row("Endpoint", Text(endpoint)) - if timeout_gateway_ceiling is not None: - details.add_row( - "Gateway RPC ceiling", - Text(timeout_gateway_ceiling), - ) - if change is not None: - details.add_row("Change", Text(change)) - _CONSOLE.print(details) - if next_step is not None: - _CONSOLE.print(Text.assemble(("Next: ", "bold"), next_step)) - - -def _render_gateway_registrations( - config_path: Path, - registrations: tuple[GatewayMiddlewareRegistration, ...], -) -> None: - """Render the middleware names that can be passed to the remove command.""" - _CONSOLE.print("[bold]OpenShell middleware registrations[/bold]") - _CONSOLE.print(Text.assemble(("Gateway file: ", "bold cyan"), str(config_path))) - if not registrations: - _CONSOLE.print("No middleware registrations found.") - return - - table = Table(box=None, pad_edge=False, padding=(0, 2), header_style="bold cyan") - table.add_column("Name", style="bold", no_wrap=True) - table.add_column("Endpoint", overflow="fold") - table.add_column("Gateway RPC ceiling", no_wrap=True) - for registration in registrations: - table.add_row( - registration.name, - registration.endpoint or "Not set", - registration.timeout_gateway_ceiling or "Not set", - ) - _CONSOLE.print(table) - _CONSOLE.print( - Text.assemble( - ("Remove one: ", "bold"), - "egress-gate remove-gateway-registration --name NAME", - ) - ) - - -def _render_egress_error(title: str, error: EgressGateError) -> None: - """Render one cataloged error without internal component terminology.""" - _render_cli_error( - title, - code=error.code.value, - message=error.summary, - hint=error.hint, - ) - - -def _render_cli_error( - title: str, - *, - code: str, - message: str, - hint: str | None = None, -) -> None: - """Render a concise content-safe CLI failure.""" - heading = Text(title, style="bold red") - heading.append(f" [{code}]", style="dim") - _ERROR_CONSOLE.print(heading) - _ERROR_CONSOLE.print(Text(message)) - if hint is not None: - _ERROR_CONSOLE.print(Text.assemble(("Next: ", "bold"), hint)) - - -_REQUEST_ACCESS_LABELS = { - GateCapability.READ_TARGET: "target", - GateCapability.READ_CONTEXT: "request context", - GateCapability.READ_HEADERS: "headers", - GateCapability.READ_BODY: "body", -} -_MUTATION_CAPABILITY_LABELS = { - GateCapability.REPLACE_BODY: "body replacement", - GateCapability.MUTATE_HEADERS: "header changes", -} -_DECISION_CAPABILITY_LABELS = { - GateCapability.ALLOW: "allow decision", - GateCapability.DENY: "deny decision", -} - - -def _load_yaml(path: Path) -> object: - try: - with path.open("rb") as source: - contents = source.read(MAX_EVALUATION_FILE_BYTES + 1) - if len(contents) > MAX_EVALUATION_FILE_BYTES: - raise ValueError - text = contents.decode("utf-8", errors="strict") - return yaml.load(text, Loader=_StrictEvaluationLoader) - except ( - OSError, - RecursionError, - UnicodeError, - ValueError, - yaml.YAMLError, - ): - raise _EvaluationCorpusError from None - - -def _compare_result( - expected: _CorpusExpected, - result: EgressResult, -) -> tuple[_FieldDifference, ...]: - source = result.decision_source - actual: dict[str, object] = { - "decision": result.decision.value, - "decision_source_kind": source.kind.value, - "gate_name": source.gate_name - if isinstance(source, GateDecisionSource) - else None, - "gate_type": source.gate_type - if isinstance(source, GateDecisionSource) - else None, - "finding_types": tuple(item.finding.type for item in result.findings), - } - expected_values: dict[str, object] = {"decision": expected.decision} - for field_name in ( - "decision_source_kind", - "gate_name", - "gate_type", - "finding_types", - ): - if field_name in expected.model_fields_set: - expected_values[field_name] = getattr(expected, field_name) - - differences: list[_FieldDifference] = [] - for field_name in ( - "decision", - "decision_source_kind", - "gate_name", - "gate_type", - "finding_types", - ): - if field_name not in expected_values: - continue - expected_value = expected_values[field_name] - actual_value = actual[field_name] - if expected_value != actual_value: - differences.append( - _FieldDifference( - field=field_name, - expected=expected_value, - actual=actual_value, - ) - ) - return tuple(differences) - - -def _format_value(value: object) -> str: - if isinstance(value, tuple): - value = list(value) - return json.dumps( - value, - allow_nan=False, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ) - - -def _load_registry(reference: str | None) -> GateRegistry: - if reference is None: - registry = create_builtin_registry() - registry.configuration_json_schema() - return registry - module_name, separator, attribute_name = reference.partition(":") - if not separator or not module_name or not attribute_name: - raise typer.BadParameter( - "Use MODULE:ATTRIBUTE, for example my_gates:registry.", - param_hint="--registry", - ) - working_directory = str(Path.cwd()) - if working_directory not in sys.path: - sys.path.insert(0, working_directory) - try: - module = importlib.import_module(module_name) - except Exception: - raise typer.BadParameter( - "Could not import the registry module. Check MODULE:ATTRIBUTE and the " - "module's dependencies.", - param_hint="--registry", - ) from None - try: - candidate = getattr(module, attribute_name) - except Exception: - raise typer.BadParameter( - "Could not find the registry attribute. Check the attribute name in " - "MODULE:ATTRIBUTE.", - param_hint="--registry", - ) from None - if isinstance(candidate, GateRegistry): - registry = candidate - elif callable(candidate): - try: - registry = candidate() - except Exception: - raise typer.BadParameter( - "The registry factory raised an exception. Run it directly to inspect " - "the startup failure.", - param_hint="--registry", - ) from None - else: - raise typer.BadParameter( - "The registry attribute must be a GateRegistry or a zero-argument factory.", - param_hint="--registry", - ) - if not isinstance(registry, GateRegistry): - raise typer.BadParameter( - "The registry factory must return a GateRegistry.", - param_hint="--registry", - ) - try: - registry.configuration_json_schema() - except GateRegistryError: - raise typer.BadParameter( - "The registry could not prepare its policy schema. Register at least one " - "valid gate before loading it.", - param_hint="--registry", - ) from None - return registry - - -def _package_version() -> str: - try: - return version("egress-gate") - except PackageNotFoundError: - return "unknown" - - -def _command_options(context: typer.Context) -> _CommandOptions: - options = context.obj - if not isinstance(options, _CommandOptions): - raise RuntimeError("Egress Gate command context is unavailable") - return options - - -if __name__ == "__main__": - app() - - -__all__ = ["app"] diff --git a/projects/egress-gate/src/egress_gate/config.py b/projects/egress-gate/src/egress_gate/config.py deleted file mode 100644 index a082cd5c..00000000 --- a/projects/egress-gate/src/egress_gate/config.py +++ /dev/null @@ -1,71 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Strict Egress Gate policy configuration.""" - -from __future__ import annotations - -from enum import StrEnum -from typing import Generic, TypeVar - -from pydantic import Field, field_validator - -from egress_gate.base import StrictDomainModel -from egress_gate.constants import MAX_PIPELINE_GATES -from egress_gate.gates.base import GateConfig -from egress_gate.string_validators import validate_scalar_string - - -class DefaultDecision(StrEnum): - """Pipeline disposition when every gate proceeds.""" - - ALLOW = "allow" - DENY = "deny" - - -_GateConfigT = TypeVar("_GateConfigT", bound=GateConfig) - - -class EgressGateConfig(StrictDomainModel, Generic[_GateConfigT]): - """Flat policy with ordered named gates and a required fallback decision.""" - - gates: tuple[_GateConfigT, ...] = Field( - min_length=1, - max_length=MAX_PIPELINE_GATES, - description="Ordered gate configurations. Each gate name must be unique.", - repr=False, - ) - default_decision: DefaultDecision = Field( - description="Decision used when every configured gate proceeds." - ) - - @field_validator("gates", mode="before") - @classmethod - def _gates_are_bounded_tuple(cls, value: object) -> object: - if not isinstance(value, list | tuple): - raise ValueError("policy gates must be a non-empty list") - return tuple(value) - - @field_validator("gates") - @classmethod - def _gate_names_are_unique( - cls, - value: tuple[_GateConfigT, ...], - ) -> tuple[_GateConfigT, ...]: - names = tuple(gate.name for gate in value) - if len(names) != len(set(names)): - raise ValueError("policy gate names must be unique") - return value - - @field_validator("default_decision", mode="before") - @classmethod - def _parse_default_decision(cls, value: object) -> DefaultDecision: - if isinstance(value, DefaultDecision): - return value - return DefaultDecision(validate_scalar_string(value)) - - -__all__ = [ - "DefaultDecision", - "EgressGateConfig", -] diff --git a/projects/egress-gate/src/egress_gate/constants.py b/projects/egress-gate/src/egress_gate/constants.py deleted file mode 100644 index a854ef9b..00000000 --- a/projects/egress-gate/src/egress_gate/constants.py +++ /dev/null @@ -1,86 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Package-wide Egress Gate constants and operational limits. - -Keep this module dependency-free within the package: it must not import from -``egress_gate``. -""" - -from __future__ import annotations - -import re -from importlib.metadata import version - -# Timeout defaults. The gateway registration value remains independently -# configurable by the operator. -DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING = 1.0 -DEFAULT_GATEWAY_REGISTRATION_TIMEOUT = "30s" - -# Middleware identity and stable response values. -SERVICE_NAME = "egress-gate" -SERVICE_VERSION = version("egress-gate") -BLOCK_REASON = "Egress Gate blocked the request" -DEFAULT_DENY_REASON_CODE = "egress_gate_default_deny" -LIMIT_REASON = ( - "Egress Gate exceeded a processing safety limit. Check Egress Gate logs " - "for the limit kind. Reduce the request or replacement size, simplify the " - "configured gates and rules, or increase the middleware processing timeout, " - "then retry." -) -LIMIT_REASON_CODE = "egress_gate_limit_exceeded" -# Text input limits. -MAX_BODY_BYTES = 4 * 1024 * 1024 -MAX_EVALUATION_FILE_BYTES = 16 * 1024 * 1024 -MAX_EVALUATION_CASES = 256 -MAX_EVALUATION_CASE_NAME_BYTES = 128 -MAX_EVALUATION_TAGS = 16 - -# Structured request-body parsing limits. -MAX_JSON_DEPTH = 128 -MAX_JSON_NODES = 100_000 -MAX_JSON_SELECTOR_SEGMENTS = 32 -MAX_JSON_SELECTORS = 32 -MAX_JSON_SELECTED_NODES = 4_096 -MAX_JSON_SELECTED_TEXT_BYTES = MAX_BODY_BYTES -MAX_MESSAGE_BLOCKS = 4_096 - -# Gate and result limits. -MAX_DETECTIONS_PER_GATE = 256 -MAX_DIAGNOSTIC_TEXT_BYTES = 1024 -MAX_PROTO_FINDING_GROUPS = 32 -MAX_PROTO_FINDING_BYTES = 4 * 1024 -MAX_FINDING_COUNT = 2**32 - 1 -MAX_RESULT_METADATA_ENTRIES = 64 -MAX_RESULT_METADATA_BYTES = 32 * 1024 -MAX_GATE_TRACES = 10 -MAX_TRACE_MUTATION_KINDS = 2 - -# Domain request-mutation limits mirrored from the OpenShell middleware -# contract. Encoded protobuf size remains a service-boundary concern. -MAX_HEADER_MUTATIONS = 64 -MAX_HEADER_MUTATION_DATA_BYTES = 32 * 1024 - -# Pipeline configuration and regex execution limits. -MAX_PIPELINE_GATES = 10 -MAX_REGEX_NAME_BYTES = 128 -MAX_REGEX_ENTITIES_PER_CATALOG = 2_000 -MAX_REGEX_RULES_PER_CATALOG = 10_000 -MAX_REGEX_PATTERN_BYTES = 16 * 1024 -MAX_REGEX_CATALOG_FILE_BYTES = 16 * 1024 * 1024 -MAX_REGEX_CATALOG_PATH_BYTES = 1024 - -# Service concurrency and transport limits. -MAX_CONCURRENT_PROCESSING = 4 -MAX_CONCURRENT_RPCS = 16 -# Mirrored from the encoded OpenShell middleware contract. -MAX_PROTO_CONTEXT_BYTES = 4 * 1024 -MAX_PROTO_CONFIG_BYTES = 64 * 1024 -MAX_PROTO_TARGET_BYTES = 32 * 1024 -MAX_PROTO_HEADERS = 128 -MAX_PROTO_HEADERS_BYTES = 64 * 1024 -PROTOBUF_ENVELOPE_ALLOWANCE_BYTES = 1024 * 1024 -MAX_RECEIVE_MESSAGE_BYTES = MAX_BODY_BYTES + PROTOBUF_ENVELOPE_ALLOWANCE_BYTES - -# Protocol validation values. -REASON_CODE_PATTERN = re.compile(r"\A[a-z][a-z0-9_]{0,63}\Z") diff --git a/projects/egress-gate/src/egress_gate/errors.py b/projects/egress-gate/src/egress_gate/errors.py deleted file mode 100644 index 99fe94c6..00000000 --- a/projects/egress-gate/src/egress_gate/errors.py +++ /dev/null @@ -1,233 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Content-safe failures shared across Egress Gate trust boundaries.""" - -from __future__ import annotations - -from dataclasses import dataclass -from enum import StrEnum - -from egress_gate.constants import MAX_PROTO_CONFIG_BYTES - - -class ErrorKind(StrEnum): - """Whether a failure is attributable to input or middleware internals.""" - - INVALID_INPUT = "invalid_input" - INTERNAL = "internal" - - -class ErrorComponent(StrEnum): - """The Egress Gate component responsible for a failure.""" - - CONFIG = "config" - GATE = "gate" - PROCESSOR = "processor" - SERVICE = "service" - SERVER = "server" - - -class ErrorCode(StrEnum): - """Stable identifiers for cataloged production failures.""" - - CONFIG_INVALID = "config_invalid" - CONFIG_PREPARATION_FAILED = "config_preparation_failed" - REQUEST_PROTOBUF_INVALID = "request_protobuf_invalid" - REQUEST_PHASE_INVALID = "request_phase_invalid" - REQUEST_ENVELOPE_INVALID = "request_envelope_invalid" - REQUEST_BODY_TOO_LARGE = "request_body_too_large" - BODY_ENCODING_INVALID = "body_encoding_invalid" - BODY_FORMAT_INVALID = "body_format_invalid" - GATE_OUTPUT_INVALID = "gate_output_invalid" - GATE_EXECUTION_FAILED = "gate_execution_failed" - SERVER_BIND_FAILED = "server_bind_failed" - UNEXPECTED_SERVICE_FAILURE = "unexpected_service_failure" - - -class EgressGateError(Exception): - """A catalog-only failure whose public representation is content-safe.""" - - def __init__(self, code: ErrorCode) -> None: - self.code = code - self._spec = _ERROR_SPECS[code] - super().__init__(str(self)) - - @property - def kind(self) -> ErrorKind: - return self._spec.kind - - @property - def component(self) -> ErrorComponent: - return self._spec.component - - @property - def operation(self) -> str: - return self._spec.operation - - @property - def summary(self) -> str: - return self._spec.summary - - @property - def hint(self) -> str: - return self._spec.hint - - def __str__(self) -> str: - return ( - f"[{self.code.value}] {self.component.value}.{self.operation}: " - f"{self.summary} Hint: {self.hint}" - ) - - -class GateError(Exception): - """Base for content-safe gate lifecycle failures.""" - - -class GateConfigurationError(GateError): - """A gate class or configured instance is invalid.""" - - -class GateContractError(GateError): - """A gate invocation or returned result violated the public contract.""" - - -class GateExecutionError(GateError): - """A gate's configured runtime failed to complete one request.""" - - -class GateLimitExceededError(GateError): - """A gate exceeded a bounded configuration or output limit.""" - - -class GateInputError(GateError): - """A gate could not interpret a bounded request input.""" - - -class BodyFormatError(GateInputError): - """A gate expected a structured request body that was malformed.""" - - -class TimeoutExpiredError(Exception): - """The shared request-processing timeout expired.""" - - def __init__(self) -> None: - super().__init__( - "Egress Gate processing timed out. Reduce the request size or simplify " - "the configured gates and rules, or increase the middleware processing " - "timeout, then retry." - ) - - -class GateRegistryError(Exception): - """A content-safe gate registration or registry lifecycle failure.""" - - -@dataclass(frozen=True) -class _ErrorSpec: - """Immutable, developer-authored classification and remediation text.""" - - kind: ErrorKind - component: ErrorComponent - operation: str - summary: str - hint: str - - -_ERROR_SPECS: dict[ErrorCode, _ErrorSpec] = { - ErrorCode.CONFIG_INVALID: _ErrorSpec( - ErrorKind.INVALID_INPUT, - ErrorComponent.CONFIG, - "parse", - "Policy configuration is invalid.", - "Keep the encoded configuration at or below " - f"{MAX_PROTO_CONFIG_BYTES // 1024} KiB, compare it with " - "`egress-gate gates schema`, then check the gates, " - "pattern catalogs, replacements, and default decision.", - ), - ErrorCode.CONFIG_PREPARATION_FAILED: _ErrorSpec( - ErrorKind.INVALID_INPUT, - ErrorComponent.CONFIG, - "prepare", - "A configured gate could not be prepared.", - "Check the configured gate's rules and resources. For the built-in regex " - "gate, remove named groups, inline flags, invalid expressions, and patterns " - "that can match empty input, then retry.", - ), - ErrorCode.REQUEST_PROTOBUF_INVALID: _ErrorSpec( - ErrorKind.INVALID_INPUT, - ErrorComponent.SERVICE, - "decode_protobuf", - "Request protobuf encoding is invalid.", - "Encode a complete request with the published OpenShell middleware " - "protobuf contract, then retry.", - ), - ErrorCode.REQUEST_PHASE_INVALID: _ErrorSpec( - ErrorKind.INVALID_INPUT, - ErrorComponent.SERVICE, - "validate_phase", - "Request evaluation phase is invalid.", - "Use the advertised pre-credentials phase.", - ), - ErrorCode.REQUEST_ENVELOPE_INVALID: _ErrorSpec( - ErrorKind.INVALID_INPUT, - ErrorComponent.SERVICE, - "validate_envelope", - "Request transport metadata exceeds an advertised limit.", - "Reduce the request context, target, or headers to the OpenShell " - "middleware contract limits.", - ), - ErrorCode.REQUEST_BODY_TOO_LARGE: _ErrorSpec( - ErrorKind.INVALID_INPUT, - ErrorComponent.SERVICE, - "validate_body_size", - "Request body exceeds the advertised size limit.", - "Reduce the request body to the maximum size in the middleware manifest.", - ), - ErrorCode.BODY_ENCODING_INVALID: _ErrorSpec( - ErrorKind.INVALID_INPUT, - ErrorComponent.SERVICE, - "decode_text", - "Request body encoding is invalid.", - "Supply a valid UTF-8 request body.", - ), - ErrorCode.BODY_FORMAT_INVALID: _ErrorSpec( - ErrorKind.INVALID_INPUT, - ErrorComponent.SERVICE, - "parse_body", - "Request body format is invalid.", - "Supply a strict JSON request body matching the configured structured " - "body scan.", - ), - ErrorCode.GATE_OUTPUT_INVALID: _ErrorSpec( - ErrorKind.INTERNAL, - ErrorComponent.PROCESSOR, - "validate_gate", - "A gate returned an invalid result.", - "Gate authors should check the evaluate contract, capabilities, findings, " - "mutations, and output limits.", - ), - ErrorCode.GATE_EXECUTION_FAILED: _ErrorSpec( - ErrorKind.INTERNAL, - ErrorComponent.GATE, - "evaluate", - "A configured gate failed.", - "Check the request ID and error code in service logs, then run the " - "configured gate's focused configuration and request tests.", - ), - ErrorCode.SERVER_BIND_FAILED: _ErrorSpec( - ErrorKind.INTERNAL, - ErrorComponent.SERVER, - "start", - "Server could not start on its listen address.", - "Choose an available listen address and port, then retry.", - ), - ErrorCode.UNEXPECTED_SERVICE_FAILURE: _ErrorSpec( - ErrorKind.INTERNAL, - ErrorComponent.SERVICE, - "evaluate_http_request", - "The middleware encountered an unexpected failure.", - "Retry once; if it recurs, report the request ID and error code from the " - "service log.", - ), -} diff --git a/projects/egress-gate/src/egress_gate/gates/__init__.py b/projects/egress-gate/src/egress_gate/gates/__init__.py deleted file mode 100644 index 312d39e3..00000000 --- a/projects/egress-gate/src/egress_gate/gates/__init__.py +++ /dev/null @@ -1,70 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Public Egress Gate authoring and built-in gate surface.""" - -from egress_gate.gates.base import ( - Gate, - GateCapability, - GateConfig, - GateResources, - Utf8BodyGate, -) -from egress_gate.gates.regex import ( - ConfidenceLevel, - RegexConfig, - RegexEntity, - RegexGate, - RegexPatternCatalog, - RegexRule, -) -from egress_gate.gates.regex_scans import ( - RegexBodyAction, - RegexBodyScan, - RegexDenyAction, - RegexDetectAction, - RegexHeaderScan, - RegexJsonFieldsScan, - RegexMessageBlocksScan, - RegexPathScan, - RegexQueryScan, - RegexReadOnlyAction, - RegexReplaceAction, - RegexScan, -) -from egress_gate.gates.registry import ( - GateDescription, - GateRegistry, - create_builtin_registry, -) -from egress_gate.result import FindingTypeDefinition - -__all__ = [ - "ConfidenceLevel", - "Gate", - "GateCapability", - "GateConfig", - "GateDescription", - "GateRegistry", - "GateResources", - "FindingTypeDefinition", - "RegexBodyAction", - "RegexBodyScan", - "RegexConfig", - "RegexDenyAction", - "RegexDetectAction", - "RegexEntity", - "RegexGate", - "RegexHeaderScan", - "RegexJsonFieldsScan", - "RegexMessageBlocksScan", - "RegexPatternCatalog", - "RegexPathScan", - "RegexQueryScan", - "RegexReadOnlyAction", - "RegexReplaceAction", - "RegexRule", - "RegexScan", - "Utf8BodyGate", - "create_builtin_registry", -] diff --git a/projects/egress-gate/src/egress_gate/gates/base.py b/projects/egress-gate/src/egress_gate/gates/base.py deleted file mode 100644 index 825fb9e7..00000000 --- a/projects/egress-gate/src/egress_gate/gates/base.py +++ /dev/null @@ -1,375 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Trusted request-level gate extension contract.""" - -from __future__ import annotations - -from abc import ABC, abstractmethod -from enum import StrEnum -from types import NoneType -from typing import ClassVar, Generic, TypeGuard, final, get_args, get_origin - -from pydantic import Field, ValidationError -from typing_extensions import TypeVar - -from egress_gate.base import StrictDomainModel -from egress_gate.errors import ( - GateConfigurationError, - GateContractError, - GateError, - GateExecutionError, - GateInputError, - TimeoutExpiredError, -) -from egress_gate.request import HttpRequest -from egress_gate.result import ( - FindingTypeDefinition, - GateEvaluation, - GateName, -) -from egress_gate.timeout import Timeout - - -class GateConfig(StrictDomainModel): - """Base for one named gate with a required literal ``kind``.""" - - name: GateName = Field( - description="Unique diagnostic name for this gate in the policy." - ) - - -class GateResources: - """Operator-owned, concurrency-safe resources borrowed by prepared gates.""" - - __slots__ = () - - -class GateCapability(StrEnum): - """One declared request access or permitted gate result.""" - - READ_TARGET = "read_target" - READ_CONTEXT = "read_context" - READ_HEADERS = "read_headers" - READ_BODY = "read_body" - REPLACE_BODY = "replace_body" - MUTATE_HEADERS = "mutate_headers" - ALLOW = "allow" - DENY = "deny" - - -GateConfigT = TypeVar("GateConfigT", bound=GateConfig) -GateResourcesT = TypeVar( - "GateResourcesT", - bound=GateResources | None, - default=None, -) - - -class Gate(ABC, Generic[GateConfigT, GateResourcesT]): - """Typed request-level gate with a validated public evaluation wrapper.""" - - capabilities: ClassVar[frozenset[GateCapability]] - finding_types: ClassVar[tuple[FindingTypeDefinition, ...]] - - @final - def __init__( - self, - config: GateConfigT, - resources: GateResourcesT, - *, - timeout: Timeout | None = None, - ) -> None: - type(self).validate_config(config, resources) - self.__config = config - self.__resources = resources - if timeout is not None and not isinstance(timeout, Timeout): - raise GateConfigurationError("gate preparation timeout is invalid") - self._initialize(timeout=timeout) - - @classmethod - def validate_config( - cls, - config: GateConfigT, - resources: GateResourcesT, - ) -> None: - """Purely validate one exact config and its registered resources.""" - cls._validate_class_contract() - config_type, resources_type = _declared_gate_types(cls) - try: - if type(config) is not config_type: - raise ValueError - config_type.model_validate(config) - except (ValidationError, ValueError): - raise GateConfigurationError("gate configuration is invalid") from None - if not _is_valid_resources(resources, resources_type): - raise GateConfigurationError("gate resources are invalid") - cls._validate_config(config, resources) - - @classmethod - def get_config_type(cls) -> type[GateConfig]: - """Return the concrete ``GateConfig`` type declared by the gate.""" - config_type, _ = _declared_gate_types(cls) - return config_type - - @classmethod - def get_resources_type(cls) -> type[GateResources] | None: - """Return the concrete operational-resource type, if any.""" - _, resources_type = _declared_gate_types(cls) - return resources_type - - @property - def config(self) -> GateConfigT: - """Return the exact validated gate configuration.""" - return self.__config - - @property - def resources(self) -> GateResourcesT: - """Return the borrowed application-owned resources.""" - return self.__resources - - @final - def evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - """Evaluate one immutable current request and validate the result.""" - if not isinstance(request, HttpRequest) or not isinstance(timeout, Timeout): - raise GateContractError("gate input types are invalid") - timeout.raise_if_expired() - try: - raw_result = self._evaluate(request, timeout=timeout) - if not isinstance(raw_result, GateEvaluation): - raise GateContractError("gate output is invalid") - try: - result = GateEvaluation.model_validate(raw_result.model_dump()) - except ValidationError: - raise GateContractError("gate output is invalid") from None - timeout.raise_if_expired() - _validate_gate_output( - type(self).capabilities, - type(self).finding_types, - result, - ) - return result - except (GateError, TimeoutExpiredError): - raise - except Exception: - raise GateExecutionError("gate evaluation failed") from None - - @classmethod - def _validate_class_contract(cls) -> None: - capabilities = getattr(cls, "capabilities", None) - if not isinstance(capabilities, frozenset) or any( - not isinstance(capability, GateCapability) for capability in capabilities - ): - raise GateConfigurationError("gate capabilities are invalid") - finding_types = getattr(cls, "finding_types", None) - if not isinstance(finding_types, tuple) or any( - not isinstance(item, FindingTypeDefinition) for item in finding_types - ): - raise GateConfigurationError("gate finding declarations are invalid") - names = tuple(item.type for item in finding_types) - if len(names) != len(set(names)): - raise GateConfigurationError("gate finding types must be unique") - config_type, _ = _declared_gate_types(cls) - if config_type is GateConfig: - raise GateConfigurationError("gate config type is not concrete") - - @classmethod - def _validate_config( - cls, - config: GateConfigT, - resources: GateResourcesT, - ) -> None: - """Optionally validate resource-backed config without side effects.""" - - def _initialize(self, *, timeout: Timeout | None = None) -> None: - """Optionally derive reusable state under the preparation deadline.""" - - @abstractmethod - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - """Return one explicit control result for the current request.""" - raise NotImplementedError - - -class Utf8BodyGate( - Gate[GateConfigT, GateResourcesT], Generic[GateConfigT, GateResourcesT] -): - """Gate helper that exposes one strict UTF-8 body to an implementation.""" - - capabilities = frozenset({GateCapability.READ_BODY}) - finding_types: ClassVar[tuple[FindingTypeDefinition, ...]] = () - - @final - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - try: - text = request.body.decode("utf-8", errors="strict") - except UnicodeDecodeError: - raise GateInputError("request body is not valid UTF-8") from None - result = self._evaluate_text(text, timeout=timeout) - if not isinstance(result, GateEvaluation): - raise GateContractError("UTF-8 body gate output is invalid") - if result.request_mutations.replacement_body is not None: - try: - result.request_mutations.replacement_body.decode( - "utf-8", errors="strict" - ) - except UnicodeDecodeError: - raise GateContractError( - "UTF-8 body gate returned a non-UTF-8 replacement" - ) from None - return result - - @abstractmethod - def _evaluate_text( - self, - text: str, - *, - timeout: Timeout, - ) -> GateEvaluation: - """Evaluate the decoded body and preserve explicit replacement intent.""" - raise NotImplementedError - - -def _validate_gate_output( - capabilities: frozenset[GateCapability], - finding_types: tuple[FindingTypeDefinition, ...], - result: GateEvaluation, -) -> None: - if ( - result.request_mutations.replacement_body is not None - and GateCapability.REPLACE_BODY not in capabilities - ): - raise GateContractError("gate returned an undeclared body replacement") - if ( - result.request_mutations.header_mutations - and GateCapability.MUTATE_HEADERS not in capabilities - ): - raise GateContractError("gate returned undeclared header mutations") - if result.control.value == "allow" and GateCapability.ALLOW not in capabilities: - raise GateContractError("gate returned an undeclared terminal allow") - if result.control.value == "deny" and GateCapability.DENY not in capabilities: - raise GateContractError("gate returned an undeclared deny") - declared_types = frozenset(item.type for item in finding_types) - if any(finding.type not in declared_types for finding in result.findings): - raise GateContractError("gate returned an undeclared finding type") - - -def _declared_gate_types( - gate_type: type[object], -) -> tuple[type[GateConfig], type[GateResources] | None]: - decorated_config_type = getattr(gate_type, "_decorated_config_type", None) - if _is_gate_config_type(decorated_config_type): - return decorated_config_type, None - for candidate in gate_type.__mro__: - for base in getattr(candidate, "__orig_bases__", ()): - origin = get_origin(base) - if origin is None or not isinstance(origin, type): - continue - if not issubclass(origin, Gate): - continue - arguments = get_args(base) - parameters = getattr(origin, "__parameters__", ()) - substitutions = dict(zip(parameters, arguments, strict=False)) - if origin is Gate: - resolved = tuple( - substitutions.get(argument, argument) for argument in arguments - ) - if len(resolved) == 1: - resolved = (*resolved, NoneType) - if len(resolved) != 2: - break - config_type, resources_type = resolved - if _is_gate_config_type(config_type): - return config_type, _normalize_resources_type(resources_type) - break - try: - inherited = _resolve_gate_base(origin, substitutions) - except GateConfigurationError: - continue - if inherited is not None: - return inherited - raise GateConfigurationError( - "gate must declare concrete configuration and resource types" - ) - - -def _resolve_gate_base( - candidate: type[object], - substitutions: dict[object, object], -) -> tuple[type[GateConfig], type[GateResources] | None] | None: - for base in getattr(candidate, "__orig_bases__", ()): - origin = get_origin(base) - if ( - origin is None - or not isinstance(origin, type) - or not issubclass(origin, Gate) - ): - continue - arguments = tuple( - substitutions.get(argument, argument) for argument in get_args(base) - ) - parameters = getattr(origin, "__parameters__", ()) - nested = dict(substitutions) - nested.update(zip(parameters, arguments, strict=False)) - if origin is Gate: - if len(arguments) == 1: - arguments = (*arguments, NoneType) - if len(arguments) == 2: - config_type, resources_type = arguments - if _is_gate_config_type(config_type): - return config_type, _normalize_resources_type(resources_type) - continue - resolved = _resolve_gate_base(origin, nested) - if resolved is not None: - return resolved - return None - - -def _is_gate_config_type(value: object) -> TypeGuard[type[GateConfig]]: - return isinstance(value, type) and issubclass(value, GateConfig) - - -def _is_gate_resources_type(value: object) -> TypeGuard[type[GateResources]]: - return isinstance(value, type) and issubclass(value, GateResources) - - -def _normalize_resources_type(value: object) -> type[GateResources] | None: - if value is None or value is NoneType: - return None - if _is_gate_resources_type(value): - return value - raise GateConfigurationError("gate resources type is invalid") - - -def _is_valid_resources( - resources: object, - resources_type: type[GateResources] | None, -) -> bool: - if resources_type is None: - return resources is None - return isinstance(resources, resources_type) - - -__all__ = [ - "Gate", - "GateCapability", - "GateConfig", - "GateConfigT", - "GateResources", - "GateResourcesT", - "Utf8BodyGate", -] diff --git a/projects/egress-gate/src/egress_gate/gates/regex.py b/projects/egress-gate/src/egress_gate/gates/regex.py deleted file mode 100644 index a1356cdc..00000000 --- a/projects/egress-gate/src/egress_gate/gates/regex.py +++ /dev/null @@ -1,793 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Bounded regex catalog compilation, matching, and gate evaluation.""" - -from __future__ import annotations - -import os -from collections import OrderedDict -from collections.abc import Iterator, Mapping -from dataclasses import dataclass -from enum import StrEnum -from pathlib import Path -from stat import S_ISREG -from string import Formatter -from typing import Literal, Protocol, Self - -import regex -import yaml -from pydantic import Field, field_validator, model_validator -from yaml.constructor import ConstructorError -from yaml.events import AliasEvent -from yaml.nodes import MappingNode -from yaml.resolver import BaseResolver - -from egress_gate.base import StrictDomainModel -from egress_gate.constants import ( - MAX_BODY_BYTES, - MAX_DETECTIONS_PER_GATE, - MAX_PROTO_FINDING_GROUPS, - MAX_REGEX_CATALOG_FILE_BYTES, - MAX_REGEX_CATALOG_PATH_BYTES, - MAX_REGEX_ENTITIES_PER_CATALOG, - MAX_REGEX_NAME_BYTES, - MAX_REGEX_PATTERN_BYTES, - MAX_REGEX_RULES_PER_CATALOG, -) -from egress_gate.errors import ( - GateConfigurationError, - GateContractError, - GateLimitExceededError, -) -from egress_gate.gates.base import Gate, GateCapability, GateConfig -from egress_gate.gates.regex_scans import ( - RegexBodyScan, - RegexDenyAction, - RegexHeaderScan, - RegexJsonFieldsScan, - RegexMessageBlocksScan, - RegexPathScan, - RegexQueryScan, - RegexReplaceAction, - RegexScan, -) -from egress_gate.request import HttpRequest, RequestMutations -from egress_gate.request_content import ( - JsonFieldsParser, - JsonMessageBlockExtractor, - MessageBlocksParser, - ParsedRequestContent, - RequestContentParser, - TextReplacement, - TextTarget, - Utf8TextParser, -) -from egress_gate.result import Finding, FindingTypeDefinition, GateEvaluation -from egress_gate.string_validators import ScalarString, validate_scalar_string -from egress_gate.timeout import Timeout - - -class ConfidenceLevel(StrEnum): - """Categorical certainty reported by the regex gate.""" - - LOW = "low" - MEDIUM = "medium" - HIGH = "high" - - -class RegexRule(StrictDomainModel): - """One regex pattern with its diagnostic identity, confidence, and flags.""" - - name: str | None = None - pattern: ScalarString = Field(repr=False) - confidence: ConfidenceLevel - ignore_case: bool = False - multiline: bool = False - dot_all: bool = False - ascii: bool = False - - @field_validator("name") - @classmethod - def _name_is_safe(cls, value: str | None) -> str | None: - return None if value is None else _validate_name(value) - - @field_validator("pattern") - @classmethod - def _pattern_is_bounded(cls, value: str) -> str: - if not value: - raise ValueError("pattern must be non-empty") - if len(value.encode("utf-8")) > MAX_REGEX_PATTERN_BYTES: - raise ValueError("pattern exceeds the size limit") - return value - - @field_validator("confidence", mode="before") - @classmethod - def _parse_confidence(cls, value: object) -> ConfidenceLevel: - if not isinstance(value, str): - raise ValueError("confidence must be a string") - return ConfidenceLevel(validate_scalar_string(value)) - - -class RegexEntity(StrictDomainModel): - """One entity name and its ordered, non-empty regex rules.""" - - name: str - rules: tuple[RegexRule, ...] = Field(repr=False) - - @field_validator("name") - @classmethod - def _name_is_safe(cls, value: str) -> str: - return _validate_name(value) - - @field_validator("rules", mode="before") - @classmethod - def _rules_are_non_empty(cls, value: object) -> object: - if not isinstance(value, list | tuple) or not value: - raise ValueError("rules must be a non-empty list") - return tuple(value) - - @model_validator(mode="after") - def _supplied_rule_names_are_unique(self) -> Self: - supplied_names = [rule.name for rule in self.rules if rule.name is not None] - if len(supplied_names) != len(set(supplied_names)): - raise ValueError("supplied rule names must be unique within an entity") - return self - - -class RegexPatternCatalog(StrictDomainModel): - """The complete ordered entity catalog for one regex gate.""" - - entities: tuple[RegexEntity, ...] = Field(repr=False) - - @field_validator("entities", mode="before") - @classmethod - def _entities_are_non_empty(cls, value: object) -> object: - if not isinstance(value, list | tuple) or not value: - raise ValueError("entities must be a non-empty list") - return tuple(value) - - @model_validator(mode="after") - def _catalog_is_bounded_and_unambiguous(self) -> Self: - names = [entity.name for entity in self.entities] - if len(names) != len(set(names)): - raise ValueError("entity names must be unique") - if len(self.entities) > MAX_REGEX_ENTITIES_PER_CATALOG: - raise ValueError("entity catalog exceeds the size limit") - if ( - sum(len(entity.rules) for entity in self.entities) - > MAX_REGEX_RULES_PER_CATALOG - ): - raise ValueError("rule catalog exceeds the size limit") - return self - - -class RegexConfig(GateConfig): - """Exact policy configuration owned by ``RegexGate``.""" - - kind: Literal["regex"] - scan: RegexScan - pattern_catalog: RegexPatternCatalog = Field( - repr=False, - description=( - "Complete structured catalog or relative path to a complete YAML catalog." - ), - ) - - @field_validator( - "pattern_catalog", - mode="before", - json_schema_input_type=RegexPatternCatalog | str, - ) - @classmethod - def _load_pattern_catalog( - cls, - value: object, - ) -> object: - if isinstance(value, str): - return _load_pattern_catalog_file(value) - return value - - @model_validator(mode="after") - def _patterns_are_valid(self) -> Self: - if any( - _contains_inline_flags(rule.pattern) - for entity in self.pattern_catalog.entities - for rule in entity.rules - ): - raise ValueError("regex pattern catalog is invalid") - return self - - -class RegexGate(Gate[RegexConfig, None]): - """Scan one raw or structured request view with bounded regex rules.""" - - capabilities = frozenset( - { - GateCapability.READ_TARGET, - GateCapability.READ_HEADERS, - GateCapability.READ_BODY, - GateCapability.REPLACE_BODY, - GateCapability.DENY, - } - ) - finding_types = (FindingTypeDefinition(type="regex_match"),) - - def _initialize(self, *, timeout: Timeout | None = None) -> None: - try: - self._content_parser = _prepare_content_parser(self.config.scan) - self._rules = _compile_pattern_catalog( - self.config.pattern_catalog, - timeout=timeout, - ) - except (RecursionError, ValueError, regex.error): - raise GateConfigurationError( - "regex gate configuration is invalid" - ) from None - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - text_view = _read_regex_text( - self.config.scan, - self._content_parser, - request, - timeout=timeout, - ) - detections_with_identity: list[tuple[_RegexDetection, str]] = [] - detections_by_target: dict[str, list[tuple[_RegexDetection, str]]] = {} - for target in text_view.targets: - target_detections = self._match_text(target.text, timeout=timeout) - detections_by_target[target.id] = target_detections - detections_with_identity.extend(target_detections) - if len(detections_with_identity) > MAX_DETECTIONS_PER_GATE: - raise GateLimitExceededError("regex detection count exceeds the limit") - detections = tuple(item[0] for item in detections_with_identity) - findings = _aggregate_findings(detections) - if len(findings) > MAX_PROTO_FINDING_GROUPS: - raise GateLimitExceededError("regex finding groups exceed the limit") - action = self.config.scan.action - if isinstance(action, RegexDenyAction) and detections: - return GateEvaluation.deny( - "egress_gate_regex_denied", - findings=findings, - ) - if not isinstance(action, RegexReplaceAction): - return GateEvaluation.proceed(findings=findings) - - replacements = tuple( - TextReplacement( - target_id=target.id, - text=_render_bounded_replacement( - target.text, - _resolve_overlaps(detections_by_target[target.id]), - action.template, - ), - ) - for target in text_view.targets - ) - replacement_body = text_view.replace_text( - replacements, - timeout=timeout, - ) - return GateEvaluation.proceed( - request_mutations=RequestMutations(replacement_body=replacement_body), - findings=findings, - ) - - def _match_text( - self, - text: str, - *, - timeout: Timeout, - ) -> list[tuple[_RegexDetection, str]]: - detections: list[tuple[_RegexDetection, str]] = [] - for rule in self._rules: - next_position = 0 - while next_position <= len(text): - with timeout.enforce(): - match = rule.compiled.search( - text, - next_position, - timeout=timeout.remaining_seconds(), - ) - if match is None: - break - start, end = match.span() - if start == end: - raise GateConfigurationError( - "regex configuration matches an empty span" - ) - if match.span(rule.marker) != (end, end): - raise GateConfigurationError( - "regex configuration marker is invalid" - ) - detections.append( - ( - _RegexDetection( - entity=rule.entity, - start=start, - end=end, - confidence=rule.confidence, - ), - rule.rule_identity, - ) - ) - if len(detections) > MAX_DETECTIONS_PER_GATE: - raise GateLimitExceededError( - "regex detection count exceeds the limit" - ) - next_position = start + 1 - detections.sort( - key=lambda item: ( - item[0].start, - item[0].end, - item[0].entity, - item[1], - ) - ) - return detections - - -@dataclass(frozen=True) -class _CompiledRule: - entity: str - rule_identity: str - confidence: ConfidenceLevel - marker: str - compiled: _CompiledPattern - - -@dataclass(frozen=True) -class _RegexDetection: - entity: str - start: int - end: int - confidence: ConfidenceLevel - - -@dataclass(frozen=True) -class _RegexTextView: - targets: tuple[TextTarget, ...] - parsed_content: ParsedRequestContent | None = None - - def replace_text( - self, - replacements: tuple[TextReplacement, ...], - *, - timeout: Timeout, - ) -> bytes: - if self.parsed_content is None: - raise GateContractError("regex source does not support replacement") - return self.parsed_content.replace_text(replacements, timeout=timeout) - - -def _prepare_content_parser(scan: RegexScan) -> RequestContentParser | None: - match scan: - case RegexBodyScan(): - return Utf8TextParser() - case RegexJsonFieldsScan(): - return JsonFieldsParser(selectors=scan.selectors) - case RegexMessageBlocksScan(): - return MessageBlocksParser( - extractor=JsonMessageBlockExtractor(scan.message_mapping), - roles=scan.roles, - block_kinds=scan.block_kinds, - ) - case _: - return None - - -def _read_regex_text( - scan: RegexScan, - parser: RequestContentParser | None, - request: HttpRequest, - *, - timeout: Timeout, -) -> _RegexTextView: - if parser is not None: - parsed_content = parser.parse(request.body, timeout=timeout) - return _RegexTextView( - targets=parsed_content.targets, - parsed_content=parsed_content, - ) - - timeout.raise_if_expired() - match scan: - case RegexPathScan(): - targets = (TextTarget(id="path", text=request.target.path),) - case RegexQueryScan(): - targets = (TextTarget(id="query", text=request.target.query),) - case RegexHeaderScan(): - names = frozenset(name.casefold() for name in scan.names) - targets = tuple( - TextTarget(id=f"header-{index}", text=header.value) - for index, header in enumerate(request.headers) - if header.name.casefold() in names - ) - case _: - raise GateContractError("regex source preparation is invalid") - return _RegexTextView(targets=targets) - - -def _aggregate_findings( - detections: tuple[_RegexDetection, ...], -) -> tuple[Finding, ...]: - counts: OrderedDict[tuple[str, ConfidenceLevel], int] = OrderedDict() - for detection in detections: - key = (detection.entity, detection.confidence) - counts[key] = counts.get(key, 0) + 1 - return tuple( - Finding( - type="regex_match", - label=entity, - count=count, - confidence=confidence.value, - ) - for (entity, confidence), count in counts.items() - ) - - -class _RegexMatch(Protocol): - def span(self, group: int | str = 0) -> tuple[int, int]: - """Return the matched span for a numbered or named group.""" - ... - - -class _CompiledPattern(Protocol): - @property - def groupindex(self) -> Mapping[str, int]: - """Return the pattern's named capture groups.""" - ... - - def search( - self, - string: str, - pos: int = 0, - *, - timeout: float | None = None, - ) -> _RegexMatch | None: - """Search from a code-point offset with a bounded timeout.""" - ... - - -class _StrictCatalogLoader(yaml.SafeLoader): - """Safe YAML loader that rejects aliases and duplicate mapping keys.""" - - def compose_node( - self, - parent: object, - index: object, - ) -> yaml.Node: - if self.check_event(AliasEvent): - raise ConstructorError( - None, - None, - "YAML aliases are not supported", - self.peek_event().start_mark, - ) - return super().compose_node(parent, index) - - -def _construct_unique_mapping( - loader: _StrictCatalogLoader, - node: MappingNode, - deep: bool = False, -) -> dict[object, object]: - loader.flatten_mapping(node) - mapping: dict[object, object] = {} - for key_node, value_node in node.value: - key = loader.construct_object(key_node, deep=deep) - try: - duplicate = key in mapping - except TypeError: - raise ConstructorError( - "while constructing a mapping", - node.start_mark, - "found an unhashable key", - key_node.start_mark, - ) from None - if duplicate: - raise ConstructorError( - "while constructing a mapping", - node.start_mark, - "found a duplicate key", - key_node.start_mark, - ) - mapping[key] = loader.construct_object(value_node, deep=deep) - return mapping - - -_StrictCatalogLoader.add_constructor( - BaseResolver.DEFAULT_MAPPING_TAG, - _construct_unique_mapping, -) - - -def _load_pattern_catalog_file(value: str) -> RegexPatternCatalog: - descriptor: int | None = None - try: - path_text = validate_scalar_string(value) - if ( - not path_text - or len(path_text.encode("utf-8")) > MAX_REGEX_CATALOG_PATH_BYTES - ): - raise ValueError - relative_path = Path(path_text) - if ( - relative_path.is_absolute() - or relative_path.suffix.lower() not in {".yaml", ".yml"} - or ".." in relative_path.parts - ): - raise ValueError - - descriptor = _open_pattern_catalog_file(relative_path) - metadata = os.fstat(descriptor) - if ( - not S_ISREG(metadata.st_mode) - or metadata.st_size > MAX_REGEX_CATALOG_FILE_BYTES - ): - raise ValueError - return _read_pattern_catalog_file( - descriptor, - metadata.st_size, - ) - except ( - OSError, - RecursionError, - UnicodeError, - ValueError, - yaml.YAMLError, - ): - raise ValueError("regex pattern catalog file is invalid") from None - finally: - if descriptor is not None: - os.close(descriptor) - - -def _open_pattern_catalog_file(relative_path: Path) -> int: - directory_flags = os.O_RDONLY | os.O_CLOEXEC | os.O_DIRECTORY | os.O_NOFOLLOW - file_flags = os.O_RDONLY | os.O_CLOEXEC | os.O_NONBLOCK | os.O_NOFOLLOW - directory = os.open(".", directory_flags) - try: - for part in relative_path.parts[:-1]: - child = os.open(part, directory_flags, dir_fd=directory) - os.close(directory) - directory = child - return os.open(relative_path.parts[-1], file_flags, dir_fd=directory) - finally: - os.close(directory) - - -def _read_pattern_catalog_file( - descriptor: int, - expected_size: int, -) -> RegexPatternCatalog: - contents = _read_bounded_file(descriptor) - if len(contents) != expected_size or len(contents) > MAX_REGEX_CATALOG_FILE_BYTES: - raise ValueError - values = yaml.load( - contents.decode("utf-8", errors="strict"), - Loader=_StrictCatalogLoader, - ) - return RegexPatternCatalog.model_validate(values) - - -def _read_bounded_file(descriptor: int) -> bytes: - contents = bytearray() - while len(contents) <= MAX_REGEX_CATALOG_FILE_BYTES: - chunk = os.read( - descriptor, - min(64 * 1024, MAX_REGEX_CATALOG_FILE_BYTES + 1 - len(contents)), - ) - if not chunk: - break - contents.extend(chunk) - return bytes(contents) - - -def _validate_name(value: str) -> str: - if ( - not isinstance(value, str) - or _NAME_PATTERN.fullmatch(value) is None - or len(value.encode("ascii")) > MAX_REGEX_NAME_BYTES - ): - raise ValueError("name is invalid") - return value - - -def _compile_pattern_catalog( - catalog: RegexPatternCatalog, - *, - timeout: Timeout | None = None, -) -> tuple[_CompiledRule, ...]: - _raise_if_expired(timeout) - rules_list: list[_CompiledRule] = [] - for global_index, (entity, rule_index, rule) in enumerate( - _iter_catalog_rules(catalog) - ): - _raise_if_expired(timeout) - rules_list.append( - _compile_rule( - entity, - rule, - catalog_index=global_index, - entity_rule_index=rule_index, - timeout=timeout, - ) - ) - _raise_if_expired(timeout) - return tuple(rules_list) - - -def _raise_if_expired(timeout: Timeout | None) -> None: - if timeout is not None: - timeout.raise_if_expired() - - -def _compile_rule( - entity: RegexEntity, - rule: RegexRule, - catalog_index: int, - entity_rule_index: int, - *, - timeout: Timeout | None = None, -) -> _CompiledRule: - _raise_if_expired(timeout) - flags = 0 - if rule.ignore_case: - flags |= regex.IGNORECASE - if rule.multiline: - flags |= regex.MULTILINE - if rule.dot_all: - flags |= regex.DOTALL - if rule.ascii: - flags |= regex.ASCII - if _contains_inline_flags(rule.pattern): - raise ValueError("inline flags are unsupported") - unmarked = regex.compile(rule.pattern, flags) - _raise_if_expired(timeout) - if unmarked.groupindex: - raise ValueError("named groups are reserved") - if timeout is None: - empty_match = unmarked.search("") - else: - with timeout.enforce(): - empty_match = unmarked.search("", timeout=timeout.remaining_seconds()) - if empty_match is not None: - raise ValueError("pattern must not match empty input") - marker = f"_eg_rule_{catalog_index:06d}" - compiled = regex.compile(f"(?:{rule.pattern})(?P<{marker}>)", flags) - _raise_if_expired(timeout) - if marker not in compiled.groupindex: - raise ValueError("internal marker is missing") - rule_identity = rule.name or f"{entity.name}.rules[{entity_rule_index}]" - return _CompiledRule( - entity=entity.name, - rule_identity=rule_identity, - confidence=rule.confidence, - marker=marker, - compiled=compiled, - ) - - -def _iter_catalog_rules( - catalog: RegexPatternCatalog, -) -> Iterator[tuple[RegexEntity, int, RegexRule]]: - for entity in catalog.entities: - for rule_index, rule in enumerate(entity.rules): - yield entity, rule_index, rule - - -def _contains_inline_flags(pattern: str) -> bool: - escaped = False - in_character_class = False - index = 0 - while index < len(pattern): - character = pattern[index] - if escaped: - escaped = False - elif character == "\\": - escaped = True - elif character == "[": - in_character_class = True - elif character == "]" and in_character_class: - in_character_class = False - elif not in_character_class and pattern.startswith("(?", index): - suffix = pattern[index + 2 :] - if _INLINE_FLAG_PATTERN.match(suffix) is not None: - return True - index += 1 - return False - - -def _resolve_overlaps( - detections: list[tuple[_RegexDetection, str]], -) -> tuple[_RegexDetection, ...]: - winners: list[_RegexDetection] = [] - ranked = sorted( - detections, - key=lambda item: ( - -_categorical_confidence_rank(item[0].confidence), - -(item[0].end - item[0].start), - item[0].start, - item[0].end, - item[0].entity, - item[1], - ), - ) - for candidate, _ in ranked: - if all( - candidate.end <= winner.start or candidate.start >= winner.end - for winner in winners - ): - winners.append(candidate) - return tuple( - sorted( - winners, - key=lambda item: (item.start, item.end, item.entity), - ) - ) - - -def _categorical_confidence_rank(confidence: object) -> int: - if not isinstance(confidence, ConfidenceLevel): - raise GateContractError("regex detection confidence is invalid") - return _CONFIDENCE_RANK[confidence] - - -def _render_bounded_replacement( - text: str, - detections: tuple[_RegexDetection, ...], - template: str, -) -> str: - projected_size = 0 - cursor = 0 - for detection in detections: - projected_size += len(text[cursor : detection.start].encode("utf-8")) - projected_size += _rendered_template_size(template, detection.entity) - if projected_size > MAX_BODY_BYTES: - raise GateLimitExceededError("regex replacement exceeds the size limit") - cursor = detection.end - projected_size += len(text[cursor:].encode("utf-8")) - if projected_size > MAX_BODY_BYTES: - raise GateLimitExceededError("regex replacement exceeds the size limit") - - parts: list[str] = [] - cursor = 0 - for detection in detections: - parts.append(text[cursor : detection.start]) - parts.append(template.format(entity=detection.entity)) - cursor = detection.end - parts.append(text[cursor:]) - return "".join(parts) - - -def _rendered_template_size(template: str, entity: str) -> int: - size = 0 - entity_size = len(entity.encode("utf-8")) - for literal, field_name, _, _ in Formatter().parse(template): - size += len(literal.encode("utf-8")) - if field_name is not None: - size += entity_size - return size - - -_NAME_PATTERN = regex.compile(r"[A-Za-z_][A-Za-z0-9_-]*\Z") -_INLINE_FLAG_PATTERN = regex.compile(r"[A-Za-z0-9-]+(?=[:)])") -_CONFIDENCE_RANK = { - ConfidenceLevel.LOW: 0, - ConfidenceLevel.MEDIUM: 1, - ConfidenceLevel.HIGH: 2, -} -__all__ = [ - "ConfidenceLevel", - "RegexConfig", - "RegexEntity", - "RegexGate", - "RegexPatternCatalog", - "RegexRule", -] diff --git a/projects/egress-gate/src/egress_gate/gates/regex_scans.py b/projects/egress-gate/src/egress_gate/gates/regex_scans.py deleted file mode 100644 index c02a61c7..00000000 --- a/projects/egress-gate/src/egress_gate/gates/regex_scans.py +++ /dev/null @@ -1,203 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Public action and source configuration for regex request scans.""" - -from __future__ import annotations - -from string import Formatter -from typing import Annotated, Literal, Self, TypeAlias - -from pydantic import Field, field_validator, model_validator - -from egress_gate.base import StrictDomainModel -from egress_gate.constants import ( - MAX_DIAGNOSTIC_TEXT_BYTES, - MAX_JSON_SELECTORS, - MAX_PROTO_HEADERS, -) -from egress_gate.request import HeaderName -from egress_gate.request_content import ( - JsonMessageMapConfig, - JsonSelector, - MessageBlockKind, - MessageRole, -) -from egress_gate.string_validators import ScalarString, validate_scalar_string - - -class RegexDetectAction(StrictDomainModel): - """Report matches and continue without changing the request.""" - - kind: Literal["detect"] - - -class RegexDenyAction(StrictDomainModel): - """Deny the request when the scan finds a match.""" - - kind: Literal["deny"] - - -class RegexReplaceAction(StrictDomainModel): - """Replace body matches with a constrained template.""" - - kind: Literal["replace"] - template: ScalarString = Field(default="[{entity}]", repr=False) - - @field_validator("template") - @classmethod - def _template_is_safe_and_bounded(cls, value: str) -> str: - if len(value.encode("utf-8")) > MAX_DIAGNOSTIC_TEXT_BYTES: - raise ValueError("replacement template exceeds the size limit") - try: - for _, field_name, format_spec, conversion in Formatter().parse(value): - if field_name is not None and field_name != "entity": - raise ValueError - if format_spec or conversion is not None: - raise ValueError - except ValueError: - raise ValueError("replacement template syntax is invalid") from None - return value - - -RegexReadOnlyAction: TypeAlias = Annotated[ - RegexDetectAction | RegexDenyAction, - Field(discriminator="kind"), -] -RegexBodyAction: TypeAlias = Annotated[ - RegexDetectAction | RegexDenyAction | RegexReplaceAction, - Field(discriminator="kind"), -] - - -class RegexBodyScan(StrictDomainModel): - """Scan the UTF-8 request body and apply a body-compatible action.""" - - kind: Literal["body"] - action: RegexBodyAction - - -class RegexPathScan(StrictDomainModel): - """Scan the request path and detect or deny matches.""" - - kind: Literal["path"] - action: RegexReadOnlyAction - - -class RegexQueryScan(StrictDomainModel): - """Scan the raw request query and detect or deny matches.""" - - kind: Literal["query"] - action: RegexReadOnlyAction - - -class RegexHeaderScan(StrictDomainModel): - """Scan values from named request headers and detect or deny matches.""" - - kind: Literal["header"] - names: tuple[HeaderName, ...] = Field(min_length=1, max_length=MAX_PROTO_HEADERS) - action: RegexReadOnlyAction - - @field_validator("names", mode="before") - @classmethod - def _names_are_a_tuple(cls, value: object) -> object: - if isinstance(value, list | tuple): - return tuple(value) - return value - - @model_validator(mode="after") - def _names_are_unique(self) -> Self: - normalized = tuple(name.casefold() for name in self.names) - if len(normalized) != len(set(normalized)): - raise ValueError("header scan names must be unique") - return self - - -class RegexJsonFieldsScan(StrictDomainModel): - """Scan selected JSON string values and optionally replace their matches.""" - - kind: Literal["json-fields"] - selectors: tuple[JsonSelector, ...] = Field( - min_length=1, - max_length=MAX_JSON_SELECTORS, - ) - action: RegexBodyAction - - @field_validator("selectors", mode="before") - @classmethod - def _selectors_are_a_tuple(cls, value: object) -> object: - if isinstance(value, list | tuple): - return tuple(value) - return value - - -class RegexMessageBlocksScan(StrictDomainModel): - """Scan normalized text-bearing JSON message blocks.""" - - kind: Literal["message-blocks"] - message_mapping: JsonMessageMapConfig - roles: tuple[MessageRole, ...] | None = None - block_kinds: tuple[MessageBlockKind, ...] | None = None - action: RegexBodyAction - - @field_validator("roles", mode="before") - @classmethod - def _parse_roles(cls, value: object) -> object: - if value is None: - return None - if not isinstance(value, list | tuple): - return value - return tuple( - item - if isinstance(item, MessageRole) - else MessageRole(validate_scalar_string(item)) - for item in value - ) - - @field_validator("block_kinds", mode="before") - @classmethod - def _parse_block_kinds(cls, value: object) -> object: - if value is None: - return None - if not isinstance(value, list | tuple): - return value - return tuple( - item - if isinstance(item, MessageBlockKind) - else MessageBlockKind(validate_scalar_string(item)) - for item in value - ) - - @model_validator(mode="after") - def _filters_are_unique(self) -> Self: - for values in (self.roles, self.block_kinds): - if values is not None and len(values) != len(set(values)): - raise ValueError("message block filters must be unique") - return self - - -RegexScan: TypeAlias = Annotated[ - RegexBodyScan - | RegexPathScan - | RegexQueryScan - | RegexHeaderScan - | RegexJsonFieldsScan - | RegexMessageBlocksScan, - Field(discriminator="kind"), -] - - -__all__ = [ - "RegexBodyAction", - "RegexBodyScan", - "RegexDenyAction", - "RegexDetectAction", - "RegexHeaderScan", - "RegexJsonFieldsScan", - "RegexMessageBlocksScan", - "RegexPathScan", - "RegexQueryScan", - "RegexReadOnlyAction", - "RegexReplaceAction", - "RegexScan", -] diff --git a/projects/egress-gate/src/egress_gate/gates/registry.py b/projects/egress-gate/src/egress_gate/gates/registry.py deleted file mode 100644 index 0670deef..00000000 --- a/projects/egress-gate/src/egress_gate/gates/registry.py +++ /dev/null @@ -1,520 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Gate registration and lazy policy-schema construction.""" - -from __future__ import annotations - -import hashlib -import inspect -import json -import re -from collections.abc import Callable, Mapping, Sequence -from dataclasses import dataclass -from enum import StrEnum -from functools import reduce -from operator import getitem, or_ -from typing import ( - TYPE_CHECKING, - Annotated, - Literal, - Protocol, - TypeGuard, - get_args, - get_origin, -) - -from pydantic import Field, TypeAdapter, ValidationError -from typing_extensions import TypeVar - -from egress_gate.errors import ( - EgressGateError, - ErrorCode, - GateConfigurationError, - GateRegistryError, -) -from egress_gate.gates.base import ( - Gate, - GateCapability, - GateConfig, - GateConfigT, - GateResources, -) -from egress_gate.gates.regex import RegexGate -from egress_gate.request import HttpRequest -from egress_gate.result import FindingTypeDefinition, GateEvaluation -from egress_gate.timeout import Timeout - -if TYPE_CHECKING: - from egress_gate.config import EgressGateConfig - from egress_gate.request_processor import RequestProcessor - - -@dataclass(frozen=True) -class GateDescription: - """Safe discovery metadata for one registered gate.""" - - gate_type: str - description: str - capabilities: frozenset[GateCapability] - finding_types: tuple[FindingTypeDefinition, ...] - resource_type: str | None - config_type: str - - -class PolicyValidationCategory(StrEnum): - """Content-safe category for one policy schema failure.""" - - REQUIRED_FIELD_MISSING = "required field is missing" - UNKNOWN_FIELD = "unknown field is not allowed" - UNKNOWN_VARIANT = "kind does not identify an installed variant" - INVALID_VALUE = "value has the wrong type, shape, or constraints" - - -class PolicyValidationError(EgressGateError): - """Cataloged policy failure with a trusted structural location.""" - - def __init__( - self, - *, - path: tuple[str | int, ...], - category: PolicyValidationCategory, - ) -> None: - super().__init__(ErrorCode.CONFIG_INVALID) - self.path = path - self.category = category - - @property - def formatted_path(self) -> str: - """Render the trusted field path without submitted values.""" - rendered = "" - for component in self.path: - if isinstance(component, int): - rendered += f"[{component}]" - elif rendered: - rendered += f".{component}" - else: - rendered = component - return rendered or "policy" - - @classmethod - def from_validation_error( - cls, - error: ValidationError, - *, - schema: Mapping[str, object], - ) -> PolicyValidationError: - """Reduce Pydantic diagnostics to one bounded, content-safe issue.""" - known_fields = _schema_property_names(schema) - issues = error.errors( - include_url=False, - include_context=False, - include_input=False, - ) - issue = min(issues, key=lambda item: _validation_error_priority(item["type"])) - path = tuple( - component - for component in issue["loc"] - if isinstance(component, int) - or (isinstance(component, str) and component in known_fields) - ) - return cls( - path=path, - category=_validation_error_category(issue["type"]), - ) - - -class GateRegistry: - """Collect trusted gates and seal their exact policy union on first use.""" - - def __init__(self, *, include_builtin_gates: bool = False) -> None: - self._registrations: dict[str, _Registration] = {} - self._config_adapter: TypeAdapter[object] | None = None - if include_builtin_gates: - self.register(RegexGate) - - def register( - self, - gate_type: type[object], - *, - resources: object = None, - ) -> None: - """Register one gate and its application-owned resources.""" - if self._config_adapter is not None: - raise GateRegistryError("cannot register after the registry is in use") - if not _is_gate_type(gate_type): - raise GateRegistryError("registered gate type is invalid") - if gate_type.__init__ is not Gate.__init__: - raise GateRegistryError( - "gate lifecycle contract requires Gate.__init__; use _initialize()" - ) - if gate_type.evaluate is not Gate.evaluate: - raise GateRegistryError( - "gate lifecycle contract requires Gate.evaluate; implement _evaluate()" - ) - - try: - gate_type._validate_class_contract() - config_type = gate_type.get_config_type() - resources_type = gate_type.get_resources_type() - except (AttributeError, TypeError, GateConfigurationError): - raise GateRegistryError("gate generic declaration is invalid") from None - if not isinstance(config_type, type) or not issubclass(config_type, GateConfig): - raise GateRegistryError("gate config type is invalid") - _validate_common_gate_config_fields(config_type) - gate_kind = _gate_kind(config_type) - if gate_kind in self._registrations: - raise GateRegistryError("gate kind is already registered") - if any( - registration.config_type is config_type - for registration in self._registrations.values() - ): - raise GateRegistryError("gate config type is already registered") - - if resources_type is None: - if resources is not None: - raise GateRegistryError("resource-free gate received resources") - elif resources is None or not isinstance(resources, resources_type): - raise GateRegistryError("gate resources do not match their declared type") - - self._registrations[gate_kind] = _Registration( - gate_type=gate_type, - config_type=config_type, - resources=resources, - ) - - def gate( - self, - *, - config: type[GateConfigT], - capabilities: frozenset[GateCapability], - finding_types: tuple[FindingTypeDefinition, ...] = (), - ) -> Callable[[_GateFunction[GateConfigT]], type[Gate[GateConfig, None]]]: - """Register a typed function as one resource-free gate.""" - config_type = config - declared_capabilities = capabilities - declared_finding_types = finding_types - - def decorate( - evaluate: _GateFunction[GateConfigT], - ) -> type[Gate[GateConfig, None]]: - class FunctionGate(Gate[GateConfig, None]): - _decorated_config_type = config_type - capabilities = declared_capabilities - finding_types = declared_finding_types - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - assert isinstance(self.config, config_type) - return evaluate(request, self.config, timeout=timeout) - - FunctionGate.__doc__ = inspect.getdoc(evaluate) or "" - self.register(FunctionGate) - return FunctionGate - - return decorate - - def validate_config(self, values: object) -> EgressGateConfig[GateConfig]: - """Parse and validate one complete pipeline without preparing gates.""" - if not isinstance(values, Mapping): - raise EgressGateError(ErrorCode.CONFIG_INVALID) - try: - config_value = self._require_config_adapter().validate_python(dict(values)) - except ValidationError as error: - raise PolicyValidationError.from_validation_error( - error, - schema=self.configuration_json_schema(), - ) from None - except (TypeError, ValueError): - raise EgressGateError(ErrorCode.CONFIG_INVALID) from None - if not _is_egress_gate_config(config_value): - raise EgressGateError(ErrorCode.CONFIG_INVALID) - config = config_value - for gate_index, configured_gate in enumerate(config.gates): - registration = self._resolve_registration(configured_gate) - try: - registration.gate_type.validate_config( - configured_gate, - registration.resources, - ) - except GateConfigurationError: - raise PolicyValidationError( - path=("gates", gate_index), - category=PolicyValidationCategory.INVALID_VALUE, - ) from None - return config - - def create_gate( - self, - config: GateConfig, - *, - timeout: Timeout | None = None, - ) -> Gate[GateConfig, GateResources | None]: - """Construct one initialized gate from its exact validated config.""" - registration = self._resolve_registration(config) - if type(config) is not registration.config_type: - raise GateRegistryError("gate config concrete type is invalid") - return registration.gate_type( - config, - registration.resources, - timeout=timeout, - ) - - def prepare_processor( - self, - validated_config: EgressGateConfig[GateConfig], - *, - timeout: Timeout, - ) -> RequestProcessor: - """Prepare one processor from a validated policy configuration. - - This is the production preparation seam shared by the service and - offline evaluation. Registration, policy validation, and resource - ownership remain registry responsibilities; the returned processor - owns only the prepared gates and immutable policy metadata. - """ - from egress_gate.request_processor import RequestProcessor - - self._require_config_adapter() - if not _is_egress_gate_config(validated_config): - raise GateRegistryError("processor configuration is invalid") - if not isinstance(timeout, Timeout): - raise GateRegistryError("processor preparation timeout is invalid") - - prepared: list[tuple[str, str, Gate[GateConfig, GateResources | None]]] = [] - for configured_gate in validated_config.gates: - timeout.raise_if_expired() - gate_type = getattr(configured_gate, "kind", None) - if not isinstance(gate_type, str): - raise GateRegistryError("gate config discriminator is invalid") - try: - gate = self.create_gate(configured_gate, timeout=timeout) - except GateConfigurationError: - raise EgressGateError(ErrorCode.CONFIG_PREPARATION_FAILED) from None - prepared.append((configured_gate.name, gate_type, gate)) - timeout.raise_if_expired() - return RequestProcessor( - validated_config, - tuple(prepared), - policy_fingerprint=self.policy_fingerprint(validated_config), - ) - - def configuration_json_schema(self) -> dict[str, object]: - """Return the complete policy JSON Schema.""" - schema = self._require_config_adapter().json_schema() - schema["title"] = "EgressGateConfig" - schema["description"] = ( - "Flat policy for Egress Gate with ordered gates and a default decision." - ) - return schema - - def describe_gates(self) -> tuple[GateDescription, ...]: - """Return safe gate metadata without constructing gate instances.""" - self._require_config_adapter() - return tuple( - GateDescription( - gate_type=gate_kind, - description=_gate_description(registration.gate_type), - capabilities=registration.gate_type.capabilities, - finding_types=registration.gate_type.finding_types, - resource_type=( - resources_type.__name__ - if (resources_type := registration.gate_type.get_resources_type()) - is not None - else None - ), - config_type=registration.config_type.__name__, - ) - for gate_kind, registration in self._registrations.items() - ) - - @staticmethod - def policy_fingerprint(config: EgressGateConfig[GateConfig]) -> str: - """Return the canonical fingerprint passed into a prepared processor.""" - canonical = json.dumps( - config.model_dump(mode="json"), - allow_nan=False, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - return hashlib.sha256(canonical).hexdigest() - - def _resolve_registration(self, config: GateConfig) -> _Registration: - self._require_config_adapter() - try: - gate_kind = getattr(config, "kind") - if not isinstance(gate_kind, str): - raise AttributeError - return self._registrations[gate_kind] - except (AttributeError, KeyError): - raise GateRegistryError("gate config is not registered") from None - - def _require_config_adapter(self) -> TypeAdapter[object]: - if self._config_adapter is None: - try: - config_type = _build_egress_gate_config_type( - tuple( - registration.config_type - for registration in self._registrations.values() - ) - ) - except (TypeError, ValueError): - raise GateRegistryError( - "gate registry has no registered gates" - ) from None - self._config_adapter = TypeAdapter[object](config_type) - return self._config_adapter - - -def create_builtin_registry() -> GateRegistry: - """Build the registry shipped by the base package.""" - return GateRegistry(include_builtin_gates=True) - - -@dataclass(frozen=True) -class _Registration: - gate_type: type[Gate[GateConfig, GateResources | None]] - config_type: type[GateConfig] - resources: GateResources | None - - -_FunctionConfigT = TypeVar( - "_FunctionConfigT", - bound=GateConfig, - contravariant=True, -) - - -class _GateFunction(Protocol[_FunctionConfigT]): - def __call__( - self, - request: HttpRequest, - config: _FunctionConfigT, - *, - timeout: Timeout, - ) -> GateEvaluation: ... - - -def _build_egress_gate_config_type( - config_types: Sequence[type[GateConfig]], -) -> object: - from egress_gate.config import EgressGateConfig - - if not config_types: - raise ValueError("at least one gate config type must be registered") - registered_union = reduce(or_, config_types) - registered_config = getitem( - Annotated, - (registered_union, Field(discriminator="kind")), - ) - return getattr(EgressGateConfig, "__class_getitem__")(registered_config) - - -def _is_gate_type( - value: object, -) -> TypeGuard[type[Gate[GateConfig, GateResources | None]]]: - return isinstance(value, type) and issubclass(value, Gate) - - -def _is_egress_gate_config( - value: object, -) -> TypeGuard[EgressGateConfig[GateConfig]]: - from egress_gate.config import EgressGateConfig - - return isinstance(value, EgressGateConfig) - - -def _gate_kind(config_type: type[GateConfig]) -> str: - field = config_type.model_fields.get("kind") - if field is None: - raise GateRegistryError("gate config lacks a kind discriminator") - if get_origin(field.annotation) is not Literal: - raise GateRegistryError("gate kind must be one string Literal") - values = get_args(field.annotation) - if len(values) != 1 or not isinstance(values[0], str): - raise GateRegistryError("gate kind must be one string Literal") - gate_kind = values[0] - if _GATE_KIND_PATTERN.fullmatch(gate_kind) is None: - raise GateRegistryError("gate kind is invalid") - if not field.is_required(): - raise GateRegistryError("gate kind must be required") - return gate_kind - - -def _validate_common_gate_config_fields(config_type: type[GateConfig]) -> None: - for ancestor in config_type.__mro__: - if ancestor is GateConfig: - break - if "name" in ancestor.__dict__.get("__annotations__", {}): - raise GateRegistryError( - "gate config must inherit name without redefining it" - ) - else: - raise GateRegistryError("gate config type is invalid") - - for field_name in ("name", "kind"): - field = config_type.model_fields.get(field_name) - if field is None: - continue - aliases = (field.alias, field.validation_alias, field.serialization_alias) - if any(alias not in (None, field_name) for alias in aliases): - raise GateRegistryError( - "gate config name and kind must use their canonical field names" - ) - - -def _gate_description(gate_type: type[object]) -> str: - description = inspect.getdoc(gate_type) or "" - first_line = description.splitlines()[0] if description else "" - if len(first_line.encode("utf-8")) > 1024: - return "" - return first_line - - -def _schema_property_names(value: object) -> frozenset[str]: - names: set[str] = set() - if isinstance(value, Mapping): - properties = value.get("properties") - if isinstance(properties, Mapping): - names.update(key for key in properties if isinstance(key, str)) - for nested in value.values(): - names.update(_schema_property_names(nested)) - elif isinstance(value, list): - for nested in value: - names.update(_schema_property_names(nested)) - return frozenset(names) - - -def _validation_error_priority(error_type: object) -> int: - return { - "missing": 0, - "extra_forbidden": 1, - "union_tag_invalid": 2, - "union_tag_not_found": 2, - }.get(error_type, 3) - - -def _validation_error_category(error_type: object) -> PolicyValidationCategory: - return { - "missing": PolicyValidationCategory.REQUIRED_FIELD_MISSING, - "extra_forbidden": PolicyValidationCategory.UNKNOWN_FIELD, - "union_tag_invalid": PolicyValidationCategory.UNKNOWN_VARIANT, - "union_tag_not_found": PolicyValidationCategory.UNKNOWN_VARIANT, - }.get(error_type, PolicyValidationCategory.INVALID_VALUE) - - -_GATE_KIND_PATTERN = re.compile(r"[a-z][a-z0-9-]{0,127}\Z") - - -__all__ = [ - "GateDescription", - "GateRegistry", - "PolicyValidationCategory", - "PolicyValidationError", - "create_builtin_registry", -] diff --git a/projects/egress-gate/src/egress_gate/gateway_config.py b/projects/egress-gate/src/egress_gate/gateway_config.py deleted file mode 100644 index 7947bb5f..00000000 --- a/projects/egress-gate/src/egress_gate/gateway_config.py +++ /dev/null @@ -1,632 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""OpenShell gateway registration management for the command-line application.""" - -from __future__ import annotations - -import copy -import os -import re -import stat -import tempfile -import tomllib -from dataclasses import dataclass -from enum import Enum -from pathlib import Path -from typing import Literal - -from egress_gate.constants import DEFAULT_GATEWAY_REGISTRATION_TIMEOUT -from egress_gate.timeout import parse_duration - - -class GatewayConfigUpdate(Enum): - """Result of writing one Egress Gate middleware registration.""" - - CREATED = "created" - ADDED = "added" - UPDATED = "updated" - UNCHANGED = "unchanged" - - -class GatewayConfigRemoval(Enum): - """Result of removing one Egress Gate middleware registration.""" - - REMOVED = "removed" - UNCHANGED = "unchanged" - - -class GatewayConfigError(ValueError): - """A safe, actionable gateway registration management error.""" - - -@dataclass(frozen=True) -class GatewayMiddlewareRegistration: - """One external middleware registration in an OpenShell gateway config.""" - - name: str - endpoint: str | None - timeout_gateway_ceiling: str | None - - -@dataclass(frozen=True) -class RememberedGatewayRegistration: - """The gateway registration most recently managed by Egress Gate.""" - - config_path: Path - middleware_name: str - - -# Mirrors OpenShell's stable-identifier byte limit for external middleware -# registrations. -MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES = 19 - - -def default_gateway_config_path() -> Path: - """Resolve OpenShell's config override or standard per-user gateway path.""" - # Mirrors the OpenShell gateway's `--config` environment override and XDG - # fallback. Package-specific deployments can still pass --config explicitly. - configured_path = os.environ.get("OPENSHELL_GATEWAY_CONFIG") - if configured_path: - return Path(configured_path) - config_home = os.environ.get("XDG_CONFIG_HOME") - if config_home: - return Path(config_home) / "openshell" / "gateway.toml" - return Path.home() / ".config" / "openshell" / "gateway.toml" - - -def default_registration_state_path() -> Path: - """Return the per-user location for the remembered gateway registration.""" - config_home = os.environ.get("XDG_CONFIG_HOME") - root = Path(config_home) if config_home else Path.home() / ".config" - return root / "openshell-egress-gate" / "registration.toml" - - -def remember_gateway_registration( - config_path: Path, - *, - middleware_name: str, -) -> None: - """Remember where the CLI most recently managed an Egress Gate registration.""" - validate_middleware_name(middleware_name) - escaped_path = ( - str(config_path.expanduser().resolve()) - .replace("\\", "\\\\") - .replace('"', '\\"') - ) - escaped_name = middleware_name.replace("\\", "\\\\").replace('"', '\\"') - _write_atomically( - default_registration_state_path(), - f'gateway_config = "{escaped_path}"\nregistration_name = "{escaped_name}"\n', - ) - - -def forget_gateway_registration( - config_path: Path, - *, - middleware_name: str, -) -> None: - """Forget the registration when it matches the CLI-managed registration.""" - remembered = load_remembered_gateway_registration() - if remembered is None or ( - remembered.config_path != config_path.expanduser().resolve() - or remembered.middleware_name != middleware_name - ): - return - state_path = default_registration_state_path() - try: - state_path.unlink(missing_ok=True) - except OSError as error: - raise GatewayConfigError( - f"Could not remove {state_path}. Check that its directory is writable." - ) from error - - -def load_remembered_gateway_registration() -> RememberedGatewayRegistration | None: - """Load the gateway registration most recently managed by the CLI.""" - state_path = default_registration_state_path() - try: - contents = state_path.read_text(encoding="utf-8") - except FileNotFoundError: - return None - except (OSError, UnicodeError) as error: - raise GatewayConfigError( - f"Could not read {state_path}. Check that it is readable UTF-8 TOML." - ) from error - try: - values = tomllib.loads(contents) - except tomllib.TOMLDecodeError as error: - raise GatewayConfigError( - f"Could not parse {state_path}. Remove it and register Egress Gate again." - ) from error - config_path = values.get("gateway_config") - middleware_name = values.get("registration_name") - if not isinstance(config_path, str) or not config_path: - raise GatewayConfigError( - f"{state_path} does not contain a valid gateway_config path. Remove it " - "and register Egress Gate again." - ) - if not isinstance(middleware_name, str): - raise GatewayConfigError( - f"{state_path} does not contain a valid registration_name. Remove it " - "and register Egress Gate again." - ) - validate_middleware_name(middleware_name) - return RememberedGatewayRegistration( - config_path=Path(config_path), - middleware_name=middleware_name, - ) - - -def read_remembered_gateway_timeout( - unit: Literal["s", "ms"] = "s", -) -> tuple[RememberedGatewayRegistration, float] | None: - """Read the current timeout for the remembered gateway registration.""" - remembered = load_remembered_gateway_registration() - if remembered is None: - return None - matches = [ - registration - for registration in list_gateway_registrations(remembered.config_path) - if registration.name == remembered.middleware_name - ] - if not matches: - raise GatewayConfigError( - f"The remembered registration {remembered.middleware_name!r} is not in " - f"{remembered.config_path}. Register Egress Gate again." - ) - duration = matches[0].timeout_gateway_ceiling - if duration is None: - raise GatewayConfigError( - f"The remembered registration {remembered.middleware_name!r} in " - f"{remembered.config_path} has no timeout. Add one or register Egress " - "Gate again." - ) - try: - timeout = parse_duration(duration, unit=unit) - except ValueError: - raise GatewayConfigError( - f"The timeout for the remembered registration " - f"{remembered.middleware_name!r} in {remembered.config_path} must use " - "whole seconds or milliseconds, such as 30s or 500ms." - ) from None - return remembered, timeout - - -def list_gateway_registrations( - path: Path, -) -> tuple[GatewayMiddlewareRegistration, ...]: - """List the external middleware registrations in an OpenShell gateway config.""" - try: - contents = path.read_text(encoding="utf-8") - except FileNotFoundError: - return () - except (OSError, UnicodeError) as error: - raise GatewayConfigError( - f"Could not read {path}. Check that the file is readable UTF-8 TOML." - ) from error - - if not contents.strip(): - return () - - registrations: list[GatewayMiddlewareRegistration] = [] - for entry in _middleware_entries(_load_gateway_config(contents, path), path): - name = entry.get("name") - endpoint = entry.get("grpc_endpoint") - timeout_gateway_ceiling = entry.get("timeout") - if not isinstance(name, str) or not name: - raise GatewayConfigError( - f"{path} contains a middleware registration without a valid name." - ) - if endpoint is not None and not isinstance(endpoint, str): - raise GatewayConfigError( - f"The middleware registration {name!r} in {path} has an invalid " - "grpc_endpoint." - ) - if timeout_gateway_ceiling is not None and not isinstance( - timeout_gateway_ceiling, str - ): - raise GatewayConfigError( - f"The middleware registration {name!r} in {path} has an invalid " - "timeout." - ) - registrations.append( - GatewayMiddlewareRegistration( - name=name, - endpoint=endpoint, - timeout_gateway_ceiling=timeout_gateway_ceiling, - ) - ) - return tuple(registrations) - - -def update_gateway_config( - path: Path, - *, - middleware_name: str, - host_ip: str, - port: int, - timeout_gateway_ceiling: str = DEFAULT_GATEWAY_REGISTRATION_TIMEOUT, -) -> GatewayConfigUpdate: - """Add or update one named Egress Gate middleware registration.""" - validate_middleware_name(middleware_name) - validate_gateway_timeout(timeout_gateway_ceiling) - endpoint = f"http://{host_ip}:{port}" - try: - original = path.read_text(encoding="utf-8") - except FileNotFoundError: - updated = _new_gateway_config( - middleware_name=middleware_name, - endpoint=endpoint, - timeout_gateway_ceiling=timeout_gateway_ceiling, - ) - _write_atomically(path, updated) - return GatewayConfigUpdate.CREATED - except (OSError, UnicodeError) as error: - raise GatewayConfigError( - f"Could not read {path}. Check that the file is readable UTF-8 TOML." - ) from error - - if not original.strip(): - updated = _new_gateway_config( - middleware_name=middleware_name, - endpoint=endpoint, - timeout_gateway_ceiling=timeout_gateway_ceiling, - ) - _write_atomically(path, updated) - return GatewayConfigUpdate.CREATED - - values = _load_gateway_config(original, path) - middleware = _middleware_entries(values, path) - matching_indexes = [ - index - for index, entry in enumerate(middleware) - if entry.get("name") == middleware_name - ] - if len(matching_indexes) > 1: - raise GatewayConfigError( - f"{path} contains multiple middleware registrations named " - f"{middleware_name!r}. Remove the duplicate entries, then retry." - ) - - blocks = list(_MIDDLEWARE_BLOCK_PATTERN.finditer(original)) - if len(blocks) != len(middleware): - raise GatewayConfigError( - f"Could not safely locate every middleware registration in {path}. " - "Format the file as standard TOML tables, then retry." - ) - - if matching_indexes: - block = blocks[matching_indexes[0]] - replacement = _update_middleware_block( - block.group(0), - endpoint=endpoint, - timeout_gateway_ceiling=timeout_gateway_ceiling, - ) - updated = original[: block.start()] + replacement + original[block.end() :] - result = GatewayConfigUpdate.UPDATED - else: - updated = _append_middleware_block( - original, - middleware_name=middleware_name, - endpoint=endpoint, - timeout_gateway_ceiling=timeout_gateway_ceiling, - ) - result = GatewayConfigUpdate.ADDED - - _load_gateway_config(updated, path) - if updated == original: - return GatewayConfigUpdate.UNCHANGED - _write_atomically(path, updated) - return result - - -def remove_gateway_config( - path: Path, - *, - middleware_name: str, -) -> GatewayConfigRemoval: - """Remove one named Egress Gate middleware registration.""" - try: - original = path.read_text(encoding="utf-8") - except FileNotFoundError: - return GatewayConfigRemoval.UNCHANGED - except (OSError, UnicodeError) as error: - raise GatewayConfigError( - f"Could not read {path}. Check that the file is readable UTF-8 TOML." - ) from error - - if not original.strip(): - return GatewayConfigRemoval.UNCHANGED - - values = _load_gateway_config(original, path) - middleware = _middleware_entries(values, path) - matching_indexes = [ - index - for index, entry in enumerate(middleware) - if entry.get("name") == middleware_name - ] - if len(matching_indexes) > 1: - raise GatewayConfigError( - f"{path} contains multiple middleware registrations named " - f"{middleware_name!r}. Remove the duplicate entries, then retry." - ) - if not matching_indexes: - return GatewayConfigRemoval.UNCHANGED - - blocks = list(_MIDDLEWARE_BLOCK_PATTERN.finditer(original)) - if len(blocks) != len(middleware): - raise GatewayConfigError( - f"Could not safely locate every middleware registration in {path}. " - "Format the file as standard TOML tables, then retry." - ) - - matching_index = matching_indexes[0] - block = blocks[matching_index] - updated = ( - original[: block.start()] - + _trailing_middleware_block_layout(block.group(0)) - + original[block.end() :] - ) - unsafe_removal_message = ( - f"Could not safely remove the middleware registration from {path}. " - "Format it as a standard TOML array table without child tables, then retry." - ) - try: - updated_values = _load_gateway_config(updated, path) - updated_middleware = _middleware_entries(updated_values, path) - except GatewayConfigError as error: - raise GatewayConfigError(unsafe_removal_message) from error - expected_middleware = [ - entry for index, entry in enumerate(middleware) if index != matching_index - ] - if updated_middleware != expected_middleware or _without_middleware_entries( - updated_values - ) != _without_middleware_entries(values): - raise GatewayConfigError(unsafe_removal_message) - _write_atomically(path, updated) - return GatewayConfigRemoval.REMOVED - - -def validate_middleware_name(name: str) -> str: - """Validate OpenShell's external middleware registration-name contract.""" - if ( - not name - or len(name.encode("utf-8")) > MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES - or any(character not in _MIDDLEWARE_NAME_CHARACTERS for character in name) - ): - raise GatewayConfigError( - "Middleware names must use " - f"1-{MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES} ASCII bytes containing " - "only letters, digits, '.', '_', '-', or '/'." - ) - if name.startswith("openshell/"): - raise GatewayConfigError( - "Operator-owned middleware names cannot start with 'openshell/'." - ) - return name - - -def validate_gateway_timeout(duration: str) -> float: - """Validate a gateway timeout that can exceed the processing minimum.""" - message = ( - "The gateway timeout must be greater than 10ms and use whole seconds or " - "milliseconds, such as 30s or 500ms." - ) - try: - seconds = parse_duration(duration) - except ValueError: - raise GatewayConfigError(message) from None - if seconds <= 0.01: - raise GatewayConfigError(message) - return seconds - - -def _new_gateway_config( - *, - middleware_name: str, - endpoint: str, - timeout_gateway_ceiling: str, -) -> str: - return "[openshell]\nversion = 1\n\n" + _middleware_block( - middleware_name=middleware_name, - endpoint=endpoint, - timeout_gateway_ceiling=timeout_gateway_ceiling, - ) - - -def _load_gateway_config(contents: str, path: Path) -> dict[str, object]: - try: - values = tomllib.loads(contents) - except tomllib.TOMLDecodeError as error: - raise GatewayConfigError( - f"{path} is not valid TOML. Fix the reported TOML syntax, then retry." - ) from error - openshell = values.get("openshell") - if not isinstance(openshell, dict) or openshell.get("version") != 1: - raise GatewayConfigError( - f"{path} must contain [openshell] with version = 1. " - "Fix the gateway config version, then retry." - ) - return values - - -def _middleware_entries( - values: dict[str, object], - path: Path, -) -> list[dict[str, object]]: - openshell = values["openshell"] - if not isinstance(openshell, dict): - raise AssertionError("validated OpenShell table is unavailable") - supervisor = openshell.get("supervisor") - if supervisor is None: - return [] - if not isinstance(supervisor, dict): - raise GatewayConfigError( - f"{path} has an invalid [openshell.supervisor] value. " - "Replace it with a TOML table, then retry." - ) - middleware = supervisor.get("middleware") - if middleware is None: - return [] - if not isinstance(middleware, list): - raise GatewayConfigError( - f"{path} has an invalid openshell.supervisor.middleware value. " - "Use [[openshell.supervisor.middleware]] tables, then retry." - ) - entries: list[dict[str, object]] = [] - for entry in middleware: - if not isinstance(entry, dict) or not all( - isinstance(key, str) for key in entry - ): - raise GatewayConfigError( - f"{path} has an invalid openshell.supervisor.middleware value. " - "Use [[openshell.supervisor.middleware]] tables, then retry." - ) - entries.append({str(key): value for key, value in entry.items()}) - return entries - - -def _append_middleware_block( - contents: str, - *, - middleware_name: str, - endpoint: str, - timeout_gateway_ceiling: str, -) -> str: - return ( - contents.rstrip() - + "\n\n" - + _middleware_block( - middleware_name=middleware_name, - endpoint=endpoint, - timeout_gateway_ceiling=timeout_gateway_ceiling, - ) - ) - - -def _middleware_block( - *, - middleware_name: str, - endpoint: str, - timeout_gateway_ceiling: str, -) -> str: - return ( - "[[openshell.supervisor.middleware]]\n" - f'name = "{middleware_name}"\n' - f'grpc_endpoint = "{endpoint}"\n' - "max_body_bytes = 4194304\n" - f'timeout = "{timeout_gateway_ceiling}"\n' - ) - - -def _update_middleware_block( - block: str, - *, - endpoint: str, - timeout_gateway_ceiling: str, -) -> str: - updated = _replace_or_append_assignment( - block, - key="grpc_endpoint", - value=f'"{endpoint}"', - ) - updated = _replace_or_append_assignment( - updated, - key="max_body_bytes", - value="4194304", - ) - return _replace_or_append_assignment( - updated, - key="timeout", - value=f'"{timeout_gateway_ceiling}"', - ) - - -def _replace_or_append_assignment(block: str, *, key: str, value: str) -> str: - pattern = re.compile( - rf"^(?P[ \t]*){re.escape(key)}[ \t]*=.*?$", - flags=re.MULTILINE, - ) - if pattern.search(block): - return pattern.sub( - lambda match: f"{match.group('indent')}{key} = {value}", - block, - count=1, - ) - return block.rstrip() + f"\n{key} = {value}\n" - - -def _trailing_middleware_block_layout(block: str) -> str: - lines = block.splitlines(keepends=True) - for index in range(len(lines) - 1, -1, -1): - stripped = lines[index].lstrip() - if stripped.strip() and not stripped.startswith("#"): - return "".join(lines[index + 1 :]) - raise AssertionError("middleware block header is unavailable") - - -def _without_middleware_entries(values: dict[str, object]) -> dict[str, object]: - copied_values = copy.deepcopy(values) - openshell = copied_values["openshell"] - if not isinstance(openshell, dict): - raise AssertionError("validated OpenShell table is unavailable") - supervisor = openshell.get("supervisor") - if not isinstance(supervisor, dict): - return copied_values - supervisor.pop("middleware", None) - if not supervisor: - openshell.pop("supervisor", None) - return copied_values - - -def _write_atomically(path: Path, contents: str) -> None: - try: - path.parent.mkdir(parents=True, exist_ok=True) - mode = stat.S_IMODE(path.stat().st_mode) if path.exists() else 0o600 - with tempfile.NamedTemporaryFile( - mode="w", - encoding="utf-8", - dir=path.parent, - prefix=f".{path.name}.", - delete=False, - ) as temporary: - temporary.write(contents) - temporary_path = Path(temporary.name) - temporary_path.chmod(mode) - temporary_path.replace(path) - except OSError as error: - raise GatewayConfigError( - f"Could not write {path}. Check that its directory is writable, then retry." - ) from error - - -_MIDDLEWARE_BLOCK_PATTERN = re.compile( - r"(?ms)^[ \t]*\[\[openshell\.supervisor\.middleware\]\][^\n]*\n" - r".*?(?=^[ \t]*\[\[?[A-Za-z0-9_-]|\Z)" -) - -_MIDDLEWARE_NAME_CHARACTERS = frozenset( - "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789._-/" -) - - -__all__ = [ - "GatewayConfigError", - "GatewayMiddlewareRegistration", - "RememberedGatewayRegistration", - "GatewayConfigRemoval", - "GatewayConfigUpdate", - "MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES", - "default_gateway_config_path", - "default_registration_state_path", - "forget_gateway_registration", - "list_gateway_registrations", - "load_remembered_gateway_registration", - "remember_gateway_registration", - "read_remembered_gateway_timeout", - "remove_gateway_config", - "update_gateway_config", - "validate_gateway_timeout", - "validate_middleware_name", -] diff --git a/projects/egress-gate/src/egress_gate/logging.py b/projects/egress-gate/src/egress_gate/logging.py deleted file mode 100644 index ed135326..00000000 --- a/projects/egress-gate/src/egress_gate/logging.py +++ /dev/null @@ -1,135 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Native Python logging configuration for Egress Gate.""" - -from __future__ import annotations - -import copy -import logging -import os -from dataclasses import dataclass -from enum import StrEnum -from typing import TextIO - - -class ColorMode(StrEnum): - """When Egress Gate should add ANSI colors to console logs.""" - - AUTO = "auto" - ALWAYS = "always" - NEVER = "never" - - -@dataclass(frozen=True) -class LoggingConfig: - """Egress Gate console logging settings.""" - - level: int | str = logging.INFO - stream: TextIO | None = None - color_mode: ColorMode = ColorMode.AUTO - - -DEFAULT_LOGGING_CONFIG = LoggingConfig() - - -def get_logger(name: str) -> logging.Logger: - """Return a logger governed by Egress Gate's shared configuration.""" - return logging.getLogger(name) - - -def configure_logging( - config: LoggingConfig = DEFAULT_LOGGING_CONFIG, -) -> None: - """Configure consistent console logging for the Egress Gate package. - - Repeated calls replace the handler installed by this function. Handlers - installed by the containing application are left unchanged. - """ - package_logger = get_logger("egress_gate") - package_logger.setLevel(config.level) - - for handler in package_logger.handlers[:]: - if isinstance(handler, _EgressGateStreamHandler): - package_logger.removeHandler(handler) - handler.close() - - handler = _EgressGateStreamHandler(config.stream) - use_colors = config.color_mode is ColorMode.ALWAYS or ( - config.color_mode is ColorMode.AUTO - and "NO_COLOR" not in os.environ - and handler.stream.isatty() - ) - handler.setFormatter(_EgressGateFormatter(use_colors=use_colors)) - package_logger.addHandler(handler) - package_logger.propagate = False - - -def reset_logging() -> None: - """Remove logging configuration installed by :func:`configure_logging`.""" - package_logger = get_logger("egress_gate") - managed_handlers = [ - handler - for handler in package_logger.handlers - if isinstance(handler, _EgressGateStreamHandler) - ] - if not managed_handlers: - return - - for handler in managed_handlers: - package_logger.removeHandler(handler) - handler.close() - package_logger.setLevel(logging.NOTSET) - package_logger.propagate = True - - -class _EgressGateStreamHandler(logging.StreamHandler[TextIO]): - """Stream handler owned by Egress Gate's logging configuration.""" - - -class _EgressGateFormatter(logging.Formatter): - """Readable console formatter with optional level-aware color.""" - - def __init__(self, *, use_colors: bool) -> None: - log_format = _PLAIN_LOG_FORMAT - if use_colors: - log_format = _COLOR_LOG_FORMAT - super().__init__(log_format, datefmt="%Y-%m-%d %H:%M:%S") - self._use_colors = use_colors - - def format(self, record: logging.LogRecord) -> str: - formatted_record = copy.copy(record) - level_name = f"{record.levelname:<8}" - if self._use_colors: - color = _LEVEL_COLORS.get(record.levelno, _ANSI_BRIGHT_BLACK) - level_name = f"{color}{level_name}{_ANSI_RESET}" - formatted_record.levelname = level_name - return super().format(formatted_record) - - -_PLAIN_LOG_FORMAT = "%(asctime)s | %(levelname)s | %(name)s | %(message)s" -_ANSI_RESET = "\033[0m" -_ANSI_DIM = "\033[2m" -_ANSI_BRIGHT_BLACK = "\033[90m" -_ANSI_CYAN = "\033[36m" -_COLOR_LOG_FORMAT = ( - f"{_ANSI_DIM}%(asctime)s{_ANSI_RESET} | " - f"%(levelname)s | {_ANSI_CYAN}%(name)s{_ANSI_RESET} | %(message)s" -) -_LEVEL_COLORS = { - logging.DEBUG: _ANSI_BRIGHT_BLACK, - logging.INFO: "\033[32m", - logging.WARNING: "\033[33m", - logging.ERROR: "\033[31m", - logging.CRITICAL: "\033[1;31m", -} - - -__all__ = [ - "ColorMode", - "DEFAULT_LOGGING_CONFIG", - "LoggingConfig", - "configure_logging", - "get_logger", - "reset_logging", -] diff --git a/projects/egress-gate/src/egress_gate/request.py b/projects/egress-gate/src/egress_gate/request.py deleted file mode 100644 index 98201ffe..00000000 --- a/projects/egress-gate/src/egress_gate/request.py +++ /dev/null @@ -1,193 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Immutable request and mutation models shared by Egress Gate components.""" - -from __future__ import annotations - -from enum import StrEnum -from typing import Annotated, Literal, TypeAlias - -from pydantic import Field, field_validator, model_validator - -from egress_gate.base import StrictDomainModel -from egress_gate.constants import ( - MAX_BODY_BYTES, - MAX_HEADER_MUTATION_DATA_BYTES, - MAX_HEADER_MUTATIONS, - MAX_PROTO_CONTEXT_BYTES, - MAX_PROTO_HEADERS, - MAX_PROTO_HEADERS_BYTES, - MAX_PROTO_TARGET_BYTES, -) -from egress_gate.string_validators import ScalarString - -HeaderName = Annotated[ScalarString, Field(min_length=1)] -HeaderValue = ScalarString - - -class Process(StrictDomainModel): - """The originating workload process and its executable ancestry.""" - - binary: ScalarString - pid: int = Field(ge=0, le=2**32 - 1) - ancestors: tuple[ScalarString, ...] = () - - -class RequestContext(StrictDomainModel): - """Sandbox and request identity supplied by OpenShell.""" - - request_id: ScalarString - sandbox_id: ScalarString - originating_process: Process | None = None - - @model_validator(mode="after") - def _context_strings_are_bounded(self) -> RequestContext: - string_bytes = len(self.request_id.encode("utf-8")) + len( - self.sandbox_id.encode("utf-8") - ) - if self.originating_process is not None: - string_bytes += len(self.originating_process.binary.encode("utf-8")) - string_bytes += sum( - len(ancestor.encode("utf-8")) - for ancestor in self.originating_process.ancestors - ) - if string_bytes > MAX_PROTO_CONTEXT_BYTES: - raise ValueError("request context strings exceed the size limit") - return self - - -class HttpTarget(StrictDomainModel): - """The bounded destination and request target visible before credentials.""" - - scheme: ScalarString - host: ScalarString - port: int = Field(ge=0, le=2**32 - 1) - method: ScalarString - path: ScalarString - query: ScalarString - - @model_validator(mode="after") - def _target_strings_are_bounded(self) -> HttpTarget: - string_bytes = sum( - len(value.encode("utf-8")) - for value in ( - self.scheme, - self.host, - self.method, - self.path, - self.query, - ) - ) - if string_bytes > MAX_PROTO_TARGET_BYTES: - raise ValueError("request target strings exceed the size limit") - return self - - -class HttpHeader(StrictDomainModel): - """One ordered, repeated request-header field.""" - - name: HeaderName - value: HeaderValue - - -class HttpRequest(StrictDomainModel): - """The immutable OpenShell HTTP request exposed to a gate.""" - - context: RequestContext - target: HttpTarget - headers: tuple[HttpHeader, ...] = Field(max_length=MAX_PROTO_HEADERS) - body: bytes = Field(max_length=MAX_BODY_BYTES, repr=False) - - @field_validator("headers") - @classmethod - def _headers_are_bounded( - cls, value: tuple[HttpHeader, ...] - ) -> tuple[HttpHeader, ...]: - encoded_size = sum( - len(header.name.encode("utf-8")) + len(header.value.encode("utf-8")) - for header in value - ) - if encoded_size > MAX_PROTO_HEADERS_BYTES: - raise ValueError("request headers exceed the size limit") - return value - - -class ExistingHeaderAction(StrEnum): - """How a header write handles existing case-insensitive fields.""" - - APPEND = "append" - OVERWRITE = "overwrite" - SKIP = "skip" - - -class WriteHeaderMutation(StrictDomainModel): - """One ordered write operation proposed by a gate.""" - - kind: Literal["write"] - name: HeaderName - value: HeaderValue - on_existing: ExistingHeaderAction - - -class RemoveHeaderMutation(StrictDomainModel): - """One ordered removal operation proposed by a gate.""" - - kind: Literal["remove"] - name: HeaderName - - -HeaderMutation: TypeAlias = Annotated[ - WriteHeaderMutation | RemoveHeaderMutation, - Field(discriminator="kind"), -] - - -class RequestMutations(StrictDomainModel): - """Validated body and header mutations proposed by one gate.""" - - replacement_body: bytes | None = Field( - default=None, - max_length=MAX_BODY_BYTES, - repr=False, - ) - header_mutations: tuple[HeaderMutation, ...] = Field( - default=(), - max_length=MAX_HEADER_MUTATIONS, - ) - - @model_validator(mode="after") - def _mutations_are_bounded(self) -> RequestMutations: - data_size = sum( - len(mutation.name.encode("utf-8")) - + ( - len(mutation.value.encode("utf-8")) - if isinstance(mutation, WriteHeaderMutation) - else 0 - ) - for mutation in self.header_mutations - ) - if data_size > MAX_HEADER_MUTATION_DATA_BYTES: - raise ValueError("request mutation header data exceeds the size limit") - return self - - @property - def is_empty(self) -> bool: - """Whether this set proposes no request mutation.""" - return self.replacement_body is None and not self.header_mutations - - -__all__ = [ - "ExistingHeaderAction", - "HeaderMutation", - "HeaderName", - "HeaderValue", - "HttpHeader", - "HttpRequest", - "HttpTarget", - "Process", - "RemoveHeaderMutation", - "RequestContext", - "RequestMutations", - "WriteHeaderMutation", -] diff --git a/projects/egress-gate/src/egress_gate/request_content/__init__.py b/projects/egress-gate/src/egress_gate/request_content/__init__.py deleted file mode 100644 index 42865533..00000000 --- a/projects/egress-gate/src/egress_gate/request_content/__init__.py +++ /dev/null @@ -1,59 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Public structured request-content parsing surface.""" - -from egress_gate.request_content.json import ( - JsonDocument, - JsonEachSegment, - JsonIndexSegment, - JsonKeySegment, - JsonNode, - JsonNodeKind, - JsonPathSegment, - JsonSelector, - JsonTextNode, -) -from egress_gate.request_content.messages import ( - JsonMessageBlockExtractor, - JsonMessageMapConfig, - MessageBlock, - MessageBlockExtractor, - MessageBlockKind, - MessageDocument, - MessageRole, -) -from egress_gate.request_content.parsers import ( - JsonFieldsParser, - MessageBlocksParser, - ParsedRequestContent, - RequestContentParser, - Utf8TextParser, -) -from egress_gate.request_content.text import TextReplacement, TextTarget - -__all__ = [ - "JsonDocument", - "JsonEachSegment", - "JsonFieldsParser", - "JsonIndexSegment", - "JsonKeySegment", - "JsonMessageBlockExtractor", - "JsonMessageMapConfig", - "JsonNode", - "JsonNodeKind", - "JsonPathSegment", - "JsonSelector", - "JsonTextNode", - "MessageBlock", - "MessageBlockExtractor", - "MessageBlockKind", - "MessageBlocksParser", - "MessageDocument", - "MessageRole", - "ParsedRequestContent", - "RequestContentParser", - "TextReplacement", - "TextTarget", - "Utf8TextParser", -] diff --git a/projects/egress-gate/src/egress_gate/request_content/_json_parser.py b/projects/egress-gate/src/egress_gate/request_content/_json_parser.py deleted file mode 100644 index a2080935..00000000 --- a/projects/egress-gate/src/egress_gate/request_content/_json_parser.py +++ /dev/null @@ -1,230 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Private bounded parser for source-aware strict JSON documents.""" - -from __future__ import annotations - -import json -import re -from dataclasses import dataclass - -from egress_gate.constants import MAX_JSON_DEPTH, MAX_JSON_NODES -from egress_gate.errors import GateLimitExceededError -from egress_gate.request_content.json_values import JsonNodeKind -from egress_gate.timeout import Timeout - - -@dataclass(frozen=True) -class _JsonNode: - id: str - path: tuple[str | int, ...] - kind: JsonNodeKind - token_start: int - token_end: int - text: str | None = None - object_members: tuple[tuple[str, _JsonNode], ...] = () - array_items: tuple[_JsonNode, ...] = () - - -class _JsonParser: - def __init__(self, source: str, timeout: Timeout) -> None: - self.source = source - self.timeout = timeout - self.index = 0 - self.node_count = 0 - self.nodes: dict[str, _JsonNode] = {} - - def parse(self) -> _JsonNode: - self._skip_whitespace() - root = self._parse_value((), 0) - self._skip_whitespace() - if self.index != len(self.source): - raise ValueError("trailing JSON data") - return root - - def _parse_value(self, path: tuple[str | int, ...], depth: int) -> _JsonNode: - self.timeout.raise_if_expired() - if self.index >= len(self.source): - raise ValueError("JSON value is missing") - character = self.source[self.index] - if character == "{": - if depth >= MAX_JSON_DEPTH: - raise GateLimitExceededError("JSON nesting depth exceeds the limit") - return self._parse_object(path, depth) - if character == "[": - if depth >= MAX_JSON_DEPTH: - raise GateLimitExceededError("JSON nesting depth exceeds the limit") - return self._parse_array(path, depth) - if character == '"': - start = self.index - text = self._parse_string() - return self._new_node( - path, - JsonNodeKind.STRING, - start, - self.index, - text=text, - ) - if character in "-0123456789": - start = self.index - match = _NUMBER_PATTERN.match(self.source, self.index) - if match is None: - raise ValueError("JSON number is invalid") - self.index = match.end() - return self._new_node(path, JsonNodeKind.NUMBER, start, self.index) - for literal, kind in ( - ("true", JsonNodeKind.BOOLEAN), - ("false", JsonNodeKind.BOOLEAN), - ("null", JsonNodeKind.NULL), - ): - if self.source.startswith(literal, self.index): - start = self.index - self.index += len(literal) - return self._new_node(path, kind, start, self.index) - raise ValueError("JSON value is invalid") - - def _parse_object(self, path: tuple[str | int, ...], depth: int) -> _JsonNode: - start = self.index - self.index += 1 - self._skip_whitespace() - members: list[tuple[str, _JsonNode]] = [] - keys: set[str] = set() - if self._consume("}"): - return self._new_node( - path, JsonNodeKind.OBJECT, start, self.index, object_members=() - ) - while True: - if self.index >= len(self.source) or self.source[self.index] != '"': - raise ValueError("JSON object key is invalid") - key = self._parse_string() - if key in keys: - raise ValueError("JSON object keys must be unique") - keys.add(key) - self._skip_whitespace() - if not self._consume(":"): - raise ValueError("JSON object separator is missing") - self._skip_whitespace() - value = self._parse_value((*path, key), depth + 1) - members.append((key, value)) - self._skip_whitespace() - if self._consume("}"): - break - if not self._consume(","): - raise ValueError("JSON object delimiter is missing") - self._skip_whitespace() - return self._new_node( - path, - JsonNodeKind.OBJECT, - start, - self.index, - object_members=tuple(members), - ) - - def _parse_array(self, path: tuple[str | int, ...], depth: int) -> _JsonNode: - start = self.index - self.index += 1 - self._skip_whitespace() - items: list[_JsonNode] = [] - if self._consume("]"): - return self._new_node( - path, JsonNodeKind.ARRAY, start, self.index, array_items=() - ) - while True: - item = self._parse_value((*path, len(items)), depth + 1) - items.append(item) - self._skip_whitespace() - if self._consume("]"): - break - if not self._consume(","): - raise ValueError("JSON array delimiter is missing") - self._skip_whitespace() - return self._new_node( - path, - JsonNodeKind.ARRAY, - start, - self.index, - array_items=tuple(items), - ) - - def _parse_string(self) -> str: - start = self.index - self.index += 1 - next_timeout_check = self.index + _LEXICAL_TIMEOUT_CHECK_INTERVAL - while self.index < len(self.source): - if self.index >= next_timeout_check: - self.timeout.raise_if_expired() - next_timeout_check = self.index + _LEXICAL_TIMEOUT_CHECK_INTERVAL - character = self.source[self.index] - if character == '"': - self.index += 1 - token = self.source[start : self.index] - value = json.loads(token) - value.encode("utf-8", errors="strict") - return value - if character == "\\": - self.index += 1 - if self.index >= len(self.source): - raise ValueError("JSON string escape is incomplete") - escape = self.source[self.index] - if escape == "u": - digits = self.source[self.index + 1 : self.index + 5] - if len(digits) != 4 or _HEX_PATTERN.fullmatch(digits) is None: - raise ValueError("JSON Unicode escape is invalid") - self.index += 5 - continue - if escape not in '"\\/bfnrt': - raise ValueError("JSON string escape is invalid") - elif ord(character) < 0x20: - raise ValueError("JSON string contains a control character") - self.index += 1 - raise ValueError("JSON string is unterminated") - - def _new_node( - self, - path: tuple[str | int, ...], - kind: JsonNodeKind, - token_start: int, - token_end: int, - *, - text: str | None = None, - object_members: tuple[tuple[str, _JsonNode], ...] = (), - array_items: tuple[_JsonNode, ...] = (), - ) -> _JsonNode: - self.node_count += 1 - if self.node_count > MAX_JSON_NODES: - raise GateLimitExceededError("JSON node count exceeds the limit") - node = _JsonNode( - id=f"json-node-{self.node_count}", - path=path, - kind=kind, - token_start=token_start, - token_end=token_end, - text=text, - object_members=object_members, - array_items=array_items, - ) - self.nodes[node.id] = node - return node - - def _skip_whitespace(self) -> None: - next_timeout_check = self.index + _LEXICAL_TIMEOUT_CHECK_INTERVAL - while self.index < len(self.source) and self.source[self.index] in " \t\r\n": - self.index += 1 - if self.index >= next_timeout_check: - self.timeout.raise_if_expired() - next_timeout_check = self.index + _LEXICAL_TIMEOUT_CHECK_INTERVAL - - def _consume(self, token: str) -> bool: - if self.source.startswith(token, self.index): - self.index += len(token) - return True - return False - - -_NUMBER_PATTERN = re.compile(r"-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?") -_HEX_PATTERN = re.compile(r"[0-9a-fA-F]{4}") -_LEXICAL_TIMEOUT_CHECK_INTERVAL = 256 - - -__all__ = ["_JsonNode", "_JsonParser"] diff --git a/projects/egress-gate/src/egress_gate/request_content/json.py b/projects/egress-gate/src/egress_gate/request_content/json.py deleted file mode 100644 index 465fde4a..00000000 --- a/projects/egress-gate/src/egress_gate/request_content/json.py +++ /dev/null @@ -1,340 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Strict source-aware JSON request-content parsing and selection.""" - -from __future__ import annotations - -import json -from typing import Annotated, Literal, TypeAlias - -from pydantic import Field, field_validator - -from egress_gate.base import StrictDomainModel -from egress_gate.constants import ( - MAX_BODY_BYTES, - MAX_JSON_SELECTED_NODES, - MAX_JSON_SELECTED_TEXT_BYTES, - MAX_JSON_SELECTOR_SEGMENTS, - MAX_JSON_SELECTORS, -) -from egress_gate.errors import BodyFormatError, GateInputError, GateLimitExceededError -from egress_gate.request_content._json_parser import _JsonNode, _JsonParser -from egress_gate.request_content.json_values import ( - JsonNode, - JsonNodeKind, - JsonTextNode, -) -from egress_gate.request_content.text import TextReplacement -from egress_gate.string_validators import ScalarString -from egress_gate.timeout import Timeout - - -class JsonKeySegment(StrictDomainModel): - """Select one object member by its exact decoded key.""" - - kind: Literal["key"] - value: ScalarString - - -class JsonIndexSegment(StrictDomainModel): - """Select one array item by its zero-based index.""" - - kind: Literal["index"] - value: int = Field(ge=0) - - -class JsonEachSegment(StrictDomainModel): - """Select every immediate array item or object member value.""" - - kind: Literal["each"] - - -JsonPathSegment: TypeAlias = Annotated[ - JsonKeySegment | JsonIndexSegment | JsonEachSegment, - Field(discriminator="kind"), -] - - -class JsonSelector(StrictDomainModel): - """One bounded path from a selected JSON node.""" - - segments: tuple[JsonPathSegment, ...] = Field( - min_length=1, - max_length=MAX_JSON_SELECTOR_SEGMENTS, - ) - - @field_validator("segments", mode="before") - @classmethod - def _segments_are_a_tuple(cls, value: object) -> object: - if isinstance(value, list | tuple): - return tuple(value) - return value - - -class JsonDocument: - """One strict JSON body with bounded traversal and source-preserving edits.""" - - __slots__ = ("_nodes", "_public_nodes", "_root", "_source") - - def __init__(self, source: str, root: _JsonNode, nodes: dict[str, _JsonNode]): - self._source = source - self._root = root - self._nodes = nodes - self._public_nodes: dict[str, JsonNode] = {} - - @classmethod - def parse(cls, body: bytes, *, timeout: Timeout) -> JsonDocument: - """Parse one complete strict UTF-8 JSON body under the shared deadline.""" - timeout.raise_if_expired() - if len(body) > MAX_BODY_BYTES: - raise GateLimitExceededError("JSON request body exceeds the size limit") - try: - source = body.decode("utf-8", errors="strict") - except UnicodeDecodeError: - raise GateInputError("request body is not valid UTF-8") from None - try: - parser = _JsonParser(source, timeout) - root = parser.parse() - except GateLimitExceededError: - raise - except (RecursionError, UnicodeError, ValueError): - raise BodyFormatError("request body is not strict JSON") from None - timeout.raise_if_expired() - return cls(source, root, parser.nodes) - - def select_nodes( - self, - selectors: tuple[JsonSelector, ...], - *, - timeout: Timeout, - ) -> tuple[JsonNode, ...]: - """Select unique nodes in selector order and then document order.""" - return self._select_from(self._root, selectors, timeout=timeout) - - def select_from( - self, - node: JsonNode, - selectors: tuple[JsonSelector, ...], - *, - timeout: Timeout, - ) -> tuple[JsonNode, ...]: - """Select unique nodes relative to one node from this document.""" - return self._select_from(self._resolve_node(node), selectors, timeout=timeout) - - def select_text( - self, - selectors: tuple[JsonSelector, ...], - *, - timeout: Timeout, - ) -> tuple[JsonTextNode, ...]: - """Select unique string nodes from the document root.""" - return self._text_nodes( - self.select_nodes(selectors, timeout=timeout), - timeout=timeout, - ) - - def select_text_from( - self, - node: JsonNode, - selectors: tuple[JsonSelector, ...], - *, - timeout: Timeout, - ) -> tuple[JsonTextNode, ...]: - """Select unique string nodes relative to one document node.""" - return self._text_nodes( - self.select_from(node, selectors, timeout=timeout), - timeout=timeout, - ) - - def array_items( - self, - node: JsonNode, - *, - timeout: Timeout, - ) -> tuple[JsonNode, ...]: - """Return the ordered immediate items of an array node.""" - internal = self._resolve_node(node) - if internal.kind is not JsonNodeKind.ARRAY: - return () - items: list[JsonNode] = [] - for index, item in enumerate(internal.array_items): - if index % _TIMEOUT_CHECK_INTERVAL == 0: - timeout.raise_if_expired() - items.append(self._public_node(item)) - timeout.raise_if_expired() - return tuple(items) - - def object_member(self, node: JsonNode, key: str) -> JsonNode | None: - """Return one exact object member without exposing mutable JSON values.""" - internal = self._resolve_node(node) - if internal.kind is not JsonNodeKind.OBJECT: - return None - for member_key, value in internal.object_members: - if member_key == key: - return self._public_node(value) - return None - - def text_value(self, node: JsonNode) -> str | None: - """Return the decoded value when a node is a JSON string.""" - internal = self._resolve_node(node) - return internal.text if internal.kind is JsonNodeKind.STRING else None - - def replace_text( - self, - replacements: tuple[TextReplacement, ...], - *, - timeout: Timeout, - ) -> bytes: - """Replace complete JSON string tokens and preserve all unrelated source.""" - timeout.raise_if_expired() - replacement_ids = tuple(item.target_id for item in replacements) - if len(replacement_ids) != len(set(replacement_ids)): - raise ValueError("JSON replacement node IDs must be unique") - edits: list[tuple[int, int, bytes]] = [] - for replacement in replacements: - timeout.raise_if_expired() - node = self._nodes.get(replacement.target_id) - if node is None or node.kind is not JsonNodeKind.STRING: - raise ValueError("JSON replacement node ID is unknown") - try: - rendered = json.dumps(replacement.text, ensure_ascii=False).encode( - "utf-8", errors="strict" - ) - except UnicodeEncodeError: - raise ValueError("JSON replacement text is invalid") from None - edits.append((node.token_start, node.token_end, rendered)) - - output_parts: list[bytes] = [] - output_size = 0 - source_cursor = 0 - for start, end, rendered in sorted(edits): - timeout.raise_if_expired() - unchanged = self._source[source_cursor:start].encode("utf-8") - output_size += len(unchanged) + len(rendered) - if output_size > MAX_BODY_BYTES: - raise GateLimitExceededError("JSON replacement body exceeds the limit") - output_parts.extend((unchanged, rendered)) - source_cursor = end - tail = self._source[source_cursor:].encode("utf-8") - output_size += len(tail) - if output_size > MAX_BODY_BYTES: - raise GateLimitExceededError("JSON replacement body exceeds the limit") - output_parts.append(tail) - timeout.raise_if_expired() - return b"".join(output_parts) - - def _select_from( - self, - start: _JsonNode, - selectors: tuple[JsonSelector, ...], - *, - timeout: Timeout, - ) -> tuple[JsonNode, ...]: - if len(selectors) > MAX_JSON_SELECTORS: - raise GateLimitExceededError("JSON selector count exceeds the limit") - selected: list[_JsonNode] = [] - seen: set[str] = set() - for selector in selectors: - current = (start,) - for segment in selector.segments: - timeout.raise_if_expired() - next_nodes: list[_JsonNode] = [] - for node in current: - next_nodes.extend(_select_segment(node, segment)) - if len(next_nodes) > MAX_JSON_SELECTED_NODES: - raise GateLimitExceededError( - "JSON selected node count exceeds the limit" - ) - current = tuple(next_nodes) - for node in current: - if node.id not in seen: - seen.add(node.id) - selected.append(node) - if len(selected) > MAX_JSON_SELECTED_NODES: - raise GateLimitExceededError( - "JSON selected node count exceeds the limit" - ) - timeout.raise_if_expired() - public_nodes: list[JsonNode] = [] - for index, node in enumerate(selected): - if index % _TIMEOUT_CHECK_INTERVAL == 0: - timeout.raise_if_expired() - public_nodes.append(self._public_node(node)) - timeout.raise_if_expired() - return tuple(public_nodes) - - def _text_nodes( - self, - nodes: tuple[JsonNode, ...], - *, - timeout: Timeout, - ) -> tuple[JsonTextNode, ...]: - selected: list[JsonTextNode] = [] - encoded_size = 0 - for index, node in enumerate(nodes): - if index % _TIMEOUT_CHECK_INTERVAL == 0: - timeout.raise_if_expired() - internal = self._resolve_node(node) - if internal.kind is not JsonNodeKind.STRING or internal.text is None: - continue - encoded_size += len(internal.text.encode("utf-8")) - if encoded_size > MAX_JSON_SELECTED_TEXT_BYTES: - raise GateLimitExceededError("JSON selected text exceeds the limit") - selected.append( - JsonTextNode(id=internal.id, path=internal.path, text=internal.text) - ) - timeout.raise_if_expired() - return tuple(selected) - - def _resolve_node(self, node: JsonNode) -> _JsonNode: - internal = self._nodes.get(node.id) - if internal is None or self._public_nodes.get(node.id) is not node: - raise ValueError("JSON node does not belong to this document") - return internal - - def _public_node(self, node: _JsonNode) -> JsonNode: - public = self._public_nodes.get(node.id) - if public is None: - public = JsonNode(id=node.id, path=node.path, kind=node.kind) - self._public_nodes[node.id] = public - return public - - -def _select_segment( - node: _JsonNode, - segment: JsonPathSegment, -) -> tuple[_JsonNode, ...]: - if isinstance(segment, JsonKeySegment): - if node.kind is not JsonNodeKind.OBJECT: - return () - return tuple( - value for key, value in node.object_members if key == segment.value - ) - if isinstance(segment, JsonIndexSegment): - if node.kind is not JsonNodeKind.ARRAY or segment.value >= len( - node.array_items - ): - return () - return (node.array_items[segment.value],) - if node.kind is JsonNodeKind.ARRAY: - return node.array_items - if node.kind is JsonNodeKind.OBJECT: - return tuple(value for _, value in node.object_members) - return () - - -_TIMEOUT_CHECK_INTERVAL = 256 - - -__all__ = [ - "JsonDocument", - "JsonEachSegment", - "JsonIndexSegment", - "JsonKeySegment", - "JsonNode", - "JsonNodeKind", - "JsonPathSegment", - "JsonSelector", - "JsonTextNode", -] diff --git a/projects/egress-gate/src/egress_gate/request_content/json_values.py b/projects/egress-gate/src/egress_gate/request_content/json_values.py deleted file mode 100644 index f0353c45..00000000 --- a/projects/egress-gate/src/egress_gate/request_content/json_values.py +++ /dev/null @@ -1,40 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Shared JSON value kinds used by public documents and the private parser.""" - -from enum import StrEnum - -from pydantic import Field - -from egress_gate.base import StrictDomainModel - - -class JsonNodeKind(StrEnum): - """The JSON value kind at one immutable document node.""" - - OBJECT = "object" - ARRAY = "array" - STRING = "string" - NUMBER = "number" - BOOLEAN = "boolean" - NULL = "null" - - -class JsonNode(StrictDomainModel): - """Opaque immutable reference to one node in a ``JsonDocument``.""" - - id: str - path: tuple[str | int, ...] = Field(repr=False) - kind: JsonNodeKind - - -class JsonTextNode(StrictDomainModel): - """One selected JSON string with a stable document-local identity.""" - - id: str - path: tuple[str | int, ...] = Field(repr=False) - text: str = Field(repr=False) - - -__all__ = ["JsonNode", "JsonNodeKind", "JsonTextNode"] diff --git a/projects/egress-gate/src/egress_gate/request_content/messages.py b/projects/egress-gate/src/egress_gate/request_content/messages.py deleted file mode 100644 index 6e7e521c..00000000 --- a/projects/egress-gate/src/egress_gate/request_content/messages.py +++ /dev/null @@ -1,222 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Normalized agent message blocks derived from structured JSON content.""" - -from __future__ import annotations - -from enum import StrEnum -from typing import Literal, Protocol, Self - -from pydantic import Field, field_validator, model_validator - -from egress_gate.base import StrictDomainModel -from egress_gate.constants import MAX_JSON_SELECTORS, MAX_MESSAGE_BLOCKS -from egress_gate.errors import GateLimitExceededError -from egress_gate.request_content.json import ( - JsonDocument, - JsonNode, - JsonNodeKind, - JsonSelector, -) -from egress_gate.string_validators import ScalarString -from egress_gate.timeout import Timeout - - -class MessageRole(StrEnum): - """A normalized role for one model-visible message block.""" - - SYSTEM = "system" - DEVELOPER = "developer" - USER = "user" - ASSISTANT = "assistant" - TOOL = "tool" - UNKNOWN = "unknown" - - -class MessageBlockKind(StrEnum): - """The normalized purpose of one text-bearing message block.""" - - TEXT = "text" - TOOL_INPUT = "tool_input" - TOOL_OUTPUT = "tool_output" - - -class MessageBlock(StrictDomainModel): - """One normalized text block backed by a JSON string node.""" - - id: str - node_id: str - message_index: int | None = Field(ge=0) - role: MessageRole - kind: MessageBlockKind - text: str = Field(repr=False) - - -class MessageDocument(StrictDomainModel): - """The bounded normalized message view of one request body.""" - - blocks: tuple[MessageBlock, ...] = Field( - max_length=MAX_MESSAGE_BLOCKS, - repr=False, - ) - - -class JsonMessageMapConfig(StrictDomainModel): - """Map conventional JSON message objects to normalized text blocks.""" - - kind: Literal["json-message-map"] - messages: JsonSelector - role_key: ScalarString = "role" - text_selectors: tuple[JsonSelector, ...] = Field( - default=(), - max_length=MAX_JSON_SELECTORS, - ) - tool_input_selectors: tuple[JsonSelector, ...] = Field( - default=(), - max_length=MAX_JSON_SELECTORS, - ) - tool_output_selectors: tuple[JsonSelector, ...] = Field( - default=(), - max_length=MAX_JSON_SELECTORS, - ) - - @field_validator( - "text_selectors", - "tool_input_selectors", - "tool_output_selectors", - mode="before", - ) - @classmethod - def _text_selectors_are_a_tuple(cls, value: object) -> object: - if isinstance(value, list | tuple): - return tuple(value) - return value - - @model_validator(mode="after") - def _selectors_are_non_empty_and_bounded(self) -> Self: - selector_count = sum( - len(selectors) - for selectors in ( - self.text_selectors, - self.tool_input_selectors, - self.tool_output_selectors, - ) - ) - if selector_count == 0: - raise ValueError("message mapping requires at least one text selector") - if selector_count > MAX_JSON_SELECTORS: - raise ValueError("message mapping selector count exceeds the limit") - return self - - -class MessageBlockExtractor(Protocol): - """Extract normalized message blocks from one parsed JSON document.""" - - def extract( - self, - document: JsonDocument, - *, - timeout: Timeout, - ) -> MessageDocument: - """Return normalized blocks backed by nodes in ``document``.""" - ... - - -class JsonMessageBlockExtractor: - """Extract normalized blocks with one validated JSON message mapping.""" - - def __init__(self, config: JsonMessageMapConfig) -> None: - self._config = config - - def extract( - self, - document: JsonDocument, - *, - timeout: Timeout, - ) -> MessageDocument: - """Return normalized blocks in message and configured selector order.""" - containers = document.select_nodes( - (self._config.messages,), - timeout=timeout, - ) - blocks: list[MessageBlock] = [] - seen_classifications: set[tuple[str, MessageBlockKind]] = set() - message_index = 0 - for container in containers: - if container.kind is not JsonNodeKind.ARRAY: - continue - for message in document.array_items(container, timeout=timeout): - timeout.raise_if_expired() - role = _message_role(document, message, self._config.role_key) - default_kind = ( - MessageBlockKind.TOOL_OUTPUT - if role is MessageRole.TOOL - else MessageBlockKind.TEXT - ) - selector_groups = ( - (self._config.text_selectors, default_kind), - ( - self._config.tool_input_selectors, - MessageBlockKind.TOOL_INPUT, - ), - ( - self._config.tool_output_selectors, - MessageBlockKind.TOOL_OUTPUT, - ), - ) - for selectors, kind in selector_groups: - for text_node in document.select_text_from( - message, - selectors, - timeout=timeout, - ): - classification = (text_node.id, kind) - if classification in seen_classifications: - continue - seen_classifications.add(classification) - blocks.append( - MessageBlock( - id=f"message-block-{len(blocks) + 1}", - node_id=text_node.id, - message_index=message_index, - role=role, - kind=kind, - text=text_node.text, - ) - ) - if len(blocks) > MAX_MESSAGE_BLOCKS: - raise GateLimitExceededError( - "message block count exceeds the limit" - ) - message_index += 1 - timeout.raise_if_expired() - return MessageDocument(blocks=tuple(blocks)) - - -def _message_role( - document: JsonDocument, - message: JsonNode, - role_key: str, -) -> MessageRole: - role_node = document.object_member(message, role_key) - if role_node is None: - return MessageRole.UNKNOWN - value = document.text_value(role_node) - if value is None: - return MessageRole.UNKNOWN - try: - return MessageRole(value) - except ValueError: - return MessageRole.UNKNOWN - - -__all__ = [ - "JsonMessageBlockExtractor", - "JsonMessageMapConfig", - "MessageBlock", - "MessageBlockExtractor", - "MessageBlockKind", - "MessageDocument", - "MessageRole", -] diff --git a/projects/egress-gate/src/egress_gate/request_content/parsers.py b/projects/egress-gate/src/egress_gate/request_content/parsers.py deleted file mode 100644 index 9c2868ce..00000000 --- a/projects/egress-gate/src/egress_gate/request_content/parsers.py +++ /dev/null @@ -1,186 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Gate-agnostic parsers for text-bearing request content.""" - -from __future__ import annotations - -from dataclasses import dataclass -from typing import Protocol - -from egress_gate.constants import MAX_BODY_BYTES -from egress_gate.errors import GateInputError, GateLimitExceededError -from egress_gate.request_content.json import JsonDocument, JsonSelector -from egress_gate.request_content.messages import ( - MessageBlockExtractor, - MessageBlockKind, - MessageRole, -) -from egress_gate.request_content.text import TextReplacement, TextTarget -from egress_gate.timeout import Timeout - - -class ParsedRequestContent(Protocol): - """A parsed text view that can render complete replacement body bytes.""" - - @property - def targets(self) -> tuple[TextTarget, ...]: - """Return independently inspected text values in parser-defined order.""" - ... - - def replace_text( - self, - replacements: tuple[TextReplacement, ...], - *, - timeout: Timeout, - ) -> bytes: - """Render complete body bytes after replacing complete target strings.""" - ... - - -class RequestContentParser(Protocol): - """A prepared stateless parser for one text-bearing request body format.""" - - def parse( - self, - body: bytes, - *, - timeout: Timeout, - ) -> ParsedRequestContent: - """Parse current body bytes into independently inspectable text targets.""" - ... - - -class Utf8TextParser: - """Expose one complete strict UTF-8 body as a text target.""" - - def parse( - self, - body: bytes, - *, - timeout: Timeout, - ) -> ParsedRequestContent: - timeout.raise_if_expired() - if len(body) > MAX_BODY_BYTES: - raise GateLimitExceededError("UTF-8 request body exceeds the size limit") - try: - text = body.decode("utf-8", errors="strict") - except UnicodeDecodeError: - raise GateInputError("request body is not valid UTF-8") from None - return _Utf8ParsedRequestContent(targets=(TextTarget(id="body", text=text),)) - - -@dataclass(frozen=True) -class JsonFieldsParser: - """Expose selected JSON string fields as independent text targets.""" - - selectors: tuple[JsonSelector, ...] - - def parse( - self, - body: bytes, - *, - timeout: Timeout, - ) -> ParsedRequestContent: - document = JsonDocument.parse(body, timeout=timeout) - nodes = document.select_text(self.selectors, timeout=timeout) - return _JsonParsedRequestContent( - targets=tuple(TextTarget(id=node.id, text=node.text) for node in nodes), - document=document, - ) - - -class MessageBlocksParser: - """Parse a JSON body into filtered normalized message-block targets.""" - - __slots__ = ("_block_kinds", "_extractor", "_roles") - - def __init__( - self, - *, - extractor: MessageBlockExtractor, - roles: tuple[MessageRole, ...] | None = None, - block_kinds: tuple[MessageBlockKind, ...] | None = None, - ) -> None: - self._extractor = extractor - self._roles = roles - self._block_kinds = block_kinds - - def parse( - self, - body: bytes, - *, - timeout: Timeout, - ) -> ParsedRequestContent: - document = JsonDocument.parse(body, timeout=timeout) - message_document = self._extractor.extract(document, timeout=timeout) - targets: list[TextTarget] = [] - seen_nodes: set[str] = set() - for block in message_document.blocks: - if self._roles is not None and block.role not in self._roles: - continue - if self._block_kinds is not None and block.kind not in self._block_kinds: - continue - if block.node_id in seen_nodes: - continue - seen_nodes.add(block.node_id) - targets.append(TextTarget(id=block.node_id, text=block.text)) - return _JsonParsedRequestContent( - targets=tuple(targets), - document=document, - ) - - -@dataclass(frozen=True) -class _Utf8ParsedRequestContent: - targets: tuple[TextTarget, ...] - - def replace_text( - self, - replacements: tuple[TextReplacement, ...], - *, - timeout: Timeout, - ) -> bytes: - timeout.raise_if_expired() - if len(replacements) != 1 or replacements[0].target_id != self.targets[0].id: - raise ValueError("UTF-8 body replacement target is invalid") - replacement_text = replacements[0].text - if len(replacement_text) > MAX_BODY_BYTES: - raise GateLimitExceededError("UTF-8 replacement body exceeds the limit") - try: - rendered = replacement_text.encode("utf-8", errors="strict") - except UnicodeEncodeError: - raise ValueError("UTF-8 body replacement text is invalid") from None - if len(rendered) > MAX_BODY_BYTES: - raise GateLimitExceededError("UTF-8 replacement body exceeds the limit") - timeout.raise_if_expired() - return rendered - - -@dataclass(frozen=True) -class _JsonParsedRequestContent: - targets: tuple[TextTarget, ...] - document: JsonDocument - - def replace_text( - self, - replacements: tuple[TextReplacement, ...], - *, - timeout: Timeout, - ) -> bytes: - replacement_ids = tuple(item.target_id for item in replacements) - if len(replacement_ids) != len(set(replacement_ids)): - raise ValueError("request-content replacement target IDs must be unique") - selected_ids = frozenset(target.id for target in self.targets) - if any(node_id not in selected_ids for node_id in replacement_ids): - raise ValueError("request-content replacement target was not selected") - return self.document.replace_text(replacements, timeout=timeout) - - -__all__ = [ - "JsonFieldsParser", - "MessageBlocksParser", - "ParsedRequestContent", - "RequestContentParser", - "Utf8TextParser", -] diff --git a/projects/egress-gate/src/egress_gate/request_content/text.py b/projects/egress-gate/src/egress_gate/request_content/text.py deleted file mode 100644 index 184c9262..00000000 --- a/projects/egress-gate/src/egress_gate/request_content/text.py +++ /dev/null @@ -1,25 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Shared immutable values for parsed request text and its replacements.""" - -from dataclasses import dataclass - - -@dataclass(frozen=True, slots=True) -class TextTarget: - """One independently inspected text value and its content-local identity.""" - - id: str - text: str - - -@dataclass(frozen=True, slots=True) -class TextReplacement: - """The complete replacement text for one parsed target.""" - - target_id: str - text: str - - -__all__ = ["TextReplacement", "TextTarget"] diff --git a/projects/egress-gate/src/egress_gate/request_processor.py b/projects/egress-gate/src/egress_gate/request_processor.py deleted file mode 100644 index 0b94dadb..00000000 --- a/projects/egress-gate/src/egress_gate/request_processor.py +++ /dev/null @@ -1,408 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Ordered gate execution over one mutable-current immutable request value.""" - -from __future__ import annotations - -from collections.abc import Sequence -from time import monotonic - -from pydantic import ValidationError - -from egress_gate.config import DefaultDecision, EgressGateConfig -from egress_gate.constants import ( - DEFAULT_DENY_REASON_CODE, - LIMIT_REASON_CODE, - MAX_FINDING_COUNT, - MAX_PROTO_FINDING_BYTES, - MAX_PROTO_FINDING_GROUPS, -) -from egress_gate.errors import ( - BodyFormatError, - EgressGateError, - ErrorCode, - GateConfigurationError, - GateContractError, - GateError, - GateExecutionError, - GateInputError, - GateLimitExceededError, - TimeoutExpiredError, -) -from egress_gate.gates.base import Gate, GateConfig, GateResources -from egress_gate.logging import get_logger -from egress_gate.request import ( - ExistingHeaderAction, - HttpHeader, - HttpRequest, - RemoveHeaderMutation, - RequestMutations, - WriteHeaderMutation, -) -from egress_gate.result import ( - DecisionSource, - DecisionSourceKind, - EgressDecision, - EgressResult, - Finding, - GateControl, - GateDecisionSource, - GateTrace, - MutationKind, - PipelineDefaultDecisionSource, - RuntimeLimitDecisionSource, - SourcedFinding, -) -from egress_gate.string_validators import validate_scalar_string -from egress_gate.timeout import Timeout - - -class RequestProcessor: - """Run configured gates in order over the current request revision.""" - - def __init__( - self, - config: EgressGateConfig[GateConfig], - configured_gates: Sequence[ - tuple[str, str, Gate[GateConfig, GateResources | None]] - ], - *, - policy_fingerprint: str | None = None, - ) -> None: - gates = tuple(configured_gates) - configured_names = tuple(name for name, _, _ in gates) - configured_types = tuple(gate_type for _, gate_type, _ in gates) - policy_names = tuple(item.name for item in config.gates) - policy_types = tuple(getattr(item, "kind", None) for item in config.gates) - if configured_names != policy_names or configured_types != policy_types: - raise ValueError("configured gates do not match the policy") - if not gates: - raise ValueError("at least one configured gate is required") - if any(not name for name in configured_names): - raise ValueError("gate names must be non-empty") - if len(configured_names) != len(set(configured_names)): - raise ValueError("gate names must be unique") - if policy_fingerprint is not None: - try: - policy_fingerprint = validate_scalar_string(policy_fingerprint) - except ValueError: - raise ValueError("policy fingerprint must be a string") from None - self._config = config - self._gates = gates - self._policy_fingerprint = 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): - raise EgressGateError(ErrorCode.REQUEST_ENVELOPE_INVALID) - if not isinstance(timeout, Timeout): - raise EgressGateError(ErrorCode.GATE_OUTPUT_INVALID) - current_request = request - accumulated_mutations = RequestMutations() - sourced_findings: list[SourcedFinding] = [] - traces: list[GateTrace] = [] - - try: - for gate_name, gate_type, gate in self._gates: - timeout.raise_if_expired() - started = monotonic() - evaluation = gate.evaluate(current_request, timeout=timeout) - mutation_kinds = _mutation_kinds(evaluation.request_mutations) - trace_finding_count = sum( - finding.count for finding in evaluation.findings - ) - if trace_finding_count > MAX_FINDING_COUNT: - raise GateLimitExceededError( - "gate trace finding count exceeds the limit" - ) - traces.append( - GateTrace( - gate_name=gate_name, - gate_type=gate_type, - control=evaluation.control, - duration_ms=max(0.0, (monotonic() - started) * 1000), - finding_count=trace_finding_count, - mutation_kinds=mutation_kinds, - ) - ) - _append_findings( - sourced_findings, - gate_name=gate_name, - findings=evaluation.findings, - ) - if len(sourced_findings) > MAX_PROTO_FINDING_GROUPS: - raise GateLimitExceededError( - "result finding groups exceed the limit" - ) - - if evaluation.control is GateControl.DENY: - return _result( - decision=EgressDecision.DENY, - source=GateDecisionSource( - kind=DecisionSourceKind.GATE, - gate_name=gate_name, - gate_type=gate_type, - ), - findings=sourced_findings, - reason_code=evaluation.reason_code, - fingerprint=self._policy_fingerprint, - traces=traces, - ) - if evaluation.control is GateControl.ALLOW: - return _result( - decision=EgressDecision.ALLOW, - source=GateDecisionSource( - kind=DecisionSourceKind.GATE, - gate_name=gate_name, - gate_type=gate_type, - ), - request_mutations=accumulated_mutations, - findings=sourced_findings, - fingerprint=self._policy_fingerprint, - traces=traces, - ) - if not evaluation.request_mutations.is_empty: - current_request = apply_request_mutations( - current_request, - evaluation.request_mutations, - ) - accumulated_mutations = _compose_request_mutations( - accumulated_mutations, - evaluation.request_mutations, - ) - timeout.raise_if_expired() - except TimeoutExpiredError: - _LOGGER.info("egress_gate_processing_limit kind=timeout") - return _runtime_limit_result(self._policy_fingerprint) - except GateLimitExceededError: - _LOGGER.info("egress_gate_processing_limit kind=resource") - return _runtime_limit_result(self._policy_fingerprint) - except BodyFormatError: - raise EgressGateError(ErrorCode.BODY_FORMAT_INVALID) from None - except GateInputError: - raise EgressGateError(ErrorCode.BODY_ENCODING_INVALID) from None - except GateConfigurationError: - raise EgressGateError(ErrorCode.CONFIG_INVALID) from None - except GateContractError: - raise EgressGateError(ErrorCode.GATE_OUTPUT_INVALID) from None - except GateExecutionError: - raise EgressGateError(ErrorCode.GATE_EXECUTION_FAILED) from None - except EgressGateError: - raise - except (ValidationError, ValueError): - raise EgressGateError(ErrorCode.GATE_OUTPUT_INVALID) from None - except GateError: - raise EgressGateError(ErrorCode.GATE_EXECUTION_FAILED) from None - except Exception: - raise EgressGateError(ErrorCode.GATE_EXECUTION_FAILED) from None - - if self._config.default_decision is DefaultDecision.ALLOW: - result = _result( - decision=EgressDecision.ALLOW, - source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - request_mutations=accumulated_mutations, - findings=sourced_findings, - fingerprint=self._policy_fingerprint, - traces=traces, - ) - else: - result = _result( - decision=EgressDecision.DENY, - source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - findings=sourced_findings, - reason_code=DEFAULT_DENY_REASON_CODE, - fingerprint=self._policy_fingerprint, - traces=traces, - ) - return result - - -def apply_request_mutations( - request: HttpRequest, - request_mutations: RequestMutations, -) -> HttpRequest: - """Apply validated mutations to the current request in operation order.""" - if not isinstance(request, HttpRequest) or not isinstance( - request_mutations, RequestMutations - ): - raise GateContractError("request mutation input is invalid") - body = ( - request.body - if request_mutations.replacement_body is None - else request_mutations.replacement_body - ) - headers = list(request.headers) - for mutation in request_mutations.header_mutations: - if isinstance(mutation, WriteHeaderMutation): - _validate_write_mutation(mutation) - matching = _header_indexes(headers, mutation.name) - if mutation.on_existing is ExistingHeaderAction.OVERWRITE: - headers = [ - header - for index, header in enumerate(headers) - if index not in matching - ] - headers.append(HttpHeader(name=mutation.name, value=mutation.value)) - elif mutation.on_existing is ExistingHeaderAction.SKIP and matching: - continue - else: - headers.append(HttpHeader(name=mutation.name, value=mutation.value)) - elif isinstance(mutation, RemoveHeaderMutation): - _validate_remove_mutation(mutation) - matching = _header_indexes(headers, mutation.name) - headers = [ - header for index, header in enumerate(headers) if index not in matching - ] - else: - raise GateContractError("request mutation is invalid") - try: - return HttpRequest( - context=request.context, - target=request.target, - headers=tuple(headers), - body=body, - ) - except (TypeError, ValueError, ValidationError): - raise GateLimitExceededError( - "request mutation exceeds a domain limit" - ) from None - - -def _append_findings( - output: list[SourcedFinding], - *, - gate_name: str, - findings: tuple[Finding, ...], -) -> None: - for finding in findings: - for index, sourced in enumerate(output): - if sourced.source_gate != gate_name or sourced.finding.type != finding.type: - continue - if ( - sourced.finding.label != finding.label - or sourced.finding.confidence != finding.confidence - or sourced.finding.severity != finding.severity - ): - continue - total = sourced.finding.count + finding.count - if total > MAX_FINDING_COUNT: - raise GateLimitExceededError("finding count exceeds the limit") - combined_finding = sourced.finding.model_copy(update={"count": total}) - if combined_finding.encoded_size_bytes > MAX_PROTO_FINDING_BYTES: - raise GateLimitExceededError( - "aggregated finding exceeds the encoded size limit" - ) - output[index] = SourcedFinding( - source_gate=gate_name, - finding=combined_finding, - ) - break - else: - output.append(SourcedFinding(source_gate=gate_name, finding=finding)) - - -def _compose_request_mutations( - first: RequestMutations, - second: RequestMutations, -) -> RequestMutations: - try: - return RequestMutations( - replacement_body=( - second.replacement_body - if second.replacement_body is not None - else first.replacement_body - ), - header_mutations=first.header_mutations + second.header_mutations, - ) - except (TypeError, ValueError, ValidationError): - raise GateLimitExceededError( - "composed request mutations exceed a pipeline processor limit" - ) from None - - -def _mutation_kinds(request_mutations: RequestMutations) -> tuple[MutationKind, ...]: - kinds: list[MutationKind] = [] - if request_mutations.replacement_body is not None: - kinds.append(MutationKind.BODY) - if request_mutations.header_mutations: - kinds.append(MutationKind.HEADERS) - return tuple(kinds) - - -def _result( - *, - decision: EgressDecision, - source: DecisionSource, - request_mutations: RequestMutations | None = None, - findings: Sequence[SourcedFinding] = (), - reason_code: str | None = None, - fingerprint: str | None, - traces: Sequence[GateTrace] = (), -) -> EgressResult: - return EgressResult( - decision=decision, - decision_source=source, - request_mutations=( - RequestMutations() if request_mutations is None else request_mutations - ), - findings=tuple(findings), - reason_code=reason_code, - policy_fingerprint=fingerprint, - traces=tuple(traces), - ) - - -def _runtime_limit_result(fingerprint: str | None) -> EgressResult: - return _result( - decision=EgressDecision.DENY, - source=RuntimeLimitDecisionSource(kind=DecisionSourceKind.RUNTIME_LIMIT), - reason_code=LIMIT_REASON_CODE, - fingerprint=fingerprint, - ) - - -def _header_indexes(headers: Sequence[HttpHeader], name: str) -> set[int]: - lowered = name.lower() - return { - index for index, header in enumerate(headers) if header.name.lower() == lowered - } - - -def _validate_write_mutation(mutation: WriteHeaderMutation) -> None: - if not mutation.name.lower().startswith("x-openshell-middleware-"): - raise GateContractError("header writes require the middleware namespace") - - -def _validate_remove_mutation(mutation: RemoveHeaderMutation) -> None: - if mutation.name.lower() in _PROTECTED_HEADER_NAMES: - raise GateContractError("protected headers cannot be removed") - - -_PROTECTED_HEADER_NAMES = frozenset( - { - "authorization", - "connection", - "content-length", - "host", - "keep-alive", - "proxy-authenticate", - "proxy-authorization", - "proxy-connection", - "te", - "trailer", - "transfer-encoding", - "upgrade", - } -) -_LOGGER = get_logger(__name__) - - -__all__ = [ - "RequestProcessor", - "apply_request_mutations", -] diff --git a/projects/egress-gate/src/egress_gate/result.py b/projects/egress-gate/src/egress_gate/result.py deleted file mode 100644 index 1d3d09f7..00000000 --- a/projects/egress-gate/src/egress_gate/result.py +++ /dev/null @@ -1,312 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Immutable gate evaluations and Egress Gate result models. - -These models deliberately contain no protobuf or gRPC types. ``SourcedFinding`` -keeps gate provenance inside the pipeline processor; the current OpenShell wire -contract serializes only the five fields on ``Finding``. -""" - -from __future__ import annotations - -from enum import StrEnum -from typing import Annotated, Literal, Self, TypeAlias - -from pydantic import ( - Field, - model_validator, -) - -from egress_gate.base import StrictDomainModel -from egress_gate.constants import ( - DEFAULT_DENY_REASON_CODE, - LIMIT_REASON_CODE, - MAX_FINDING_COUNT, - MAX_GATE_TRACES, - MAX_PROTO_FINDING_BYTES, - MAX_PROTO_FINDING_GROUPS, - MAX_RESULT_METADATA_BYTES, - MAX_RESULT_METADATA_ENTRIES, - MAX_TRACE_MUTATION_KINDS, - REASON_CODE_PATTERN, -) -from egress_gate.request import RequestMutations -from egress_gate.string_validators import BoundedMetadataString - -ReasonCode = Annotated[str, Field(pattern=REASON_CODE_PATTERN)] -FindingType: TypeAlias = BoundedMetadataString -FindingLabel: TypeAlias = BoundedMetadataString -GateName: TypeAlias = BoundedMetadataString -GateType: TypeAlias = BoundedMetadataString - - -class GateControl(StrEnum): - """The complete control result of one gate invocation.""" - - PROCEED = "proceed" - ALLOW = "allow" - DENY = "deny" - - -class EgressDecision(StrEnum): - """The final OpenShell request disposition.""" - - ALLOW = "allow" - DENY = "deny" - - -class DecisionSourceKind(StrEnum): - """The owner of the final decision.""" - - GATE = "gate" - PIPELINE_DEFAULT = "pipeline_default" - RUNTIME_LIMIT = "runtime_limit" - - -class MutationKind(StrEnum): - """The kinds of mutation represented in a gate trace.""" - - BODY = "body" - HEADERS = "headers" - - -class Finding(StrictDomainModel): - """One audit-safe observation matching the current OpenShell wire shape.""" - - type: FindingType - label: FindingLabel - count: int = Field(default=1, ge=1, le=MAX_FINDING_COUNT) - confidence: BoundedMetadataString | None = None - severity: BoundedMetadataString | None = None - - @property - def encoded_size_bytes(self) -> int: - """Return the size of this finding in the OpenShell wire format.""" - encoded_size = ( - _encoded_string_field_size(self.type) - + _encoded_string_field_size(self.label) - + 1 - + _varint_size(self.count) - ) - if self.confidence is not None: - encoded_size += _encoded_string_field_size(self.confidence) - if self.severity is not None: - encoded_size += _encoded_string_field_size(self.severity) - return encoded_size - - @model_validator(mode="after") - def _wire_size_is_bounded(self) -> Self: - if self.encoded_size_bytes > MAX_PROTO_FINDING_BYTES: - raise ValueError("finding exceeds the encoded size limit") - return self - - -class FindingTypeDefinition(StrictDomainModel): - """A processor-owned declaration for one possible finding type.""" - - type: FindingType - - -class SourcedFinding(StrictDomainModel): - """Runtime-internal finding provenance excluded from wire serialization.""" - - source_gate: GateName - finding: Finding - - -class GateDecisionSource(StrictDomainModel): - """A final decision made by one configured gate.""" - - kind: Literal[DecisionSourceKind.GATE] - gate_name: GateName - gate_type: GateType - - -class PipelineDefaultDecisionSource(StrictDomainModel): - """A final decision made by the pipeline default.""" - - kind: Literal[DecisionSourceKind.PIPELINE_DEFAULT] - - -class RuntimeLimitDecisionSource(StrictDomainModel): - """A fail-closed decision caused by a pipeline processor safety limit.""" - - kind: Literal[DecisionSourceKind.RUNTIME_LIMIT] - - -DecisionSource: TypeAlias = Annotated[ - GateDecisionSource | PipelineDefaultDecisionSource | RuntimeLimitDecisionSource, - Field(discriminator="kind"), -] - - -class GateEvaluation(StrictDomainModel): - """Validated output of one gate invocation.""" - - control: GateControl - request_mutations: RequestMutations = Field(default_factory=RequestMutations) - findings: tuple[Finding, ...] = Field( - default=(), - max_length=MAX_PROTO_FINDING_GROUPS, - ) - reason_code: ReasonCode | None = None - - @model_validator(mode="after") - def _control_contract_is_valid(self) -> Self: - if self.control is GateControl.PROCEED: - if self.reason_code is not None: - raise ValueError("proceed evaluations cannot carry a reason code") - return self - if not self.request_mutations.is_empty: - raise ValueError("terminal evaluations cannot carry request mutations") - if self.control is GateControl.ALLOW and self.reason_code is not None: - raise ValueError("allow evaluations cannot carry a reason code") - if self.control is GateControl.DENY and self.reason_code is None: - raise ValueError("deny evaluations require a reason code") - return self - - @classmethod - def proceed( - cls, - *, - request_mutations: RequestMutations | None = None, - findings: tuple[Finding, ...] = (), - ) -> Self: - """Create a non-terminal evaluation.""" - return cls( - control=GateControl.PROCEED, - request_mutations=( - RequestMutations() if request_mutations is None else request_mutations - ), - findings=findings, - ) - - @classmethod - def allow(cls, *, findings: tuple[Finding, ...] = ()) -> Self: - """Create a terminal allow evaluation.""" - return cls(control=GateControl.ALLOW, findings=findings) - - @classmethod - def deny( - cls, - reason_code: ReasonCode, - *, - findings: tuple[Finding, ...] = (), - ) -> Self: - """Create a terminal deny evaluation.""" - return cls( - control=GateControl.DENY, - reason_code=reason_code, - findings=findings, - ) - - -class GateTrace(StrictDomainModel): - """Content-safe processor trace data for one configured gate.""" - - gate_name: GateName - gate_type: GateType - control: GateControl - duration_ms: float = Field(ge=0, allow_inf_nan=False) - finding_count: int = Field(ge=0, le=MAX_FINDING_COUNT) - mutation_kinds: tuple[MutationKind, ...] = Field( - default=(), - max_length=MAX_TRACE_MUTATION_KINDS, - ) - - -class ResultMetadata(StrictDomainModel): - """One bounded processor-owned result metadata entry.""" - - key: BoundedMetadataString - value: BoundedMetadataString - - -class EgressResult(StrictDomainModel): - """Final domain result returned after pipeline execution.""" - - decision: EgressDecision - decision_source: DecisionSource - request_mutations: RequestMutations = Field(default_factory=RequestMutations) - findings: tuple[SourcedFinding, ...] = Field( - default=(), - max_length=MAX_PROTO_FINDING_GROUPS, - ) - reason_code: ReasonCode | None = None - metadata: tuple[ResultMetadata, ...] = Field( - default=(), - max_length=MAX_RESULT_METADATA_ENTRIES, - ) - policy_fingerprint: BoundedMetadataString | None = None - traces: tuple[GateTrace, ...] = Field( - default=(), - max_length=MAX_GATE_TRACES, - ) - - @model_validator(mode="after") - def _result_contract_is_valid(self) -> Self: - metadata_bytes = sum( - len(item.key.encode("utf-8")) + len(item.value.encode("utf-8")) - for item in self.metadata - ) - if metadata_bytes > MAX_RESULT_METADATA_BYTES: - raise ValueError("result metadata exceeds the size limit") - source_kind = self.decision_source.kind - if self.decision is EgressDecision.DENY: - if not self.request_mutations.is_empty: - raise ValueError("denied results cannot carry request mutations") - if self.reason_code is None: - raise ValueError("denied results require a reason code") - elif self.reason_code is not None: - raise ValueError("allowed results cannot carry a reason code") - - if source_kind is DecisionSourceKind.RUNTIME_LIMIT: - if self.decision is not EgressDecision.DENY: - raise ValueError("runtime-limit results must deny") - if self.reason_code != LIMIT_REASON_CODE: - raise ValueError("runtime-limit results require the limit reason") - elif source_kind is DecisionSourceKind.PIPELINE_DEFAULT: - if self.decision is EgressDecision.DENY: - if self.reason_code != DEFAULT_DENY_REASON_CODE: - raise ValueError("default denies require the default reason") - elif self.reason_code is not None: - raise ValueError("default allows cannot carry a reason code") - return self - - -def _varint_size(value: int) -> int: - size = 1 - while value >= 0x80: - value >>= 7 - size += 1 - return size - - -def _encoded_string_field_size(value: str) -> int: - length = len(value.encode("utf-8")) - return 1 + _varint_size(length) + length - - -__all__ = [ - "DecisionSource", - "DecisionSourceKind", - "EgressDecision", - "EgressResult", - "Finding", - "FindingTypeDefinition", - "FindingLabel", - "FindingType", - "GateControl", - "GateDecisionSource", - "GateEvaluation", - "GateName", - "GateTrace", - "GateType", - "MutationKind", - "PipelineDefaultDecisionSource", - "ReasonCode", - "ResultMetadata", - "RuntimeLimitDecisionSource", - "SourcedFinding", -] diff --git a/projects/egress-gate/src/egress_gate/service/__init__.py b/projects/egress-gate/src/egress_gate/service/__init__.py deleted file mode 100644 index 40edea99..00000000 --- a/projects/egress-gate/src/egress_gate/service/__init__.py +++ /dev/null @@ -1,9 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""gRPC transport and servicer for the Egress Gate middleware.""" - -from egress_gate.service.server import EgressGateServer -from egress_gate.service.servicer import EgressGateMiddleware - -__all__ = ["EgressGateMiddleware", "EgressGateServer"] diff --git a/projects/egress-gate/src/egress_gate/service/server.py b/projects/egress-gate/src/egress_gate/service/server.py deleted file mode 100644 index 146838d6..00000000 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ /dev/null @@ -1,191 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Programmatic Egress Gate gRPC server lifecycle.""" - -from __future__ import annotations - -import asyncio -from collections.abc import Awaitable, Callable -from typing import Protocol, runtime_checkable - -import grpc -from google.protobuf.message import DecodeError - -from egress_gate.bindings import supervisor_middleware_pb2_grpc as pb2_grpc -from egress_gate.constants import ( - DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - MAX_CONCURRENT_RPCS, - MAX_RECEIVE_MESSAGE_BYTES, -) -from egress_gate.errors import EgressGateError, ErrorCode -from egress_gate.gates.registry import GateRegistry -from egress_gate.logging import get_logger -from egress_gate.service.servicer import EgressGateMiddleware - -DEFAULT_LISTEN_ADDRESS = "127.0.0.1:50051" - - -class EgressGateServer: - """One-shot programmatic server for an application gate registry.""" - - def __init__( - self, - registry: GateRegistry, - *, - timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - ) -> None: - self._middleware = EgressGateMiddleware( - registry, - timeout_middleware_processing=timeout_middleware_processing, - ) - - def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: - """Serve synchronously until termination.""" - try: - asyncio.run(self.serve_async(listen)) - except KeyboardInterrupt: - return - - async def serve_async(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: - """Serve asynchronously until termination, then close owned resources.""" - server = _create_grpc_server(self._middleware) - try: - try: - requested_port = _validated_listen_port(listen) - bound_port = server.add_insecure_port(listen) - if bound_port != requested_port: - raise EgressGateError(ErrorCode.SERVER_BIND_FAILED) - _LOGGER.info( - "egress_gate_server_bound listen=%r " - "timeout_middleware_processing=%s", - listen, - self._middleware.timeout_middleware_processing, - ) - await server.start() - except RuntimeError: - raise EgressGateError(ErrorCode.SERVER_BIND_FAILED) from None - await server.wait_for_termination() - finally: - try: - await _stop_grpc_server(server) - finally: - await self._middleware.close() - - -_LOGGER = get_logger(__name__) - - -def _create_grpc_server( - middleware: EgressGateMiddleware, -) -> grpc.aio.Server: - server = grpc.aio.server( - interceptors=(_MalformedProtobufInterceptor(),), - maximum_concurrent_rpcs=MAX_CONCURRENT_RPCS, - options=(("grpc.max_receive_message_length", MAX_RECEIVE_MESSAGE_BYTES),), - ) - pb2_grpc.add_SupervisorMiddlewareServicer_to_server(middleware, server) - return server - - -class _MalformedProtobufInterceptor(grpc.aio.ServerInterceptor): - """Map protobuf decoding failures to the public invalid-input contract.""" - - async def intercept_service( - self, - continuation: Callable[ - [grpc.HandlerCallDetails], Awaitable[grpc.RpcMethodHandler] - ], - handler_call_details: grpc.HandlerCallDetails, - ) -> grpc.RpcMethodHandler: - generic_handler = await continuation(handler_call_details) - if not isinstance(generic_handler, _UnaryUnaryRpcMethodHandler): - return generic_handler - handler = generic_handler - if handler.request_deserializer is None or handler.unary_unary is None: - return generic_handler - - deserialize = handler.request_deserializer - unary_unary = handler.unary_unary - - def deserialize_safely(data: bytes) -> object: - try: - return deserialize(data) - except DecodeError: - return _MALFORMED_PROTOBUF - - async def invoke_safely( - request: object, - context: grpc.aio.ServicerContext[object, object], - ) -> object: - if request is _MALFORMED_PROTOBUF: - await context.abort( - grpc.StatusCode.INVALID_ARGUMENT, - str(EgressGateError(ErrorCode.REQUEST_PROTOBUF_INVALID)), - ) - return await unary_unary(request, context) - - return grpc.unary_unary_rpc_method_handler( - invoke_safely, - request_deserializer=deserialize_safely, - response_serializer=handler.response_serializer, - ) - - -async def _stop_grpc_server(server: grpc.aio.Server) -> None: - shutdown = asyncio.create_task(server.stop(grace=0)) - try: - await asyncio.shield(shutdown) - except asyncio.CancelledError: - if not shutdown.done(): - await shutdown - raise - - -_MALFORMED_PROTOBUF = object() - - -@runtime_checkable -class _UnaryUnaryRpcMethodHandler(Protocol): - request_deserializer: Callable[[bytes], object] | None - response_serializer: Callable[[object], bytes] | None - unary_unary: ( - Callable[[object, grpc.aio.ServicerContext[object, object]], Awaitable[object]] - | None - ) - - -def _validated_listen_port(listen: str) -> int: - if not isinstance(listen, str): - raise EgressGateError(ErrorCode.SERVER_BIND_FAILED) - if listen.startswith("["): - closing_bracket = listen.rfind("]") - if ( - closing_bracket < 2 - or listen[closing_bracket + 1 : closing_bracket + 2] != ":" - ): - raise EgressGateError(ErrorCode.SERVER_BIND_FAILED) - host = listen[1:closing_bracket] - port_text = listen[closing_bracket + 2 :] - else: - host, separator, port_text = listen.rpartition(":") - if not separator or not host or ":" in host: - raise EgressGateError(ErrorCode.SERVER_BIND_FAILED) - if ( - not host - or not port_text - or len(port_text) > 5 - or not port_text.isascii() - or not port_text.isdecimal() - ): - raise EgressGateError(ErrorCode.SERVER_BIND_FAILED) - port = int(port_text) - if not 1 <= port <= 65_535: - raise EgressGateError(ErrorCode.SERVER_BIND_FAILED) - return port - - -__all__ = [ - "DEFAULT_LISTEN_ADDRESS", - "EgressGateServer", -] diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py deleted file mode 100644 index a5099a08..00000000 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ /dev/null @@ -1,587 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""gRPC boundary for the protobuf-free Egress Gate pipeline processor.""" - -from __future__ import annotations - -import asyncio -import json -import math -import time -from collections.abc import Callable, Iterable -from concurrent.futures import Future, ThreadPoolExecutor -from threading import Lock -from typing import Never, Protocol, TypedDict, TypeVar - -import grpc -from google.protobuf import json_format -from google.protobuf.message import Message - -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 -from egress_gate.constants import ( - BLOCK_REASON, - DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - LIMIT_REASON, - LIMIT_REASON_CODE, - MAX_BODY_BYTES, - MAX_CONCURRENT_PROCESSING, - MAX_PROTO_CONFIG_BYTES, - MAX_PROTO_CONTEXT_BYTES, - MAX_PROTO_FINDING_BYTES, - MAX_PROTO_FINDING_GROUPS, - MAX_PROTO_HEADERS, - MAX_PROTO_HEADERS_BYTES, - MAX_PROTO_TARGET_BYTES, - REASON_CODE_PATTERN, - SERVICE_NAME, - SERVICE_VERSION, -) -from egress_gate.errors import ( - EgressGateError, - ErrorCode, - ErrorKind, - GateConfigurationError, - GateRegistryError, - TimeoutExpiredError, -) -from egress_gate.gates.base import GateConfig -from egress_gate.gates.registry import GateRegistry -from egress_gate.logging import get_logger -from egress_gate.request import ( - HeaderMutation, - HttpHeader, - HttpRequest, - HttpTarget, - Process, - RemoveHeaderMutation, - RequestContext, - WriteHeaderMutation, -) -from egress_gate.request_processor import RequestProcessor -from egress_gate.result import ( - DecisionSourceKind, - EgressDecision, - EgressResult, - SourcedFinding, -) -from egress_gate.string_validators import validate_bounded_metadata_string -from egress_gate.timeout import ( - Timeout, - format_timeout_middleware_processing, - validate_timeout_middleware_processing, -) - - -class EgressGateMiddleware(pb2_grpc.SupervisorMiddlewareServicer): - """Validate, prepare, resolve, and run Egress Gate policies.""" - - def __init__( - self, - registry: GateRegistry, - *, - timeout_middleware_processing: float = DEFAULT_TIMEOUT_MIDDLEWARE_PROCESSING, - ) -> None: - registry.configuration_json_schema() - self._registry = registry - self._timeout_middleware_processing_seconds = ( - validate_timeout_middleware_processing(timeout_middleware_processing) - ) - self._policy = _ActivePolicy(registry) - self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING) - self._processing_executor = ThreadPoolExecutor( - max_workers=MAX_CONCURRENT_PROCESSING, - thread_name_prefix="egress-gate-processing", - ) - - @property - def timeout_middleware_processing(self) -> str: - """Return the configured middleware processing timeout.""" - return format_timeout_middleware_processing( - self._timeout_middleware_processing_seconds - ) - - async def close(self) -> None: - """Wait for in-flight synchronous gates during shutdown.""" - self._processing_executor.shutdown(wait=True, cancel_futures=True) - self._policy.clear() - - async def Describe( - self, - request: object, - context: grpc.aio.ServicerContext[object, pb2.MiddlewareManifest], - ) -> pb2.MiddlewareManifest: - """Describe the binding and its complete policy schema.""" - # The protocol does not expose the operator-configured gateway timeout - # to this service. An empty binding timeout leaves that RPC limit under - # gateway ownership instead of replacing it with the internal budget. - return pb2.MiddlewareManifest( - name=SERVICE_NAME, - service_version=SERVICE_VERSION, - bindings=[ - pb2.MiddlewareBinding( - operation=pb2.SUPERVISOR_MIDDLEWARE_OPERATION_HTTP_REQUEST, - phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, - max_body_bytes=MAX_BODY_BYTES, - ) - ], - ) - - async def ValidateConfig( - self, - request: pb2.ValidateConfigRequest, - context: grpc.aio.ServicerContext[ - pb2.ValidateConfigRequest, - pb2.ValidateConfigResponse, - ], - ) -> pb2.ValidateConfigResponse: - """Validate expanded configuration without preparing processor state.""" - return await self._run_in_worker(lambda: self._validate_config(request)) - - async def EvaluateHttpRequest( - self, - request: pb2.HttpRequestEvaluation, - context: grpc.aio.ServicerContext[ - pb2.HttpRequestEvaluation, - pb2.HttpRequestResult, - ], - ) -> pb2.HttpRequestResult: - """Resolve the prepared pipeline and evaluate one current request.""" - return await self._evaluate_rpc(request, context) - - def _validate_config( - self, - request: pb2.ValidateConfigRequest, - ) -> pb2.ValidateConfigResponse: - try: - if request.config.ByteSize() > MAX_PROTO_CONFIG_BYTES: - raise EgressGateError(ErrorCode.CONFIG_INVALID) - self._registry.validate_config(_mapping_from_proto(request.config)) - except EgressGateError as error: - return pb2.ValidateConfigResponse(valid=False, reason=str(error)) - except Exception: - error = EgressGateError(ErrorCode.UNEXPECTED_SERVICE_FAILURE) - return pb2.ValidateConfigResponse(valid=False, reason=str(error)) - return pb2.ValidateConfigResponse(valid=True) - - async def _evaluate_rpc( - self, - request: pb2.HttpRequestEvaluation, - context: _AbortContext, - ) -> pb2.HttpRequestResult: - started = time.monotonic() - request_id = _request_id_for_logging(request.context.request_id) - failure: EgressGateError | None = None - action = "error" - finding_count = 0 - source_kind = "none" - try: - timeout = Timeout.from_seconds(self._timeout_middleware_processing_seconds) - response, source_kind = await self._evaluate_http_request( - request, - timeout, - ) - action = "allow" if response.decision == pb2.DECISION_ALLOW else "deny" - finding_count = sum(finding.count for finding in response.findings) - return response - except TimeoutExpiredError: - response = _limit_deny() - action = "deny" - source_kind = DecisionSourceKind.RUNTIME_LIMIT.value - return response - except EgressGateError as error: - failure = error - except Exception: - failure = EgressGateError(ErrorCode.UNEXPECTED_SERVICE_FAILURE) - finally: - log_extra = _evaluation_log_extra( - request_id=request_id, - started=started, - action=action, - finding_count=finding_count, - source_kind=source_kind, - failure=failure, - ) - _LOGGER.info( - "egress_gate_evaluation request_id=%s duration_ms=%.3f " - "action=%s finding_count=%d decision_source_kind=%s error_code=%s", - _request_id_for_log_message(log_extra["request_id"]), - log_extra["duration_ms"], - log_extra["action"], - log_extra["finding_count"], - log_extra["decision_source_kind"], - log_extra["error_code"] or "none", - extra=log_extra, - ) - status = ( - grpc.StatusCode.INVALID_ARGUMENT - if failure.kind is ErrorKind.INVALID_INPUT - else grpc.StatusCode.INTERNAL - ) - await context.abort(status, str(failure)) - - async def _evaluate_http_request( - self, - request: pb2.HttpRequestEvaluation, - timeout: Timeout, - ) -> tuple[pb2.HttpRequestResult, str]: - if request.phase != pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS: - raise EgressGateError(ErrorCode.REQUEST_PHASE_INVALID) - if len(request.body) > MAX_BODY_BYTES: - raise EgressGateError(ErrorCode.REQUEST_BODY_TOO_LARGE) - _validate_evaluation_envelope(request) - result = await self._run_in_worker( - lambda: self._prepare_and_process( - request, - timeout, - ), - timeout=timeout, - ) - timeout.raise_if_expired() - response, source_kind = _result_to_proto(result) - timeout.raise_if_expired() - return response, source_kind - - def _prepare_and_process( - self, - request: pb2.HttpRequestEvaluation, - timeout: Timeout, - ) -> EgressResult: - domain_request = _request_from_proto(request) - values = _mapping_from_proto(request.config) - processor = self._policy.processor_for( - values, - timeout=timeout, - ) - return processor.process(domain_request, timeout=timeout) - - async def _run_in_worker( - self, - operation: Callable[[], _WorkerResultT], - *, - timeout: Timeout | None = None, - ) -> _WorkerResultT: - """Run one bounded synchronous operation without blocking the event loop.""" - try: - if timeout is None: - await self._processing_slots.acquire() - else: - await asyncio.wait_for( - self._processing_slots.acquire(), - timeout=timeout.remaining_seconds(), - ) - except TimeoutError: - raise TimeoutExpiredError from None - try: - if timeout is not None: - timeout.raise_if_expired() - worker = self._processing_executor.submit(operation) - future = asyncio.create_task(_await_worker(worker)) - except BaseException: - self._processing_slots.release() - raise - future.add_done_callback(self._worker_finished) - return await asyncio.shield(future) - - def _worker_finished(self, future: asyncio.Future[object]) -> None: - self._processing_slots.release() - if not future.cancelled(): - future.exception() - - -class _ActivePolicy: - """Own one active validated policy and its prepared immutable gates.""" - - def __init__(self, registry: GateRegistry) -> None: - self._registry = registry - self._config: EgressGateConfig[GateConfig] | None = None - self._processor: RequestProcessor | None = None - self._lock = Lock() - - def processor_for( - self, - values: object, - *, - timeout: Timeout, - ) -> RequestProcessor: - """Validate and activate a complete candidate under the shared deadline.""" - config = self._registry.validate_config(values) - timeout.raise_if_expired() - if not self._lock.acquire(timeout=timeout.remaining_seconds()): - raise TimeoutExpiredError - try: - timeout.raise_if_expired() - if config == self._config and self._processor is not None: - return self._processor - processor = self._registry.prepare_processor(config, timeout=timeout) - timeout.raise_if_expired() - - self._config = config - self._processor = processor - return processor - except (GateConfigurationError, GateRegistryError): - raise EgressGateError(ErrorCode.CONFIG_INVALID) from None - finally: - self._lock.release() - - def clear(self) -> None: - """Release the active policy.""" - with self._lock: - self._config = None - self._processor = None - - -_WorkerResultT = TypeVar("_WorkerResultT") - - -async def _await_worker(worker: Future[_WorkerResultT]) -> _WorkerResultT: - """Bridge a worker without relying on broken cross-thread loop wakeups.""" - while not worker.done(): - await asyncio.sleep(0.001) - return worker.result() - - -class _AbortContext(Protocol): - async def abort(self, code: grpc.StatusCode, details: str) -> Never: ... - - -class _EvaluationLogExtra(TypedDict): - request_id: str - duration_ms: float - action: str - finding_count: int - decision_source_kind: str - error_code: str | None - - -def _evaluation_log_extra( - *, - request_id: str, - started: float, - action: str, - finding_count: int, - source_kind: str, - failure: EgressGateError | None, -) -> _EvaluationLogExtra: - return { - "request_id": request_id, - "duration_ms": round((time.monotonic() - started) * 1000, 3), - "action": action, - "finding_count": finding_count, - "decision_source_kind": source_kind, - "error_code": failure.code.value if failure is not None else None, - } - - -def _request_id_for_logging(request_id: object) -> str: - try: - return validate_bounded_metadata_string(request_id) - except ValueError: - return _INVALID_REQUEST_ID - - -def _request_id_for_log_message(request_id: str) -> str: - return json.dumps(request_id, ensure_ascii=False).replace(" ", r"\u0020") - - -def _mapping_from_proto(config: Message) -> dict[str, object]: - try: - values: object = json_format.MessageToDict(config) - except Exception: - raise EgressGateError(ErrorCode.CONFIG_INVALID) from None - if not isinstance(values, dict) or any(not isinstance(key, str) for key in values): - raise EgressGateError(ErrorCode.CONFIG_INVALID) - return { - key: _normalize_proto_numbers(item) - for key, item in values.items() - if isinstance(key, str) - } - - -def _normalize_proto_numbers(value: object) -> object: - if isinstance(value, float): - if ( - math.isfinite(value) - and value.is_integer() - and -_MAX_PROTO_SAFE_INTEGER <= value <= _MAX_PROTO_SAFE_INTEGER - ): - return int(value) - return value - if isinstance(value, list): - return [_normalize_proto_numbers(item) for item in value] - if isinstance(value, dict): - return {key: _normalize_proto_numbers(item) for key, item in value.items()} - return value - - -def _request_from_proto(request: pb2.HttpRequestEvaluation) -> HttpRequest: - process = None - if request.context.HasField("originating_process"): - process = Process( - binary=request.context.originating_process.binary, - pid=request.context.originating_process.pid, - ancestors=tuple(request.context.originating_process.ancestors), - ) - try: - return HttpRequest( - context=RequestContext( - request_id=request.context.request_id, - sandbox_id=request.context.sandbox_id, - originating_process=process, - ), - target=HttpTarget( - scheme=request.target.scheme, - host=request.target.host, - port=request.target.port, - method=request.target.method, - path=request.target.path, - query=request.target.query, - ), - headers=tuple( - HttpHeader(name=header.name, value=header.value) - for header in request.headers - ), - body=request.body, - ) - except (TypeError, ValueError): - raise EgressGateError(ErrorCode.REQUEST_ENVELOPE_INVALID) from None - - -def _validate_evaluation_envelope(request: pb2.HttpRequestEvaluation) -> None: - if request.config.ByteSize() > MAX_PROTO_CONFIG_BYTES: - raise EgressGateError(ErrorCode.CONFIG_INVALID) - if ( - request.context.ByteSize() > MAX_PROTO_CONTEXT_BYTES - or request.target.ByteSize() > MAX_PROTO_TARGET_BYTES - or len(request.headers) > MAX_PROTO_HEADERS - or _encoded_headers_size(request.headers) > MAX_PROTO_HEADERS_BYTES - ): - raise EgressGateError(ErrorCode.REQUEST_ENVELOPE_INVALID) - - -def _encoded_headers_size(headers: Iterable[Message]) -> int: - total = 0 - for header in headers: - size = header.ByteSize() - total += 1 + _varint_size(size) + size - return total - - -def _varint_size(value: int) -> int: - size = 1 - while value >= 0x80: - value >>= 7 - size += 1 - return size - - -def _result_to_proto( - result: EgressResult, -) -> tuple[pb2.HttpRequestResult, str]: - response = _serialize_result(result) - if ( - response.reason_code == LIMIT_REASON_CODE - and result.reason_code != LIMIT_REASON_CODE - ): - return response, DecisionSourceKind.RUNTIME_LIMIT.value - return response, result.decision_source.kind.value - - -def _serialize_result(result: EgressResult) -> pb2.HttpRequestResult: - try: - response = pb2.HttpRequestResult( - decision=( - pb2.DECISION_ALLOW - if result.decision is EgressDecision.ALLOW - else pb2.DECISION_DENY - ), - reason_code=result.reason_code or "", - ) - for sourced in result.findings: - finding = _finding_to_proto(sourced) - if finding.ByteSize() > MAX_PROTO_FINDING_BYTES: - return _limit_deny() - response.findings.append(finding) - if len(response.findings) > MAX_PROTO_FINDING_GROUPS: - return _limit_deny() - if result.decision is EgressDecision.ALLOW: - if result.request_mutations.replacement_body is not None: - if len(result.request_mutations.replacement_body) > MAX_BODY_BYTES: - return _limit_deny() - response.body = result.request_mutations.replacement_body - response.has_body = True - for mutation in result.request_mutations.header_mutations: - _append_header_mutation(response, mutation) - response.metadata.update( - {entry.key: entry.value for entry in result.metadata} - ) - if ( - _encoded_headers_size(response.header_mutations) - > MAX_PROTO_HEADERS_BYTES - ): - return _limit_deny() - return response - if ( - result.reason_code is None - or REASON_CODE_PATTERN.fullmatch(result.reason_code) is None - ): - return _limit_deny() - response.reason = ( - LIMIT_REASON if result.reason_code == LIMIT_REASON_CODE else BLOCK_REASON - ) - return response - except (TypeError, ValueError): - return _limit_deny() - - -def _finding_to_proto(sourced: SourcedFinding) -> pb2.Finding: - finding = sourced.finding - return pb2.Finding( - type=finding.type, - label=finding.label, - count=finding.count, - confidence=finding.confidence or "", - severity=finding.severity or "", - ) - - -def _append_header_mutation( - response: pb2.HttpRequestResult, - mutation: HeaderMutation, -) -> None: - if isinstance(mutation, WriteHeaderMutation): - action = { - "append": pb2.EXISTING_HEADER_ACTION_APPEND, - "overwrite": pb2.EXISTING_HEADER_ACTION_OVERWRITE, - "skip": pb2.EXISTING_HEADER_ACTION_SKIP, - }[mutation.on_existing.value] - response.header_mutations.add( - write=pb2.WriteHeader( - name=mutation.name, - value=mutation.value, - on_existing=action, - ) - ) - elif isinstance(mutation, RemoveHeaderMutation): - response.header_mutations.add(remove=pb2.RemoveHeader(name=mutation.name)) - else: - raise ValueError("header mutation is invalid") - - -def _limit_deny() -> pb2.HttpRequestResult: - _LOGGER.info("egress_gate_processing_limit kind=resource") - return pb2.HttpRequestResult( - decision=pb2.DECISION_DENY, - reason=LIMIT_REASON, - reason_code=LIMIT_REASON_CODE, - ) - - -_LOGGER = get_logger(__name__) -_INVALID_REQUEST_ID = "invalid" -_MAX_PROTO_SAFE_INTEGER = (1 << 53) - 1 - - -__all__ = ["EgressGateMiddleware"] diff --git a/projects/egress-gate/src/egress_gate/string_validators.py b/projects/egress-gate/src/egress_gate/string_validators.py deleted file mode 100644 index 9464d761..00000000 --- a/projects/egress-gate/src/egress_gate/string_validators.py +++ /dev/null @@ -1,51 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Reusable string validators and the field types built from them.""" - -from typing import Annotated - -from pydantic import BeforeValidator - -from egress_gate.constants import MAX_DIAGNOSTIC_TEXT_BYTES - - -def validate_scalar_string(value: object) -> str: - """Validate and return a string containing only Unicode scalar values.""" - if not isinstance(value, str): - raise ValueError("value must be a string") - try: - value.encode("utf-8", errors="strict") - except UnicodeEncodeError: - raise ValueError("string must contain valid Unicode scalar values") - return value - - -def validate_bounded_metadata_string(value: object) -> str: - """Validate and return a non-empty, printable, bounded metadata string.""" - validated = validate_scalar_string(value) - if not validated: - raise ValueError("string must not be empty") - if not validated.isprintable(): - raise ValueError("metadata must contain only printable characters") - if ( - len(validated) > MAX_DIAGNOSTIC_TEXT_BYTES - or len(validated.encode("utf-8")) > MAX_DIAGNOSTIC_TEXT_BYTES - ): - raise ValueError("metadata exceeds the UTF-8 byte limit") - return validated - - -ScalarString = Annotated[str, BeforeValidator(validate_scalar_string)] -BoundedMetadataString = Annotated[ - str, - BeforeValidator(validate_bounded_metadata_string), -] - - -__all__ = [ - "BoundedMetadataString", - "ScalarString", - "validate_bounded_metadata_string", - "validate_scalar_string", -] diff --git a/projects/egress-gate/src/egress_gate/timeout.py b/projects/egress-gate/src/egress_gate/timeout.py deleted file mode 100644 index c56d334b..00000000 --- a/projects/egress-gate/src/egress_gate/timeout.py +++ /dev/null @@ -1,138 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""A shared monotonic timeout for one request-pipeline run.""" - -from __future__ import annotations - -import math -import re -from collections.abc import Iterator -from contextlib import contextmanager -from time import monotonic -from typing import Literal, Self - -from pydantic import Field, ValidationError - -from egress_gate.base import StrictDomainModel -from egress_gate.errors import TimeoutExpiredError - -TIMEOUT_DURATION_PATTERN = r"^(?P[1-9][0-9]{0,8})(?Pms|s)$" -_TIMEOUT_DURATION_PATTERN = re.compile(TIMEOUT_DURATION_PATTERN) - - -class _DurationValue(StrictDomainModel): - """A duration string validated with the shared OpenShell-style pattern.""" - - value: str = Field(pattern=TIMEOUT_DURATION_PATTERN) - - -def validate_timeout_middleware_processing(seconds: float) -> float: - """Return a supported internal processing timeout, in seconds.""" - if ( - isinstance(seconds, bool) - or not isinstance(seconds, int | float) - or not math.isfinite(seconds) - ): - raise ValueError( - "timeout_middleware_processing must be at least 10ms, using whole " - "milliseconds" - ) - validated_seconds = float(seconds) - milliseconds = validated_seconds * 1000 - if not math.isfinite(milliseconds): - raise ValueError( - "timeout_middleware_processing must be at least 10ms, using whole " - "milliseconds" - ) - rounded_milliseconds = round(milliseconds) - if rounded_milliseconds < 10 or not math.isclose( - milliseconds, rounded_milliseconds - ): - raise ValueError( - "timeout_middleware_processing must be at least 10ms, using whole " - "milliseconds" - ) - return validated_seconds - - -def format_timeout_middleware_processing(seconds: float) -> str: - """Format a processing timeout for the OpenShell duration contract.""" - validated_seconds = validate_timeout_middleware_processing(seconds) - rounded_milliseconds = round(validated_seconds * 1000) - if rounded_milliseconds % 1000 == 0: - return f"{rounded_milliseconds // 1000}s" - return f"{rounded_milliseconds}ms" - - -def parse_duration( - duration: str, - unit: Literal["s", "ms"] = "s", -) -> float: - """Parse a validated OpenShell-style duration into the requested unit.""" - try: - validated_duration = _DurationValue(value=duration).value - except ValidationError: - raise ValueError("timeout must be an integer duration such as 10s or 500ms") - match = _TIMEOUT_DURATION_PATTERN.fullmatch(validated_duration) - if match is None: - raise AssertionError("Pydantic accepted an unmatched timeout duration") - magnitude = int(match.group("magnitude")) - milliseconds = magnitude if match.group("unit") == "ms" else magnitude * 1000 - return milliseconds / 1000 if unit == "s" else float(milliseconds) - - -def parse_timeout_duration(duration: str) -> float: - """Parse and validate an internal middleware processing duration.""" - seconds = parse_duration(duration) - try: - format_timeout_middleware_processing(seconds) - except ValueError: - raise ValueError( - "timeout must be at least 10ms, using whole milliseconds" - ) from None - return float(seconds) - - -class Timeout(StrictDomainModel): - """A monotonic deadline shared across gate preparation and execution.""" - - deadline: float = Field(allow_inf_nan=False) - - @classmethod - def from_seconds(cls, seconds: float) -> Self: - """Create a timeout from a finite positive duration.""" - return cls( - deadline=monotonic() + validate_timeout_middleware_processing(seconds) - ) - - def remaining_seconds(self) -> float: - """Return the positive duration remaining or raise ``TimeoutExpiredError``.""" - remaining = self.deadline - monotonic() - if remaining <= 0: - raise TimeoutExpiredError - return remaining - - def raise_if_expired(self) -> None: - """Raise ``TimeoutExpiredError`` when no time remains.""" - self.remaining_seconds() - - @contextmanager - def enforce(self) -> Iterator[None]: - """Enforce this deadline around one delegated operation.""" - self.raise_if_expired() - try: - yield - except TimeoutError: - raise TimeoutExpiredError from None - self.raise_if_expired() - - -__all__ = [ - "TIMEOUT_DURATION_PATTERN", - "Timeout", - "format_timeout_middleware_processing", - "parse_duration", - "parse_timeout_duration", - "validate_timeout_middleware_processing", -] diff --git a/projects/egress-gate/tests/__init__.py b/projects/egress-gate/tests/__init__.py deleted file mode 100644 index 90e51f47..00000000 --- a/projects/egress-gate/tests/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Egress Gate test package.""" diff --git a/projects/egress-gate/tests/gates/__init__.py b/projects/egress-gate/tests/gates/__init__.py deleted file mode 100644 index f9142bc8..00000000 --- a/projects/egress-gate/tests/gates/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Gate contract and built-in gate tests.""" diff --git a/projects/egress-gate/tests/gates/test_base.py b/projects/egress-gate/tests/gates/test_base.py deleted file mode 100644 index 574323ce..00000000 --- a/projects/egress-gate/tests/gates/test_base.py +++ /dev/null @@ -1,284 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Contract tests for trusted request-level gates.""" - -from __future__ import annotations - -from concurrent.futures import ThreadPoolExecutor -from threading import Lock -from typing import Literal - -import pytest - -from egress_gate.errors import GateContractError, GateInputError -from egress_gate.gates import ( - Gate, - GateCapability, - GateConfig, - GateResources, - RegexConfig, - RegexGate, - Utf8BodyGate, -) -from egress_gate.request import ( - ExistingHeaderAction, - HttpRequest, - HttpTarget, - RequestContext, - RequestMutations, - WriteHeaderMutation, -) -from egress_gate.result import Finding, GateControl, GateEvaluation -from egress_gate.timeout import Timeout - - -class _RequestConfig(GateConfig): - kind: Literal["test-request"] - - -class _RequestGate(Gate[_RequestConfig, None]): - capabilities = frozenset({GateCapability.READ_TARGET, GateCapability.DENY}) - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - timeout.raise_if_expired() - if request.target.host == "blocked.example": - return GateEvaluation.deny("egress_gate_test_denied") - return GateEvaluation.proceed() - - -class _CounterResources(GateResources): - __slots__ = ("lock", "calls") - - def __init__(self) -> None: - self.lock = Lock() - self.calls = 0 - - -class _CounterConfig(GateConfig): - kind: Literal["test-counter"] - - -class _CounterGate(Gate[_CounterConfig, _CounterResources]): - capabilities = frozenset({GateCapability.READ_BODY}) - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request - timeout.raise_if_expired() - resources = self.resources - with resources.lock: - resources.calls += 1 - return GateEvaluation.proceed() - - -class _UndeclaredOutputGate(Gate[_RequestConfig, None]): - capabilities = frozenset() - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, timeout - return GateEvaluation.proceed( - findings=(Finding(type="undeclared", label="test"),) - ) - - -class _CapabilityBypassGate(_UndeclaredOutputGate): - def _validate_output(self, result: GateEvaluation) -> None: - del result - - -class _InvalidEvaluationGate(Gate[_RequestConfig, None]): - capabilities = frozenset({GateCapability.DENY}) - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, timeout - return GateEvaluation.proceed().model_copy(update={"control": GateControl.DENY}) - - -class _UndeclaredBodyMutationGate(Gate[_RequestConfig, None]): - capabilities = frozenset() - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, timeout - return GateEvaluation.proceed( - request_mutations=RequestMutations(replacement_body=b"changed") - ) - - -class _UndeclaredHeaderMutationGate(Gate[_RequestConfig, None]): - capabilities = frozenset() - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, timeout - return GateEvaluation.proceed( - request_mutations=RequestMutations( - header_mutations=( - WriteHeaderMutation( - kind="write", - name="x-openshell-middleware-test", - value="changed", - on_existing=ExistingHeaderAction.OVERWRITE, - ), - ) - ) - ) - - -class _InvalidUtf8ReplacementGate(Utf8BodyGate[_RequestConfig, None]): - capabilities = frozenset({GateCapability.READ_BODY, GateCapability.REPLACE_BODY}) - finding_types = () - - def _evaluate_text( - self, - text: str, - *, - timeout: Timeout, - ) -> GateEvaluation: - del text, timeout - return GateEvaluation.proceed( - request_mutations=RequestMutations(replacement_body=b"\xff") - ) - - -def _request(*, body: bytes = b"payload", host: str = "example.com") -> HttpRequest: - return HttpRequest( - context=RequestContext(request_id="request-1", sandbox_id="sandbox-1"), - target=HttpTarget( - scheme="https", - host=host, - port=443, - method="POST", - path="/v1/items", - query="", - ), - headers=(), - body=body, - ) - - -def test_gate_uses_exact_config_and_resource_types() -> None: - config = _RequestConfig(name="test", kind="test-request") - gate = _RequestGate(config, None) - - assert gate.config is config - assert gate.resources is None - assert _RequestGate.get_config_type() is _RequestConfig - assert _RequestGate.get_resources_type() is None - assert ( - gate.evaluate( - _request(host="blocked.example"), timeout=Timeout.from_seconds(1) - ).control.value - == "deny" - ) - - -def test_gate_public_wrapper_enforces_declared_output_capabilities() -> None: - with pytest.raises(GateContractError, match="undeclared finding"): - _UndeclaredOutputGate( - _RequestConfig(name="test", kind="test-request"), None - ).evaluate(_request(), timeout=Timeout.from_seconds(1)) - - with pytest.raises(GateContractError, match="undeclared body replacement"): - _UndeclaredBodyMutationGate( - _RequestConfig(name="test", kind="test-request"), None - ).evaluate(_request(), timeout=Timeout.from_seconds(1)) - - with pytest.raises(GateContractError, match="undeclared header mutations"): - _UndeclaredHeaderMutationGate( - _RequestConfig(name="test", kind="test-request"), None - ).evaluate(_request(), timeout=Timeout.from_seconds(1)) - - with pytest.raises(GateContractError, match="undeclared finding"): - _CapabilityBypassGate( - _RequestConfig(name="test", kind="test-request"), - None, - ).evaluate(_request(), timeout=Timeout.from_seconds(1)) - - -def test_utf8_body_gate_rejects_a_non_utf8_replacement() -> None: - gate = _InvalidUtf8ReplacementGate( - _RequestConfig(name="test", kind="test-request"), None - ) - - with pytest.raises(GateContractError, match="non-UTF-8 replacement"): - gate.evaluate(_request(), timeout=Timeout.from_seconds(1)) - - -def test_gate_public_wrapper_classifies_invalid_models_as_contract_errors() -> None: - with pytest.raises(GateContractError, match="gate output is invalid"): - _InvalidEvaluationGate( - _RequestConfig(name="test", kind="test-request"), - None, - ).evaluate(_request(), timeout=Timeout.from_seconds(1)) - - -def test_gate_rejects_invalid_utf8_as_gate_input() -> None: - config = RegexConfig.model_validate( - { - "name": "regex", - "kind": "regex", - "scan": {"kind": "body", "action": {"kind": "detect"}}, - "pattern_catalog": { - "entities": [ - { - "name": "token", - "rules": [{"pattern": "secret", "confidence": "high"}], - } - ] - }, - } - ) - - with pytest.raises(GateInputError, match="valid UTF-8"): - RegexGate(config, None).evaluate( - _request(body=b"\xff"), timeout=Timeout.from_seconds(1) - ) - - -def test_resource_backed_gate_is_safe_for_concurrent_evaluations() -> None: - resources = _CounterResources() - gate = _CounterGate(_CounterConfig(name="counter", kind="test-counter"), resources) - - def evaluate(_: int) -> GateEvaluation: - return gate.evaluate(_request(), timeout=Timeout.from_seconds(1)) - - with ThreadPoolExecutor(max_workers=8) as executor: - results = tuple(executor.map(evaluate, range(32))) - - assert all(result.control.value == "proceed" for result in results) - assert resources.calls == 32 diff --git a/projects/egress-gate/tests/gates/test_function_gate.py b/projects/egress-gate/tests/gates/test_function_gate.py deleted file mode 100644 index a7a37df8..00000000 --- a/projects/egress-gate/tests/gates/test_function_gate.py +++ /dev/null @@ -1,162 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for the resource-free function-gate authoring helper.""" - -from __future__ import annotations - -from typing import ClassVar, Literal, Self - -import pytest -from pydantic import ConfigDict, model_validator - -from egress_gate.errors import GateContractError, GateRegistryError -from egress_gate.gates import ( - Gate, - GateCapability, - GateConfig, - GateRegistry, -) -from egress_gate.request import HttpRequest, HttpTarget, RequestContext -from egress_gate.result import GateEvaluation -from egress_gate.timeout import Timeout - - -class _KeywordConfig(GateConfig): - kind: Literal["keyword"] - keyword: str - - -_registry = GateRegistry() - - -@_registry.gate( - config=_KeywordConfig, - capabilities=frozenset({GateCapability.READ_BODY, GateCapability.DENY}), -) -def _keyword_gate( - request: HttpRequest, - config: _KeywordConfig, - *, - timeout: Timeout, -) -> GateEvaluation: - """Deny a request that contains the configured keyword.""" - timeout.raise_if_expired() - if config.keyword.encode("utf-8") in request.body: - return GateEvaluation.deny("keyword_denied") - return GateEvaluation.proceed() - - -_invalid_registry = GateRegistry() - - -@_invalid_registry.gate(config=_KeywordConfig, capabilities=frozenset()) -def _undeclared_deny( - request: HttpRequest, - config: _KeywordConfig, - *, - timeout: Timeout, -) -> GateEvaluation: - del request, config, timeout - return GateEvaluation.deny("keyword_denied") - - -def test_gate_decorator_builds_an_ordinary_resource_free_gate_type() -> None: - assert issubclass(_keyword_gate, Gate) - assert _keyword_gate.get_config_type() is _KeywordConfig - assert _keyword_gate.get_resources_type() is None - - configured = _KeywordConfig(name="keywords", kind="keyword", keyword="SECRET") - instance = _keyword_gate(configured, None) - - assert instance.config is configured - assert ( - instance.evaluate( - _request(body=b"contains SECRET"), - timeout=Timeout.from_seconds(1), - ).control.value - == "deny" - ) - - -def test_decorated_gate_uses_the_standard_output_contract() -> None: - configured = _KeywordConfig(name="keywords", kind="keyword", keyword="SECRET") - - with pytest.raises(GateContractError, match="undeclared deny"): - _undeclared_deny(configured, None).evaluate( - _request(), timeout=Timeout.from_seconds(1) - ) - - -def test_registry_bound_decorator_registers_and_seals_on_first_use() -> None: - config = _registry.validate_config( - { - "gates": [ - { - "name": "keywords", - "kind": "keyword", - "keyword": "SECRET", - } - ], - "default_decision": "allow", - } - ) - - descriptions = _registry.describe_gates() - assert tuple(item.gate_type for item in descriptions) == ("keyword",) - assert descriptions[0].description == ( - "Deny a request that contains the configured keyword." - ) - assert _registry.create_gate(config.gates[0]).get_config_type() is _KeywordConfig - with pytest.raises(GateRegistryError, match="registry is in use"): - _registry.register(_keyword_gate) - - -def test_decorated_gate_does_not_revalidate_config_during_evaluation() -> None: - class CountingConfig(GateConfig): - model_config = ConfigDict(revalidate_instances="always") - - kind: Literal["counting"] - validation_count: ClassVar[int] = 0 - - @model_validator(mode="after") - def count_validation(self) -> Self: - type(self).validation_count += 1 - return self - - registry = GateRegistry() - - @registry.gate(config=CountingConfig, capabilities=frozenset()) - def counting_gate( - request: HttpRequest, - config: CountingConfig, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, config, timeout - return GateEvaluation.proceed() - - configured = CountingConfig(name="counting", kind="counting") - gate = counting_gate(configured, None) - validation_count = CountingConfig.validation_count - - gate.evaluate(_request(), timeout=Timeout.from_seconds(1)) - gate.evaluate(_request(), timeout=Timeout.from_seconds(1)) - - assert CountingConfig.validation_count == validation_count - - -def _request(*, body: bytes = b"ordinary") -> HttpRequest: - return HttpRequest( - context=RequestContext(request_id="request-1", sandbox_id="sandbox-1"), - target=HttpTarget( - scheme="https", - host="example.com", - port=443, - method="POST", - path="/v1/items", - query="", - ), - headers=(), - body=body, - ) diff --git a/projects/egress-gate/tests/gates/test_regex.py b/projects/egress-gate/tests/gates/test_regex.py deleted file mode 100644 index 7828109a..00000000 --- a/projects/egress-gate/tests/gates/test_regex.py +++ /dev/null @@ -1,834 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Behavior, scans, actions, safety, caching, and concurrency tests for regex.""" - -from __future__ import annotations - -from concurrent.futures import ThreadPoolExecutor -from pathlib import Path -from unittest.mock import Mock - -import pytest -from pydantic import ValidationError - -import egress_gate.gates.regex as regex_module -from egress_gate.errors import ( - GateConfigurationError, - GateLimitExceededError, - TimeoutExpiredError, -) -from egress_gate.gates import RegexConfig, RegexGate, RegexPatternCatalog -from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext -from egress_gate.result import GateControl, GateEvaluation -from egress_gate.timeout import Timeout - - -def _config( - rules: list[dict[str, object]], - *, - action_kind: str = "detect", - template: object | None = None, - scan: dict[str, object] | None = None, -) -> RegexConfig: - scan_values = {"kind": "body"} if scan is None else dict(scan) - action: dict[str, object] = {"kind": action_kind} - if template is not None: - action["template"] = template - scan_values["action"] = action - values: dict[str, object] = { - "name": "regex", - "kind": "regex", - "scan": scan_values, - "pattern_catalog": { - "entities": [{"name": "token", "rules": rules}], - }, - } - return RegexConfig.model_validate(values) - - -def _request( - body: bytes, - *, - path: str = "/", - query: str = "", - headers: tuple[HttpHeader, ...] = (), -) -> HttpRequest: - return HttpRequest( - context=RequestContext(request_id="request-1", sandbox_id="sandbox-1"), - target=HttpTarget( - scheme="https", - host="example.com", - port=443, - method="POST", - path=path, - query=query, - ), - headers=headers, - body=body, - ) - - -def _run(config: RegexConfig, text: str) -> GateEvaluation: - return RegexGate(config, None).evaluate( - _request(text.encode("utf-8")), timeout=Timeout.from_seconds(1) - ) - - -def _catalog(pattern: str) -> RegexPatternCatalog: - return RegexPatternCatalog.model_validate( - { - "entities": [ - { - "name": "token", - "rules": [{"pattern": pattern, "confidence": "high"}], - } - ] - } - ) - - -def test_detect_action_reports_overlaps_without_mutating_the_body() -> None: - evaluation = _run( - _config( - [ - {"name": "pair", "pattern": "aa", "confidence": "high"}, - {"name": "suffix", "pattern": "a$", "confidence": "medium"}, - ] - ), - "aaa", - ) - - assert evaluation.control is GateControl.PROCEED - assert evaluation.request_mutations.replacement_body is None - assert len(evaluation.findings) == 2 - assert sum(finding.count for finding in evaluation.findings) == 3 - assert {finding.label for finding in evaluation.findings} == {"token"} - - -def test_equivalent_detections_are_aggregated_before_evaluation_bounds() -> None: - evaluation = _run( - _config([{"pattern": "x", "confidence": "high"}]), - "x" * 33, - ) - - assert evaluation.findings == ( - regex_module.Finding( - type="regex_match", - label="token", - count=33, - confidence="high", - ), - ) - - -def test_deny_action_is_terminal_and_uses_the_stable_gate_reason() -> None: - evaluation = _run( - _config( - [{"pattern": "secret", "confidence": "high"}], - action_kind="deny", - ), - "contains secret", - ) - - assert evaluation.control is GateControl.DENY - assert evaluation.reason_code == "egress_gate_regex_denied" - assert evaluation.request_mutations.is_empty - assert len(evaluation.findings) == 1 - - -def test_replace_action_preserves_explicit_replacement_intent() -> None: - config = _config( - [{"pattern": "secret", "confidence": "high"}], - action_kind="replace", - template="[{entity}]", - ) - - changed = _run(config, "contains secret") - unchanged = _run(config, "no match") - - assert changed.request_mutations.replacement_body == b"contains [token]" - assert unchanged.request_mutations.replacement_body == b"no match" - assert not unchanged.request_mutations.is_empty - - -@pytest.mark.parametrize( - ("scan", "http_request"), - [ - ({"kind": "path"}, _request(b"", path="/contains-secret")), - ({"kind": "query"}, _request(b"", query="value=secret")), - ( - {"kind": "header", "names": ["x-note"]}, - _request( - b"", - headers=( - HttpHeader(name="X-Note", value="contains secret"), - HttpHeader(name="x-other", value="secret"), - ), - ), - ), - ], -) -def test_detect_action_matches_the_configured_request_scan( - scan: dict[str, object], - http_request: HttpRequest, -) -> None: - config = _config( - [{"pattern": "secret", "confidence": "high"}], - scan=scan, - ) - - evaluation = RegexGate(config, None).evaluate( - http_request, - timeout=Timeout.from_seconds(1), - ) - - assert evaluation.control is GateControl.PROCEED - assert len(evaluation.findings) == 1 - assert evaluation.request_mutations.is_empty - - -def test_header_scan_matches_each_selected_repeated_value() -> None: - config = _config( - [{"pattern": "secret", "confidence": "high"}], - scan={"kind": "header", "names": ["x-note"]}, - ) - request = _request( - b"secret in ignored body", - headers=( - HttpHeader(name="X-Note", value="first secret"), - HttpHeader(name="x-note", value="second secret"), - ), - ) - - evaluation = RegexGate(config, None).evaluate( - request, - timeout=Timeout.from_seconds(1), - ) - - assert evaluation.findings[0].count == 2 - - -def test_non_body_scan_can_make_a_terminal_deny_decision() -> None: - config = _config( - [{"pattern": "admin", "confidence": "high"}], - scan={"kind": "path"}, - action_kind="deny", - ) - - evaluation = RegexGate(config, None).evaluate( - _request(b"", path="/admin/settings"), - timeout=Timeout.from_seconds(1), - ) - - assert evaluation.control is GateControl.DENY - assert evaluation.reason_code == "egress_gate_regex_denied" - - -def test_json_fields_scan_only_matches_selected_string_nodes() -> None: - config = _config( - [{"pattern": "secret", "confidence": "high"}], - scan={ - "kind": "json-fields", - "selectors": [ - { - "segments": [ - {"kind": "key", "value": "messages"}, - {"kind": "each"}, - {"kind": "key", "value": "content"}, - ] - } - ], - }, - ) - - evaluation = RegexGate(config, None).evaluate( - _request(b'{"metadata":"secret","messages":[{"content":"secret"}]}'), - timeout=Timeout.from_seconds(1), - ) - - assert evaluation.findings[0].count == 1 - assert evaluation.request_mutations.is_empty - - -def test_json_fields_replace_preserves_unselected_json_source() -> None: - config = _config( - [{"pattern": "secret", "confidence": "high"}], - scan={ - "kind": "json-fields", - "selectors": [ - { - "segments": [ - {"kind": "key", "value": "messages"}, - {"kind": "each"}, - {"kind": "key", "value": "content"}, - ] - } - ], - }, - action_kind="replace", - template="[{entity}]", - ) - - evaluation = RegexGate(config, None).evaluate( - _request( - b'{ "messages" : [{"content":"a secret"}], "number":1.00, ' - b'"escaped":"\\u0078" }' - ), - timeout=Timeout.from_seconds(1), - ) - - assert evaluation.request_mutations.replacement_body == ( - b'{ "messages" : [{"content":"a [token]"}], "number":1.00, ' - b'"escaped":"\\u0078" }' - ) - - -def test_json_fields_replace_preserves_explicit_intent_without_matches() -> None: - body = b'{ "messages": [{"content":"safe"}], "number":1.00 }' - config = _config( - [{"pattern": "secret", "confidence": "high"}], - scan={ - "kind": "json-fields", - "selectors": [ - { - "segments": [ - {"kind": "key", "value": "messages"}, - {"kind": "each"}, - {"kind": "key", "value": "content"}, - ] - } - ], - }, - action_kind="replace", - ) - - evaluation = RegexGate(config, None).evaluate( - _request(body), - timeout=Timeout.from_seconds(1), - ) - - assert evaluation.request_mutations.replacement_body == body - - -def test_json_fields_matches_do_not_span_distinct_selected_nodes() -> None: - config = _config( - [{"pattern": "secretsecret", "confidence": "high"}], - scan={ - "kind": "json-fields", - "selectors": [ - { - "segments": [ - {"kind": "key", "value": "messages"}, - {"kind": "each"}, - ] - } - ], - }, - ) - - evaluation = RegexGate(config, None).evaluate( - _request(b'{"messages":["secret","secret"]}'), - timeout=Timeout.from_seconds(1), - ) - - assert evaluation.findings == () - - -def test_message_blocks_scan_filters_normalized_roles() -> None: - config = _config( - [{"pattern": "secret", "confidence": "high"}], - scan={ - "kind": "message-blocks", - "message_mapping": { - "kind": "json-message-map", - "messages": {"segments": [{"kind": "key", "value": "messages"}]}, - "role_key": "role", - "text_selectors": [{"segments": [{"kind": "key", "value": "content"}]}], - }, - "roles": ["user"], - }, - action_kind="replace", - ) - - evaluation = RegexGate(config, None).evaluate( - _request( - b'{"messages":[' - b'{"role":"assistant","content":"secret"},' - b'{"role":"user","content":"secret"}' - b"]}" - ), - timeout=Timeout.from_seconds(1), - ) - - assert evaluation.findings[0].count == 1 - assert evaluation.request_mutations.replacement_body == ( - b'{"messages":[' - b'{"role":"assistant","content":"secret"},' - b'{"role":"user","content":"[token]"}' - b"]}" - ) - - -def test_message_blocks_scan_filters_overlapping_explicit_tool_classification() -> None: - selector = {"segments": [{"kind": "key", "value": "content"}]} - config = _config( - [{"pattern": "secret", "confidence": "high"}], - scan={ - "kind": "message-blocks", - "message_mapping": { - "kind": "json-message-map", - "messages": {"segments": [{"kind": "key", "value": "messages"}]}, - "text_selectors": [selector], - "tool_input_selectors": [selector], - }, - "block_kinds": ["tool_input"], - }, - action_kind="deny", - ) - - evaluation = RegexGate(config, None).evaluate( - _request(b'{"messages":[{"role":"assistant","content":"secret"}]}'), - timeout=Timeout.from_seconds(1), - ) - - assert evaluation.control is GateControl.DENY - assert evaluation.findings[0].count == 1 - - -def test_message_block_filters_must_be_unique() -> None: - with pytest.raises(ValidationError, match="unique"): - _config( - [{"pattern": "secret", "confidence": "high"}], - scan={ - "kind": "message-blocks", - "message_mapping": { - "kind": "json-message-map", - "messages": {"segments": [{"kind": "key", "value": "messages"}]}, - "text_selectors": [ - {"segments": [{"kind": "key", "value": "content"}]} - ], - }, - "roles": ["user", "user"], - }, - ) - - -@pytest.mark.parametrize("kind", ["path", "query", "header"]) -def test_replace_action_is_structurally_unavailable_for_non_body_scans( - kind: str, -) -> None: - scan: dict[str, object] = {"kind": kind} - if kind == "header": - scan["names"] = ["x-note"] - - with pytest.raises(ValidationError): - _config( - [{"pattern": "secret", "confidence": "high"}], - scan=scan, - action_kind="replace", - template="[{entity}]", - ) - - -def test_scan_and_action_kinds_and_unique_header_names_are_required() -> None: - with pytest.raises(ValidationError): - _config([{"pattern": "secret", "confidence": "high"}], scan={}) - with pytest.raises(ValidationError): - _config( - [{"pattern": "secret", "confidence": "high"}], - scan={"type": "body"}, - ) - with pytest.raises(ValidationError, match="unique"): - _config( - [{"pattern": "secret", "confidence": "high"}], - scan={"kind": "header", "names": ["X-Note", "x-note"]}, - ) - - -def test_action_shape_rejects_missing_kinds_and_unrelated_template_fields() -> None: - with pytest.raises(ValidationError): - RegexConfig.model_validate( - { - "name": "regex", - "kind": "regex", - "scan": {"kind": "body", "action": {}}, - "pattern_catalog": _catalog("x"), - } - ) - - with pytest.raises(ValidationError): - _config( - [{"pattern": "x", "confidence": "high"}], - action_kind="detect", - template="[{entity}]", - ) - - -def test_retired_flat_source_and_mode_shape_is_rejected() -> None: - with pytest.raises(ValidationError): - RegexConfig.model_validate( - { - "name": "regex", - "kind": "regex", - "source": {"kind": "body"}, - "pattern_catalog": _catalog("x"), - "mode": "detect", - } - ) - - -def test_regex_features_and_backreferences_keep_their_original_semantics() -> None: - backreference = _run( - _config([{"pattern": r"(a)\1", "confidence": "high"}]), - "aa", - ) - flags = _run( - _config( - [ - { - "pattern": "^x.$", - "confidence": "high", - "ignore_case": True, - "multiline": True, - "dot_all": True, - "ascii": True, - } - ] - ), - "X\n", - ) - - assert len(backreference.findings) == 1 - assert len(flags.findings) == 1 - - -@pytest.mark.parametrize("pattern", ["", "(?i:x)"]) -def test_structurally_invalid_patterns_are_rejected_content_safely( - pattern: str, -) -> None: - with pytest.raises(ValidationError) as exception_info: - _config([{"pattern": pattern, "confidence": "high"}]) - - if pattern: - assert pattern not in str(exception_info.value) - - -@pytest.mark.parametrize("pattern", ["x*", "(?Px)"]) -def test_compile_dependent_pattern_errors_are_rejected_during_preparation( - pattern: str, -) -> None: - config = _config([{"pattern": pattern, "confidence": "high"}]) - - with pytest.raises(GateConfigurationError) as exception_info: - RegexGate(config, None, timeout=Timeout.from_seconds(1)) - - assert pattern not in str(exception_info.value) - - -@pytest.mark.parametrize( - ("pattern", "text"), - [ - ("x|(?=SECRET-zero-width-493)", "SECRET-zero-width-493"), - ("(?=secret)", "secret"), - ("(?<=prefix)", "prefix"), - (r"\b", "secret"), - ("x|(?:y|(?=secret))", "secret"), - ], -) -def test_contextual_zero_width_matches_fail_during_evaluation( - pattern: str, - text: str, -) -> None: - config = _config([{"pattern": pattern, "confidence": "high"}]) - - with pytest.raises( - GateConfigurationError, - match="regex configuration matches an empty span", - ) as exception_info: - _run(config, text) - - assert pattern not in str(exception_info.value) - - -@pytest.mark.parametrize( - ("pattern", "text"), - [ - ("(?<=prefix)secret(?=suffix)", "prefixsecretsuffix"), - (r"\bsecret\b", "a secret value"), - ( - r"(? None: - evaluation = _run( - _config([{"pattern": pattern, "confidence": "high"}]), - text, - ) - assert len(evaluation.findings) == 1 - - -def test_duplicate_rule_names_are_rejected_but_unnamed_rules_are_allowed() -> None: - with pytest.raises(ValidationError): - _config( - [ - {"name": "duplicate", "pattern": "x", "confidence": "high"}, - {"name": "duplicate", "pattern": "y", "confidence": "high"}, - ] - ) - - config = _config( - [ - {"pattern": "x", "confidence": "high"}, - {"pattern": "y", "confidence": "high"}, - ] - ) - assert len(config.pattern_catalog.entities[0].rules) == 2 - - -def test_replacement_selects_ranked_non_overlapping_winners() -> None: - evaluation = _run( - _config( - [ - {"name": "long-low", "pattern": "abc", "confidence": "low"}, - {"name": "short-high", "pattern": "bc", "confidence": "high"}, - ], - action_kind="replace", - template="<{entity}>", - ), - "abc", - ) - - assert evaluation.request_mutations.replacement_body == b"a" - assert len(evaluation.findings) == 2 - - -@pytest.mark.parametrize( - ("rules", "text", "expected"), - [ - ( - [ - {"name": "short", "pattern": "ab", "confidence": "high"}, - {"name": "long", "pattern": "abc", "confidence": "high"}, - ], - "abc", - b"", - ), - ( - [ - {"name": "earlier", "pattern": "ab", "confidence": "high"}, - {"name": "later", "pattern": "ba", "confidence": "high"}, - ], - "aba", - b"a", - ), - ], -) -def test_equal_confidence_overlap_prefers_length_then_start( - rules: list[dict[str, object]], - text: str, - expected: bytes, -) -> None: - evaluation = _run( - _config(rules, action_kind="replace", template="<{entity}>"), - text, - ) - - assert evaluation.request_mutations.replacement_body == expected - assert evaluation.findings[0].count == 2 - - -def test_identical_overlap_uses_entity_name_as_a_stable_tie_breaker() -> None: - config = RegexConfig.model_validate( - { - "name": "regex", - "kind": "regex", - "scan": { - "kind": "body", - "action": {"kind": "replace", "template": "<{entity}>"}, - }, - "pattern_catalog": { - "entities": [ - { - "name": "zeta", - "rules": [{"pattern": "abc", "confidence": "high"}], - }, - { - "name": "alpha", - "rules": [{"pattern": "abc", "confidence": "high"}], - }, - ] - }, - } - ) - - evaluation = _run(config, "abc") - - assert evaluation.request_mutations.replacement_body == b"" - assert [finding.label for finding in evaluation.findings] == ["alpha", "zeta"] - - -@pytest.mark.parametrize( - "template", - [ - "{unknown}", - "{entity.attr}", - "{entity!r}", - "{entity:>10}", - "{", - ], -) -def test_replacement_template_language_is_constrained( - template: str, -) -> None: - with pytest.raises(ValidationError): - _config( - [{"pattern": "x", "confidence": "high"}], - action_kind="replace", - template=template, - ) - - -def test_replacement_size_is_projected_before_rendering( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(regex_module, "MAX_BODY_BYTES", 4) - config = _config( - [{"pattern": "x", "confidence": "high"}], - action_kind="replace", - template="[{entity}]", - ) - - with pytest.raises(GateLimitExceededError): - _run(config, "x") - - -def test_pattern_search_has_an_enforceable_timeout() -> None: - config = _config([{"pattern": "(a+)+$", "confidence": "high"}]) - - with pytest.raises(TimeoutExpiredError): - RegexGate(config, None).evaluate( - _request((b"a" * 100_000) + b"!"), - timeout=Timeout.from_seconds(0.01), - ) - - -def test_patterns_compile_during_preparation_not_validation_or_each_run( - monkeypatch: pytest.MonkeyPatch, -) -> None: - original_compile = regex_module.regex.compile - recording_compile = Mock(wraps=original_compile) - monkeypatch.setattr(regex_module.regex, "compile", recording_compile) - config = _config([{"pattern": "x", "confidence": "high"}]) - assert recording_compile.call_count == 0 - gate = RegexGate(config, None, timeout=Timeout.from_seconds(1)) - prepared_count = recording_compile.call_count - - gate.evaluate(_request(b"x"), timeout=Timeout.from_seconds(1)) - gate.evaluate(_request(b"x"), timeout=Timeout.from_seconds(1)) - - assert prepared_count > 0 - assert recording_compile.call_count == prepared_count - - -def test_request_content_parser_is_prepared_once_and_reused( - monkeypatch: pytest.MonkeyPatch, -) -> None: - original_prepare = regex_module._prepare_content_parser - recording_prepare = Mock(wraps=original_prepare) - monkeypatch.setattr(regex_module, "_prepare_content_parser", recording_prepare) - - gate = RegexGate( - _config([{"pattern": "x", "confidence": "high"}]), - None, - timeout=Timeout.from_seconds(1), - ) - gate.evaluate(_request(b"x"), timeout=Timeout.from_seconds(1)) - gate.evaluate(_request(b"x"), timeout=Timeout.from_seconds(1)) - - recording_prepare.assert_called_once() - - -def test_gate_preparation_honors_an_expired_timeout() -> None: - config = _config([{"pattern": "x", "confidence": "high"}]) - - with pytest.raises(TimeoutExpiredError): - RegexGate(config, None, timeout=Timeout(deadline=0)) - - -def test_regex_gate_is_safe_for_concurrent_runs() -> None: - gate = RegexGate( - _config([{"pattern": "x", "confidence": "high"}]), - None, - ) - - def run(text: str) -> int: - return len( - gate.evaluate( - _request(text.encode()), timeout=Timeout.from_seconds(1) - ).findings - ) - - with ThreadPoolExecutor(max_workers=4) as executor: - counts = tuple(executor.map(run, ("x",) * 16)) - - assert counts == (1,) * 16 - - -def test_relative_yaml_catalog_loading_rejects_aliases_and_traversal( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - directory = tmp_path - monkeypatch.chdir(directory) - (directory / "patterns.yaml").write_text( - "entities:\n" - " - name: token\n" - " rules:\n" - " - pattern: secret\n" - " confidence: high\n" - ) - - config = RegexConfig.model_validate( - { - "name": "regex", - "kind": "regex", - "scan": {"kind": "body", "action": {"kind": "detect"}}, - "pattern_catalog": "patterns.yaml", - } - ) - assert len(_run(config, "secret").findings) == 1 - - with pytest.raises(ValidationError): - RegexConfig.model_validate( - { - "name": "regex", - "kind": "regex", - "scan": {"kind": "body", "action": {"kind": "detect"}}, - "pattern_catalog": "../patterns.yaml", - } - ) - (directory / "aliases.yaml").write_text( - "shared: &shared\n" - " name: token\n" - " rules:\n" - " - pattern: secret\n" - " confidence: high\n" - "entities:\n" - " - *shared\n" - ) - with pytest.raises(ValidationError): - RegexConfig.model_validate( - { - "name": "regex", - "kind": "regex", - "scan": {"kind": "body", "action": {"kind": "detect"}}, - "pattern_catalog": "aliases.yaml", - } - ) diff --git a/projects/egress-gate/tests/gates/test_registry.py b/projects/egress-gate/tests/gates/test_registry.py deleted file mode 100644 index 57dbbf91..00000000 --- a/projects/egress-gate/tests/gates/test_registry.py +++ /dev/null @@ -1,408 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Registry sealing and exact pipeline-schema tests.""" - -from __future__ import annotations - -from collections.abc import Mapping -from typing import Literal - -import pytest -from pydantic import ConfigDict, Field - -from egress_gate.constants import MAX_PIPELINE_GATES -from egress_gate.errors import EgressGateError, GateRegistryError -from egress_gate.gates import ( - Gate, - GateCapability, - GateConfig, - GateRegistry, - GateResources, - create_builtin_registry, -) -from egress_gate.request import HttpRequest -from egress_gate.request_processor import RequestProcessor -from egress_gate.result import GateEvaluation -from egress_gate.timeout import Timeout - - -class _RegistryConfig(GateConfig): - kind: Literal["registry-test"] - answer: int - - -class _RegistryGate(Gate[_RegistryConfig, None]): - """A small resource-free gate used to exercise registry assembly.""" - - capabilities = frozenset({GateCapability.READ_CONTEXT}) - finding_types = () - - def _initialize(self, *, timeout: Timeout | None = None) -> None: - self.preparation_timeout = timeout - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request - timeout.raise_if_expired() - return GateEvaluation.proceed() - - -class _ResourceConfig(GateConfig): - kind: Literal["resource-test"] - - -class _ResourceBundle(GateResources): - __slots__ = ("name",) - - def __init__(self, name: str) -> None: - self.name = name - - -class _ResourceGate(Gate[_ResourceConfig, _ResourceBundle]): - capabilities = frozenset() - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request - timeout.raise_if_expired() - return GateEvaluation.proceed() - - -def _pipeline(config: dict[str, object]) -> dict[str, object]: - return { - "gates": [{"name": "one", **config}], - "default_decision": "allow", - } - - -def test_builtin_registry_seals_on_first_use_and_contains_only_regex() -> None: - registry = create_builtin_registry() - - assert tuple(item.gate_type for item in registry.describe_gates()) == ("regex",) - schema = registry.configuration_json_schema() - assert _discriminator_names(schema) == {"kind"} - properties = _object_dict(schema.get("properties")) - assert set(properties) == {"gates", "default_decision"} - gates_schema = _object_dict(properties["gates"]) - assert gates_schema["minItems"] == 1 - assert gates_schema["maxItems"] == MAX_PIPELINE_GATES - assert "Ordered gate configurations" in str(gates_schema["description"]) - assert "Flat policy" in str(schema["description"]) - default_schema = _object_dict(properties["default_decision"]) - assert "every configured gate proceeds" in str(default_schema["description"]) - definitions = schema["$defs"] - assert isinstance(definitions, dict) - assert all(isinstance(key, str) for key in definitions) - regex_schema = next( - value for key, value in definitions.items() if key == "RegexConfig" - ) - regex_schema = _object_dict(regex_schema) - required = next(value for key, value in regex_schema.items() if key == "required") - assert isinstance(required, list) - assert "name" in required - assert "kind" in required - assert "scan" in required - regex_properties = _object_dict(regex_schema["properties"]) - name_schema = _object_dict(regex_properties["name"]) - assert "Unique diagnostic name" in str(name_schema["description"]) - body_scan_schema = next( - value for key, value in definitions.items() if key == "RegexBodyScan" - ) - header_scan_schema = next( - value for key, value in definitions.items() if key == "RegexHeaderScan" - ) - assert "RegexReplaceAction" in str(body_scan_schema) - assert "RegexReplaceAction" not in str(header_scan_schema) - json_scan_schema = next( - value for key, value in definitions.items() if key == "RegexJsonFieldsScan" - ) - message_scan_schema = next( - value for key, value in definitions.items() if key == "RegexMessageBlocksScan" - ) - assert "RegexReplaceAction" in str(json_scan_schema) - assert "RegexReplaceAction" in str(message_scan_schema) - assert "JsonSelector" in str(json_scan_schema) - assert "JsonMessageMapConfig" in str(message_scan_schema) - - with pytest.raises(EgressGateError): - registry.validate_config( - _pipeline( - { - "pattern_catalog": { - "entities": [ - { - "name": "token", - "rules": [{"pattern": "secret", "confidence": "high"}], - } - ] - }, - "scan": {"kind": "body", "action": {"kind": "detect"}}, - } - ) - ) - with pytest.raises(GateRegistryError, match="registry is in use"): - registry.register(_RegistryGate) - - -def test_registry_validates_exact_pipeline_and_gate_config() -> None: - registry = GateRegistry() - registry.register(_RegistryGate) - - config = registry.validate_config( - _pipeline({"kind": "registry-test", "answer": 42}) - ) - - assert config.default_decision.value == "allow" - assert type(config.gates[0]) is _RegistryConfig - gate = registry.create_gate(config.gates[0]) - assert type(gate) is _RegistryGate - assert gate.config.answer == 42 - - -def test_registry_requires_an_explicit_gate_discriminator() -> None: - class DefaultedConfig(GateConfig): - kind: Literal["defaulted"] = "defaulted" - - class DefaultedGate(Gate[DefaultedConfig, None]): - capabilities = frozenset() - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, timeout - return GateEvaluation.proceed() - - with pytest.raises(GateRegistryError, match="gate kind must be required"): - GateRegistry().register(DefaultedGate) - - class FactoryDefaultedConfig(GateConfig): - kind: Literal["factory-defaulted"] = Field( - default_factory=lambda: "factory-defaulted" - ) - - class FactoryDefaultedGate(Gate[FactoryDefaultedConfig, None]): - capabilities = frozenset() - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, timeout - return GateEvaluation.proceed() - - with pytest.raises(GateRegistryError, match="gate kind must be required"): - GateRegistry().register(FactoryDefaultedGate) - - -def test_registry_requires_gate_configs_to_inherit_the_common_name() -> None: - class DefaultNameConfig(GateConfig): - name: str = "implicit" - kind: Literal["default-name"] - - class DefaultNameGate(Gate[DefaultNameConfig, None]): - capabilities = frozenset() - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, timeout - return GateEvaluation.proceed() - - class IntegerNameConfig(GateConfig): - name: int - kind: Literal["integer-name"] - - class IntegerNameGate(Gate[IntegerNameConfig, None]): - capabilities = frozenset() - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, timeout - return GateEvaluation.proceed() - - class UnboundedNameConfig(GateConfig): - name: str - kind: Literal["unbounded-name"] - - class UnboundedNameGate(Gate[UnboundedNameConfig, None]): - capabilities = frozenset() - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, timeout - return GateEvaluation.proceed() - - for gate_type in (DefaultNameGate, IntegerNameGate, UnboundedNameGate): - with pytest.raises(GateRegistryError, match="inherit name"): - GateRegistry().register(gate_type) - - -def test_registry_requires_canonical_common_field_names() -> None: - class AliasedConfig(GateConfig): - model_config = ConfigDict(alias_generator=str.upper) - - kind: Literal["aliased"] - value: str - - class AliasedGate(Gate[AliasedConfig, None]): - capabilities = frozenset() - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, timeout - return GateEvaluation.proceed() - - with pytest.raises(GateRegistryError, match="canonical field names"): - GateRegistry().register(AliasedGate) - - -def test_registry_forwards_the_shared_preparation_timeout() -> None: - registry = GateRegistry() - registry.register(_RegistryGate) - config = registry.validate_config( - _pipeline({"kind": "registry-test", "answer": 42}) - ) - timeout = Timeout.from_seconds(1) - - gate = registry.create_gate(config.gates[0], timeout=timeout) - - assert isinstance(gate, _RegistryGate) - assert gate.preparation_timeout is timeout - - -def test_registry_prepares_the_production_processor_from_validated_config() -> None: - registry = GateRegistry() - registry.register(_RegistryGate) - config = registry.validate_config( - _pipeline({"kind": "registry-test", "answer": 42}) - ) - - processor = registry.prepare_processor( - config, - timeout=Timeout.from_seconds(1), - ) - - assert isinstance(processor, RequestProcessor) - - -def test_registry_injects_typed_application_resources() -> None: - resources = _ResourceBundle("shared-client") - registry = GateRegistry() - registry.register(_ResourceGate, resources=resources) - - config = registry.validate_config(_pipeline({"kind": "resource-test"})) - gate = registry.create_gate(config.gates[0]) - - assert gate.resources is resources - - with pytest.raises(GateRegistryError): - GateRegistry().register(_ResourceGate) - with pytest.raises(GateRegistryError): - GateRegistry().register(_ResourceGate, resources=object()) - - -def test_registry_rejects_unknown_policy_shapes() -> None: - registry = GateRegistry() - registry.register(_RegistryGate) - - for values in ( - {"unexpected": {}}, - _pipeline({"gate": "registry-test", "answer": 1}), - _pipeline({"kind": "missing", "answer": 1}), - _pipeline({"kind": "registry-test", "answer": 1, "extra": True}), - { - "gates": [{"name": "one", "kind": "registry-test", "answer": 1}], - }, - ): - with pytest.raises(EgressGateError): - registry.validate_config(values) - - -def test_registry_lifecycle_and_fingerprint_are_deterministic() -> None: - registry = GateRegistry() - with pytest.raises(GateRegistryError, match="no registered gates"): - registry.configuration_json_schema() - - registry.register(_RegistryGate) - first = registry.validate_config(_pipeline({"kind": "registry-test", "answer": 1})) - second = registry.validate_config(_pipeline({"kind": "registry-test", "answer": 2})) - with pytest.raises(GateRegistryError, match="registry is in use"): - registry.register(_ResourceGate) - - assert registry.policy_fingerprint(first) != registry.policy_fingerprint(second) - assert registry.policy_fingerprint(first) == registry.policy_fingerprint(first) - - -def test_registry_rejects_duplicate_gate_names_before_preparation() -> None: - registry = GateRegistry() - registry.register(_RegistryGate) - values = { - "gates": [ - {"name": "same", "kind": "registry-test", "answer": 1}, - {"name": "same", "kind": "registry-test", "answer": 2}, - ], - "default_decision": "allow", - } - - with pytest.raises(EgressGateError): - registry.validate_config(values) - - -def _discriminator_names(value: object) -> set[object]: - if isinstance(value, Mapping): - names = { - discriminator.get("propertyName") - for key, discriminator in value.items() - if key == "discriminator" and isinstance(discriminator, Mapping) - } - for nested in value.values(): - names.update(_discriminator_names(nested)) - return names - if isinstance(value, list | tuple): - names: set[object] = set() - for nested in value: - names.update(_discriminator_names(nested)) - return names - return set() - - -def _object_dict(value: object) -> dict[str, object]: - assert isinstance(value, dict) - assert all(isinstance(key, str) for key in value) - return {key: nested for key, nested in value.items() if isinstance(key, str)} diff --git a/projects/egress-gate/tests/request_content/__init__.py b/projects/egress-gate/tests/request_content/__init__.py deleted file mode 100644 index ba711b59..00000000 --- a/projects/egress-gate/tests/request_content/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Structured request-content tests.""" diff --git a/projects/egress-gate/tests/request_content/test_json.py b/projects/egress-gate/tests/request_content/test_json.py deleted file mode 100644 index 3901ffee..00000000 --- a/projects/egress-gate/tests/request_content/test_json.py +++ /dev/null @@ -1,359 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Contracts for strict JSON selection and source-preserving replacement.""" - -from __future__ import annotations - -import pytest -from pydantic import ValidationError - -import egress_gate.request_content._json_parser as json_parser_module -import egress_gate.request_content.json as json_module -from egress_gate.errors import ( - BodyFormatError, - GateInputError, - GateLimitExceededError, - TimeoutExpiredError, -) -from egress_gate.request_content import ( - JsonDocument, - JsonEachSegment, - JsonIndexSegment, - JsonKeySegment, - JsonPathSegment, - JsonSelector, - TextReplacement, -) -from egress_gate.timeout import Timeout - - -def _selector(*segments: JsonPathSegment) -> JsonSelector: - return JsonSelector(segments=segments) - - -def _parse(body: bytes) -> JsonDocument: - return JsonDocument.parse(body, timeout=Timeout.from_seconds(1)) - - -def test_select_text_uses_typed_paths_and_deduplicates_overlapping_selectors() -> None: - document = _parse( - b'{"messages":[{"content":"first"},{"content":"second"}],"ignored":"x"}' - ) - all_content = _selector( - JsonKeySegment(kind="key", value="messages"), - JsonEachSegment(kind="each"), - JsonKeySegment(kind="key", value="content"), - ) - second_content = _selector( - JsonKeySegment(kind="key", value="messages"), - JsonIndexSegment(kind="index", value=1), - JsonKeySegment(kind="key", value="content"), - ) - - nodes = document.select_text( - (all_content, second_content), - timeout=Timeout.from_seconds(1), - ) - - assert tuple(node.text for node in nodes) == ("first", "second") - assert tuple(node.path for node in nodes) == ( - ("messages", 0, "content"), - ("messages", 1, "content"), - ) - assert len({node.id for node in nodes}) == 2 - - -def test_missing_paths_and_non_string_terminals_produce_no_text_nodes() -> None: - document = _parse(b'{"message":{"count":2}}') - - nodes = document.select_text( - ( - _selector(JsonKeySegment(kind="key", value="missing")), - _selector( - JsonKeySegment(kind="key", value="message"), - JsonKeySegment(kind="key", value="count"), - ), - ), - timeout=Timeout.from_seconds(1), - ) - - assert nodes == () - - -def test_replace_text_preserves_every_unselected_source_byte() -> None: - body = ( - b'{ "messages" : [ {"content":"secret\\nvalue", "count":1.00} ], ' - b'"unchanged":"\\u0078" }' - ) - document = _parse(body) - selector = _selector( - JsonKeySegment(kind="key", value="messages"), - JsonEachSegment(kind="each"), - JsonKeySegment(kind="key", value="content"), - ) - node = document.select_text((selector,), timeout=Timeout.from_seconds(1))[0] - - replacement = document.replace_text( - (TextReplacement(target_id=node.id, text='safe\n"value"'),), - timeout=Timeout.from_seconds(1), - ) - - assert replacement == ( - b'{ "messages" : [ {"content":"safe\\n\\"value\\"", "count":1.00} ], ' - b'"unchanged":"\\u0078" }' - ) - - -def test_replace_text_rejects_duplicate_or_unknown_node_ids() -> None: - document = _parse(b'{"value":"one"}') - node = document.select_text( - (_selector(JsonKeySegment(kind="key", value="value")),), - timeout=Timeout.from_seconds(1), - )[0] - - with pytest.raises(ValueError, match="unique"): - document.replace_text( - ( - TextReplacement(target_id=node.id, text="two"), - TextReplacement(target_id=node.id, text="three"), - ), - timeout=Timeout.from_seconds(1), - ) - with pytest.raises(ValueError, match="unknown"): - document.replace_text( - (TextReplacement(target_id="unknown", text="two"),), - timeout=Timeout.from_seconds(1), - ) - - -def test_replace_text_handles_the_maximum_selected_nodes_in_linear_time() -> None: - from egress_gate.constants import MAX_JSON_SELECTED_NODES - - values = ",".join( - f'"{index}":"{"x" * 900}"' for index in range(MAX_JSON_SELECTED_NODES) - ) - document = _parse(f"{{{values}}}".encode()) - nodes = document.select_text( - (_selector(JsonEachSegment(kind="each")),), - timeout=Timeout.from_seconds(1), - ) - - replaced = document.replace_text( - tuple(TextReplacement(target_id=node.id, text="safe") for node in nodes), - timeout=Timeout.from_seconds(1), - ) - - assert replaced.count(b'"safe"') == MAX_JSON_SELECTED_NODES - - -def test_node_references_are_bound_to_the_document_that_created_them() -> None: - first = _parse(b'{"value":"one"}') - second = _parse(b'{"value":"one"}') - selector = _selector(JsonKeySegment(kind="key", value="value")) - first_node = first.select_nodes( - (selector,), - timeout=Timeout.from_seconds(1), - )[0] - - with pytest.raises(ValueError, match="does not belong"): - second.select_from( - first_node, - (selector,), - timeout=Timeout.from_seconds(1), - ) - - -def test_long_strings_and_whitespace_check_the_shared_timeout( - monkeypatch: pytest.MonkeyPatch, -) -> None: - checks = 0 - - def record_check(_timeout: Timeout) -> None: - nonlocal checks - checks += 1 - - monkeypatch.setattr(Timeout, "raise_if_expired", record_check) - - _parse((" " * 10_000 + '"' + "x" * 10_000 + '"').encode()) - - assert checks >= 7 - - -def test_array_item_materialization_checks_the_shared_timeout( - monkeypatch: pytest.MonkeyPatch, -) -> None: - document = _parse( - ('{"items":[' + ",".join("0" for _ in range(1000)) + "]}").encode() - ) - root = document.select_nodes( - (_selector(JsonKeySegment(kind="key", value="items")),), - timeout=Timeout.from_seconds(1), - )[0] - checks = 0 - - def record_check(_timeout: Timeout) -> None: - nonlocal checks - checks += 1 - - monkeypatch.setattr(Timeout, "raise_if_expired", record_check) - - document.array_items(root, timeout=Timeout.from_seconds(1)) - - assert checks >= 4 - - -def test_text_node_projection_stops_when_the_shared_timeout_expires( - monkeypatch: pytest.MonkeyPatch, -) -> None: - document = _parse( - ('{"values":[' + ",".join('"value"' for _ in range(1000)) + "]}").encode() - ) - selector = _selector( - JsonKeySegment(kind="key", value="values"), - JsonEachSegment(kind="each"), - ) - checks = 0 - - def expire_during_projection(_timeout: Timeout) -> None: - nonlocal checks - checks += 1 - if checks == 5: - raise TimeoutExpiredError - - monkeypatch.setattr(Timeout, "raise_if_expired", expire_during_projection) - - with pytest.raises(TimeoutExpiredError): - document.select_text((selector,), timeout=Timeout.from_seconds(1)) - - assert checks == 5 - - -def test_public_node_projection_stops_when_the_shared_timeout_expires( - monkeypatch: pytest.MonkeyPatch, -) -> None: - document = _parse( - ('{"values":[' + ",".join("0" for _ in range(1000)) + "]}").encode() - ) - selector = _selector( - JsonKeySegment(kind="key", value="values"), - JsonEachSegment(kind="each"), - ) - checks = 0 - - def expire_during_projection(_timeout: Timeout) -> None: - nonlocal checks - checks += 1 - if checks == 5: - raise TimeoutExpiredError - - monkeypatch.setattr(Timeout, "raise_if_expired", expire_during_projection) - - with pytest.raises(TimeoutExpiredError): - document.select_nodes((selector,), timeout=Timeout.from_seconds(1)) - - assert checks == 5 - - -@pytest.mark.parametrize( - "body", - [ - b'{"duplicate":1,"duplicate":2}', - b'{"constant":NaN}', - b'{"trailing":true,}', - b'"\\ud800"', - ], -) -def test_parse_rejects_non_strict_json(body: bytes) -> None: - with pytest.raises(BodyFormatError): - _parse(body) - - -def test_parse_distinguishes_invalid_utf8_from_invalid_json() -> None: - with pytest.raises(GateInputError): - _parse(b'"\xff"') - - -def test_parse_enforces_the_request_body_size_limit( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(json_module, "MAX_BODY_BYTES", 4) - - _parse(b"null") - with pytest.raises(GateLimitExceededError, match="size limit"): - _parse(b" null") - - -def test_selector_models_are_strict_bounded_and_discriminated() -> None: - with pytest.raises(ValidationError): - JsonSelector.model_validate({"segments": []}) - with pytest.raises(ValidationError): - JsonSelector.model_validate({"segments": [{"kind": "unknown"}]}) - with pytest.raises(ValidationError): - JsonIndexSegment.model_validate({"kind": "index", "value": -1}) - with pytest.raises(ValidationError): - JsonKeySegment.model_validate({"kind": "key", "value": 1}) - - -def test_document_depth_is_bounded() -> None: - from egress_gate.constants import MAX_JSON_DEPTH - - accepted = ("[" * MAX_JSON_DEPTH + "0" + "]" * MAX_JSON_DEPTH).encode() - body = ("[" * (MAX_JSON_DEPTH + 1) + "0" + "]" * (MAX_JSON_DEPTH + 1)).encode() - - _parse(accepted) - with pytest.raises(GateLimitExceededError): - _parse(body) - - -def test_document_node_count_accepts_the_limit_and_rejects_the_next_node( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(json_parser_module, "MAX_JSON_NODES", 3) - - _parse(b'["one","two"]') - with pytest.raises(GateLimitExceededError, match="node count"): - _parse(b'["one","two","three"]') - - -def test_selector_segment_count_accepts_the_limit_and_rejects_the_next() -> None: - from egress_gate.constants import MAX_JSON_SELECTOR_SEGMENTS - - segment = JsonEachSegment(kind="each") - - JsonSelector(segments=(segment,) * MAX_JSON_SELECTOR_SEGMENTS) - with pytest.raises(ValidationError): - JsonSelector(segments=(segment,) * (MAX_JSON_SELECTOR_SEGMENTS + 1)) - - -def test_selector_count_accepts_the_limit_and_rejects_the_next() -> None: - from egress_gate.constants import MAX_JSON_SELECTORS - - document = _parse(b'{"value":"one"}') - selector = _selector(JsonKeySegment(kind="key", value="value")) - - document.select_nodes( - (selector,) * MAX_JSON_SELECTORS, - timeout=Timeout.from_seconds(1), - ) - with pytest.raises(GateLimitExceededError, match="selector count"): - document.select_nodes( - (selector,) * (MAX_JSON_SELECTORS + 1), - timeout=Timeout.from_seconds(1), - ) - - -def test_selected_node_count_accepts_the_limit_and_rejects_the_next( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(json_module, "MAX_JSON_SELECTED_NODES", 2) - selector = _selector( - JsonKeySegment(kind="key", value="values"), - JsonEachSegment(kind="each"), - ) - - accepted = _parse(b'{"values":["one","two"]}') - assert len(accepted.select_nodes((selector,), timeout=Timeout.from_seconds(1))) == 2 - rejected = _parse(b'{"values":["one","two","three"]}') - with pytest.raises(GateLimitExceededError, match="selected node count"): - rejected.select_nodes((selector,), timeout=Timeout.from_seconds(1)) diff --git a/projects/egress-gate/tests/request_content/test_messages.py b/projects/egress-gate/tests/request_content/test_messages.py deleted file mode 100644 index 005d3198..00000000 --- a/projects/egress-gate/tests/request_content/test_messages.py +++ /dev/null @@ -1,167 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Contracts for normalized message blocks backed by JSON text nodes.""" - -from __future__ import annotations - -import pytest - -import egress_gate.request_content.messages as messages_module -from egress_gate.errors import GateLimitExceededError -from egress_gate.request_content import ( - JsonDocument, - JsonEachSegment, - JsonKeySegment, - JsonMessageBlockExtractor, - JsonMessageMapConfig, - JsonPathSegment, - JsonSelector, - MessageBlockKind, - MessageRole, -) -from egress_gate.timeout import Timeout - - -def _selector(*segments: JsonPathSegment) -> JsonSelector: - return JsonSelector(segments=segments) - - -def test_json_message_map_normalizes_roles_and_content_nodes() -> None: - document = JsonDocument.parse( - ( - b'{"request":{"messages":[' - b'{"role":"user","content":"hello"},' - b'{"role":"tool","content":[{"text":"result"}]},' - b'{"role":"future","content":"unknown"}' - b"]}}" - ), - timeout=Timeout.from_seconds(1), - ) - config = JsonMessageMapConfig( - kind="json-message-map", - messages=_selector( - JsonKeySegment(kind="key", value="request"), - JsonKeySegment(kind="key", value="messages"), - ), - role_key="role", - text_selectors=( - _selector(JsonKeySegment(kind="key", value="content")), - _selector( - JsonKeySegment(kind="key", value="content"), - JsonEachSegment(kind="each"), - JsonKeySegment(kind="key", value="text"), - ), - ), - ) - - messages = JsonMessageBlockExtractor(config).extract( - document, - timeout=Timeout.from_seconds(1), - ) - - assert tuple(block.text for block in messages.blocks) == ( - "hello", - "result", - "unknown", - ) - assert tuple(block.role for block in messages.blocks) == ( - MessageRole.USER, - MessageRole.TOOL, - MessageRole.UNKNOWN, - ) - assert tuple(block.kind for block in messages.blocks) == ( - MessageBlockKind.TEXT, - MessageBlockKind.TOOL_OUTPUT, - MessageBlockKind.TEXT, - ) - assert len({block.node_id for block in messages.blocks}) == 3 - - -def test_json_message_map_can_classify_explicit_tool_input_and_output_nodes() -> None: - document = JsonDocument.parse( - ( - b'{"messages":[' - b'{"role":"assistant","tool_call":{"arguments":"input"}},' - b'{"role":"tool","content":"output"}' - b"]}" - ), - timeout=Timeout.from_seconds(1), - ) - config = JsonMessageMapConfig( - kind="json-message-map", - messages=_selector(JsonKeySegment(kind="key", value="messages")), - tool_input_selectors=( - _selector( - JsonKeySegment(kind="key", value="tool_call"), - JsonKeySegment(kind="key", value="arguments"), - ), - ), - tool_output_selectors=(_selector(JsonKeySegment(kind="key", value="content")),), - ) - - messages = JsonMessageBlockExtractor(config).extract( - document, - timeout=Timeout.from_seconds(1), - ) - - assert tuple((block.kind, block.text) for block in messages.blocks) == ( - (MessageBlockKind.TOOL_INPUT, "input"), - (MessageBlockKind.TOOL_OUTPUT, "output"), - ) - - -def test_message_block_count_accepts_the_limit_and_rejects_the_next( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(messages_module, "MAX_MESSAGE_BLOCKS", 2) - extractor = JsonMessageBlockExtractor( - JsonMessageMapConfig( - kind="json-message-map", - messages=_selector(JsonKeySegment(kind="key", value="messages")), - text_selectors=(_selector(JsonKeySegment(kind="key", value="content")),), - ) - ) - accepted = JsonDocument.parse( - b'{"messages":[{"content":"one"},{"content":"two"}]}', - timeout=Timeout.from_seconds(1), - ) - rejected = JsonDocument.parse( - b'{"messages":[{"content":"one"},{"content":"two"},{"content":"three"}]}', - timeout=Timeout.from_seconds(1), - ) - - assert len(extractor.extract(accepted, timeout=Timeout.from_seconds(1)).blocks) == 2 - with pytest.raises(GateLimitExceededError, match="message block count"): - extractor.extract(rejected, timeout=Timeout.from_seconds(1)) - - -def test_message_block_limit_counts_unique_node_kind_classifications( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(messages_module, "MAX_MESSAGE_BLOCKS", 2) - selector = _selector(JsonKeySegment(kind="key", value="content")) - extractor = JsonMessageBlockExtractor( - JsonMessageMapConfig( - kind="json-message-map", - messages=_selector(JsonKeySegment(kind="key", value="messages")), - text_selectors=(selector,), - tool_output_selectors=(selector,), - ) - ) - document = JsonDocument.parse( - ( - b'{"messages":[' - b'{"role":"tool","content":"one"},' - b'{"role":"tool","content":"two"}' - b"]}" - ), - timeout=Timeout.from_seconds(1), - ) - - blocks = extractor.extract(document, timeout=Timeout.from_seconds(1)).blocks - - assert tuple((block.kind, block.text) for block in blocks) == ( - (MessageBlockKind.TOOL_OUTPUT, "one"), - (MessageBlockKind.TOOL_OUTPUT, "two"), - ) diff --git a/projects/egress-gate/tests/request_content/test_parsers.py b/projects/egress-gate/tests/request_content/test_parsers.py deleted file mode 100644 index 6677b470..00000000 --- a/projects/egress-gate/tests/request_content/test_parsers.py +++ /dev/null @@ -1,209 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Contracts for reusable request-content text parsers.""" - -from __future__ import annotations - -from dataclasses import FrozenInstanceError - -import pytest - -import egress_gate.request_content.parsers as parsers_module -from egress_gate.errors import GateInputError, GateLimitExceededError -from egress_gate.request_content import ( - JsonEachSegment, - JsonFieldsParser, - JsonKeySegment, - JsonMessageBlockExtractor, - JsonMessageMapConfig, - JsonSelector, - MessageBlockKind, - MessageBlocksParser, - MessageRole, - TextReplacement, - Utf8TextParser, -) -from egress_gate.timeout import Timeout - - -def _content_selector() -> JsonSelector: - return JsonSelector( - segments=( - JsonKeySegment(kind="key", value="messages"), - JsonEachSegment(kind="each"), - JsonKeySegment(kind="key", value="content"), - ) - ) - - -def test_text_replacement_is_named_and_immutable() -> None: - replacement = TextReplacement(target_id="target", text="safe") - - assert replacement.target_id == "target" - assert replacement.text == "safe" - with pytest.raises(FrozenInstanceError): - setattr(replacement, "text", "changed") - - -def test_utf8_parser_extracts_and_replaces_the_complete_body() -> None: - content = Utf8TextParser().parse( - b"secret", - timeout=Timeout.from_seconds(1), - ) - - assert tuple((target.id, target.text) for target in content.targets) == ( - ("body", "secret"), - ) - assert ( - content.replace_text( - (TextReplacement(target_id="body", text="safe"),), - timeout=Timeout.from_seconds(1), - ) - == b"safe" - ) - - -def test_utf8_parser_rejects_invalid_body_encoding() -> None: - with pytest.raises(GateInputError, match="not valid UTF-8"): - Utf8TextParser().parse( - b"\xff", - timeout=Timeout.from_seconds(1), - ) - - -def test_utf8_parser_enforces_input_and_replacement_body_limits( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setattr(parsers_module, "MAX_BODY_BYTES", 4) - parser = Utf8TextParser() - - parser.parse(b"four", timeout=Timeout.from_seconds(1)) - with pytest.raises(GateLimitExceededError, match="size limit"): - parser.parse(b"three", timeout=Timeout.from_seconds(1)) - - content = parser.parse(b"x", timeout=Timeout.from_seconds(1)) - assert ( - content.replace_text( - (TextReplacement(target_id="body", text="éé"),), - timeout=Timeout.from_seconds(1), - ) - == "éé".encode() - ) - with pytest.raises(GateLimitExceededError, match="exceeds the limit"): - content.replace_text( - (TextReplacement(target_id="body", text="ééx"),), - timeout=Timeout.from_seconds(1), - ) - - -def test_json_fields_parser_owns_source_preserving_replacement() -> None: - parser = JsonFieldsParser(selectors=(_content_selector(),)) - body = b'{ "messages":[{"content":"secret"}], "number":1.00 }' - - content = parser.parse(body, timeout=Timeout.from_seconds(1)) - - assert tuple(target.text for target in content.targets) == ("secret",) - assert ( - content.replace_text( - (TextReplacement(target_id=content.targets[0].id, text="safe"),), - timeout=Timeout.from_seconds(1), - ) - == b'{ "messages":[{"content":"safe"}], "number":1.00 }' - ) - - -def test_json_fields_parser_rejects_replacement_of_an_unselected_node() -> None: - parser = JsonFieldsParser(selectors=(_content_selector(),)) - body = b'{"messages":[{"content":"selected"}],"ignored":"secret"}' - content = parser.parse(body, timeout=Timeout.from_seconds(1)) - ignored_parser = JsonFieldsParser( - selectors=( - JsonSelector(segments=(JsonKeySegment(kind="key", value="ignored"),)), - ) - ) - ignored = ignored_parser.parse(body, timeout=Timeout.from_seconds(1)).targets[0] - - with pytest.raises(ValueError, match="was not selected"): - content.replace_text( - (TextReplacement(target_id=ignored.id, text="exposed"),), - timeout=Timeout.from_seconds(1), - ) - - -def test_message_blocks_parser_applies_mapping_and_filters() -> None: - parser = MessageBlocksParser( - extractor=JsonMessageBlockExtractor( - JsonMessageMapConfig( - kind="json-message-map", - messages=JsonSelector( - segments=(JsonKeySegment(kind="key", value="messages"),) - ), - text_selectors=( - JsonSelector( - segments=(JsonKeySegment(kind="key", value="content"),) - ), - ), - ) - ), - roles=(MessageRole.USER,), - ) - - content = parser.parse( - ( - b'{"messages":[' - b'{"role":"assistant","content":"ignored"},' - b'{"role":"user","content":"selected"}' - b"]}" - ), - timeout=Timeout.from_seconds(1), - ) - - assert tuple(target.text for target in content.targets) == ("selected",) - - -def test_message_blocks_parser_deduplicates_shared_text_nodes() -> None: - selector = JsonSelector(segments=(JsonKeySegment(kind="key", value="content"),)) - parser = MessageBlocksParser( - extractor=JsonMessageBlockExtractor( - JsonMessageMapConfig( - kind="json-message-map", - messages=JsonSelector( - segments=(JsonKeySegment(kind="key", value="messages"),) - ), - text_selectors=(selector,), - tool_output_selectors=(selector,), - ) - ) - ) - - content = parser.parse( - b'{"messages":[{"role":"tool","content":"once"}]}', - timeout=Timeout.from_seconds(1), - ) - - assert tuple(target.text for target in content.targets) == ("once",) - - -def test_message_blocks_parser_filters_before_deduplicating_classifications() -> None: - selector = JsonSelector(segments=(JsonKeySegment(kind="key", value="content"),)) - parser = MessageBlocksParser( - extractor=JsonMessageBlockExtractor( - JsonMessageMapConfig( - kind="json-message-map", - messages=JsonSelector( - segments=(JsonKeySegment(kind="key", value="messages"),) - ), - text_selectors=(selector,), - tool_input_selectors=(selector,), - ) - ), - block_kinds=(MessageBlockKind.TOOL_INPUT,), - ) - - content = parser.parse( - b'{"messages":[{"role":"assistant","content":"selected"}]}', - timeout=Timeout.from_seconds(1), - ) - - assert tuple(target.text for target in content.targets) == ("selected",) diff --git a/projects/egress-gate/tests/service/__init__.py b/projects/egress-gate/tests/service/__init__.py deleted file mode 100644 index 9db2b256..00000000 --- a/projects/egress-gate/tests/service/__init__.py +++ /dev/null @@ -1,4 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Egress Gate service tests.""" diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py deleted file mode 100644 index 1ecbec0e..00000000 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ /dev/null @@ -1,227 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Loopback coverage for the generated OpenShell gRPC service.""" - -from __future__ import annotations - -from collections.abc import AsyncIterator -from contextlib import asynccontextmanager - -import grpc -import pytest -from google.protobuf import empty_pb2, json_format, message_factory -from google.protobuf.message import Message - -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 -from egress_gate.gates import create_builtin_registry -from egress_gate.service import server as server_module -from egress_gate.service.servicer import EgressGateMiddleware - - -def _config(*, action_kind: str = "replace") -> Message: - action: dict[str, object] = {"kind": action_kind} - if action_kind == "replace": - action["template"] = "[{entity}]" - values: dict[str, object] = { - "gates": [ - { - "name": "identifiers", - "kind": "regex", - "scan": {"kind": "body", "action": action}, - "pattern_catalog": { - "entities": [ - { - "name": "email", - "rules": [ - { - "pattern": r"[a-z]+@[a-z]+\.[a-z]+", - "confidence": "high", - } - ], - } - ] - }, - } - ], - "default_decision": "allow", - } - request = pb2.ValidateConfigRequest() - json_format.ParseDict(values, request.config) - return request.config - - -def _evaluation( - body: bytes, - *, - action_kind: str = "replace", -) -> pb2.HttpRequestEvaluation: - return pb2.HttpRequestEvaluation( - phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, - context=pb2.RequestContext(request_id="grpc-integration", sandbox_id="sandbox"), - config=_config(action_kind=action_kind), - target=pb2.HttpRequestTarget( - scheme="https", - host="example.com", - port=443, - method="POST", - path="/", - query="", - ), - body=body, - ) - - -def _progressive_redaction_config() -> Message: - values = { - "gates": [ - { - "name": name, - "kind": "regex", - "scan": { - "kind": "body", - "action": {"kind": "replace", "template": "[{entity}]"}, - }, - "pattern_catalog": { - "entities": [ - { - "name": entity, - "rules": [{"pattern": pattern, "confidence": "high"}], - } - ] - }, - } - for name, entity, pattern in ( - ("redact-email", "email", r"alice@example\.com"), - ("redact-api-key", "api_key", r"sk-[0-9]+"), - ("redact-phone", "phone", r"555-[0-9]{4}"), - ) - ], - "default_decision": "allow", - } - request = pb2.ValidateConfigRequest() - json_format.ParseDict(values, request.config) - return request.config - - -@asynccontextmanager -async def _running_stub( - middleware: EgressGateMiddleware, -) -> AsyncIterator[tuple[pb2_grpc.SupervisorMiddlewareStub, grpc.aio.Channel]]: - server = server_module._create_grpc_server(middleware) - port = server.add_insecure_port("127.0.0.1:0") - assert port > 0 - await server.start() - channel = grpc.aio.insecure_channel(f"127.0.0.1:{port}") - try: - yield pb2_grpc.SupervisorMiddlewareStub(channel), channel - finally: - await channel.close() - await server.stop(grace=0) - await middleware.close() - - -@pytest.mark.asyncio -async def test_generated_stub_round_trip_covers_manifest_and_gate_actions() -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - async with _running_stub(middleware) as (stub, _): - empty_message_type = message_factory.GetMessageClass( - empty_pb2.DESCRIPTOR.message_types_by_name["Empty"] - ) - empty_message: Message = empty_message_type() - manifest = await stub.Describe(empty_message) - replaced = await stub.EvaluateHttpRequest(_evaluation(b"contact a@b.com")) - detected = await stub.EvaluateHttpRequest( - _evaluation(b"contact a@b.com", action_kind="detect") - ) - denied_config = _config(action_kind="deny") - denied_request = _evaluation(b"contact a@b.com", action_kind="deny") - denied_request.config.CopyFrom(denied_config) - denied = await stub.EvaluateHttpRequest(denied_request) - - assert manifest.name == "egress-gate" - assert len(manifest.bindings) == 1 - assert replaced.decision == pb2.DECISION_ALLOW - assert replaced.has_body is True - assert replaced.body == b"contact [email]" - assert detected.decision == pb2.DECISION_ALLOW - assert detected.has_body is False - assert len(detected.findings) == 1 - assert denied.decision == pb2.DECISION_DENY - assert denied.reason_code == "egress_gate_regex_denied" - - -@pytest.mark.asyncio -async def test_generated_stub_returns_three_gate_progressive_redaction() -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - request = _evaluation(b"email=alice@example.com api_key=sk-123456 phone=555-0100") - request.config.CopyFrom(_progressive_redaction_config()) - - async with _running_stub(middleware) as (stub, _): - response = await stub.EvaluateHttpRequest(request) - - assert response.decision == pb2.DECISION_ALLOW - assert response.has_body is True - assert response.body == b"email=[email] api_key=[api_key] phone=[phone]" - assert [finding.label for finding in response.findings] == [ - "email", - "api_key", - "phone", - ] - - -@pytest.mark.asyncio -async def test_generated_stub_maps_invalid_phase_to_invalid_argument() -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - async with _running_stub(middleware) as (stub, _): - request = _evaluation(b"body") - request.phase = pb2.SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED - - with pytest.raises(grpc.aio.AioRpcError) as error: - await stub.EvaluateHttpRequest(request) - - assert error.value.code() is grpc.StatusCode.INVALID_ARGUMENT - assert "request_phase_invalid" in (error.value.details() or "") - - -@pytest.mark.asyncio -async def test_malformed_protobuf_maps_to_content_safe_invalid_argument() -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - async with _running_stub(middleware) as (stub, channel): - raw_evaluate = channel.unary_unary( - "/openshell.middleware.v1.SupervisorMiddleware/EvaluateHttpRequest", - request_serializer=lambda value: value, - response_deserializer=lambda value: value, - ) - with pytest.raises(grpc.aio.AioRpcError) as error: - await raw_evaluate(b"\x12\x02\x0a\xff") - - recovered = await stub.EvaluateHttpRequest(_evaluation(b"body")) - - details = error.value.details() or "" - assert error.value.code() is grpc.StatusCode.INVALID_ARGUMENT - assert details == str(EgressGateError(ErrorCode.REQUEST_PROTOBUF_INVALID)) - assert "DecodeError" not in details - assert "HttpRequestEvaluation" not in details - assert recovered.decision == pb2.DECISION_ALLOW - - -@pytest.mark.asyncio -async def test_generated_stub_maps_gate_failure_to_internal( - monkeypatch: pytest.MonkeyPatch, -) -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - - def fail_processing(*args: object, **kwargs: object) -> object: - del args, kwargs - raise EgressGateError(ErrorCode.GATE_EXECUTION_FAILED) - - monkeypatch.setattr(middleware, "_prepare_and_process", fail_processing) - async with _running_stub(middleware) as (stub, _): - with pytest.raises(grpc.aio.AioRpcError) as error: - await stub.EvaluateHttpRequest(_evaluation(b"body")) - - assert error.value.code() is grpc.StatusCode.INTERNAL - assert "gate_execution_failed" in (error.value.details() or "") diff --git a/projects/egress-gate/tests/service/test_server.py b/projects/egress-gate/tests/service/test_server.py deleted file mode 100644 index 9578f4bd..00000000 --- a/projects/egress-gate/tests/service/test_server.py +++ /dev/null @@ -1,207 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Programmatic Egress Gate server lifecycle and transport-isolation tests.""" - -from __future__ import annotations - -import asyncio -import subprocess -import sys - -import grpc -import pytest - -from egress_gate.constants import MAX_CONCURRENT_RPCS, MAX_RECEIVE_MESSAGE_BYTES -from egress_gate.errors import EgressGateError, ErrorCode, GateRegistryError -from egress_gate.gates import GateRegistry, create_builtin_registry -from egress_gate.service import server as server_module -from egress_gate.service.server import EgressGateServer -from egress_gate.service.servicer import EgressGateMiddleware - - -class _FakeServer: - def __init__(self, *, bound_port: int = 50051) -> None: - self.bound_port = bound_port - self.addresses: list[str] = [] - self.started = False - self.waited = False - self.stop_graces: list[float | None] = [] - - def add_insecure_port(self, address: str) -> int: - self.addresses.append(address) - return self.bound_port - - async def start(self) -> None: - self.started = True - - async def wait_for_termination(self) -> bool: - self.waited = True - return True - - async def stop(self, grace: float | None) -> None: - self.stop_graces.append(grace) - - -def test_server_rejects_a_registry_without_gates() -> None: - with pytest.raises(GateRegistryError, match="no registered gates"): - EgressGateServer(GateRegistry()) - - -@pytest.mark.parametrize( - "seconds", - [True, 0, 0.001, 1.0001, float("inf")], -) -def test_server_validates_timeout_middleware_processing( - seconds: bool | int | float, -) -> None: - with pytest.raises(ValueError, match="timeout"): - EgressGateServer( - create_builtin_registry(), - timeout_middleware_processing=seconds, - ) - - -def test_server_uses_timeout_middleware_processing_for_pipeline_and_logs() -> None: - server = EgressGateServer( - create_builtin_registry(), - timeout_middleware_processing=4.5, - ) - try: - assert server._middleware._timeout_middleware_processing_seconds == 4.5 - assert server._middleware.timeout_middleware_processing == "4500ms" - assert not hasattr(server._middleware, "_timeout_middleware_processing") - assert not hasattr( - server._middleware._policy, - "_timeout_middleware_processing_seconds", - ) - finally: - asyncio.run(server._middleware.close()) - - -def test_server_seals_the_registry_during_initialization() -> None: - registry = create_builtin_registry() - server = EgressGateServer(registry) - try: - with pytest.raises(GateRegistryError, match="registry is in use"): - registry.register(object) - finally: - asyncio.run(server._middleware.close()) - - -def test_server_sets_transport_limits_and_registers_middleware( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_server = object() - transport_options: list[tuple[int, tuple[tuple[str, int], ...]]] = [] - registrations: list[tuple[EgressGateMiddleware, object]] = [] - - def fake_factory( - *, - interceptors: tuple[grpc.aio.ServerInterceptor, ...], - maximum_concurrent_rpcs: int, - options: tuple[tuple[str, int], ...], - ) -> object: - assert len(interceptors) == 1 - assert isinstance(interceptors[0], server_module._MalformedProtobufInterceptor) - transport_options.append((maximum_concurrent_rpcs, options)) - return fake_server - - def record_registration(middleware: EgressGateMiddleware, server: object) -> None: - registrations.append((middleware, server)) - - middleware = EgressGateMiddleware(create_builtin_registry()) - monkeypatch.setattr(grpc.aio, "server", fake_factory) - monkeypatch.setattr( - server_module.pb2_grpc, - "add_SupervisorMiddlewareServicer_to_server", - record_registration, - ) - try: - result = server_module._create_grpc_server(middleware) - finally: - asyncio.run(middleware.close()) - - assert result is fake_server - assert transport_options == [ - ( - MAX_CONCURRENT_RPCS, - (("grpc.max_receive_message_length", MAX_RECEIVE_MESSAGE_BYTES),), - ) - ] - assert registrations == [(middleware, fake_server)] - - -@pytest.mark.parametrize( - ("listen", "port"), - [("127.0.0.1:1", 1), ("middleware.local:65535", 65_535), ("[::1]:50051", 50_051)], -) -def test_listen_address_accepts_supported_tcp_forms(listen: str, port: int) -> None: - assert server_module._validated_listen_port(listen) == port - - -@pytest.mark.parametrize( - "listen", - ["127.0.0.1:0", "127.0.0.1:65536", "127.0.0.1:-1", "[::1]", "::1:50051"], -) -def test_listen_address_rejects_invalid_forms(listen: str) -> None: - with pytest.raises(EgressGateError) as error: - server_module._validated_listen_port(listen) - assert error.value.code is ErrorCode.SERVER_BIND_FAILED - - -@pytest.mark.asyncio -async def test_serve_async_starts_waits_and_closes_resources( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_server = _FakeServer(bound_port=50053) - closed: list[EgressGateMiddleware] = [] - - async def record_close(middleware: EgressGateMiddleware) -> None: - closed.append(middleware) - - server = EgressGateServer(create_builtin_registry()) - monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) - monkeypatch.setattr(EgressGateMiddleware, "close", record_close) - - await server.serve_async("127.0.0.1:50053") - - assert fake_server.addresses == ["127.0.0.1:50053"] - assert fake_server.started is True - assert fake_server.waited is True - assert fake_server.stop_graces == [0] - assert closed == [server._middleware] - - -@pytest.mark.asyncio -async def test_serve_async_sanitizes_bind_failures_and_closes_resources( - monkeypatch: pytest.MonkeyPatch, -) -> None: - fake_server = _FakeServer(bound_port=0) - closed: list[EgressGateMiddleware] = [] - - async def record_close(middleware: EgressGateMiddleware) -> None: - closed.append(middleware) - - server = EgressGateServer(create_builtin_registry()) - monkeypatch.setattr(server_module, "_create_grpc_server", lambda _: fake_server) - monkeypatch.setattr(EgressGateMiddleware, "close", record_close) - - with pytest.raises(EgressGateError) as error: - await server.serve_async("invalid-sensitive-listen:50051") - - assert error.value.code is ErrorCode.SERVER_BIND_FAILED - assert "invalid-sensitive-listen" not in str(error.value) - assert fake_server.stop_graces == [0] - assert closed == [server._middleware] - - -def test_programmatic_server_import_does_not_load_cli_or_gate_transport() -> None: - probe = ( - "import sys; " - "from egress_gate.service import EgressGateServer; " - "assert EgressGateServer.__name__ == 'EgressGateServer'; " - "assert 'egress_gate.cli' not in sys.modules; " - "assert 'typer' not in sys.modules" - ) - subprocess.run([sys.executable, "-c", probe], check=True) diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py deleted file mode 100644 index 22cfe86d..00000000 --- a/projects/egress-gate/tests/service/test_servicer.py +++ /dev/null @@ -1,716 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Transport-boundary tests for the canonical OpenShell protobuf adapter.""" - -from __future__ import annotations - -import asyncio -import logging -from concurrent.futures import ThreadPoolExecutor -from threading import Barrier, Event -from typing import Never -from unittest.mock import Mock - -import grpc -import pytest -from google.protobuf import json_format -from google.protobuf.message import Message - -from egress_gate.bindings import supervisor_middleware_pb2 as pb2 -from egress_gate.config import EgressGateConfig -from egress_gate.constants import ( - BLOCK_REASON, - DEFAULT_DENY_REASON_CODE, - LIMIT_REASON, - LIMIT_REASON_CODE, - MAX_BODY_BYTES, - MAX_PROTO_CONFIG_BYTES, - MAX_PROTO_CONTEXT_BYTES, - MAX_PROTO_FINDING_BYTES, - MAX_PROTO_HEADERS, - MAX_PROTO_HEADERS_BYTES, - MAX_PROTO_TARGET_BYTES, -) -from egress_gate.errors import EgressGateError, ErrorCode, GateRegistryError -from egress_gate.gates import ( - GateConfig, - RegexConfig, - RegexReplaceAction, - create_builtin_registry, -) -from egress_gate.request import ( - ExistingHeaderAction, - RequestMutations, - WriteHeaderMutation, -) -from egress_gate.result import ( - DecisionSourceKind, - EgressDecision, - EgressResult, - Finding, - PipelineDefaultDecisionSource, - RuntimeLimitDecisionSource, - SourcedFinding, -) -from egress_gate.service import servicer as servicer_module -from egress_gate.service.servicer import EgressGateMiddleware -from egress_gate.timeout import Timeout - - -def _values( - *, - action_kind: str = "detect", - default_decision: str = "allow", - scan: dict[str, object] | None = None, -) -> dict[str, object]: - action: dict[str, object] = {"kind": action_kind} - if action_kind == "replace": - action["template"] = "[{entity}]" - scan_values = {"kind": "body"} if scan is None else dict(scan) - scan_values["action"] = action - config: dict[str, object] = { - "kind": "regex", - "scan": scan_values, - "pattern_catalog": { - "entities": [ - { - "name": "token", - "rules": [{"pattern": "secret", "confidence": "high"}], - } - ] - }, - } - return { - "gates": [{"name": "body", **config}], - "default_decision": default_decision, - } - - -def _proto_config(values: dict[str, object]) -> Message: - config = pb2.ValidateConfigRequest().config - json_format.ParseDict(values, config) - return config - - -def _request(body: bytes = b"secret") -> pb2.HttpRequestEvaluation: - return pb2.HttpRequestEvaluation( - phase=pb2.SUPERVISOR_MIDDLEWARE_PHASE_PRE_CREDENTIALS, - context=pb2.RequestContext(request_id="request-1", sandbox_id="sandbox-1"), - config=_proto_config(_values()), - target=pb2.HttpRequestTarget( - scheme="https", - host="example.com", - port=443, - method="POST", - path="/", - query="", - ), - headers=[pb2.HttpHeader(name="x-test", value="one")], - body=body, - ) - - -class _SuccessfulEvaluationContext: - async def abort(self, code: grpc.StatusCode, details: str) -> Never: - del code, details - raise AssertionError("successful evaluation unexpectedly aborted") - - -def test_manifest_leaves_the_gateway_rpc_timeout_to_the_operator() -> None: - middleware = EgressGateMiddleware( - create_builtin_registry(), - timeout_middleware_processing=4.5, - ) - try: - manifest = asyncio.run(middleware.Describe(object(), Mock())) - finally: - asyncio.run(middleware.close()) - - assert manifest.bindings[0].timeout == "" - - -def test_copied_proto_remains_the_current_five_field_finding_contract() -> None: - evaluation = pb2.HttpRequestEvaluation() - finding = pb2.Finding() - - assert isinstance(evaluation.config, Message) - assert not hasattr(evaluation, "config_fingerprint") - assert not hasattr(finding, "source") - assert not hasattr(finding, "attributes") - - -def test_validate_config_is_pure_and_reports_invalid_config() -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - try: - valid = middleware._validate_config( - pb2.ValidateConfigRequest(config=_proto_config(_values())) - ) - invalid = middleware._validate_config( - pb2.ValidateConfigRequest(config=_proto_config({"unexpected": {}})) - ) - finally: - asyncio.run(middleware.close()) - - assert valid.valid is True - assert invalid.valid is False - assert "config_invalid" in invalid.reason - - -def test_validate_config_rejects_oversized_wire_config_before_parsing() -> None: - exact = pb2.ValidateConfigRequest() - json_format.ParseDict({"padding": "x" * 65_515}, exact.config) - oversized = pb2.ValidateConfigRequest() - json_format.ParseDict({"padding": "x" * 65_516}, oversized.config) - assert exact.config.ByteSize() == MAX_PROTO_CONFIG_BYTES - assert oversized.config.ByteSize() == MAX_PROTO_CONFIG_BYTES + 1 - - middleware = EgressGateMiddleware(create_builtin_registry()) - try: - exact_response = middleware._validate_config(exact) - oversized_response = middleware._validate_config(oversized) - finally: - asyncio.run(middleware.close()) - - assert exact_response.valid is False - assert oversized_response.valid is False - assert "config_invalid" in oversized_response.reason - - -def test_evaluation_enforces_exact_encoded_transport_boundaries() -> None: - request = _request(body=b"") - request.context.sandbox_id = "" - request.context.request_id = "x" * 4_093 - assert request.context.ByteSize() == MAX_PROTO_CONTEXT_BYTES - servicer_module._validate_evaluation_envelope(request) - request.context.request_id += "x" - assert request.context.ByteSize() == MAX_PROTO_CONTEXT_BYTES + 1 - with pytest.raises(EgressGateError) as context_error: - servicer_module._validate_evaluation_envelope(request) - assert context_error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID - - request = _request(body=b"") - request.target.Clear() - request.target.host = "x" * 32_764 - assert request.target.ByteSize() == MAX_PROTO_TARGET_BYTES - servicer_module._validate_evaluation_envelope(request) - request.target.host += "x" - with pytest.raises(EgressGateError) as target_error: - servicer_module._validate_evaluation_envelope(request) - assert target_error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID - - request = _request(body=b"") - request.headers.clear() - request.headers.add(name="x", value="x" * 65_525) - assert servicer_module._encoded_headers_size(request.headers) == ( - MAX_PROTO_HEADERS_BYTES - ) - servicer_module._validate_evaluation_envelope(request) - request.headers[0].value += "x" - with pytest.raises(EgressGateError) as header_error: - servicer_module._validate_evaluation_envelope(request) - assert header_error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID - - request = _request(body=b"") - request.headers.clear() - for _ in range(MAX_PROTO_HEADERS): - request.headers.add() - servicer_module._validate_evaluation_envelope(request) - request.headers.add() - with pytest.raises(EgressGateError): - servicer_module._validate_evaluation_envelope(request) - - -def test_request_adapter_builds_the_full_domain_request() -> None: - domain = servicer_module._request_from_proto(_request(b"bytes")) - - assert domain.body == b"bytes" - assert domain.context.request_id == "request-1" - assert domain.target.host == "example.com" - assert domain.headers[0].name == "x-test" - - -def test_result_adapter_serializes_only_five_finding_fields_and_empty_body_intent() -> ( - None -): - finding = Finding( - type="t" * 1024, - label="l" * 1024, - confidence="c" * 1024, - severity="s" * 1010, - ) - result = EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - request_mutations=RequestMutations(replacement_body=b""), - findings=(SourcedFinding(source_gate="body", finding=finding),), - ) - - response, _ = servicer_module._result_to_proto(result) - - assert response.decision == pb2.DECISION_ALLOW - assert response.has_body is True - assert response.body == b"" - assert response.findings[0].ByteSize() == MAX_PROTO_FINDING_BYTES - assert {field.name for field, _ in response.findings[0].ListFields()} == { - "type", - "label", - "count", - "confidence", - "severity", - } - - -def test_result_adapter_preserves_ordered_header_mutations_and_deny_reason() -> None: - allowed = EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - request_mutations=RequestMutations( - header_mutations=( - WriteHeaderMutation( - kind="write", - name="x-openshell-middleware-reviewed", - value="true", - on_existing=ExistingHeaderAction.OVERWRITE, - ), - ) - ), - ) - denied = EgressResult( - decision=EgressDecision.DENY, - decision_source=RuntimeLimitDecisionSource( - kind=DecisionSourceKind.RUNTIME_LIMIT - ), - reason_code=LIMIT_REASON_CODE, - ) - - allowed_response, _ = servicer_module._result_to_proto(allowed) - denied_response, _ = servicer_module._result_to_proto(denied) - - assert allowed_response.header_mutations[0].write.name == ( - "x-openshell-middleware-reviewed" - ) - assert denied_response.reason == LIMIT_REASON - assert denied_response.reason_code == LIMIT_REASON_CODE - - -def test_processor_preparation_reuses_only_the_current_validated_policy() -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - try: - first = middleware._policy.processor_for( - _values(), timeout=Timeout.from_seconds(1) - ) - same = middleware._policy.processor_for( - _values(), timeout=Timeout.from_seconds(1) - ) - changed = middleware._policy.processor_for( - _values(action_kind="replace"), timeout=Timeout.from_seconds(1) - ) - finally: - asyncio.run(middleware.close()) - - assert same is first - assert changed is not first - - -def test_concurrent_same_candidate_is_prepared_once( - monkeypatch: pytest.MonkeyPatch, -) -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - original_create_gate = middleware._registry.create_gate - workers_ready = Barrier(2) - build_started = Event() - release_build = Event() - - def blocked_create_gate( - config: GateConfig, - *, - timeout: Timeout | None = None, - ) -> object: - build_started.set() - assert release_build.wait(2) - return original_create_gate(config, timeout=timeout) - - create_gate = Mock(side_effect=blocked_create_gate) - monkeypatch.setattr(middleware._registry, "create_gate", create_gate) - - def resolve_candidate() -> object: - workers_ready.wait(timeout=2) - return middleware._policy.processor_for( - _values(), - timeout=Timeout.from_seconds(2), - ) - - try: - with ThreadPoolExecutor(max_workers=2) as executor: - first_future = executor.submit(resolve_candidate) - second_future = executor.submit(resolve_candidate) - assert build_started.wait(1) - assert not first_future.done() - assert not second_future.done() - release_build.set() - first = first_future.result() - second = second_future.result() - finally: - release_build.set() - asyncio.run(middleware.close()) - - assert second is first - assert create_gate.call_count == 1 - - -def test_failed_candidate_leaves_the_old_policy_active( - monkeypatch: pytest.MonkeyPatch, -) -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - old = middleware._policy.processor_for(_values(), timeout=Timeout.from_seconds(1)) - original_create_gate = middleware._registry.create_gate - - def fail_changed_candidate( - config: GateConfig, - *, - timeout: Timeout | None = None, - ) -> object: - if isinstance(config, RegexConfig) and isinstance( - config.scan.action, RegexReplaceAction - ): - raise GateRegistryError("candidate preparation failed") - return original_create_gate(config, timeout=timeout) - - monkeypatch.setattr( - middleware._registry, - "create_gate", - fail_changed_candidate, - ) - try: - with pytest.raises(EgressGateError) as error: - middleware._policy.processor_for( - _values(action_kind="replace"), - timeout=Timeout.from_seconds(1), - ) - active = middleware._policy.processor_for( - _values(), timeout=Timeout.from_seconds(1) - ) - finally: - asyncio.run(middleware.close()) - - assert error.value.code is ErrorCode.CONFIG_INVALID - assert active is old - - -def test_invalid_request_cannot_publish_a_changed_policy() -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - old = middleware._policy.processor_for(_values(), timeout=Timeout.from_seconds(1)) - invalid = _request() - invalid.config.CopyFrom(_proto_config(_values(action_kind="replace"))) - invalid.headers[0].name = "" - - try: - with pytest.raises(EgressGateError) as error: - middleware._prepare_and_process(invalid, Timeout.from_seconds(1)) - assert middleware._policy._processor is old - finally: - asyncio.run(middleware.close()) - - assert error.value.code is ErrorCode.REQUEST_ENVELOPE_INVALID - - -def test_in_flight_processor_reference_survives_policy_replacement() -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - try: - old = middleware._policy.processor_for( - _values(), timeout=Timeout.from_seconds(1) - ) - replacement = middleware._policy.processor_for( - _values(action_kind="replace"), timeout=Timeout.from_seconds(1) - ) - domain_request = servicer_module._request_from_proto(_request()) - - old_result = old.process(domain_request, timeout=Timeout.from_seconds(1)) - replacement_result = replacement.process( - domain_request, - timeout=Timeout.from_seconds(1), - ) - finally: - asyncio.run(middleware.close()) - - assert old_result.request_mutations.replacement_body is None - assert replacement_result.request_mutations.replacement_body == b"[token]" - - -@pytest.mark.asyncio -async def test_cancelled_candidate_keeps_its_slot_until_worker_exits( - monkeypatch: pytest.MonkeyPatch, -) -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - started = Event() - release = Event() - original_build = middleware._registry.prepare_processor - - def blocked_build( - config: EgressGateConfig[GateConfig], - *, - timeout: Timeout, - ) -> object: - started.set() - assert release.wait(2) - return original_build(config, timeout=timeout) - - monkeypatch.setattr(middleware._registry, "prepare_processor", blocked_build) - changed_request = _request() - changed_request.config.CopyFrom(_proto_config(_values(action_kind="replace"))) - task = asyncio.create_task( - middleware._evaluate_http_request( - changed_request, - Timeout.from_seconds(5), - ) - ) - try: - assert await asyncio.to_thread(started.wait, 1) - task.cancel() - with pytest.raises(asyncio.CancelledError): - await task - assert middleware._processing_slots._value == 3 - - release.set() - for _ in range(100): - if middleware._processing_slots._value == 4: - break - await asyncio.sleep(0.01) - - assert middleware._processing_slots._value == 4 - finally: - release.set() - await middleware.close() - - -@pytest.mark.asyncio -async def test_result_serialization_is_bracketed_by_the_shared_timeout( - monkeypatch: pytest.MonkeyPatch, -) -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - result = EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - ) - events: list[str] = [] - original_serialize = servicer_module._result_to_proto - - async def return_result(*args: object, **kwargs: object) -> EgressResult: - del args, kwargs - return result - - def record_deadline_check(self: Timeout) -> None: - del self - events.append("deadline") - - def record_serialization( - value: EgressResult, - ) -> tuple[pb2.HttpRequestResult, str]: - events.append("serialize") - return original_serialize(value) - - monkeypatch.setattr(middleware, "_run_in_worker", return_result) - monkeypatch.setattr(Timeout, "raise_if_expired", record_deadline_check) - monkeypatch.setattr( - servicer_module, - "_result_to_proto", - record_serialization, - ) - try: - await middleware._evaluate_http_request( - _request(), - Timeout.from_seconds(1), - ) - finally: - await middleware.close() - - assert events == ["deadline", "serialize", "deadline"] - - -@pytest.mark.asyncio -@pytest.mark.parametrize( - ("action_kind", "expected_source"), - (("detect", "pipeline_default"), ("deny", "gate")), -) -async def test_evaluation_log_records_decision_source( - action_kind: str, - expected_source: str, - caplog: pytest.LogCaptureFixture, -) -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - request = _request() - request.config.CopyFrom(_proto_config(_values(action_kind=action_kind))) - try: - with caplog.at_level(logging.INFO, logger=servicer_module.__name__): - await middleware._evaluate_rpc(request, _SuccessfulEvaluationContext()) - finally: - await middleware.close() - - record = next( - item - for item in caplog.records - if item.message.startswith("egress_gate_evaluation") - ) - assert getattr(record, "decision_source_kind", None) == expected_source - - -@pytest.mark.asyncio -async def test_evaluation_log_records_runtime_limit_source( - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - - async def return_limit( - *args: object, - **kwargs: object, - ) -> tuple[pb2.HttpRequestResult, str]: - del args, kwargs - return servicer_module._limit_deny(), DecisionSourceKind.RUNTIME_LIMIT.value - - monkeypatch.setattr( - middleware, - "_evaluate_http_request", - return_limit, - ) - try: - with caplog.at_level(logging.INFO, logger=servicer_module.__name__): - await middleware._evaluate_rpc(_request(), _SuccessfulEvaluationContext()) - finally: - await middleware.close() - - record = next( - item - for item in caplog.records - if item.message.startswith("egress_gate_evaluation") - ) - assert getattr(record, "decision_source_kind", None) == "runtime_limit" - - -def test_serialized_limit_result_reports_runtime_limit_source() -> None: - result = EgressResult( - decision=EgressDecision.DENY, - decision_source=RuntimeLimitDecisionSource( - kind=DecisionSourceKind.RUNTIME_LIMIT - ), - reason_code=LIMIT_REASON_CODE, - ) - - response, source_kind = servicer_module._result_to_proto(result) - - assert response.reason_code == LIMIT_REASON_CODE - assert source_kind == DecisionSourceKind.RUNTIME_LIMIT.value - - -def test_invalid_utf8_is_an_input_failure_before_wire_evaluation() -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - request = _request(body=b"\xff") - try: - with pytest.raises(EgressGateError) as error: - asyncio.run( - middleware._evaluate_http_request( - request, - Timeout.from_seconds(1), - ) - ) - finally: - asyncio.run(middleware.close()) - - assert error.value.code is ErrorCode.BODY_ENCODING_INVALID - - -def test_structured_replacement_is_serialized_as_a_complete_body_mutation() -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - request = _request(body=b'{ "messages":[{"content":"secret"}], "n":1.00 }') - request.config.CopyFrom( - _proto_config( - _values( - action_kind="replace", - scan={ - "kind": "json-fields", - "selectors": [ - { - "segments": [ - {"kind": "key", "value": "messages"}, - {"kind": "each"}, - {"kind": "key", "value": "content"}, - ] - } - ], - }, - ) - ) - ) - try: - response, _ = asyncio.run( - middleware._evaluate_http_request(request, Timeout.from_seconds(1)) - ) - finally: - asyncio.run(middleware.close()) - - assert response.has_body is True - assert response.body == b'{ "messages":[{"content":"[token]"}], "n":1.00 }' - - -def test_invalid_json_is_an_input_failure_for_a_structured_scan() -> None: - middleware = EgressGateMiddleware(create_builtin_registry()) - request = _request(body=b"{") - request.config.CopyFrom( - _proto_config( - _values( - scan={ - "kind": "json-fields", - "selectors": [{"segments": [{"kind": "key", "value": "messages"}]}], - } - ) - ) - ) - try: - with pytest.raises(EgressGateError) as error: - asyncio.run( - middleware._evaluate_http_request( - request, - Timeout.from_seconds(1), - ) - ) - finally: - asyncio.run(middleware.close()) - - assert error.value.code is ErrorCode.BODY_FORMAT_INVALID - - -def test_service_request_body_limit_is_checked_before_worker_execution() -> None: - request = _request(body=b"x" * (MAX_BODY_BYTES + 1)) - middleware = EgressGateMiddleware(create_builtin_registry()) - try: - with pytest.raises(EgressGateError) as error: - asyncio.run( - middleware._evaluate_http_request( - request, - Timeout.from_seconds(1), - ) - ) - finally: - asyncio.run(middleware.close()) - - assert error.value.code is ErrorCode.REQUEST_BODY_TOO_LARGE - - -def test_default_deny_reason_is_wire_safe() -> None: - result = EgressResult( - decision=EgressDecision.DENY, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - reason_code=DEFAULT_DENY_REASON_CODE, - ) - response, _ = servicer_module._result_to_proto(result) - assert response.reason == BLOCK_REASON - assert response.reason_code == DEFAULT_DENY_REASON_CODE diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py deleted file mode 100644 index 7ee04eec..00000000 --- a/projects/egress-gate/tests/test_cli.py +++ /dev/null @@ -1,700 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Command-line tests for Egress Gate discovery and policy schema surfaces.""" - -from __future__ import annotations - -import json -import subprocess -import sys -from pathlib import Path -from types import ModuleType - -import pytest -import yaml -from rich.text import Text -from typer.testing import CliRunner - -from egress_gate.cli import _load_registry, app -from egress_gate.errors import GateRegistryError -from egress_gate.gates import GateRegistry, create_builtin_registry -from egress_gate.gateway_config import MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES - - -def test_cli_does_not_offer_request_content_logging() -> None: - result = CliRunner().invoke(app, ["--help"], color=True) - - assert result.exit_code == 0 - help_output = Text.from_ansi(result.stdout).plain - assert "--debug" in help_output - assert "--debug-log-content" not in help_output - - -def test_cli_bare_command_is_successful_help() -> None: - result = CliRunner().invoke(app, []) - - assert result.exit_code == 0 - assert "Usage: egress-gate" in result.stdout - assert "Register Egress Gate with OpenShell." in result.stdout - assert "Inspect installed gates and policy schema." in result.stdout - - -def test_cli_reports_the_installed_version() -> None: - result = CliRunner().invoke(app, ["--version"]) - - assert result.exit_code == 0 - assert result.stdout == "egress-gate 0.1.0\n" - - -def test_cli_narrow_help_preserves_complete_option_names() -> None: - result = CliRunner().invoke( - app, - ["add-gateway-registration", "--help"], - env={"COLUMNS": "40"}, - ) - - assert result.exit_code == 0 - assert "--host-ip" in result.stdout - assert "--config" in result.stdout - assert "--timeout" in result.stdout - assert "--host…" not in result.stdout - assert "--conf…" not in result.stdout - - -def test_cli_serve_uses_one_concise_processing_timeout( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - calls: list[tuple[float, str]] = [] - - class FakeServer: - def __init__( - self, - registry: GateRegistry, - *, - timeout_middleware_processing: float, - ) -> None: - del registry - self.timeout_middleware_processing = timeout_middleware_processing - - def serve_sync(self, listen: str) -> None: - calls.append((self.timeout_middleware_processing, listen)) - - monkeypatch.setattr( - "egress_gate.service.server.EgressGateServer", - FakeServer, - ) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - - result = CliRunner().invoke( - app, - ["serve", "--listen", "127.0.0.1:50055", "--timeout", "4500ms"], - ) - - assert result.exit_code == 0, result.output - assert calls == [(4.5, "127.0.0.1:50055")] - - help_result = CliRunner().invoke(app, ["serve", "--help"]) - serve_help = " ".join(help_result.stdout.split()) - assert "--timeout " in serve_help - assert "--timeout-seconds" not in serve_help - assert "s for seconds or ms for milliseconds" in serve_help - assert "Minimum 10ms" in serve_help - assert "RPC timeout" in serve_help - - evaluate_help = CliRunner().invoke(app, ["evaluate", "--help"]) - assert evaluate_help.exit_code == 0, evaluate_help.output - normalized_evaluate_help = " ".join(evaluate_help.stdout.split()) - assert "s for seconds or ms for milliseconds" in normalized_evaluate_help - assert "Minimum 10ms" in normalized_evaluate_help - - -def test_cli_serve_rejects_timeout_at_remembered_gateway_ceiling( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "user-config")) - config = tmp_path / "gateway.toml" - registration = CliRunner().invoke( - app, - [ - "add-gateway-registration", - "--host-ip", - "192.0.2.10", - "--config", - str(config), - ], - ) - assert registration.exit_code == 0, registration.output - config.write_text(config.read_text().replace('timeout = "30s"', 'timeout = "1s"')) - - result = CliRunner().invoke(app, ["serve", "--timeout", "1s"]) - - assert result.exit_code == 2 - assert "must be less than the 1s gateway timeout" in result.stderr - assert str(config) in result.stderr - - -def test_cli_gates_describes_the_request_level_builtin() -> None: - result = CliRunner().invoke(app, ["gates", "list"]) - - assert result.exit_code == 0 - assert "Installed gates" in result.stdout - assert "regex" in result.stdout - assert "regex_match" in result.stdout - assert "Request access" in result.stdout - assert "target, headers, body" in result.stdout - assert "Possible results" in result.stdout - assert "body replacement, findings, deny decision" in result.stdout - assert "RegexConfig" in result.stdout - assert "Python resources" not in result.stdout - - -def test_cli_configuration_schema_exposes_flat_policy() -> None: - result = CliRunner().invoke(app, ["gates", "schema"]) - - assert result.exit_code == 0 - schema = json.loads(result.stdout) - assert schema["title"] == "EgressGateConfig" - assert set(schema["properties"]) == {"gates", "default_decision"} - assert schema["properties"]["gates"]["minItems"] == 1 - assert schema["properties"]["gates"]["maxItems"] == 10 - - -def test_registry_loader_accepts_a_singleton_or_factory_and_seals_it( - monkeypatch: pytest.MonkeyPatch, -) -> None: - module = ModuleType("test_registry_source") - singleton = create_builtin_registry() - module.__dict__["registry"] = singleton - module.__dict__["create_registry"] = create_builtin_registry - module.__dict__["empty"] = GateRegistry() - monkeypatch.setitem(sys.modules, module.__name__, module) - - assert _load_registry("test_registry_source:registry") is singleton - factory_registry = _load_registry("test_registry_source:create_registry") - with pytest.raises(GateRegistryError, match="registry is in use"): - singleton.register(object) - with pytest.raises(GateRegistryError, match="registry is in use"): - factory_registry.register(object) - with pytest.raises(Exception, match="at least one valid gate"): - _load_registry("test_registry_source:empty") - - -@pytest.mark.parametrize( - "reference", - ["missing-separator", "test_registry_source:missing"], -) -def test_registry_loader_rejects_invalid_references(reference: str) -> None: - with pytest.raises(Exception): - _load_registry(reference) - - -def test_cli_evaluate_runs_the_builtin_policy_corpus() -> None: - project_dir = Path(__file__).parents[1] - result = CliRunner().invoke( - app, - [ - "evaluate", - "--policy", - str(project_dir / "examples/regex-redaction/egress-gate-config.yaml"), - "--cases", - str(project_dir / "examples/regex-redaction/cases.yaml"), - ], - ) - - assert result.exit_code == 0, result.output - assert "Policy evaluation" in result.stdout - assert "PASS" in result.stdout - assert "email-is-detected-and-request-is-allowed" in result.stdout - assert "2 passed · 0 failed · 2 total" in result.stdout - - -@pytest.mark.parametrize( - ("registry_reference", "example_directory"), - [ - ("examples.custom-gate.keyword_gate:registry", "custom-gate"), - ("examples.class-based-gate.keyword_gate:registry", "class-based-gate"), - ], -) -def test_cli_evaluate_runs_the_custom_gate_examples( - registry_reference: str, - example_directory: str, -) -> None: - project_dir = Path(__file__).parents[1] - result = CliRunner().invoke( - app, - [ - "--registry", - registry_reference, - "evaluate", - "--policy", - str(project_dir / f"examples/{example_directory}/egress-gate-config.yaml"), - "--cases", - str(project_dir / f"examples/{example_directory}/cases.yaml"), - ], - ) - - assert result.exit_code == 0, result.output - assert "configured-keyword-is-denied" in result.stdout - assert "other-bodies-proceed-to-the-default" in result.stdout - assert "2 passed · 0 failed · 2 total" in result.stdout - - -@pytest.mark.parametrize( - ("registry_reference", "example_directory", "registration_name"), - [ - (None, "regex-redaction", "eg-regex"), - ( - "examples.custom-gate.keyword_gate:registry", - "custom-gate", - "egress-function", - ), - ( - "examples.class-based-gate.keyword_gate:registry", - "class-based-gate", - "egress-class", - ), - ], -) -def test_openshell_example_policies_use_valid_gate_configuration( - monkeypatch: pytest.MonkeyPatch, - registry_reference: str | None, - example_directory: str, - registration_name: str, -) -> None: - project_dir = Path(__file__).parents[1] - policy_path = project_dir / f"examples/{example_directory}/policy.yaml" - policy = yaml.safe_load(policy_path.read_text()) - middleware = next(iter(policy["network_middlewares"].values())) - standalone_config = yaml.safe_load( - (policy_path.parent / "egress-gate-config.yaml").read_text() - ) - assert middleware["middleware"] == registration_name - assert len(registration_name) <= MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES - if example_directory == "regex-redaction": - monkeypatch.chdir(policy_path.parent) - _load_registry(registry_reference).validate_config(middleware["config"]) - - embedded_config = middleware["config"] - for gate in embedded_config["gates"]: - pattern_catalog = gate.get("pattern_catalog") - if isinstance(pattern_catalog, str): - gate["pattern_catalog"] = yaml.safe_load( - (policy_path.parent / pattern_catalog).read_text() - ) - - assert embedded_config == standalone_config - - -@pytest.mark.parametrize( - ("example_directory", "name"), - [ - ("regex-redaction", "eg-regex"), - ("custom-gate", "egress-function"), - ("class-based-gate", "egress-class"), - ], -) -def test_example_workflows_use_one_registration_and_sandbox_name( - example_directory: str, - name: str, -) -> None: - project_dir = Path(__file__).parents[1] - readme = (project_dir / f"examples/{example_directory}/README.md").read_text() - normalized_readme = " ".join(readme.replace("\\\n", " ").split()) - - assert f"--host-ip YOUR_HOST_IPV4 --name {name} --port 50051" in normalized_readme - assert f"openshell sandbox create --name {name}" in normalized_readme - assert f"openshell sandbox delete {name}" in readme - assert f"remove-gateway-registration --name {name}" in normalized_readme - assert "stop any running OpenShell gateways" in normalized_readme - assert ( - "A running gateway does not reload middleware registrations" - in normalized_readme - ) - - -def test_installed_executable_loads_a_registry_from_the_working_directory() -> None: - project_dir = Path(__file__).parents[1] - executable = Path(sys.executable).with_name("egress-gate") - - result = subprocess.run( - [ - executable, - "--registry", - "examples.custom-gate.keyword_gate:registry", - "gates", - "list", - ], - cwd=project_dir, - capture_output=True, - check=False, - text=True, - ) - - assert result.returncode == 0, result.stderr - assert "Installed gates" in result.stdout - assert "keyword-deny" in result.stdout - - -def test_cli_validate_checks_policy_without_preparing_gates( - monkeypatch: pytest.MonkeyPatch, -) -> None: - project_dir = Path(__file__).parents[1] - - def unexpected_preparation(*args: object, **kwargs: object) -> object: - del args, kwargs - raise AssertionError("validation prepared a gate") - - monkeypatch.setattr(GateRegistry, "create_gate", unexpected_preparation) - result = CliRunner().invoke( - app, - [ - "validate", - "--policy", - str(project_dir / "examples/regex-redaction/egress-gate-config.yaml"), - ], - ) - - assert result.exit_code == 0, result.output - assert result.stdout == "✓ Policy is valid\n" - - -def test_cli_validate_rejects_invalid_policy(tmp_path: Path) -> None: - policy = tmp_path / "invalid.yaml" - policy.write_text("unexpected: true\n") - - result = CliRunner().invoke( - app, - ["validate", "--policy", str(policy)], - ) - - assert result.exit_code == 1 - assert "Policy validation failed [config_invalid]" in result.stderr - assert "Policy field gates: required field is missing" in result.stderr - assert "egress-gate gates schema" in result.stderr - - -def test_cli_validate_reports_a_safe_structural_path(tmp_path: Path) -> None: - sentinel = "scna-sensitive-sentinel" - policy = tmp_path / "invalid.yaml" - policy.write_text( - """gates: - - name: one - kind: regex - scna-sensitive-sentinel: {} - pattern_catalog: {} -default_decision: allow -""" - ) - - result = CliRunner().invoke(app, ["validate", "--policy", str(policy)]) - - assert result.exit_code == 1 - assert "Policy field gates[0].scan: required field is missing" in result.stderr - assert sentinel not in result.output - - -def test_cli_evaluate_catalogs_regex_preparation_failures(tmp_path: Path) -> None: - project_dir = Path(__file__).parents[1] - policy = tmp_path / "named-group.yaml" - policy.write_text( - """gates: - - name: identifiers - kind: regex - scan: - kind: body - action: {kind: detect} - pattern_catalog: - entities: - - name: token - rules: - - pattern: '(?Psecret)' - confidence: high -default_decision: allow -""" - ) - - result = CliRunner().invoke( - app, - [ - "evaluate", - "--policy", - str(policy), - "--cases", - str(project_dir / "examples/regex-redaction/cases.yaml"), - ], - ) - - assert result.exit_code == 2 - assert "Evaluation failed [config_preparation_failed]" in result.stderr - assert "remove named groups" in result.stderr - assert "sensitive_name" not in result.output - assert "custom gate and application-owned resource setup" not in result.output - - -def test_cli_evaluate_names_a_failing_case_and_keeps_completed_results( - tmp_path: Path, -) -> None: - project_dir = Path(__file__).parents[1] - original = (project_dir / "examples/regex-redaction/cases.yaml").read_text() - cases = tmp_path / "invalid-utf8.yaml" - cases.write_text( - original.replace( - 'encoding: utf8\n value: "ordinary text"', - 'encoding: base64\n value: "/w=="', - ) - ) - - result = CliRunner().invoke( - app, - [ - "evaluate", - "--policy", - str(project_dir / "examples/regex-redaction/egress-gate-config.yaml"), - "--cases", - str(cases), - ], - ) - - assert result.exit_code == 2 - assert "Completed before failure" in result.stdout - assert "email-is-detected-and-request-is-allowed" in result.stdout - assert "Evaluation failed for case ordinary-body-is-allowed" in result.stderr - assert "[body_encoding_invalid]" in result.stderr - assert '"/w=="' not in result.output - - -def test_cli_add_gateway_registration_reports_the_result( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "user-config")) - config = tmp_path / "gateway.toml" - result = CliRunner().invoke( - app, - [ - "add-gateway-registration", - "--host-ip", - "192.0.2.10", - "--config", - str(config), - "--timeout", - "45s", - ], - ) - - assert result.exit_code == 0, result.output - assert "Gateway registration is ready" in result.stdout - assert "Gateway file" in result.stdout - assert str(config) in "".join(result.stdout.split()) - assert "Registration" in result.stdout - assert "egress-gate" in result.stdout - assert "Endpoint" in result.stdout - assert "http://192.0.2.10:50051" in result.stdout - assert "Gateway RPC ceiling" in result.stdout - assert "45s" in result.stdout - assert 'timeout = "45s"' in config.read_text() - assert "Created the gateway configuration file" in result.stdout - assert "Next: Start Egress Gate" in result.stdout - - -def test_cli_rejects_gateway_timeout_without_processing_headroom( - tmp_path: Path, -) -> None: - config = tmp_path / "gateway.toml" - - result = CliRunner().invoke( - app, - [ - "add-gateway-registration", - "--host-ip", - "192.0.2.10", - "--config", - str(config), - "--timeout", - "10ms", - ], - ) - - assert result.exit_code == 2 - assert "gateway timeout must be greater than 10ms" in result.stderr - assert not config.exists() - - -def test_cli_removal_forgets_registration_before_later_serve( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - calls: list[str] = [] - - class FakeServer: - def __init__( - self, - registry: GateRegistry, - *, - timeout_middleware_processing: float, - ) -> None: - del registry, timeout_middleware_processing - - def serve_sync(self, listen: str) -> None: - calls.append(listen) - - monkeypatch.setattr("egress_gate.service.server.EgressGateServer", FakeServer) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "user-config")) - config = tmp_path / "gateway.toml" - add_result = CliRunner().invoke( - app, - [ - "add-gateway-registration", - "--host-ip", - "192.0.2.10", - "--config", - str(config), - ], - ) - assert add_result.exit_code == 0, add_result.output - - remove_result = CliRunner().invoke( - app, - [ - "remove-gateway-registration", - "--name", - "egress-gate", - "--config", - str(config), - ], - ) - assert remove_result.exit_code == 0, remove_result.output - - serve_result = CliRunner().invoke(app, ["serve"]) - - assert serve_result.exit_code == 0, serve_result.output - assert calls == ["127.0.0.1:50051"] - - -def test_cli_lists_gateway_registration_names_for_removal(tmp_path: Path) -> None: - config = tmp_path / "gateway.toml" - config.write_text( - "[openshell]\n" - "version = 1\n\n" - "[[openshell.supervisor.middleware]]\n" - 'name = "eg-regex"\n' - 'grpc_endpoint = "http://192.0.2.10:50051"\n' - 'timeout = "30s"\n\n' - "[[openshell.supervisor.middleware]]\n" - 'name = "other-service"\n' - 'grpc_endpoint = "http://192.0.2.20:9000"\n' - ) - - result = CliRunner().invoke( - app, - ["list-gateway-registrations", "--config", str(config)], - ) - - assert result.exit_code == 0, result.output - assert "OpenShell middleware registrations" in result.stdout - assert "eg-regex" in result.stdout - assert "http://192.0.2.10:50051" in result.stdout - assert "Gateway RPC ceiling" in result.stdout - assert "30s" in result.stdout - assert "other-service" in result.stdout - assert "remove-gateway-registration --name NAME" in result.stdout - - -def test_cli_lists_no_registrations_when_gateway_config_is_missing( - tmp_path: Path, -) -> None: - result = CliRunner().invoke( - app, - [ - "list-gateway-registrations", - "--config", - str(tmp_path / "missing.toml"), - ], - ) - - assert result.exit_code == 0, result.output - assert "No middleware registrations found." in result.stdout - - -def test_cli_evaluate_reports_content_safe_mismatch_status(tmp_path: Path) -> None: - project_dir = Path(__file__).parents[1] - cases = tmp_path / "cases.yaml" - original = (project_dir / "examples/regex-redaction/cases.yaml").read_text() - cases.write_text(original.replace("decision: allow", "decision: deny", 1)) - - result = CliRunner().invoke( - app, - [ - "evaluate", - "--policy", - str(project_dir / "examples/regex-redaction/egress-gate-config.yaml"), - "--cases", - str(cases), - ], - ) - - assert result.exit_code == 1 - assert "FAIL" in result.stdout - assert "email-is-detected" in result.stdout - assert "decision:" in result.stdout - assert '"deny"' in result.stdout - assert '"allow"' in result.stdout - assert "1 passed · 1 failed · 2 total" in result.stdout - assert "{}" not in result.stdout - - -@pytest.mark.parametrize( - "invalid_yaml", - [ - "version: 1\ncases: &cases []\n", - "version: 1\ncases:\n - name: one\n name: two\n", - ], -) -def test_cli_evaluate_rejects_non_strict_corpus_yaml( - tmp_path: Path, - invalid_yaml: str, -) -> None: - project_dir = Path(__file__).parents[1] - cases = tmp_path / "cases.yaml" - cases.write_text(invalid_yaml) - - result = CliRunner().invoke( - app, - [ - "evaluate", - "--policy", - str(project_dir / "examples/regex-redaction/egress-gate-config.yaml"), - "--cases", - str(cases), - ], - ) - - assert result.exit_code == 2 - assert "Evaluation could not start [invalid_cases_file]" in result.stderr - assert "valid version 1 YAML test suite" in result.stderr - assert "YAML aliases" not in result.output - - -def test_cli_evaluate_explains_an_invalid_timeout() -> None: - project_dir = Path(__file__).parents[1] - result = CliRunner().invoke( - app, - [ - "evaluate", - "--policy", - str(project_dir / "examples/regex-redaction/egress-gate-config.yaml"), - "--cases", - str(project_dir / "examples/regex-redaction/cases.yaml"), - "--timeout", - "9ms", - ], - color=True, - ) - - assert result.exit_code == 2 - error_output = Text.from_ansi(result.stderr).plain - assert "Invalid value for --timeout" in error_output - assert "at least 10ms" in error_output diff --git a/projects/egress-gate/tests/test_config.py b/projects/egress-gate/tests/test_config.py deleted file mode 100644 index 8d703e44..00000000 --- a/projects/egress-gate/tests/test_config.py +++ /dev/null @@ -1,130 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Strict pipeline configuration tests.""" - -from __future__ import annotations - -import pytest -from pydantic import ValidationError - -from egress_gate.config import DefaultDecision, EgressGateConfig -from egress_gate.constants import MAX_PIPELINE_GATES -from egress_gate.gates import RegexConfig - - -def _regex_config() -> dict[str, object]: - return { - "kind": "regex", - "scan": {"kind": "body", "action": {"kind": "detect"}}, - "pattern_catalog": { - "entities": [ - { - "name": "token", - "rules": [{"pattern": "secret", "confidence": "high"}], - } - ] - }, - } - - -def _values(*, default_decision: str = "allow") -> dict[str, object]: - return { - "gates": [{"name": "body", **_regex_config()}], - "default_decision": default_decision, - } - - -def test_pipeline_uses_required_default_and_exact_gate_entries() -> None: - config = EgressGateConfig[RegexConfig].model_validate(_values()) - - assert config.default_decision is DefaultDecision.ALLOW - assert config.gates[0].name == "body" - assert type(config.gates[0]) is RegexConfig - - -def test_pipeline_default_deny_is_explicit() -> None: - config = EgressGateConfig[RegexConfig].model_validate( - _values(default_decision="deny") - ) - assert config.default_decision is DefaultDecision.DENY - - missing_default = { - "gates": [{"name": "body", **_regex_config()}], - } - with pytest.raises(ValidationError): - EgressGateConfig[RegexConfig].model_validate(missing_default) - - -def test_pipeline_rejects_unknown_fields_and_duplicate_names() -> None: - unknown = { - "gates": [{"name": "body", **_regex_config()}], - "default_decision": "allow", - "unexpected": True, - } - with pytest.raises(ValidationError): - EgressGateConfig[RegexConfig].model_validate(unknown) - - duplicate = { - "gates": [ - {"name": "body", **_regex_config()}, - {"name": "body", **_regex_config()}, - ], - "default_decision": "allow", - } - with pytest.raises(ValidationError) as duplicate_error: - EgressGateConfig[RegexConfig].model_validate(duplicate) - assert duplicate_error.value.errors()[0]["loc"] == ("gates",) - - -def test_removed_policy_wrappers_are_rejected() -> None: - nested_policy = {"pipeline": _values()} - with pytest.raises(ValidationError): - EgressGateConfig[RegexConfig].model_validate(nested_policy) - - nested_gate = { - "gates": [{"name": "body", "config": _regex_config()}], - "default_decision": "allow", - } - with pytest.raises(ValidationError): - EgressGateConfig[RegexConfig].model_validate(nested_gate) - - -def test_pipeline_gate_count_has_an_exact_boundary() -> None: - exact_gates = [ - {"name": f"body-{index}", **_regex_config()} - for index in range(MAX_PIPELINE_GATES) - ] - exact = { - "gates": exact_gates, - "default_decision": "allow", - } - config = EgressGateConfig[RegexConfig].model_validate(exact) - assert len(config.gates) == MAX_PIPELINE_GATES - - too_many_gates = [ - *exact_gates, - {"name": "body-over", **_regex_config()}, - ] - too_many = { - "gates": too_many_gates, - "default_decision": "allow", - } - with pytest.raises(ValidationError): - EgressGateConfig[RegexConfig].model_validate(too_many) - - -def test_regex_scan_structurally_restricts_header_actions() -> None: - invalid = _regex_config() - invalid["scan"] = { - "kind": "header", - "names": ["x-note"], - "action": {"kind": "replace", "template": "[{entity}]"}, - } - with pytest.raises(ValidationError): - EgressGateConfig[RegexConfig].model_validate( - { - "gates": [{"name": "header", **invalid}], - "default_decision": "allow", - } - ) diff --git a/projects/egress-gate/tests/test_errors.py b/projects/egress-gate/tests/test_errors.py deleted file mode 100644 index 72ce634d..00000000 --- a/projects/egress-gate/tests/test_errors.py +++ /dev/null @@ -1,60 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -import inspect - -from egress_gate.errors import ( - EgressGateError, - ErrorCode, - ErrorComponent, - ErrorKind, -) - - -def test_every_error_code_has_one_safe_complete_specification() -> None: - sentinel = "sensitive-request-value-8472" - - assert len({code.value for code in ErrorCode}) == len(ErrorCode) - for code in ErrorCode: - error = EgressGateError(code) - message = str(error) - - assert f"[{code.value}]" in message - assert error.component.value in message - assert error.operation in message - assert error.summary in message - assert error.hint in message - assert sentinel not in message - assert repr(error) == f"EgressGateError({message!r})" - - -def test_error_kinds_distinguish_invalid_input_from_internal_failures() -> None: - assert EgressGateError(ErrorCode.CONFIG_INVALID).kind is ErrorKind.INVALID_INPUT - assert EgressGateError(ErrorCode.GATE_EXECUTION_FAILED).kind is ErrorKind.INTERNAL - assert EgressGateError(ErrorCode.CONFIG_INVALID).component is ErrorComponent.CONFIG - - -def test_config_error_explains_the_transport_size_limit() -> None: - error = EgressGateError(ErrorCode.CONFIG_INVALID) - - assert "encoded configuration at or below 64 KiB" in error.hint - - -def test_config_preparation_error_has_builtin_regex_guidance() -> None: - error = EgressGateError(ErrorCode.CONFIG_PREPARATION_FAILED) - - assert error.kind is ErrorKind.INVALID_INPUT - assert "remove named groups" in error.hint - - -def test_malformed_protobuf_error_gives_safe_wire_contract_guidance() -> None: - error = EgressGateError(ErrorCode.REQUEST_PROTOBUF_INVALID) - - assert error.kind is ErrorKind.INVALID_INPUT - assert error.component is ErrorComponent.SERVICE - assert error.operation == "decode_protobuf" - assert "published OpenShell middleware protobuf contract" in error.hint - - -def test_egress_gate_error_exposes_only_a_catalog_code_parameter() -> None: - assert list(inspect.signature(EgressGateError).parameters) == ["code"] diff --git a/projects/egress-gate/tests/test_gateway_config.py b/projects/egress-gate/tests/test_gateway_config.py deleted file mode 100644 index 237f56e8..00000000 --- a/projects/egress-gate/tests/test_gateway_config.py +++ /dev/null @@ -1,506 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""OpenShell gateway registration management tests.""" - -from __future__ import annotations - -import tomllib -from pathlib import Path - -import pytest - -from egress_gate.gateway_config import ( - MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES, - GatewayConfigError, - GatewayConfigRemoval, - GatewayConfigUpdate, - GatewayMiddlewareRegistration, - default_gateway_config_path, - default_registration_state_path, - list_gateway_registrations, - load_remembered_gateway_registration, - read_remembered_gateway_timeout, - remember_gateway_registration, - remove_gateway_config, - update_gateway_config, - validate_middleware_name, -) - - -def test_default_gateway_config_path_uses_xdg_config_home( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.delenv("OPENSHELL_GATEWAY_CONFIG", raising=False) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - - assert default_gateway_config_path() == tmp_path / "openshell" / "gateway.toml" - - -def test_default_gateway_config_path_uses_home_config_without_xdg( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.delenv("OPENSHELL_GATEWAY_CONFIG", raising=False) - monkeypatch.delenv("XDG_CONFIG_HOME", raising=False) - monkeypatch.setattr(Path, "home", lambda: tmp_path) - - assert ( - default_gateway_config_path() - == tmp_path / ".config" / "openshell" / "gateway.toml" - ) - - -def test_default_gateway_config_path_honors_openshell_override( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - configured_path = tmp_path / "custom.toml" - monkeypatch.setenv("OPENSHELL_GATEWAY_CONFIG", str(configured_path)) - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "ignored")) - - assert default_gateway_config_path() == configured_path - - -def test_remembered_registration_reads_current_gateway_timeout( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path / "user-config")) - gateway_config = tmp_path / "gateway.toml" - gateway_config.write_text( - "[openshell]\n" - "version = 1\n\n" - "[[openshell.supervisor.middleware]]\n" - 'name = "egress-gate"\n' - 'grpc_endpoint = "http://192.0.2.10:50051"\n' - 'timeout = "2500ms"\n' - ) - - remember_gateway_registration( - gateway_config, - middleware_name="egress-gate", - ) - - remembered = load_remembered_gateway_registration() - assert remembered is not None - assert remembered.config_path == gateway_config.resolve() - assert remembered.middleware_name == "egress-gate" - assert default_registration_state_path().stat().st_mode & 0o777 == 0o600 - assert read_remembered_gateway_timeout() == (remembered, 2.5) - assert read_remembered_gateway_timeout(unit="ms") == (remembered, 2500.0) - - -def test_remembered_registration_is_optional( - monkeypatch: pytest.MonkeyPatch, - tmp_path: Path, -) -> None: - monkeypatch.setenv("XDG_CONFIG_HOME", str(tmp_path)) - - assert load_remembered_gateway_registration() is None - assert read_remembered_gateway_timeout() is None - - -def test_middleware_name_validation_matches_openshell_constraints() -> None: - assert MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES == 19 - longest_name = "a" * MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES - - assert validate_middleware_name(longest_name) == longest_name - - for invalid_name in ( - "", - "a" * (MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES + 1), - "egress gate", - "priväcy-guard", - "openshell/egress-gate", - ): - with pytest.raises(GatewayConfigError): - validate_middleware_name(invalid_name) - - -def test_update_gateway_config_creates_minimal_default_config( - tmp_path: Path, -) -> None: - path = tmp_path / "openshell" / "gateway.toml" - - result = update_gateway_config( - path, - middleware_name="egress-gate", - host_ip="192.168.1.20", - port=50051, - ) - - assert result is GatewayConfigUpdate.CREATED - assert path.stat().st_mode & 0o777 == 0o600 - assert tomllib.loads(path.read_text()) == { - "openshell": { - "version": 1, - "supervisor": { - "middleware": [ - { - "name": "egress-gate", - "grpc_endpoint": "http://192.168.1.20:50051", - "max_body_bytes": 4_194_304, - "timeout": "30s", - } - ] - }, - } - } - - -def test_list_gateway_registrations_returns_names_and_endpoints( - tmp_path: Path, -) -> None: - path = tmp_path / "gateway.toml" - path.write_text( - "[openshell]\n" - "version = 1\n\n" - "[[openshell.supervisor.middleware]]\n" - 'name = "eg-regex"\n' - 'grpc_endpoint = "http://10.0.0.3:50051"\n' - 'timeout = "30s"\n\n' - "[[openshell.supervisor.middleware]]\n" - 'name = "other-service"\n' - ) - - assert list_gateway_registrations(path) == ( - GatewayMiddlewareRegistration( - name="eg-regex", - endpoint="http://10.0.0.3:50051", - timeout_gateway_ceiling="30s", - ), - GatewayMiddlewareRegistration( - name="other-service", - endpoint=None, - timeout_gateway_ceiling=None, - ), - ) - - -def test_list_gateway_registrations_returns_empty_for_missing_file( - tmp_path: Path, -) -> None: - assert list_gateway_registrations(tmp_path / "missing.toml") == () - - -def test_update_gateway_config_appends_without_rewriting_existing_settings( - tmp_path: Path, -) -> None: - path = tmp_path / "gateway.toml" - original = ( - "# Keep this operator comment.\n" - "[openshell]\n" - "version = 1\n\n" - "[openshell.gateway]\n" - 'compute_drivers = ["docker"]\n' - ) - path.write_text(original) - - result = update_gateway_config( - path, - middleware_name="egress-gate", - host_ip="10.0.0.12", - port=50052, - ) - - assert result is GatewayConfigUpdate.ADDED - assert path.read_text().startswith(original.rstrip() + "\n\n") - - -def test_update_gateway_config_updates_only_the_named_registration( - tmp_path: Path, -) -> None: - path = tmp_path / "gateway.toml" - path.write_text( - "[openshell]\n" - "version = 1\n\n" - "[[openshell.supervisor.middleware]]\n" - 'name = "other-service"\n' - 'grpc_endpoint = "http://10.0.0.2:9000"\n' - "max_body_bytes = 1000\n" - 'timeout = "1s"\n\n' - "[[openshell.supervisor.middleware]]\n" - 'name = "egress-gate"\n' - "# Keep this registration comment.\n" - 'grpc_endpoint = "http://10.0.0.3:50051"\n' - "max_body_bytes = 2048\n" - 'timeout = "2s"\n' - ) - - result = update_gateway_config( - path, - middleware_name="egress-gate", - host_ip="10.0.0.4", - port=50053, - timeout_gateway_ceiling="45s", - ) - - assert result is GatewayConfigUpdate.UPDATED - contents = path.read_text() - assert 'grpc_endpoint = "http://10.0.0.2:9000"' in contents - assert "# Keep this registration comment." in contents - assert 'grpc_endpoint = "http://10.0.0.4:50053"' in contents - assert "max_body_bytes = 4194304" in contents - assert 'timeout = "45s"' in contents - - repeated = update_gateway_config( - path, - middleware_name="egress-gate", - host_ip="10.0.0.4", - port=50053, - timeout_gateway_ceiling="45s", - ) - - assert repeated is GatewayConfigUpdate.UNCHANGED - - -@pytest.mark.parametrize("timeout", ["1m", "10ms"]) -def test_update_gateway_config_rejects_invalid_timeout( - tmp_path: Path, - timeout: str, -) -> None: - with pytest.raises(GatewayConfigError, match="gateway timeout"): - update_gateway_config( - tmp_path / "gateway.toml", - middleware_name="egress-gate", - host_ip="10.0.0.4", - port=50053, - timeout_gateway_ceiling=timeout, - ) - - -def test_update_gateway_config_adds_the_operator_timeout_ceiling_when_missing( - tmp_path: Path, -) -> None: - path = tmp_path / "gateway.toml" - path.write_text( - "[openshell]\n" - "version = 1\n\n" - "[[openshell.supervisor.middleware]]\n" - 'name = "egress-gate"\n' - 'grpc_endpoint = "http://10.0.0.3:50051"\n' - "max_body_bytes = 4194304\n" - ) - - result = update_gateway_config( - path, - middleware_name="egress-gate", - host_ip="10.0.0.3", - port=50051, - ) - - assert result is GatewayConfigUpdate.UPDATED - assert 'timeout = "30s"' in path.read_text() - - -@pytest.mark.parametrize( - "contents", - [ - "[openshell\n", - "[other]\nversion = 1\n", - "[openshell]\nversion = 2\n", - ], -) -def test_update_gateway_config_rejects_invalid_existing_config( - tmp_path: Path, - contents: str, -) -> None: - path = tmp_path / "gateway.toml" - path.write_text(contents) - - with pytest.raises(GatewayConfigError): - update_gateway_config( - path, - middleware_name="egress-gate", - host_ip="192.168.1.20", - port=50051, - ) - - assert path.read_text() == contents - - -def test_remove_gateway_config_removes_only_the_named_registration( - tmp_path: Path, -) -> None: - path = tmp_path / "gateway.toml" - path.write_text( - "# Keep this operator comment.\n" - "[openshell]\n" - "version = 1\n\n" - "[[openshell.supervisor.middleware]]\n" - 'name = "egress-gate-regex"\n' - 'grpc_endpoint = "http://10.0.0.3:50051"\n' - "max_body_bytes = 4194304\n" - 'timeout = "5s"\n\n' - "# Keep this other-service comment.\n" - "[[openshell.supervisor.middleware]]\n" - 'name = "other-service"\n' - 'grpc_endpoint = "http://10.0.0.2:9000"\n' - "max_body_bytes = 1000\n" - 'timeout = "1s"\n' - ) - - result = remove_gateway_config( - path, - middleware_name="egress-gate-regex", - ) - - assert result is GatewayConfigRemoval.REMOVED - contents = path.read_text() - assert "egress-gate-regex" not in contents - assert "# Keep this operator comment." in contents - assert "# Keep this other-service comment." in contents - assert 'name = "other-service"' in contents - assert tomllib.loads(contents)["openshell"]["supervisor"]["middleware"] == [ - { - "name": "other-service", - "grpc_endpoint": "http://10.0.0.2:9000", - "max_body_bytes": 1000, - "timeout": "1s", - } - ] - - -def test_remove_gateway_config_can_remove_a_legacy_long_name(tmp_path: Path) -> None: - path = tmp_path / "gateway.toml" - path.write_text( - "[openshell]\n" - "version = 1\n\n" - "[[openshell.supervisor.middleware]]\n" - 'name = "legacy-registration-name"\n' - 'grpc_endpoint = "http://10.0.0.3:50051"\n' - ) - - result = remove_gateway_config( - path, - middleware_name="legacy-registration-name", - ) - - assert result is GatewayConfigRemoval.REMOVED - assert "legacy-registration-name" not in path.read_text() - - -@pytest.mark.parametrize("create_file", [False, True]) -def test_remove_gateway_config_is_unchanged_when_registration_is_absent( - tmp_path: Path, - create_file: bool, -) -> None: - path = tmp_path / "gateway.toml" - if create_file: - path.write_text("[openshell]\nversion = 1\n") - - result = remove_gateway_config( - path, - middleware_name="egress-gate-regex", - ) - - assert result is GatewayConfigRemoval.UNCHANGED - if create_file: - assert path.read_text() == "[openshell]\nversion = 1\n" - else: - assert not path.exists() - - -def test_remove_gateway_config_rejects_duplicate_named_registrations( - tmp_path: Path, -) -> None: - path = tmp_path / "gateway.toml" - contents = ( - "[openshell]\n" - "version = 1\n\n" - "[[openshell.supervisor.middleware]]\n" - 'name = "egress-gate-regex"\n\n' - "[[openshell.supervisor.middleware]]\n" - 'name = "egress-gate-regex"\n' - ) - path.write_text(contents) - - with pytest.raises(GatewayConfigError, match="multiple middleware registrations"): - remove_gateway_config( - path, - middleware_name="egress-gate-regex", - ) - - assert path.read_text() == contents - - -def test_remove_gateway_config_rejects_registration_child_tables( - tmp_path: Path, -) -> None: - path = tmp_path / "gateway.toml" - contents = ( - "[openshell]\n" - "version = 1\n\n" - "[[openshell.supervisor.middleware]]\n" - 'name = "egress-gate-regex"\n' - 'grpc_endpoint = "http://10.0.0.3:50051"\n\n' - "[openshell.supervisor.middleware.metadata]\n" - 'owner = "privacy-team"\n' - ) - path.write_text(contents) - - with pytest.raises(GatewayConfigError, match="Could not safely remove"): - remove_gateway_config( - path, - middleware_name="egress-gate-regex", - ) - - assert path.read_text() == contents - - -def test_remove_gateway_config_rejects_table_headers_inside_multiline_strings( - tmp_path: Path, -) -> None: - path = tmp_path / "gateway.toml" - contents = ( - "[openshell]\n" - "version = 1\n" - 'note = """\n' - "[[openshell.supervisor.middleware]]\n" - "fake = true\n" - "[[not.a.real.table]]\n" - "still string\n" - '"""\n\n' - "[openshell.supervisor]\n" - "middleware = [\n" - ' { name = "egress-gate-regex", ' - 'grpc_endpoint = "http://10.0.0.3:50051" },\n' - "]\n" - ) - path.write_text(contents) - - with pytest.raises(GatewayConfigError, match="Could not safely remove"): - remove_gateway_config( - path, - middleware_name="egress-gate-regex", - ) - - assert path.read_text() == contents - - -def test_remove_gateway_config_reports_unsafe_multiline_registration_layout( - tmp_path: Path, -) -> None: - path = tmp_path / "gateway.toml" - contents = ( - "[openshell]\n" - "version = 1\n\n" - "[[openshell.supervisor.middleware]]\n" - 'name = "egress-gate-regex"\n' - 'description = """\n' - "[looks.like.a.table]\n" - "still string\n" - '"""\n' - 'grpc_endpoint = "http://10.0.0.3:50051"\n' - ) - path.write_text(contents) - - with pytest.raises(GatewayConfigError, match="Could not safely remove"): - remove_gateway_config( - path, - middleware_name="egress-gate-regex", - ) - - assert path.read_text() == contents diff --git a/projects/egress-gate/tests/test_logging.py b/projects/egress-gate/tests/test_logging.py deleted file mode 100644 index 4070c5b2..00000000 --- a/projects/egress-gate/tests/test_logging.py +++ /dev/null @@ -1,180 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Egress Gate logging configuration tests.""" - -from __future__ import annotations - -import ast -import logging -import re -from collections.abc import Iterator -from io import StringIO -from pathlib import Path - -import pytest - -from egress_gate.logging import ( - DEFAULT_LOGGING_CONFIG, - ColorMode, - LoggingConfig, - configure_logging, - get_logger, - reset_logging, -) - - -@pytest.fixture(autouse=True) -def _restore_logging() -> Iterator[None]: - reset_logging() - yield - reset_logging() - - -def test_configure_logging_emits_consistent_package_logs() -> None: - stream = StringIO() - configure_logging(LoggingConfig(stream=stream)) - - logging.getLogger("egress_gate.service").info("server_started") - logging.getLogger("egress_gate.service").debug("hidden_detail") - - output = stream.getvalue() - assert re.fullmatch( - r"\d{4}-\d{2}-\d{2} \d{2}:\d{2}:\d{2}" - r" \| INFO \| egress_gate\.service \| server_started\n", - output, - ) - assert "hidden_detail" not in output - assert "\033[" not in output - - -def test_default_logging_config_uses_info_and_terminal_aware_colors() -> None: - assert DEFAULT_LOGGING_CONFIG == LoggingConfig( - level=logging.INFO, - stream=None, - color_mode=ColorMode.AUTO, - ) - - -def test_configure_logging_colors_interactive_output( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.delenv("NO_COLOR", raising=False) - stream = _TerminalStream() - configure_logging(LoggingConfig(stream=stream)) - - logging.getLogger("egress_gate.service").warning("resource_pressure") - - output = stream.getvalue() - assert "\033[33mWARNING \033[0m" in output - assert "\033[36megress_gate.service\033[0m" in output - assert output.endswith(" | resource_pressure\n") - - -@pytest.mark.parametrize("no_color", ["", "1"]) -def test_configure_logging_honors_no_color_for_interactive_output( - monkeypatch: pytest.MonkeyPatch, - no_color: str, -) -> None: - monkeypatch.setenv("NO_COLOR", no_color) - stream = _TerminalStream() - configure_logging(LoggingConfig(stream=stream)) - - logging.getLogger("egress_gate.service").warning("resource_pressure") - - assert "\033[" not in stream.getvalue() - - -def test_configure_logging_can_disable_terminal_colors() -> None: - stream = _TerminalStream() - configure_logging(LoggingConfig(stream=stream, color_mode=ColorMode.NEVER)) - - logging.getLogger("egress_gate.service").error("startup_failed") - - assert "\033[" not in stream.getvalue() - - -def test_configure_logging_can_force_colors_for_redirected_output( - monkeypatch: pytest.MonkeyPatch, -) -> None: - monkeypatch.setenv("NO_COLOR", "1") - stream = StringIO() - configure_logging(LoggingConfig(stream=stream, color_mode=ColorMode.ALWAYS)) - - logging.getLogger("egress_gate.service").info("server_started") - - assert "\033[32mINFO \033[0m" in stream.getvalue() - - -def test_get_logger_returns_named_package_logger() -> None: - logger = get_logger("egress_gate.custom_gate") - - assert logger is logging.getLogger("egress_gate.custom_gate") - - -def test_configure_logging_accepts_native_log_levels() -> None: - stream = StringIO() - configure_logging(LoggingConfig(level=logging.DEBUG, stream=stream)) - - logging.getLogger("egress_gate.request_processor").debug("processing_diagnostic") - - assert ( - "DEBUG | egress_gate.request_processor | processing_diagnostic" - in stream.getvalue() - ) - - -def test_configure_logging_replaces_its_previous_handler() -> None: - first_stream = StringIO() - second_stream = StringIO() - configure_logging(LoggingConfig(stream=first_stream)) - configure_logging(LoggingConfig(stream=second_stream)) - - logging.getLogger("egress_gate").info("configured_once") - - assert "configured_once" not in first_stream.getvalue() - assert second_stream.getvalue().count("configured_once") == 1 - - -def test_reset_logging_restores_application_logging() -> None: - package_logger = logging.getLogger("egress_gate") - application_handler = logging.NullHandler() - package_logger.addHandler(application_handler) - configure_logging(LoggingConfig(stream=StringIO())) - - reset_logging() - - try: - assert package_logger.handlers == [application_handler] - assert package_logger.level == logging.NOTSET - assert package_logger.propagate is True - finally: - package_logger.removeHandler(application_handler) - - -def test_source_modules_use_the_shared_logging_module() -> None: - direct_imports: list[str] = [] - for path in _SOURCE_ROOT.rglob("*.py"): - if path == _LOGGING_MODULE or "bindings" in path.parts: - continue - tree = ast.parse(path.read_text(encoding="utf-8")) - if any( - ( - isinstance(node, ast.Import) - and any(imported.name == "logging" for imported in node.names) - ) - or (isinstance(node, ast.ImportFrom) and node.module == "logging") - for node in ast.walk(tree) - ): - direct_imports.append(str(path.relative_to(_SOURCE_ROOT))) - - assert direct_imports == [] - - -_SOURCE_ROOT = Path(__file__).parents[1] / "src" / "egress_gate" -_LOGGING_MODULE = _SOURCE_ROOT / "logging.py" - - -class _TerminalStream(StringIO): - def isatty(self) -> bool: - return True diff --git a/projects/egress-gate/tests/test_request.py b/projects/egress-gate/tests/test_request.py deleted file mode 100644 index 1f02136d..00000000 --- a/projects/egress-gate/tests/test_request.py +++ /dev/null @@ -1,268 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Focused boundary tests for the protobuf-free request domain models.""" - -from __future__ import annotations - -import pytest -from pydantic import TypeAdapter, ValidationError - -from egress_gate.constants import ( - MAX_BODY_BYTES, - MAX_HEADER_MUTATION_DATA_BYTES, - MAX_HEADER_MUTATIONS, - MAX_PROTO_CONTEXT_BYTES, - MAX_PROTO_HEADERS, - MAX_PROTO_HEADERS_BYTES, - MAX_PROTO_TARGET_BYTES, -) -from egress_gate.request import ( - ExistingHeaderAction, - HeaderMutation, - HttpHeader, - HttpRequest, - HttpTarget, - Process, - RemoveHeaderMutation, - RequestContext, - RequestMutations, - WriteHeaderMutation, -) - - -def _request( - *, body: bytes = b"payload", headers: tuple[HttpHeader, ...] = () -) -> HttpRequest: - return HttpRequest( - context=RequestContext( - request_id="request-1", - sandbox_id="sandbox-1", - originating_process=Process( - binary="/usr/bin/python", - pid=42, - ancestors=("/usr/bin/init",), - ), - ), - target=HttpTarget( - scheme="https", - host="example.com", - port=443, - method="POST", - path="/v1/items", - query="page=1", - ), - headers=headers, - body=body, - ) - - -def test_request_is_immutable_and_preserves_ordered_headers() -> None: - headers = ( - HttpHeader(name="x-test", value="first"), - HttpHeader(name="x-test", value="second"), - ) - request = _request(headers=headers) - - assert request.body == b"payload" - assert request.headers == headers - assert request.context.originating_process is not None - assert request.context.originating_process.ancestors == ("/usr/bin/init",) - - with pytest.raises(ValidationError): - setattr(request, "body", b"changed") - - -@pytest.mark.parametrize( - ("body", "valid"), - [(b"x" * MAX_BODY_BYTES, True), (b"x" * (MAX_BODY_BYTES + 1), False)], -) -def test_request_body_boundary(body: bytes, valid: bool) -> None: - if valid: - assert _request(body=body).body == body - else: - with pytest.raises(ValidationError): - _request(body=body) - - -def test_header_count_and_data_boundaries() -> None: - headers = tuple( - HttpHeader(name=f"x-{index}", value="v") for index in range(MAX_PROTO_HEADERS) - ) - assert len(_request(headers=headers).headers) == MAX_PROTO_HEADERS - - with pytest.raises(ValidationError): - _request(headers=headers + (HttpHeader(name="x-over", value="v"),)) - - exact_data = (HttpHeader(name="x", value="x" * (MAX_PROTO_HEADERS_BYTES - 1)),) - assert _request(headers=exact_data).headers == exact_data - with pytest.raises(ValidationError): - _request(headers=(HttpHeader(name="x", value="x" * MAX_PROTO_HEADERS_BYTES),)) - - -def test_request_mutations_distinguish_no_replacement_from_empty_body() -> None: - no_replacement = RequestMutations() - empty_replacement = RequestMutations(replacement_body=b"") - - assert no_replacement.is_empty - assert not empty_replacement.is_empty - - -def test_request_mutations_preserve_ordered_discriminated_header_mutations() -> None: - adapter = TypeAdapter(HeaderMutation) - discriminator = adapter.json_schema().get("discriminator") - assert isinstance(discriminator, dict) - assert discriminator.get("propertyName") == "kind" - - request_mutations = RequestMutations( - header_mutations=( - WriteHeaderMutation( - kind="write", - name="x-test", - value="one", - on_existing=ExistingHeaderAction.APPEND, - ), - RemoveHeaderMutation(kind="remove", name="x-old"), - ) - ) - - assert request_mutations.header_mutations[0].kind == "write" - assert request_mutations.header_mutations[1].kind == "remove" - - with pytest.raises(ValidationError): - adapter.validate_python({"name": "x-test"}) - with pytest.raises(ValidationError): - adapter.validate_python({"operation": "remove", "name": "x-test"}) - - -def test_request_mutations_reject_invalid_bounds() -> None: - mutation = RemoveHeaderMutation(kind="remove", name="x-test") - assert ( - len( - RequestMutations( - header_mutations=tuple(mutation for _ in range(MAX_HEADER_MUTATIONS)) - ).header_mutations - ) - == MAX_HEADER_MUTATIONS - ) - with pytest.raises(ValidationError): - RequestMutations( - header_mutations=tuple(mutation for _ in range(MAX_HEADER_MUTATIONS + 1)) - ) - - exact_data = RequestMutations( - header_mutations=( - WriteHeaderMutation( - kind="write", - name="x", - value="x" * (MAX_HEADER_MUTATION_DATA_BYTES - 1), - on_existing=ExistingHeaderAction.OVERWRITE, - ), - ) - ) - assert exact_data.header_mutations - with pytest.raises(ValidationError): - RequestMutations( - header_mutations=( - WriteHeaderMutation( - kind="write", - name="x", - value="x" * MAX_HEADER_MUTATION_DATA_BYTES, - on_existing=ExistingHeaderAction.OVERWRITE, - ), - ) - ) - - assert ( - len( - RequestMutations(replacement_body=b"x" * MAX_BODY_BYTES).replacement_body - or b"" - ) - == MAX_BODY_BYTES - ) - with pytest.raises(ValidationError): - RequestMutations(replacement_body=b"x" * (MAX_BODY_BYTES + 1)) - - -def test_request_models_reject_non_tuple_sequences_and_extra_fields() -> None: - values: dict[str, object] = { - "context": RequestContext(request_id="id", sandbox_id="sandbox"), - "target": HttpTarget( - scheme="https", - host="example.com", - port=443, - method="GET", - path="/", - query="", - ), - "headers": [HttpHeader(name="x", value="y")], - "body": b"payload", - } - with pytest.raises(ValidationError): - HttpRequest.model_validate(values) - - with pytest.raises(ValidationError): - HttpHeader(name="", value="value") - - with pytest.raises(ValidationError): - HttpTarget.model_validate( - { - "scheme": "https", - "host": "example.com", - "port": 443, - "method": "GET", - "path": "/", - "query": "", - "extra": "forbidden", - } - ) - - -def test_request_context_string_aggregate_has_an_exact_boundary() -> None: - exact = RequestContext( - request_id="r" * (MAX_PROTO_CONTEXT_BYTES - 1), - sandbox_id="s", - ) - assert len(exact.request_id.encode()) + len(exact.sandbox_id.encode()) == ( - MAX_PROTO_CONTEXT_BYTES - ) - - with pytest.raises(ValidationError): - RequestContext( - request_id="r" * MAX_PROTO_CONTEXT_BYTES, - sandbox_id="s", - ) - - -def test_http_target_string_aggregate_has_an_exact_boundary() -> None: - exact = HttpTarget( - scheme="s" * (MAX_PROTO_TARGET_BYTES - 4), - host="h", - port=443, - method="m", - path="p", - query="q", - ) - assert ( - sum( - len(value.encode()) - for value in ( - exact.scheme, - exact.host, - exact.method, - exact.path, - exact.query, - ) - ) - == MAX_PROTO_TARGET_BYTES - ) - - with pytest.raises(ValidationError): - HttpTarget( - scheme="s" * (MAX_PROTO_TARGET_BYTES - 3), - host="h", - port=443, - method="m", - path="p", - query="q", - ) diff --git a/projects/egress-gate/tests/test_request_processor.py b/projects/egress-gate/tests/test_request_processor.py deleted file mode 100644 index c7272e82..00000000 --- a/projects/egress-gate/tests/test_request_processor.py +++ /dev/null @@ -1,1135 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Ordered current-request execution and decision-provenance tests.""" - -from __future__ import annotations - -import inspect -from time import monotonic -from typing import Literal - -import pytest - -from egress_gate.config import DefaultDecision -from egress_gate.constants import ( - DEFAULT_DENY_REASON_CODE, - LIMIT_REASON_CODE, - MAX_FINDING_COUNT, - MAX_HEADER_MUTATION_DATA_BYTES, - MAX_HEADER_MUTATIONS, - MAX_PROTO_FINDING_GROUPS, - MAX_PROTO_HEADERS, - MAX_PROTO_HEADERS_BYTES, -) -from egress_gate.errors import ( - EgressGateError, - ErrorCode, - GateContractError, -) -from egress_gate.gates import ( - Gate, - GateCapability, - GateConfig, - GateRegistry, -) -from egress_gate.request import ( - ExistingHeaderAction, - HttpHeader, - HttpRequest, - HttpTarget, - RemoveHeaderMutation, - RequestContext, - RequestMutations, - WriteHeaderMutation, -) -from egress_gate.request_processor import RequestProcessor, apply_request_mutations -from egress_gate.result import ( - DecisionSourceKind, - EgressDecision, - Finding, - FindingTypeDefinition, - GateDecisionSource, - GateEvaluation, -) -from egress_gate.timeout import Timeout - -_BOUNDARY_FINDING_TYPE = "t" * 1024 - - -class _ControlConfig(GateConfig): - kind: Literal["test-control"] - control: Literal["proceed", "allow", "deny"] = "proceed" - replacement: str | None = None - expected_body: str | None = None - expected_header_name: str | None = None - expected_header_value: str | None = None - header_value: str | None = None - header_name: str = "x-openshell-middleware-test" - header_action: Literal["append", "overwrite", "skip"] = "overwrite" - remove_header: str | None = None - header_count: int = 0 - finding_label: str | None = None - finding_count: int = 1 - emit_twice: bool = False - boundary_finding: bool = False - reason_code: str | None = None - - -class _ControlGate(Gate[_ControlConfig, None]): - capabilities = frozenset( - { - GateCapability.READ_BODY, - GateCapability.REPLACE_BODY, - GateCapability.MUTATE_HEADERS, - GateCapability.ALLOW, - GateCapability.DENY, - } - ) - finding_types = ( - FindingTypeDefinition(type="test_observation"), - FindingTypeDefinition(type=_BOUNDARY_FINDING_TYPE), - ) - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - timeout.raise_if_expired() - if ( - self.config.expected_body is not None - and request.body.decode("utf-8") != self.config.expected_body - ): - raise AssertionError("later gate did not see the current request") - if self.config.expected_header_name is not None: - values = tuple( - header.value - for header in request.headers - if header.name.lower() == self.config.expected_header_name.lower() - ) - expected = ( - () - if self.config.expected_header_value is None - else (self.config.expected_header_value,) - ) - if values != expected: - raise AssertionError("later gate did not see current request headers") - findings: tuple[Finding, ...] = () - if self.config.boundary_finding: - finding = Finding( - type=_BOUNDARY_FINDING_TYPE, - label="x" * 1024, - count=64, - confidence="c" * 1024, - severity="s" * 1010, - ) - findings = (finding, finding) - elif self.config.finding_label is not None: - finding = Finding( - type="test_observation", - label=self.config.finding_label, - count=self.config.finding_count, - ) - findings = (finding,) - if self.config.emit_twice: - findings += (finding,) - if self.config.control == "deny": - return GateEvaluation.deny( - self.config.reason_code or "egress_gate_test_denied", - findings=findings, - ) - if self.config.control == "allow": - return GateEvaluation.allow(findings=findings) - mutations: tuple[WriteHeaderMutation | RemoveHeaderMutation, ...] = tuple( - WriteHeaderMutation( - kind="write", - name=f"x-openshell-middleware-test-{index}", - value=self.config.header_value or "true", - on_existing=ExistingHeaderAction.OVERWRITE, - ) - for index in range(self.config.header_count) - ) - if self.config.header_value is not None and not mutations: - mutations = ( - WriteHeaderMutation( - kind="write", - name=self.config.header_name, - value=self.config.header_value, - on_existing=ExistingHeaderAction(self.config.header_action), - ), - ) - if self.config.remove_header is not None: - mutations += ( - RemoveHeaderMutation(kind="remove", name=self.config.remove_header), - ) - return GateEvaluation.proceed( - request_mutations=RequestMutations( - replacement_body=( - None - if self.config.replacement is None - else self.config.replacement.encode("utf-8") - ), - header_mutations=mutations, - ), - findings=findings, - ) - - -def _request( - *, body: bytes = b"original", headers: tuple[HttpHeader, ...] = () -) -> HttpRequest: - return HttpRequest( - context=RequestContext(request_id="request-1", sandbox_id="sandbox-1"), - target=HttpTarget( - scheme="https", - host="example.com", - port=443, - method="POST", - path="/", - query="", - ), - headers=headers, - body=body, - ) - - -def _regex_config( - action_kind: str = "detect", - *, - scan: dict[str, object] | None = None, - entity: str = "token", - pattern: str = "secret", - template: str = "[{entity}]", -) -> dict[str, object]: - scan_values = {"kind": "body"} if scan is None else dict(scan) - action: dict[str, object] = {"kind": action_kind} - if action_kind == "replace": - action["template"] = template - scan_values["action"] = action - return { - "kind": "regex", - "scan": scan_values, - "pattern_catalog": { - "entities": [ - { - "name": entity, - "rules": [{"pattern": pattern, "confidence": "high"}], - } - ] - }, - } - - -def _processor( - gate_values: tuple[tuple[str, dict[str, object]], ...], - *, - default_decision: DefaultDecision = DefaultDecision.ALLOW, - include_regex: bool = False, -) -> RequestProcessor: - registry = GateRegistry(include_builtin_gates=include_regex) - registry.register(_ControlGate) - values = { - "gates": [{"name": name, **config} for name, config in gate_values], - "default_decision": default_decision.value, - } - config = registry.validate_config(values) - prepared_items = [] - for entry in config.gates: - gate_type = getattr(entry, "kind", None) - if not isinstance(gate_type, str): - raise AssertionError("test gate config has no discriminator") - prepared_items.append((entry.name, gate_type, registry.create_gate(entry))) - prepared = tuple(prepared_items) - return RequestProcessor( - config, - prepared, - policy_fingerprint="policy-fingerprint", - ) - - -def test_processor_process_requires_the_service_created_timeout() -> None: - process_signature = inspect.signature(RequestProcessor.process) - assert "timeout_seconds" not in inspect.signature(RequestProcessor).parameters - assert ( - process_signature.parameters["timeout"].kind is inspect.Parameter.KEYWORD_ONLY - ) - - processor = _processor((("one", {"kind": "test-control", "control": "proceed"}),)) - result = processor.process(_request(), timeout=Timeout.from_seconds(1)) - assert result.decision is EgressDecision.ALLOW - - -def test_processor_applies_mutations_to_the_current_request_and_preserves_intent() -> ( - None -): - processor = _processor( - ( - ( - "redact", - { - "kind": "test-control", - "replacement": "redacted", - "finding_label": "secret", - }, - ), - ( - "observe", - { - "kind": "test-control", - "expected_body": "redacted", - "header_value": "true", - "finding_label": "observed", - }, - ), - ) - ) - - result = processor.process(_request(), timeout=Timeout.from_seconds(1)) - - assert result.decision is EgressDecision.ALLOW - assert result.decision_source.kind is DecisionSourceKind.PIPELINE_DEFAULT - assert result.request_mutations.replacement_body == b"redacted" - mutation = result.request_mutations.header_mutations[0] - assert isinstance(mutation, WriteHeaderMutation) - assert mutation.value == "true" - assert [(item.source_gate, item.finding.label) for item in result.findings] == [ - ("redact", "secret"), - ("observe", "observed"), - ] - assert [trace.gate_type for trace in result.traces] == [ - "test-control", - "test-control", - ] - assert result.policy_fingerprint == "policy-fingerprint" - - -def test_three_gates_aggregate_interacting_body_and_header_mutations() -> None: - original = _request( - body=b"original secret", - headers=( - HttpHeader(name="X-OpenShell-Middleware-State", value="old-one"), - HttpHeader(name="x-openshell-middleware-state", value="old-two"), - HttpHeader(name="x-remove", value="discard"), - HttpHeader(name="x-keep", value="preserve"), - ), - ) - processor = _processor( - ( - ( - "first", - { - "kind": "test-control", - "replacement": "first redaction", - "header_name": "x-openshell-middleware-state", - "header_value": "stage-one", - "header_action": "overwrite", - }, - ), - ( - "second", - { - "kind": "test-control", - "expected_body": "first redaction", - "expected_header_name": "x-openshell-middleware-state", - "expected_header_value": "stage-one", - "replacement": "", - "header_name": "x-openshell-middleware-chain", - "header_value": "stage-two", - "header_action": "append", - "remove_header": "x-openshell-middleware-state", - }, - ), - ( - "third", - { - "kind": "test-control", - "expected_body": "", - "expected_header_name": "x-openshell-middleware-state", - "replacement": "final body", - "header_name": "x-openshell-middleware-state", - "header_value": "stage-three", - "header_action": "append", - "remove_header": "x-remove", - }, - ), - ) - ) - - result = processor.process(original, timeout=Timeout.from_seconds(1)) - final_request = apply_request_mutations(original, result.request_mutations) - first_mutations = RequestMutations( - replacement_body=b"first redaction", - header_mutations=( - WriteHeaderMutation( - kind="write", - name="x-openshell-middleware-state", - value="stage-one", - on_existing=ExistingHeaderAction.OVERWRITE, - ), - ), - ) - second_mutations = RequestMutations( - replacement_body=b"", - header_mutations=( - WriteHeaderMutation( - kind="write", - name="x-openshell-middleware-chain", - value="stage-two", - on_existing=ExistingHeaderAction.APPEND, - ), - RemoveHeaderMutation( - kind="remove", - name="x-openshell-middleware-state", - ), - ), - ) - third_mutations = RequestMutations( - replacement_body=b"final body", - header_mutations=( - WriteHeaderMutation( - kind="write", - name="x-openshell-middleware-state", - value="stage-three", - on_existing=ExistingHeaderAction.APPEND, - ), - RemoveHeaderMutation(kind="remove", name="x-remove"), - ), - ) - sequential_request = original - for mutations in (first_mutations, second_mutations, third_mutations): - sequential_request = apply_request_mutations(sequential_request, mutations) - - assert result.decision is EgressDecision.ALLOW - assert result.request_mutations == RequestMutations( - replacement_body=b"final body", - header_mutations=( - first_mutations.header_mutations - + second_mutations.header_mutations - + third_mutations.header_mutations - ), - ) - assert final_request == sequential_request - assert final_request.body == b"final body" - assert final_request.headers == ( - HttpHeader(name="x-keep", value="preserve"), - HttpHeader(name="x-openshell-middleware-chain", value="stage-two"), - HttpHeader(name="x-openshell-middleware-state", value="stage-three"), - ) - - -def test_three_regex_gates_progressively_redact_the_current_body() -> None: - original = _request( - body=( - b"Customer record\n" - b"email: alice@example.com\n" - b"api key: sk-123456\n" - b"phone: 555-0100\n" - ) - ) - processor = _processor( - ( - ( - "redact-email", - _regex_config( - "replace", - entity="email", - pattern=r"alice@example\.com", - ), - ), - ( - "redact-api-key", - _regex_config( - "replace", - entity="api_key", - pattern=r"sk-[0-9]+", - ), - ), - ( - "redact-phone", - _regex_config( - "replace", - entity="phone", - pattern=r"555-[0-9]{4}", - ), - ), - ), - include_regex=True, - ) - - result = processor.process(original, timeout=Timeout.from_seconds(1)) - final_request = apply_request_mutations(original, result.request_mutations) - - expected_body = ( - b"Customer record\nemail: [email]\napi key: [api_key]\nphone: [phone]\n" - ) - assert result.decision is EgressDecision.ALLOW - assert result.request_mutations.replacement_body == expected_body - assert final_request.body == expected_body - assert [(item.source_gate, item.finding.type) for item in result.findings] == [ - ("redact-email", "regex_match"), - ("redact-api-key", "regex_match"), - ("redact-phone", "regex_match"), - ] - - -def test_structured_regex_scan_parses_the_body_replaced_by_an_earlier_gate() -> None: - processor = _processor( - ( - ( - "wrap", - { - "kind": "test-control", - "replacement": '{"messages":[{"content":"secret"}]}', - }, - ), - ( - "redact", - _regex_config( - "replace", - scan={ - "kind": "json-fields", - "selectors": [ - { - "segments": [ - {"kind": "key", "value": "messages"}, - {"kind": "each"}, - {"kind": "key", "value": "content"}, - ] - } - ], - }, - ), - ), - ), - include_regex=True, - ) - - result = processor.process(_request(), timeout=Timeout.from_seconds(1)) - - assert result.request_mutations.replacement_body == ( - b'{"messages":[{"content":"[token]"}]}' - ) - - -def test_later_regex_gates_use_the_body_after_overlapping_text_is_redacted() -> None: - original = _request(body=b"credential: alice@example.com") - processor = _processor( - ( - ( - "redact-email", - _regex_config( - "replace", - entity="email", - pattern=r"alice@example\.com", - ), - ), - ( - "redact-original-domain", - _regex_config( - "replace", - entity="domain", - pattern=r"example\.com", - ), - ), - ( - "classify-redaction", - _regex_config( - "replace", - entity="redacted_email", - pattern=r"\[email\]", - template="<{entity}>", - ), - ), - ), - include_regex=True, - ) - - result = processor.process(original, timeout=Timeout.from_seconds(1)) - final_request = apply_request_mutations(original, result.request_mutations) - - assert final_request.body == b"credential: " - assert [(item.source_gate, item.finding.label) for item in result.findings] == [ - ("redact-email", "email"), - ("classify-redaction", "redacted_email"), - ] - - -def test_final_empty_body_replacement_is_preserved_across_gates() -> None: - original = _request(body=b"original") - processor = _processor( - ( - ("first", {"kind": "test-control", "replacement": "intermediate"}), - ( - "second", - { - "kind": "test-control", - "expected_body": "intermediate", - "replacement": "", - }, - ), - ( - "observe", - { - "kind": "test-control", - "expected_body": "", - }, - ), - ) - ) - - result = processor.process(original, timeout=Timeout.from_seconds(1)) - final_request = apply_request_mutations(original, result.request_mutations) - - assert result.decision is EgressDecision.ALLOW - assert result.request_mutations.replacement_body == b"" - assert final_request.body == b"" - - -def test_header_skip_and_append_remain_ordered_across_gates() -> None: - original = _request( - headers=(HttpHeader(name="X-OpenShell-Middleware-State", value="original"),) - ) - processor = _processor( - ( - ( - "overwrite", - { - "kind": "test-control", - "header_name": "x-openshell-middleware-state", - "header_value": "first", - "header_action": "overwrite", - }, - ), - ( - "skip", - { - "kind": "test-control", - "expected_header_name": "x-openshell-middleware-state", - "expected_header_value": "first", - "header_name": "x-openshell-middleware-state", - "header_value": "ignored", - "header_action": "skip", - }, - ), - ( - "append", - { - "kind": "test-control", - "expected_header_name": "x-openshell-middleware-state", - "expected_header_value": "first", - "header_name": "x-openshell-middleware-state", - "header_value": "second", - "header_action": "append", - }, - ), - ) - ) - - result = processor.process(original, timeout=Timeout.from_seconds(1)) - final_request = apply_request_mutations(original, result.request_mutations) - - assert final_request.headers == ( - HttpHeader(name="x-openshell-middleware-state", value="first"), - HttpHeader(name="x-openshell-middleware-state", value="second"), - ) - - -def test_terminal_allow_returns_mutations_from_prior_gates() -> None: - original = _request(body=b"original") - processor = _processor( - ( - ("body", {"kind": "test-control", "replacement": "updated"}), - ( - "header", - { - "kind": "test-control", - "expected_body": "updated", - "header_name": "x-openshell-middleware-reviewed", - "header_value": "true", - }, - ), - ( - "allow", - { - "kind": "test-control", - "control": "allow", - "expected_body": "updated", - "expected_header_name": "x-openshell-middleware-reviewed", - "expected_header_value": "true", - }, - ), - ) - ) - - result = processor.process(original, timeout=Timeout.from_seconds(1)) - final_request = apply_request_mutations(original, result.request_mutations) - - assert result.decision is EgressDecision.ALLOW - assert isinstance(result.decision_source, GateDecisionSource) - assert result.decision_source.gate_name == "allow" - assert final_request.body == b"updated" - assert final_request.headers == ( - HttpHeader(name="x-openshell-middleware-reviewed", value="true"), - ) - - -def test_regex_gate_sees_header_mutations_from_an_earlier_gate() -> None: - processor = _processor( - ( - ( - "add-header", - { - "kind": "test-control", - "header_value": "contains secret", - }, - ), - ( - "inspect-header", - _regex_config( - "deny", - scan={ - "kind": "header", - "names": ["x-openshell-middleware-test"], - }, - ), - ), - ), - include_regex=True, - ) - - result = processor.process(_request(), timeout=Timeout.from_seconds(1)) - - assert result.decision is EgressDecision.DENY - assert isinstance(result.decision_source, GateDecisionSource) - assert result.decision_source.gate_name == "inspect-header" - assert result.request_mutations.is_empty - - -def test_processor_aggregates_equivalent_findings_by_gate_provenance() -> None: - processor = _processor( - ( - ( - "one", - {"kind": "test-control", "finding_label": "same", "emit_twice": True}, - ), - ) - ) - result = processor.process(_request(), timeout=Timeout.from_seconds(1)) - - assert len(result.findings) == 1 - assert result.findings[0].source_gate == "one" - assert result.findings[0].finding.count == 2 - - -def test_aggregated_finding_size_exhaustion_returns_a_runtime_limit() -> None: - processor = _processor( - ( - ( - "one", - { - "kind": "test-control", - "boundary_finding": True, - }, - ), - ) - ) - - result = processor.process(_request(), timeout=Timeout.from_seconds(1)) - - assert result.decision is EgressDecision.DENY - assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT - assert result.reason_code == LIMIT_REASON_CODE - assert result.findings == () - - -def test_terminal_decisions_skip_later_gates() -> None: - deny = _processor( - ( - ( - "deny", - { - "kind": "test-control", - "control": "deny", - "reason_code": "policy_denied", - }, - ), - ( - "never", - { - "kind": "test-control", - "expected_body": "this gate must not run", - }, - ), - ) - ) - allow = _processor( - ( - ("allow", {"kind": "test-control", "control": "allow"}), - ( - "never", - { - "kind": "test-control", - "expected_body": "this gate must not run", - }, - ), - ) - ) - - denied = deny.process(_request(), timeout=Timeout.from_seconds(1)) - allowed = allow.process(_request(), timeout=Timeout.from_seconds(1)) - - assert denied.decision is EgressDecision.DENY - assert denied.decision_source.kind is DecisionSourceKind.GATE - assert isinstance(denied.decision_source, GateDecisionSource) - assert denied.decision_source.gate_name == "deny" - assert denied.reason_code == "policy_denied" - assert allowed.decision is EgressDecision.ALLOW - assert isinstance(allowed.decision_source, GateDecisionSource) - assert allowed.decision_source.gate_name == "allow" - - -def test_default_deny_owns_its_reason_and_discards_accumulated_mutations() -> None: - processor = _processor( - (("redact", {"kind": "test-control", "replacement": "redacted"}),), - default_decision=DefaultDecision.DENY, - ) - - result = processor.process(_request(), timeout=Timeout.from_seconds(1)) - - assert result.decision is EgressDecision.DENY - assert result.decision_source.kind is DecisionSourceKind.PIPELINE_DEFAULT - assert result.reason_code == DEFAULT_DENY_REASON_CODE - assert result.request_mutations.is_empty - - -def test_expired_shared_timeout_returns_atomic_runtime_limit_result() -> None: - processor = _processor((("one", {"kind": "test-control", "control": "proceed"}),)) - - result = processor.process( - _request(), - timeout=Timeout(deadline=monotonic() - 1), - ) - - assert result.decision is EgressDecision.DENY - assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT - assert result.reason_code == LIMIT_REASON_CODE - assert result.request_mutations.is_empty - - -def test_regex_finding_group_overflow_is_an_atomic_runtime_limit() -> None: - processor = _processor( - ( - ( - "regex", - { - "kind": "regex", - "scan": {"kind": "body", "action": {"kind": "detect"}}, - "pattern_catalog": { - "entities": [ - { - "name": f"entity-{index}", - "rules": [{"pattern": "x", "confidence": "high"}], - } - for index in range(MAX_PROTO_FINDING_GROUPS + 1) - ] - }, - }, - ), - ), - include_regex=True, - ) - - result = processor.process( - _request(body=b"x"), - timeout=Timeout.from_seconds(1), - ) - - assert result.decision is EgressDecision.DENY - assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT - assert result.reason_code == LIMIT_REASON_CODE - assert not result.findings - assert result.request_mutations.is_empty - - -def test_composed_header_mutation_overflow_is_an_atomic_runtime_limit() -> None: - processor = _processor( - ( - ( - "first", - {"kind": "test-control", "header_count": MAX_HEADER_MUTATIONS // 2}, - ), - ( - "second", - { - "kind": "test-control", - "header_count": MAX_HEADER_MUTATIONS // 2 + 1, - }, - ), - ) - ) - - result = processor.process(_request(), timeout=Timeout.from_seconds(1)) - - assert result.decision is EgressDecision.DENY - assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT - assert result.reason_code == LIMIT_REASON_CODE - assert result.request_mutations.is_empty - assert result.findings == () - assert result.traces == () - - -def test_composed_header_mutation_data_overflow_is_an_atomic_runtime_limit() -> None: - half_limit = MAX_HEADER_MUTATION_DATA_BYTES // 2 - processor = _processor( - ( - ( - "first", - { - "kind": "test-control", - "header_name": "x-openshell-middleware-first", - "header_value": "a" * half_limit, - }, - ), - ( - "second", - { - "kind": "test-control", - "header_name": "x-openshell-middleware-second", - "header_value": "b" * half_limit, - }, - ), - ) - ) - - result = processor.process(_request(), timeout=Timeout.from_seconds(1)) - - assert result.decision is EgressDecision.DENY - assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT - assert result.reason_code == LIMIT_REASON_CODE - assert result.request_mutations.is_empty - assert result.findings == () - assert result.traces == () - - -@pytest.mark.parametrize( - "current_request", - [ - _request( - headers=tuple( - HttpHeader(name=f"x-{index}", value="value") - for index in range(MAX_PROTO_HEADERS) - ) - ), - _request( - headers=( - HttpHeader( - name="x-existing", - value="x" * (MAX_PROTO_HEADERS_BYTES - len("x-existing")), - ), - ) - ), - ], - ids=("header-count", "header-bytes"), -) -def test_mutation_that_overflows_the_current_request_is_an_atomic_runtime_limit( - current_request: HttpRequest, -) -> None: - processor = _processor( - ( - ( - "append", - { - "kind": "test-control", - "header_name": "x-openshell-middleware-added", - "header_value": "value", - "header_action": "append", - }, - ), - ) - ) - - result = processor.process(current_request, timeout=Timeout.from_seconds(1)) - - assert result.decision is EgressDecision.DENY - assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT - assert result.reason_code == LIMIT_REASON_CODE - assert result.request_mutations.is_empty - assert result.findings == () - assert result.traces == () - - -@pytest.mark.parametrize( - "gate_config", - [ - { - "kind": "test-control", - "header_name": "authorization", - "header_value": "secret", - }, - { - "kind": "test-control", - "remove_header": "Host", - }, - ], - ids=("write-outside-namespace", "remove-protected-header"), -) -def test_processor_translates_disallowed_header_mutations_to_a_stable_error( - gate_config: dict[str, object], -) -> None: - processor = _processor((("invalid", gate_config),)) - - with pytest.raises(EgressGateError) as error: - processor.process(_request(), timeout=Timeout.from_seconds(1)) - - assert error.value.code is ErrorCode.GATE_OUTPUT_INVALID - - -def test_trace_finding_count_overflow_is_an_atomic_runtime_limit() -> None: - processor = _processor( - ( - ( - "observations", - { - "kind": "test-control", - "finding_label": "same", - "finding_count": MAX_FINDING_COUNT, - "emit_twice": True, - }, - ), - ) - ) - - result = processor.process(_request(), timeout=Timeout.from_seconds(1)) - - assert result.decision is EgressDecision.DENY - assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT - assert result.reason_code == LIMIT_REASON_CODE - assert result.request_mutations.is_empty - assert result.findings == () - assert result.traces == () - - -def test_invalid_utf8_is_translated_to_the_stable_input_error() -> None: - processor = _processor( - (("regex", _regex_config()),), - include_regex=True, - ) - - with pytest.raises(EgressGateError) as error: - processor.process(_request(body=b"\xff"), timeout=Timeout.from_seconds(1)) - - assert error.value.code is ErrorCode.BODY_ENCODING_INVALID - - -def test_invalid_json_is_translated_to_the_stable_body_format_error() -> None: - processor = _processor( - ( - ( - "regex", - _regex_config( - scan={ - "kind": "json-fields", - "selectors": [ - {"segments": [{"kind": "key", "value": "messages"}]} - ], - } - ), - ), - ), - include_regex=True, - ) - - with pytest.raises(EgressGateError) as error: - processor.process(_request(body=b"{"), timeout=Timeout.from_seconds(1)) - - assert error.value.code is ErrorCode.BODY_FORMAT_INVALID - - -def test_prepared_gate_type_is_part_of_the_processor_contract() -> None: - registry = GateRegistry() - registry.register(_ControlGate) - config = registry.validate_config( - { - "gates": [{"name": "one", "kind": "test-control", "control": "proceed"}], - "default_decision": "allow", - } - ) - gate = registry.create_gate(config.gates[0]) - - with pytest.raises(ValueError): - RequestProcessor( - config, - (("one", "wrong-type", gate),), - ) - - -def test_header_mutations_are_ordered_and_protected() -> None: - original = _request( - headers=( - HttpHeader(name="x-openshell-middleware-test", value="old"), - HttpHeader(name="x-other", value="keep"), - ) - ) - request_mutations = RequestMutations( - header_mutations=( - WriteHeaderMutation( - kind="write", - name="x-openshell-middleware-test", - value="new", - on_existing=ExistingHeaderAction.OVERWRITE, - ), - WriteHeaderMutation( - kind="write", - name="x-openshell-middleware-added", - value="one", - on_existing=ExistingHeaderAction.APPEND, - ), - WriteHeaderMutation( - kind="write", - name="x-openshell-middleware-added", - value="two", - on_existing=ExistingHeaderAction.SKIP, - ), - RemoveHeaderMutation(kind="remove", name="x-other"), - ) - ) - updated = apply_request_mutations(original, request_mutations) - - assert updated.headers == ( - HttpHeader(name="x-openshell-middleware-test", value="new"), - HttpHeader(name="x-openshell-middleware-added", value="one"), - ) - - skip_absent = apply_request_mutations( - _request(), - RequestMutations( - header_mutations=( - WriteHeaderMutation( - kind="write", - name="x-openshell-middleware-added", - value="created", - on_existing=ExistingHeaderAction.SKIP, - ), - ) - ), - ) - assert skip_absent.headers == ( - HttpHeader(name="x-openshell-middleware-added", value="created"), - ) - - with pytest.raises(GateContractError): - apply_request_mutations( - original, - RequestMutations( - header_mutations=( - WriteHeaderMutation( - kind="write", - name="authorization", - value="secret", - on_existing=ExistingHeaderAction.APPEND, - ), - ) - ), - ) diff --git a/projects/egress-gate/tests/test_result.py b/projects/egress-gate/tests/test_result.py deleted file mode 100644 index d21c3b0d..00000000 --- a/projects/egress-gate/tests/test_result.py +++ /dev/null @@ -1,390 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Focused invariant and boundary tests for Egress Gate result models.""" - -from __future__ import annotations - -import math - -import pytest -from pydantic import TypeAdapter, ValidationError - -from egress_gate.constants import ( - DEFAULT_DENY_REASON_CODE, - LIMIT_REASON_CODE, - MAX_FINDING_COUNT, - MAX_GATE_TRACES, - MAX_PROTO_FINDING_BYTES, - MAX_PROTO_FINDING_GROUPS, - MAX_RESULT_METADATA_BYTES, - MAX_RESULT_METADATA_ENTRIES, - MAX_TRACE_MUTATION_KINDS, -) -from egress_gate.request import RequestMutations -from egress_gate.result import ( - DecisionSource, - DecisionSourceKind, - EgressDecision, - EgressResult, - Finding, - GateControl, - GateDecisionSource, - GateEvaluation, - GateTrace, - MutationKind, - PipelineDefaultDecisionSource, - ResultMetadata, - RuntimeLimitDecisionSource, - SourcedFinding, -) - - -def _finding(**values: object) -> Finding: - defaults: dict[str, object] = {"type": "sensitive_entity", "label": "email"} - defaults.update(values) - return Finding.model_validate(defaults) - - -def test_finding_matches_the_current_five_field_wire_contract() -> None: - finding = _finding(count=MAX_FINDING_COUNT, confidence="high", severity="medium") - - assert set(Finding.model_fields) == { - "type", - "label", - "count", - "confidence", - "severity", - } - assert finding.count == MAX_FINDING_COUNT - - with pytest.raises(ValidationError): - Finding.model_validate( - {"type": "sensitive_entity", "label": "email", "source_gate": "hidden"} - ) - - -@pytest.mark.parametrize("count", [0, MAX_FINDING_COUNT + 1]) -def test_finding_count_is_bounded(count: int) -> None: - with pytest.raises(ValidationError): - _finding(count=count) - - -def test_finding_encoded_size_has_an_exact_four_kibibyte_boundary() -> None: - exact = Finding( - type="t" * 1024, - label="l" * 1024, - confidence="c" * 1024, - severity="s" * 1010, - ) - assert exact.encoded_size_bytes == MAX_PROTO_FINDING_BYTES - - with pytest.raises(ValidationError): - Finding( - type="t" * 1024, - label="l" * 1024, - confidence="c" * 1024, - severity="s" * 1011, - ) - assert MAX_PROTO_FINDING_BYTES == 4 * 1024 - - -def test_gate_evaluation_helpers_and_control_invariants() -> None: - finding = _finding() - assert GateEvaluation.proceed(findings=(finding,)).control is GateControl.PROCEED - assert GateEvaluation.allow().request_mutations.is_empty - assert GateEvaluation.deny("egress_gate_blocked").reason_code == ( - "egress_gate_blocked" - ) - - with pytest.raises(ValidationError): - GateEvaluation( - control=GateControl.ALLOW, - request_mutations=RequestMutations(replacement_body=b"x"), - ) - with pytest.raises(ValidationError): - GateEvaluation(control=GateControl.DENY) - with pytest.raises(ValidationError): - GateEvaluation(control=GateControl.PROCEED, reason_code="invalid_control") - - -@pytest.mark.parametrize("reason_code", ["", "UPPERCASE", "has-hyphen", "x" * 65]) -def test_reason_codes_use_stable_identifier_format(reason_code: str) -> None: - with pytest.raises(ValidationError): - GateEvaluation.deny(reason_code) - - -def test_decision_source_keeps_gate_provenance_outside_finding() -> None: - adapter = TypeAdapter(DecisionSource) - discriminator = adapter.json_schema().get("discriminator") - assert isinstance(discriminator, dict) - assert discriminator.get("propertyName") == "kind" - source = adapter.validate_python( - { - "kind": "gate", - "gate_name": "identifiers", - "gate_type": "regex", - } - ) - sourced = SourcedFinding(source_gate="identifiers", finding=_finding()) - - assert isinstance(source, GateDecisionSource) - assert source.kind is DecisionSourceKind.GATE - assert sourced.finding.model_dump() == _finding().model_dump() - assert "source_gate" not in sourced.finding.model_dump() - - with pytest.raises(ValidationError): - adapter.validate_python({"kind": "gate", "gate_name": "identifiers"}) - with pytest.raises(ValidationError): - adapter.validate_python({"kind": "runtime_limit", "gate_name": "identifiers"}) - - -def test_egress_result_suppresses_mutations_on_deny_by_rejecting_them() -> None: - finding = SourcedFinding(source_gate="identifiers", finding=_finding()) - allowed = EgressResult( - decision=EgressDecision.ALLOW, - decision_source=GateDecisionSource( - kind=DecisionSourceKind.GATE, gate_name="identifiers", gate_type="regex" - ), - request_mutations=RequestMutations(replacement_body=b"redacted"), - findings=(finding,), - ) - assert allowed.request_mutations.replacement_body == b"redacted" - - with pytest.raises(ValidationError): - EgressResult( - decision=EgressDecision.DENY, - decision_source=RuntimeLimitDecisionSource( - kind=DecisionSourceKind.RUNTIME_LIMIT - ), - request_mutations=RequestMutations(replacement_body=b"must-not-leak"), - reason_code="egress_gate_limit_exceeded", - ) - with pytest.raises(ValidationError): - EgressResult( - decision=EgressDecision.DENY, - decision_source=RuntimeLimitDecisionSource( - kind=DecisionSourceKind.RUNTIME_LIMIT - ), - ) - with pytest.raises(ValidationError): - EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - reason_code="not-allowed-on-allow", - ) - - -def test_egress_result_limits_finding_groups_and_trace_values() -> None: - finding = SourcedFinding(source_gate="identifiers", finding=_finding()) - findings = tuple( - SourcedFinding( - source_gate=f"gate-{index}", finding=_finding(label=f"label-{index}") - ) - for index in range(MAX_PROTO_FINDING_GROUPS) - ) - result = EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - findings=findings, - ) - assert len(result.findings) == MAX_PROTO_FINDING_GROUPS - assert finding.finding.type == "sensitive_entity" - - with pytest.raises(ValidationError): - EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - findings=findings + (finding,), - ) - - trace = GateTrace( - gate_name="identifiers", - gate_type="regex", - control=GateControl.PROCEED, - duration_ms=0, - finding_count=0, - mutation_kinds=(MutationKind.BODY,), - ) - assert trace.duration_ms == 0 - with pytest.raises(ValidationError): - GateTrace( - gate_name="identifiers", - gate_type="regex", - control=GateControl.PROCEED, - duration_ms=math.inf, - finding_count=0, - ) - - -def test_gate_evaluation_and_result_group_limits_have_exact_boundaries() -> None: - findings = tuple( - _finding(label=f"label-{index}") for index in range(MAX_PROTO_FINDING_GROUPS) - ) - assert len(GateEvaluation.proceed(findings=findings).findings) == ( - MAX_PROTO_FINDING_GROUPS - ) - with pytest.raises(ValidationError): - GateEvaluation.proceed(findings=findings + (_finding(label="over"),)) - - sourced = tuple( - SourcedFinding(source_gate=f"gate-{index}", finding=finding) - for index, finding in enumerate(findings) - ) - result = EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - findings=sourced, - ) - assert len(result.findings) == MAX_PROTO_FINDING_GROUPS - with pytest.raises(ValidationError): - EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - findings=sourced - + (SourcedFinding(source_gate="over", finding=_finding(label="over")),), - ) - - -def test_metadata_count_and_aggregate_byte_limits_have_exact_boundaries() -> None: - entries = tuple( - ResultMetadata( - key=f"k{index}", - value="v" * (510 if index < 10 else 509), - ) - for index in range(MAX_RESULT_METADATA_ENTRIES) - ) - result = EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - metadata=entries, - ) - assert len(result.metadata) == MAX_RESULT_METADATA_ENTRIES - assert ( - sum(len(entry.key.encode()) + len(entry.value.encode()) for entry in entries) - == MAX_RESULT_METADATA_BYTES - ) - - with pytest.raises(ValidationError): - EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - metadata=entries[:-1] - + ( - ResultMetadata( - key="k63", - value="v" * 510, - ), - ), - ) - with pytest.raises(ValidationError): - EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - metadata=entries + (ResultMetadata(key="over", value="v"),), - ) - - -def test_trace_count_and_mutation_kind_limits_have_exact_boundaries() -> None: - trace = GateTrace( - gate_name="gate", - gate_type="test", - control=GateControl.PROCEED, - duration_ms=0, - finding_count=0, - mutation_kinds=(MutationKind.BODY, MutationKind.HEADERS), - ) - assert len(trace.mutation_kinds) == MAX_TRACE_MUTATION_KINDS - with pytest.raises(ValidationError): - GateTrace( - gate_name="gate", - gate_type="test", - control=GateControl.PROCEED, - duration_ms=0, - finding_count=0, - mutation_kinds=( - MutationKind.BODY, - MutationKind.HEADERS, - MutationKind.BODY, - ), - ) - - traces = tuple( - trace.model_copy(update={"gate_name": f"gate-{index}"}) - for index in range(MAX_GATE_TRACES) - ) - result = EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - traces=traces, - ) - assert len(result.traces) == MAX_GATE_TRACES - with pytest.raises(ValidationError): - EgressResult( - decision=EgressDecision.ALLOW, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - traces=traces + (trace,), - ) - - -def test_decision_source_reason_code_ownership_is_strict() -> None: - default_deny = EgressResult( - decision=EgressDecision.DENY, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - reason_code=DEFAULT_DENY_REASON_CODE, - ) - runtime_limit = EgressResult( - decision=EgressDecision.DENY, - decision_source=RuntimeLimitDecisionSource( - kind=DecisionSourceKind.RUNTIME_LIMIT - ), - reason_code=LIMIT_REASON_CODE, - ) - assert default_deny.reason_code == DEFAULT_DENY_REASON_CODE - assert runtime_limit.reason_code == LIMIT_REASON_CODE - - with pytest.raises(ValidationError): - EgressResult( - decision=EgressDecision.ALLOW, - decision_source=RuntimeLimitDecisionSource( - kind=DecisionSourceKind.RUNTIME_LIMIT - ), - ) - with pytest.raises(ValidationError): - EgressResult( - decision=EgressDecision.DENY, - decision_source=RuntimeLimitDecisionSource( - kind=DecisionSourceKind.RUNTIME_LIMIT - ), - reason_code=DEFAULT_DENY_REASON_CODE, - ) - with pytest.raises(ValidationError): - EgressResult( - decision=EgressDecision.DENY, - decision_source=PipelineDefaultDecisionSource( - kind=DecisionSourceKind.PIPELINE_DEFAULT - ), - reason_code=LIMIT_REASON_CODE, - ) diff --git a/projects/egress-gate/tests/test_timeout.py b/projects/egress-gate/tests/test_timeout.py deleted file mode 100644 index 692f3da3..00000000 --- a/projects/egress-gate/tests/test_timeout.py +++ /dev/null @@ -1,93 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""Tests for the shared gate-pipeline timeout.""" - -from time import monotonic - -import pytest - -from egress_gate.errors import TimeoutExpiredError -from egress_gate.timeout import ( - Timeout, - format_timeout_middleware_processing, - parse_duration, - parse_timeout_duration, -) - - -@pytest.mark.parametrize( - "seconds", - [True, 0, -1, 0.001, 0.0101, 1.0001, float("inf")], -) -def test_timeout_duration_has_minimum_and_whole_milliseconds( - seconds: bool | int | float, -) -> None: - with pytest.raises( - ValueError, - match="at least 10ms, using whole milliseconds", - ): - Timeout.from_seconds(seconds) - - -@pytest.mark.parametrize( - ("seconds", "duration"), - [(0.01, "10ms"), (1.0, "1s"), (4.5, "4500ms"), (45.0, "45s")], -) -def test_middleware_timeout_uses_the_contract_duration_format( - seconds: float, - duration: str, -) -> None: - assert format_timeout_middleware_processing(seconds) == duration - - -@pytest.mark.parametrize( - ("duration", "seconds"), - [("10ms", 0.01), ("500ms", 0.5), ("1s", 1.0), ("45s", 45.0)], -) -def test_timeout_duration_parser_accepts_concise_cli_values( - duration: str, - seconds: float, -) -> None: - assert parse_timeout_duration(duration) == seconds - - -@pytest.mark.parametrize( - "duration", - ["", "1", "1.5s", "0s", "9ms", "1m", "1000000000s"], -) -def test_timeout_duration_parser_rejects_unsupported_values(duration: str) -> None: - with pytest.raises(ValueError, match="timeout"): - parse_timeout_duration(duration) - - -def test_shared_duration_parser_supports_gateway_values_and_units() -> None: - assert parse_duration("1ms") == 0.001 - assert parse_duration("45s", unit="ms") == 45_000.0 - - -def test_expired_timeout_raises_typed_signal() -> None: - timeout = Timeout(deadline=monotonic() - 1) - - with pytest.raises(TimeoutExpiredError) as captured: - timeout.raise_if_expired() - - assert str(captured.value) == ( - "Egress Gate processing timed out. Reduce the request size or simplify " - "the configured gates and rules, or increase the middleware processing " - "timeout, then retry." - ) - - -def test_timeout_context_translates_delegated_timeout_error() -> None: - timeout = Timeout.from_seconds(1) - - with pytest.raises(TimeoutExpiredError), timeout.enforce(): - raise TimeoutError - - -def test_timeout_context_preserves_unrelated_errors() -> None: - timeout = Timeout.from_seconds(1) - - with pytest.raises(RuntimeError), timeout.enforce(): - raise RuntimeError diff --git a/projects/egress-gate/tests/test_typing_policy.py b/projects/egress-gate/tests/test_typing_policy.py deleted file mode 100644 index 63e120fe..00000000 --- a/projects/egress-gate/tests/test_typing_policy.py +++ /dev/null @@ -1,586 +0,0 @@ -# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. -# SPDX-License-Identifier: Apache-2.0 - -"""AST-enforced type-safety policy for handwritten Python.""" - -from __future__ import annotations - -import ast -from enum import Enum, auto -from pathlib import Path - -from typing_extensions import override - -_HANDWRITTEN_ROOTS = ("src", "tests", "examples") -_GENERATED_BINDINGS = Path("src/egress_gate/bindings") -_TYPING_MODULES = frozenset({"typing", "typing_extensions"}) - - -class _TypingOrigin(Enum): - MODULE = auto() - CAST = auto() - DYNAMIC = auto() - LITERAL = auto() - ANNOTATED = auto() - - -class _TypingPolicyVisitor(ast.NodeVisitor): - """Resolve prohibited typing symbols without matching unrelated names.""" - - def __init__(self, relative_path: Path) -> None: - self._relative_path = relative_path - self._scopes: list[dict[str, _TypingOrigin | None]] = [{}] - self.violations: list[str] = [] - - def _record(self, node: ast.AST, message: str) -> None: - line_number = ( - node.lineno if isinstance(node, ast.expr | ast.stmt | ast.arg) else 0 - ) - self.violations.append( - f"{self._relative_path}:{line_number}: prohibited {message}" - ) - - def _lookup(self, name: str) -> _TypingOrigin | None: - for scope in reversed(self._scopes): - if name in scope: - return scope[name] - return None - - def _expression_origin(self, node: ast.expr) -> _TypingOrigin | None: - if isinstance(node, ast.Name): - return self._lookup(node.id) - if isinstance(node, ast.Attribute): - owner_origin = self._expression_origin(node.value) - if owner_origin is _TypingOrigin.MODULE: - if node.attr == "cast": - return _TypingOrigin.CAST - if node.attr == "Any": - return _TypingOrigin.DYNAMIC - if node.attr == "Literal": - return _TypingOrigin.LITERAL - if node.attr == "Annotated": - return _TypingOrigin.ANNOTATED - return None - - def _bind(self, name: str, origin: _TypingOrigin | None) -> None: - self._scopes[-1][name] = origin - - def _bind_target( - self, target: ast.expr, origin: _TypingOrigin | None = None - ) -> None: - if isinstance(target, ast.Name): - self._bind(target.id, origin) - elif isinstance(target, ast.List | ast.Tuple): - for element in target.elts: - self._bind_target(element) - elif isinstance(target, ast.Starred): - self._bind_target(target.value) - - def _visit_annotation(self, annotation: ast.expr | None) -> None: - if annotation is None: - return - self._visit_type_expression(annotation) - - @staticmethod - def _subscript_items(node: ast.expr) -> tuple[ast.expr, ...]: - return tuple(node.elts) if isinstance(node, ast.Tuple) else (node,) - - def _visit_type_expression(self, node: ast.expr) -> None: - if self._expression_origin(node) is _TypingOrigin.DYNAMIC: - self._record(node, "explicit Any") - return - if isinstance(node, ast.Constant) and isinstance(node.value, str): - try: - parsed = ast.parse(node.value, mode="eval") - except SyntaxError: - return - if self._type_expression_contains_dynamic(parsed.body): - self._record(node, "explicit Any annotation") - return - if isinstance(node, ast.Subscript): - self.visit(node.value) - items = self._subscript_items(node.slice) - origin = self._expression_origin(node.value) - if origin is _TypingOrigin.LITERAL: - for item in items: - self.visit(item) - return - if origin is _TypingOrigin.ANNOTATED and items: - self._visit_type_expression(items[0]) - for item in items[1:]: - self.visit(item) - return - for item in items: - self._visit_type_expression(item) - return - if isinstance(node, ast.BinOp): - self._visit_type_expression(node.left) - self._visit_type_expression(node.right) - return - if isinstance(node, ast.List | ast.Tuple): - for item in node.elts: - self._visit_type_expression(item) - return - if isinstance(node, ast.Starred): - self._visit_type_expression(node.value) - return - self.visit(node) - - def _inspect_type_comment( - self, - node: ast.stmt | ast.arg, - type_comment: str | None, - *, - function: bool = False, - ) -> None: - if type_comment is None: - return - try: - parsed = ast.parse(type_comment, mode="func_type" if function else "eval") - except SyntaxError: - return - if self._type_expression_contains_dynamic(parsed): - self._record(node, "explicit Any type comment") - - def _value_contains_dynamic(self, node: ast.AST) -> bool: - if ( - isinstance(node, ast.expr) - and self._expression_origin(node) is _TypingOrigin.DYNAMIC - ): - return True - if isinstance(node, ast.Constant) and isinstance(node.value, str): - return False - return any( - self._value_contains_dynamic(child) for child in ast.iter_child_nodes(node) - ) - - def _type_expression_contains_dynamic(self, node: ast.AST, depth: int = 0) -> bool: - if depth > 8: - return False - if ( - isinstance(node, ast.expr) - and self._expression_origin(node) is _TypingOrigin.DYNAMIC - ): - return True - if isinstance(node, ast.Constant) and isinstance(node.value, str): - try: - parsed = ast.parse(node.value, mode="eval") - except SyntaxError: - return False - return self._type_expression_contains_dynamic(parsed.body, depth + 1) - if isinstance(node, ast.Subscript): - items = self._subscript_items(node.slice) - origin = self._expression_origin(node.value) - if origin is _TypingOrigin.LITERAL: - return any(self._value_contains_dynamic(item) for item in items) - if origin is _TypingOrigin.ANNOTATED and items: - return self._type_expression_contains_dynamic(items[0], depth) or any( - self._value_contains_dynamic(item) for item in items[1:] - ) - return any( - self._type_expression_contains_dynamic(child, depth) - for child in ast.iter_child_nodes(node) - ) - - def _visit_arguments(self, arguments: ast.arguments) -> None: - all_arguments = ( - *arguments.posonlyargs, - *arguments.args, - *arguments.kwonlyargs, - ) - for argument in all_arguments: - self._visit_annotation(argument.annotation) - self._inspect_type_comment(argument, argument.type_comment) - if arguments.vararg is not None: - self._visit_annotation(arguments.vararg.annotation) - self._inspect_type_comment(arguments.vararg, arguments.vararg.type_comment) - if arguments.kwarg is not None: - self._visit_annotation(arguments.kwarg.annotation) - self._inspect_type_comment(arguments.kwarg, arguments.kwarg.type_comment) - for default in arguments.defaults: - self.visit(default) - for default in arguments.kw_defaults: - if default is not None: - self.visit(default) - - def _bind_arguments(self, arguments: ast.arguments) -> None: - for argument in ( - *arguments.posonlyargs, - *arguments.args, - *arguments.kwonlyargs, - ): - self._bind(argument.arg, None) - if arguments.vararg is not None: - self._bind(arguments.vararg.arg, None) - if arguments.kwarg is not None: - self._bind(arguments.kwarg.arg, None) - - def _visit_function(self, node: ast.FunctionDef | ast.AsyncFunctionDef) -> None: - for decorator in node.decorator_list: - self.visit(decorator) - self._visit_arguments(node.args) - self._visit_annotation(node.returns) - self._inspect_type_comment(node, node.type_comment, function=True) - self._bind(node.name, None) - self._scopes.append({}) - try: - self._bind_arguments(node.args) - for statement in node.body: - self.visit(statement) - finally: - self._scopes.pop() - - @override - def visit_Import(self, node: ast.Import) -> None: - for imported in node.names: - bound_name = imported.asname or imported.name.split(".", maxsplit=1)[0] - origin = _TypingOrigin.MODULE if imported.name in _TYPING_MODULES else None - self._bind(bound_name, origin) - - @override - def visit_ImportFrom(self, node: ast.ImportFrom) -> None: - is_typing_import = node.level == 0 and node.module in _TYPING_MODULES - for imported in node.names: - if imported.name == "*": - if is_typing_import: - self._record(node, "typing wildcard import") - continue - bound_name = imported.asname or imported.name - origin: _TypingOrigin | None = None - if is_typing_import and imported.name == "cast": - origin = _TypingOrigin.CAST - self._record(node, "typing cast import") - elif is_typing_import and imported.name == "Any": - origin = _TypingOrigin.DYNAMIC - self._record(node, "explicit Any import") - elif is_typing_import and imported.name == "Literal": - origin = _TypingOrigin.LITERAL - elif is_typing_import and imported.name == "Annotated": - origin = _TypingOrigin.ANNOTATED - self._bind(bound_name, origin) - - @override - def visit_Assign(self, node: ast.Assign) -> None: - self._inspect_type_comment(node, node.type_comment) - self.visit(node.value) - origin = self._expression_origin(node.value) - for target in node.targets: - self._bind_target(target, origin) - - @override - def visit_AnnAssign(self, node: ast.AnnAssign) -> None: - self._visit_annotation(node.annotation) - if node.value is not None: - self.visit(node.value) - origin = self._expression_origin(node.value) - else: - origin = None - self._bind_target(node.target, origin) - - @override - def visit_NamedExpr(self, node: ast.NamedExpr) -> None: - self.visit(node.value) - self._bind_target(node.target, self._expression_origin(node.value)) - - def _visit_for(self, node: ast.For | ast.AsyncFor) -> None: - self.visit(node.iter) - self._bind_target(node.target) - self._inspect_type_comment(node, node.type_comment) - for statement in (*node.body, *node.orelse): - self.visit(statement) - - @override - def visit_For(self, node: ast.For) -> None: - self._visit_for(node) - - @override - def visit_AsyncFor(self, node: ast.AsyncFor) -> None: - self._visit_for(node) - - def _visit_with(self, node: ast.With | ast.AsyncWith) -> None: - for item in node.items: - self.visit(item.context_expr) - if item.optional_vars is not None: - self._bind_target(item.optional_vars) - self._inspect_type_comment(node, node.type_comment) - for statement in node.body: - self.visit(statement) - - @override - def visit_With(self, node: ast.With) -> None: - self._visit_with(node) - - @override - def visit_AsyncWith(self, node: ast.AsyncWith) -> None: - self._visit_with(node) - - @override - def visit_Call(self, node: ast.Call) -> None: - if self._expression_origin(node.func) is _TypingOrigin.CAST: - self._record(node, "typing cast call") - else: - self.visit(node.func) - for argument in node.args: - self.visit(argument) - for keyword in node.keywords: - self.visit(keyword.value) - - @override - def visit_Name(self, node: ast.Name) -> None: - origin = self._lookup(node.id) - if origin is _TypingOrigin.CAST: - self._record(node, "typing cast reference") - elif origin is _TypingOrigin.DYNAMIC: - self._record(node, "explicit Any") - - @override - def visit_Attribute(self, node: ast.Attribute) -> None: - origin = self._expression_origin(node) - if origin is _TypingOrigin.CAST: - self._record(node, "typing cast reference") - elif origin is _TypingOrigin.DYNAMIC: - self._record(node, "explicit Any") - else: - self.visit(node.value) - - @override - def visit_FunctionDef(self, node: ast.FunctionDef) -> None: - self._visit_function(node) - - @override - def visit_AsyncFunctionDef(self, node: ast.AsyncFunctionDef) -> None: - self._visit_function(node) - - @override - def visit_Lambda(self, node: ast.Lambda) -> None: - self._visit_arguments(node.args) - self._scopes.append({}) - try: - self._bind_arguments(node.args) - self.visit(node.body) - finally: - self._scopes.pop() - - @override - def visit_ClassDef(self, node: ast.ClassDef) -> None: - for decorator in node.decorator_list: - self.visit(decorator) - for base in node.bases: - self.visit(base) - for keyword in node.keywords: - self.visit(keyword.value) - self._bind(node.name, None) - self._scopes.append({}) - try: - for statement in node.body: - self.visit(statement) - finally: - self._scopes.pop() - - -def _handwritten_python_files(project_root: Path) -> tuple[Path, ...]: - files: list[Path] = [] - for source_root_name in _HANDWRITTEN_ROOTS: - source_root = project_root / source_root_name - if not source_root.is_dir(): - continue - for path in source_root.rglob("*"): - if path.suffix not in {".py", ".pyi"}: - continue - relative_path = path.relative_to(project_root) - if relative_path.is_relative_to(_GENERATED_BINDINGS): - continue - files.append(path) - return tuple(sorted(files)) - - -def _typing_policy_violations(project_root: Path) -> tuple[str, ...]: - violations: list[str] = [] - for path in _handwritten_python_files(project_root): - tree = ast.parse( - path.read_text(encoding="utf-8"), filename=str(path), type_comments=True - ) - visitor = _TypingPolicyVisitor(path.relative_to(project_root)) - visitor.visit(tree) - violations.extend(visitor.violations) - return tuple(violations) - - -def test_handwritten_python_is_cast_free_and_has_no_explicit_any() -> None: - project_root = Path(__file__).resolve().parents[1] - - assert _typing_policy_violations(project_root) == () - - -def test_typing_policy_rejects_all_supported_typing_origins(tmp_path: Path) -> None: - source_root = tmp_path / "src" - source_root.mkdir() - (source_root / "bad.py").write_text( - """ -import typing -import typing as t -import typing_extensions as extensions -from typing import Annotated as TypeAnnotated -from typing import Any as DynamicType -from typing_extensions import Annotated as ExtensionAnnotated -from typing_extensions import cast as narrow - -direct_dynamic: DynamicType -qualified_dynamic: t.Any -quoted_dynamic: "typing.Any" -extension_quoted: "extensions.Any" -nested_quoted: list["typing.Any"] -annotated_nested: t.Annotated["typing.Any", "metadata"] -annotated_alias_nested: TypeAnnotated["typing.Any", "metadata"] -annotated_extension_nested: extensions.Annotated["extensions.Any", "metadata"] -quoted_annotated_nested: "typing.Annotated['typing.Any', 'metadata']" -quoted_annotated_alias_nested: "TypeAnnotated['typing.Any', 'metadata']" -quoted_extension_annotated_alias_nested: ( - "ExtensionAnnotated['extensions.Any', 'metadata']" -) -literal_actual_dynamic: typing.Literal[typing.Any] -annotated_metadata_actual_dynamic: typing.Annotated[str, typing.Any] -direct_cast = narrow(str, object()) -qualified_cast = extensions.cast(str, object()) -rebound = t.cast -rebound_cast = rebound(str, object()) -""", - encoding="utf-8", - ) - (source_root / "bad_argument_comments.py").write_text( - """ -import typing - -def parameters( - positional_only, # type: typing.Any - /, - positional, # type: typing.Any - *variadic, # type: typing.Any - keyword_only, # type: typing.Any - **keywords, # type: typing.Any -): - return positional_only - -async def async_parameter( - value, # type: typing.Any -): - return value -""", - encoding="utf-8", - ) - (source_root / "bad_type_comments.py").write_text( - """ -import typing - -assigned = None # type: typing.Any - -for item in (): # type: typing.Any - pass - -with open(__file__) as stream: # type: typing.Any - pass - -def commented(value): - # type: (typing.Any) -> typing.Any - return value -""", - encoding="utf-8", - ) - - violations = _typing_policy_violations(tmp_path) - regular_violations = tuple( - violation for violation in violations if "src/bad.py:" in violation - ) - type_comment_violations = tuple( - violation - for violation in violations - if "src/bad_type_comments.py:" in violation - ) - argument_comment_violations = tuple( - violation - for violation in violations - if "src/bad_argument_comments.py:" in violation - ) - - assert any("explicit Any import" in violation for violation in regular_violations) - assert sum("explicit Any" in violation for violation in regular_violations) == 14 - assert any("typing cast import" in violation for violation in regular_violations) - assert sum("typing cast call" in violation for violation in regular_violations) == 3 - assert any("typing cast reference" in violation for violation in regular_violations) - assert len(type_comment_violations) == 4 - assert all( - "explicit Any type comment" in violation - for violation in type_comment_violations - ) - assert len(argument_comment_violations) == 6 - assert all( - "explicit Any type comment" in violation - for violation in argument_comment_violations - ) - - -def test_typing_policy_allows_unrelated_cast_and_any_names(tmp_path: Path) -> None: - source_root = tmp_path / "src" - source_root.mkdir() - (source_root / "allowed.py").write_text( - """ -import typing -import typing_extensions as extensions -from typing import Annotated as TypeAnnotated -from typing import Literal as TypeLiteral -from typing_extensions import Annotated as ExtensionAnnotated -from typing_extensions import Literal as ExtensionLiteral - - -class Domain: - Any = "ordinary domain value" - - -class Converter: - def cast(self, value: object) -> object: - return value - - -def cast(value: object) -> object: - return value - - -method_result = Converter().cast(Domain.Any) -function_result = cast(object()) -ordinary_string = "typing.Any" -ordinary_comment = None # type: object -literal_module: typing.Literal["typing.Any"] -literal_alias: TypeLiteral["typing.Any"] -literal_extension: extensions.Literal["extensions.Any"] -literal_extension_alias: ExtensionLiteral["extensions.Any"] -annotated_module: typing.Annotated[str, "typing.Any"] -annotated_alias: TypeAnnotated[str, "typing.Any"] -annotated_extension: extensions.Annotated[str, "extensions.Any"] -annotated_extension_alias: ExtensionAnnotated[str, "extensions.Any"] -quoted_literal: "typing.Literal['typing.Any']" -quoted_annotated: "extensions.Annotated[str, 'extensions.Any']" -quoted_literal_alias: "TypeLiteral['typing.Any']" -quoted_extension_literal_alias: "ExtensionLiteral['extensions.Any']" -quoted_annotated_alias: "TypeAnnotated[str, 'typing.Any']" -quoted_extension_annotated_alias: "ExtensionAnnotated[str, 'extensions.Any']" -""", - encoding="utf-8", - ) - - assert _typing_policy_violations(tmp_path) == () - - -def test_typing_policy_excludes_only_generated_bindings(tmp_path: Path) -> None: - generated = tmp_path / _GENERATED_BINDINGS - generated.mkdir(parents=True) - (generated / "generated.py").write_text( - "from typing import Any, cast\nvalue: Any = cast(Any, None)\n", - encoding="utf-8", - ) - handwritten = tmp_path / "src/egress_gate/generated_elsewhere.py" - handwritten.write_text("from typing import Any\nvalue: Any\n", encoding="utf-8") - - violations = _typing_policy_violations(tmp_path) - - assert violations - assert all("generated_elsewhere.py" in violation for violation in violations) diff --git a/projects/egress-gate/uv.lock b/projects/egress-gate/uv.lock deleted file mode 100644 index 88ae26e6..00000000 --- a/projects/egress-gate/uv.lock +++ /dev/null @@ -1,1030 +0,0 @@ -version = 1 -revision = 3 -requires-python = ">=3.11" - -[[package]] -name = "annotated-doc" -version = "0.0.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/57/ba/046ceea27344560984e26a590f90bc7f4a75b06701f653222458922b558c/annotated_doc-0.0.4.tar.gz", hash = "sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4", size = 7288, upload-time = "2025-11-10T22:07:42.062Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/d3/26bf1008eb3d2daa8ef4cacc7f3bfdc11818d111f7e2d0201bc6e3b49d45/annotated_doc-0.0.4-py3-none-any.whl", hash = "sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320", size = 5303, upload-time = "2025-11-10T22:07:40.673Z" }, -] - -[[package]] -name = "annotated-types" -version = "0.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ee/67/531ea369ba64dcff5ec9c3402f9f51bf748cec26dde048a2f973a4eea7f5/annotated_types-0.7.0.tar.gz", hash = "sha256:aff07c09a53a08bc8cfccb9c85b05f1aa9a2a6f23728d790723543408344ce89", size = 16081, upload-time = "2024-05-20T21:33:25.928Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/78/b6/6307fbef88d9b5ee7421e68d78a9f162e0da4900bc5f5793f6d3d0e34fb8/annotated_types-0.7.0-py3-none-any.whl", hash = "sha256:1f02e8b43a8fbbc3f3e0d4f0f4bfc8131bcb4eebe8849b8e5c773f3a1c582a53", size = 13643, upload-time = "2024-05-20T21:33:24.1Z" }, -] - -[[package]] -name = "boolean-py" -version = "5.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c4/cf/85379f13b76f3a69bca86b60237978af17d6aa0bc5998978c3b8cf05abb2/boolean_py-5.0.tar.gz", hash = "sha256:60cbc4bad079753721d32649545505362c754e121570ada4658b852a3a318d95", size = 37047, upload-time = "2025-04-03T10:39:49.734Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e5/ca/78d423b324b8d77900030fa59c4aa9054261ef0925631cd2501dd015b7b7/boolean_py-5.0-py3-none-any.whl", hash = "sha256:ef28a70bd43115208441b53a045d1549e2f0ec6e3d08a9d142cbc41c1938e8d9", size = 26577, upload-time = "2025-04-03T10:39:48.449Z" }, -] - -[[package]] -name = "cachecontrol" -version = "0.14.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "msgpack" }, - { name = "requests" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/2d/f6/c972b32d80760fb79d6b9eeb0b3010a46b89c0b23cf6329417ff7886cd22/cachecontrol-0.14.4.tar.gz", hash = "sha256:e6220afafa4c22a47dd0badb319f84475d79108100d04e26e8542ef7d3ab05a1", size = 16150, upload-time = "2025-11-14T04:32:13.138Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/ef/79/c45f2d53efe6ada1110cf6f9fca095e4ff47a0454444aefdde6ac4789179/cachecontrol-0.14.4-py3-none-any.whl", hash = "sha256:b7ac014ff72ee199b5f8af1de29d60239954f223e948196fa3d84adaffc71d2b", size = 22247, upload-time = "2025-11-14T04:32:11.733Z" }, -] - -[package.optional-dependencies] -filecache = [ - { name = "filelock" }, -] - -[[package]] -name = "certifi" -version = "2026.7.22" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } -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 = "charset-normalizer" -version = "3.4.9" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/bd/2a/23f34ec9d04624958e137efdc394888716353190e75f25dd22c7a2c7a8aa/charset_normalizer-3.4.9.tar.gz", hash = "sha256:673611bbd43f0810bec0b0f028ddeaaa501190339cac411f347ac76917c3ae7b", size = 152439, upload-time = "2026-07-07T14:34:58.454Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/0b/e3/85ec501f206fb049259288c1f3506e53876937fb00edb47009348e66756b/charset_normalizer-3.4.9-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:0e94703ec9684807f20cfb5eed95c70f67f2a8f21ad620146d7b5a13677b93e5", size = 317075, upload-time = "2026-07-07T14:32:56.021Z" }, - { url = "https://files.pythonhosted.org/packages/c3/69/2a5385192e67175f7d8bd5ce4f57c24bc956439adeae5c13a99aa28a53d1/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2a441ea71902098ffe78c5abe6c494f44160b4af614ed16c3d9a3b1d17fd8ee2", size = 213837, upload-time = "2026-07-07T14:32:57.78Z" }, - { url = "https://files.pythonhosted.org/packages/b3/46/03ddc7da576d814fe0a36dd1f0fd3258e95404b4b2e3c026b7923d7e133f/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:304b13570067b2547562e308af560b3963857b1fa90bd6afd978130130fe2d6a", size = 235503, upload-time = "2026-07-07T14:32:59.205Z" }, - { url = "https://files.pythonhosted.org/packages/4e/6e/de0229a7ef40f6f9d28a837eebf4ec47bdca5dab4e900c84f22919af636a/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:4773092f8019072343a7447203308b176e10199920eb02d6195e81bbb3274c29", size = 229944, upload-time = "2026-07-07T14:33:00.803Z" }, - { url = "https://files.pythonhosted.org/packages/a5/34/49b9060e8418b14fb5cba9cf6bfb383111e2538a03a1fb18e66a95aeb3d5/charset_normalizer-3.4.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:04ce310cb89c15df659582aee80a0603788732a5e017d5bd5c81158106ce249c", size = 221276, upload-time = "2026-07-07T14:33:02.199Z" }, - { url = "https://files.pythonhosted.org/packages/44/95/80282cce0fae9c3061203d723ee87da996aed79679e65d8935050ee7ca1f/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_armv7l.whl", hash = "sha256:c0323c9daef75ef2e5083624b4585018a0c9d5e3b40f607eed81a311270b934b", size = 205260, upload-time = "2026-07-07T14:33:03.698Z" }, - { url = "https://files.pythonhosted.org/packages/0c/74/2f62c8821b969ea3bd67cc2e6976834f48ca5d12664d2559ebcd9bcfbed7/charset_normalizer-3.4.9-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:871ff67ea1aad4dfd91736464934d56b32dac49f9fbe16cddba36198a7b3a0db", size = 217786, upload-time = "2026-07-07T14:33:05.12Z" }, - { url = "https://files.pythonhosted.org/packages/d9/8d/feabb82cb49fcad14515b1d7d1ca4787b0da7fc723a212bf89bc9e0fac52/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:67830fc78e67501f47bb950471b2dcb9b35b140084429318e862895a8e89c993", size = 216798, upload-time = "2026-07-07T14:33:06.629Z" }, - { url = "https://files.pythonhosted.org/packages/a5/ff/c946d63bc3786d5b84d960b0f7ab7e25b828486a946b5aa997625bcaf6a6/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_armv7l.whl", hash = "sha256:3d92613ec25e43b05f042302531ec0f00b8445190e43325880cbd6ab7c2581da", size = 206429, upload-time = "2026-07-07T14:33:08.006Z" }, - { url = "https://files.pythonhosted.org/packages/af/ba/5e5007c370702f85d2ef75791fac7943ed41e080364a673b20142e430e3e/charset_normalizer-3.4.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:280081916dc341820640489a66e4696049401ef1cf6dd672f672e70ad915aca3", size = 223066, upload-time = "2026-07-07T14:33:09.783Z" }, - { url = "https://files.pythonhosted.org/packages/83/d5/9096aa3cf532dfad237861544eb47a0f20d5adbf1039760fed8eaae935d9/charset_normalizer-3.4.9-cp311-cp311-win32.whl", hash = "sha256:ac351b3b8014eead140e77e9717e2992c6bbe30b63bc3422422eb84865412e3d", size = 150456, upload-time = "2026-07-07T14:33:11.217Z" }, - { url = "https://files.pythonhosted.org/packages/ed/a1/e29995109e455dc8eff8d0fac6ae509be39561318a7cfeac5d33ad029213/charset_normalizer-3.4.9-cp311-cp311-win_amd64.whl", hash = "sha256:6366a16e1a25018694d6a5d784d09b046edc9eac40ea2b54065c3052672516a1", size = 161410, upload-time = "2026-07-07T14:33:12.743Z" }, - { url = "https://files.pythonhosted.org/packages/4f/8d/1569f4d0032d6ba2a4fe4591c35bf87868c600c41a71eb5c2e1ffa8464c2/charset_normalizer-3.4.9-cp311-cp311-win_arm64.whl", hash = "sha256:1d22856ffbe153a602df38e4a5464f0b748a54002e0d69ac6d2ad0a197cc99ec", size = 152649, upload-time = "2026-07-07T14:33:14.173Z" }, - { url = "https://files.pythonhosted.org/packages/70/4a/ecbd131485c07fcdfad54e28946d513e3da22ef3b4bd854dcafae54ec739/charset_normalizer-3.4.9-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:45b0cc4e3556cd875e09102988d1ab8356c998b596c9fced84547c8138b487a0", size = 319300, upload-time = "2026-07-07T14:33:15.666Z" }, - { url = "https://files.pythonhosted.org/packages/ec/96/5d9364e3342d69f3a045e1777bc47c85c383e6e9466d561b33fdb419d1f9/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9b2aff1c7b3884512b9512c3eaadd9bab39fb45042ffaaa1dd08ff2b9f8109d9", size = 215802, upload-time = "2026-07-07T14:33:17.031Z" }, - { url = "https://files.pythonhosted.org/packages/4b/4c/5361f9aa7f2cb58d94f2ab831b3d493f69efb1d239654b4744e3c09527cb/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9104ed0bd76a429d46f9ec0dbc9b08ad1d2dcdf2b00a5a0daa1c145329b35b44", size = 237171, upload-time = "2026-07-07T14:33:18.576Z" }, - { url = "https://files.pythonhosted.org/packages/50/78/ce342ca4ff30b2eb49fe6d9578df85974f90c67d294113e94efdd9664cbd/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:7b86a2b16095d250c6f58b3d9b2eee6f4147754344f3dab0922f7c9bf7d226c9", size = 233075, upload-time = "2026-07-07T14:33:20.084Z" }, - { url = "https://files.pythonhosted.org/packages/01/c4/4fa4c8b3097a11f3c5f09a35b72ed6855fb1d332469504962ab7bafcc702/charset_normalizer-3.4.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5e226f6218febc71f6c1fc2fafb91c226f75bdc1d8fb12d66823716e891608fd", size = 224256, upload-time = "2026-07-07T14:33:21.747Z" }, - { url = "https://files.pythonhosted.org/packages/87/3a/ad914516df7e358a81aae018caa5e0470ba827fa6d763b1d2e87d920a5f6/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_armv7l.whl", hash = "sha256:90c44bc373b7687f6948b693cceaea1348ae0975d7474746559494468e3c1d84", size = 208784, upload-time = "2026-07-07T14:33:23.313Z" }, - { url = "https://files.pythonhosted.org/packages/d7/74/3c12f9755717dfe5c5c87da63f35d765fa0c00382ec26bf23f7fae34f2ba/charset_normalizer-3.4.9-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9cdef90ae47919cae358d8ab15797a800ed41da7aba5d72419fb510729e2ed4b", size = 219928, upload-time = "2026-07-07T14:33:24.814Z" }, - { url = "https://files.pythonhosted.org/packages/33/9a/895095b83e7907abd6d3d99aad3a38ad0d9686cc186cb0c94c24320fe63e/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:60f44ade2cf573dad7a277e6f8ca9a51a21dda572b13bd7d8539bb3cd5dbedde", size = 218489, upload-time = "2026-07-07T14:33:26.42Z" }, - { url = "https://files.pythonhosted.org/packages/a1/34/ef5c05f412f42520d7709b7d3784d19640839eb7366ded1755511585429f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_armv7l.whl", hash = "sha256:a1786910334ed46ab1dd73222f2cd1e05c2c3bb39f6dddb4f8b36fc382058a39", size = 210267, upload-time = "2026-07-07T14:33:27.952Z" }, - { url = "https://files.pythonhosted.org/packages/83/dc/9b29fa4412b318bf3bfea985c35d67eb55e04b59a7c3f2237168b0e0be6f/charset_normalizer-3.4.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:03d07803992c6c7bbc976327f34b18b6160327fc81cb82c9d504720ac0be3b62", size = 226030, upload-time = "2026-07-07T14:33:29.397Z" }, - { url = "https://files.pythonhosted.org/packages/0e/42/6dbc00b8cd16011691203e33570fa42ed5746599a2e878112d16eab403a3/charset_normalizer-3.4.9-cp312-cp312-win32.whl", hash = "sha256:78841cccf1af7b40f6f716338d50c0902dbe88d9f800b3c973b7a9a0a693a642", size = 151185, upload-time = "2026-07-07T14:33:30.781Z" }, - { url = "https://files.pythonhosted.org/packages/80/cc/f920afd1a23c58ccd53c1d36085a71893a4737ff5e66e0371efab6809850/charset_normalizer-3.4.9-cp312-cp312-win_amd64.whl", hash = "sha256:4b3dac63058cc36820b0dd072f89898604e2d39686fe05321729d00d8ac185a0", size = 162557, upload-time = "2026-07-07T14:33:32.176Z" }, - { url = "https://files.pythonhosted.org/packages/f0/e6/0386d43a261ff4e4b30c5857af7df877254b46bec7b9d1b74b6bf969a90b/charset_normalizer-3.4.9-cp312-cp312-win_arm64.whl", hash = "sha256:78fa18e436a1a0e58dbd7e02fc4473f3f32cceb12df9dfca542d075961c307d2", size = 152665, upload-time = "2026-07-07T14:33:33.711Z" }, - { url = "https://files.pythonhosted.org/packages/b2/06/97ec2aeae780b31d742b6352218b43841a6871e2564578ca522dce4a45c3/charset_normalizer-3.4.9-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:440eede837960000d74978f0eba527be106b5b9aee0daf779d395276ed0b0614", size = 317688, upload-time = "2026-07-07T14:33:35.408Z" }, - { url = "https://files.pythonhosted.org/packages/d0/39/8ff066c672434225f8d25f8b739f992af250944392173dcc88362681c9bf/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:21e764fd1e70b6a3e205a0e46f3051701f98a8cb3fad66eeb80e48bb502f8698", size = 214982, upload-time = "2026-07-07T14:33:36.996Z" }, - { url = "https://files.pythonhosted.org/packages/92/8f/3a47a3667c83c2df9483d91644c6c107de3bf8874aa1793da9d3012eb986/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e4fd89cc178bced6ad29cb3e6dd4aa63fa5017c3524dbd0b25998fb64a87cc8b", size = 236460, upload-time = "2026-07-07T14:33:38.536Z" }, - { url = "https://files.pythonhosted.org/packages/f1/60/b22cdbee7e4013dab8b0d7647fc6181120fbbbc8f7025c226d15bd5a47fc/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bd47ba7fc3ca94896759ea0109775132d3e7ab921fbf54038e1bab2e46c313c9", size = 232003, upload-time = "2026-07-07T14:33:40.059Z" }, - { url = "https://files.pythonhosted.org/packages/ea/f8/72eb13dcabe7257035cea8aefd922caad2f110d252bf9f67c4c2ca763aee/charset_normalizer-3.4.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:84fd18bcc17526fc2b3c1af7d2b9217d32c9c04448c16ec693b9b4f1985c3d33", size = 223149, upload-time = "2026-07-07T14:33:41.631Z" }, - { url = "https://files.pythonhosted.org/packages/b0/3e/faee8f9de92b14ee1198e9163252bb15efee7301b31256a3b6d9ebfdd0dd/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:5b10cd92fc5c498b35a8635df6d5a100207f88b63a4dc1de7ef9a548e1e2cd63", size = 207901, upload-time = "2026-07-07T14:33:43.209Z" }, - { url = "https://files.pythonhosted.org/packages/3a/25/45f30093ae27dd7b92a793b61882a38685f993700113ca36e0c9c14965e1/charset_normalizer-3.4.9-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:a4fbdde9dd4a9ce5fd52c2b3a347bb50cc89483ef783f1cb00d408c13f7a96c0", size = 219176, upload-time = "2026-07-07T14:33:44.725Z" }, - { url = "https://files.pythonhosted.org/packages/48/18/c8f397329c35e32f6a837e488986f4ae03bd2abebc453b48714991630c2f/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:416c229f77e5ea25b3dfd4b582f8d73d7e43c22320302b9ab128a2d3a0b38efe", size = 217356, upload-time = "2026-07-07T14:33:46.192Z" }, - { url = "https://files.pythonhosted.org/packages/86/7e/5ce0bba863470fd1902d5e5843968951bddf38abe4742fc97116ef4598b3/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:75286256590a6320cf106a0d28970d3560aad9ee09aa7b34fb40524792436d35", size = 209614, upload-time = "2026-07-07T14:33:47.705Z" }, - { url = "https://files.pythonhosted.org/packages/6c/ef/2473d3c4d869155be4af1191111d59c4d5c4e0173026f7e85b176e23bf65/charset_normalizer-3.4.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:69b157c5d3292bcd443faca052f3096f637f1e074b98212a933c074ae23dc3b8", size = 224991, upload-time = "2026-07-07T14:33:49.238Z" }, - { url = "https://files.pythonhosted.org/packages/d0/a3/53ddae3db108a088156aa8ddfafd411ebbc1340f48c5573f697b27f69a39/charset_normalizer-3.4.9-cp313-cp313-win32.whl", hash = "sha256:51307f5c71007673a2bf8232ad973483d281e74cb99c8c5a990af1eefa6277d9", size = 150622, upload-time = "2026-07-07T14:33:50.711Z" }, - { url = "https://files.pythonhosted.org/packages/e8/ef/6953a77c7cf2c2ff9998e6f575ab3e380119f100223381565a4f94c1f836/charset_normalizer-3.4.9-cp313-cp313-win_amd64.whl", hash = "sha256:fe2c7201c642b7c308f1675355ad7ff7b66acfe3541625efe5a3ad38f29d6115", size = 161947, upload-time = "2026-07-07T14:33:52.197Z" }, - { url = "https://files.pythonhosted.org/packages/6e/fb/d560d1d1555debbfe7849d9cac6145c1b537709d79576bf22557ed803b82/charset_normalizer-3.4.9-cp313-cp313-win_arm64.whl", hash = "sha256:611057cc5d5c0afc743ba8be6bd828c17e0aaa8643f9d0a9b9bb7dea80eb8012", size = 152594, upload-time = "2026-07-07T14:33:53.486Z" }, - { url = "https://files.pythonhosted.org/packages/7e/8d/496817fa0944239ecae662dd57ea765cfeaec6a735f9f025d4b7b72e7143/charset_normalizer-3.4.9-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:0327fcd59a935777d83410750c50600ee9571af2846f71ce40f25b13da1ef380", size = 317253, upload-time = "2026-07-07T14:33:54.994Z" }, - { url = "https://files.pythonhosted.org/packages/2b/f9/ef4a69ea338ad3c0deceea0f5f7d2380ae8b52132b06d652cb0d2cd86706/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8a79d9f4d8001473a30c163556b3c3bfebec837495a412dde78b51672f6134f9", size = 215898, upload-time = "2026-07-07T14:33:56.334Z" }, - { url = "https://files.pythonhosted.org/packages/8c/e7/5ddfd76fc061eb52de219658a4aa431cbacadf0a0219c8854f00da50d289/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:33bdcc2a32c0a0e861f60841a512c8acc658c87c2ac59d89e3a46dacf7d866e4", size = 236718, upload-time = "2026-07-07T14:33:57.9Z" }, - { url = "https://files.pythonhosted.org/packages/49/ba/768fa3f36048d81c477a0ce61f813bc1454d80917ccfe550abd9f44f5e24/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:f840ed6d8ecba8255df8c42b87fadeda98ddfc6eeec05e2dc66e26d46dd6f58a", size = 232519, upload-time = "2026-07-07T14:33:59.811Z" }, - { url = "https://files.pythonhosted.org/packages/f4/c4/b3e049d2aa3766180c78507110543d9d50894cc97f57de543f1be521dcdc/charset_normalizer-3.4.9-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c25fe15c70c59eb7c5ce8c06a1f3fa1da0ecc5ea1e7a5922c40fd2fa9b0d5046", size = 223143, upload-time = "2026-07-07T14:34:01.517Z" }, - { url = "https://files.pythonhosted.org/packages/19/79/55c32d06d76ae4feafe053f061f3e3ab70bcf19f4007797ce8c3efda7830/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:f7fb7d750cfa0a070d2c24e831fd3481019a60dd317ea2b39acbcebc08b6ed81", size = 206742, upload-time = "2026-07-07T14:34:03.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/e0/47c079dd82d217c807479cd59ffd30af56307ea31c108b75758970459ad3/charset_normalizer-3.4.9-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4d1c96a7a18b9690a4d46df09e3e3382406ae3213727cd1019ebade1c4a81917", size = 219191, upload-time = "2026-07-07T14:34:04.657Z" }, - { url = "https://files.pythonhosted.org/packages/42/ab/b9bc2e77d6b44a7e46ef62ec5cac1c9a6ba7b9135a5d560f002696ec9995/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:a4cfde78a9f2880208d16a93b795726a3017d5977e08d1e162a7a31322479c41", size = 218328, upload-time = "2026-07-07T14:34:06.115Z" }, - { url = "https://files.pythonhosted.org/packages/f1/78/c9c71d599f5aa2d42bcdd35cbbd46d7f535351a57e40ff7d8e5a7e219401/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:d4d6fcde76f94f5cb9e43e9e9a61f16dacefd228cbbf6f1a09bd9b219a92f1a1", size = 207406, upload-time = "2026-07-07T14:34:07.554Z" }, - { url = "https://files.pythonhosted.org/packages/f6/39/c914445c321a845097ce4f6ac7de9a18228a77b766272125a1ce00d851eb/charset_normalizer-3.4.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:898f0e9068ca27d37f8e83a5b962821df851532e6c4a7d615c1c033f9da6eedf", size = 225157, upload-time = "2026-07-07T14:34:09.061Z" }, - { url = "https://files.pythonhosted.org/packages/9b/f2/c0d4b8508565a36bc5c624e88ed297f5b0b1095011034d7f5b83a69908b5/charset_normalizer-3.4.9-cp314-cp314-win32.whl", hash = "sha256:c1c948747b03be832dceed96ca815cef7360de9aa19d37c730f8e3f6101aca48", size = 151095, upload-time = "2026-07-07T14:34:10.901Z" }, - { url = "https://files.pythonhosted.org/packages/49/fd/a1d26144398c67486422a72bf5812cda22cb4ccfcd95a290fb41ceb4b8e2/charset_normalizer-3.4.9-cp314-cp314-win_amd64.whl", hash = "sha256:16b65ea0f2465b6fb52aa22de5eca612aa964ddfec00a912e26f4656cbef890b", size = 162796, upload-time = "2026-07-07T14:34:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/20/95/d75e82f8ce9fd323ebf059c16c9aadefb22a1ecde13b7840b35835e4886c/charset_normalizer-3.4.9-cp314-cp314-win_arm64.whl", hash = "sha256:40a126142a56b2dfc0aacbad1de8310cbf60da7656db0e6b16eebd48e3e93519", size = 153334, upload-time = "2026-07-07T14:34:14.044Z" }, - { url = "https://files.pythonhosted.org/packages/00/5e/17398df3a139985ba9d11ed072531986f408c8fca952835ef1ab1820c02b/charset_normalizer-3.4.9-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:609b3ba8fcc0fb5ab7af00719d0fb6ad0cb518e48e7712d12fd68f1327951198", size = 338848, upload-time = "2026-07-07T14:34:15.688Z" }, - { url = "https://files.pythonhosted.org/packages/cd/91/7253a32e86b7e1d1239b1b36ba6dd0f021a21107ab33054b53119cc083b9/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:51447e9aa2684679af07ca5021c3db526e0284347ebf4ffcec1154c3350cfe32", size = 223022, upload-time = "2026-07-07T14:34:17.248Z" }, - { url = "https://files.pythonhosted.org/packages/cb/32/2e64bd2be10e89c61e57ebe6a93fd98ae88eb7ebe414b5121f22c96c69eb/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:cc1b0fff8ead343dae06305f954eb8468ba0ec1a97881f42489d198e4ce3c632", size = 241590, upload-time = "2026-07-07T14:34:18.813Z" }, - { url = "https://files.pythonhosted.org/packages/3d/ef/d96ec496cfea0c21db43b0ad03891308b02388d054cc902cf0e5a1ad6a88/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fa36ec09ef71d158186bc79e359ff5fdd6e7996fe8ab638f00d6b93139ba4fcf", size = 239584, upload-time = "2026-07-07T14:34:20.52Z" }, - { url = "https://files.pythonhosted.org/packages/d4/ce/9af95f7876194bd7a14e3dfe4a4de2e0bff02666a3910d72beafd06cc297/charset_normalizer-3.4.9-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:df115d4d83168fdf2cae48ef1ff6d1cb4c466364e30861b37121de0f3bf1b990", size = 230224, upload-time = "2026-07-07T14:34:22.189Z" }, - { url = "https://files.pythonhosted.org/packages/52/94/af74dde74a3996bd959c350709bfe50e297823d70a8c1cbd54b838880863/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:f86c6358749bd4fda175388691e3ba8c46e24c5347d0afd20f9b7edfc9faf07d", size = 212667, upload-time = "2026-07-07T14:34:23.857Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f0/f1c4fe746c395922961b5916ed1d7d6e7d4c84851d19ed43cc89980ec953/charset_normalizer-3.4.9-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:32286a2c8d167e897177b673176c1e3e00d4057caf5d2b64eef9a3666b03018e", size = 227179, upload-time = "2026-07-07T14:34:25.586Z" }, - { url = "https://files.pythonhosted.org/packages/e4/56/6c745619ac397e8871e2bcd3cea1eec86b877488f33888b3aef5c3ed506e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:83aed2c10721ddd90f68140685391b50811a880af20654c59af6b6c66c40513c", size = 225372, upload-time = "2026-07-07T14:34:27.212Z" }, - { url = "https://files.pythonhosted.org/packages/78/ad/98aae8630ac71f16711968e38a5acfecce41b778bf2f0312851020f565a8/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cd6c3d4b783c556fa00bf540854e42f135e2f256abd29669fcd0da0f2dec79c2", size = 215222, upload-time = "2026-07-07T14:34:28.774Z" }, - { url = "https://files.pythonhosted.org/packages/f7/40/9593d54209765207a7f11073c06494c1721e4ca4a0a426c597679bf7f91e/charset_normalizer-3.4.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ee2f2a527e3c1a6e6411eb4209642e138b544a2d72fe5d0d76daf77b24063534", size = 231958, upload-time = "2026-07-07T14:34:30.345Z" }, - { url = "https://files.pythonhosted.org/packages/b1/27/693ee5e8a18191eb38647360c51cd505013e2bd3b366aa43fd5344c21e3c/charset_normalizer-3.4.9-cp314-cp314t-win32.whl", hash = "sha256:0d861473f743244d349b50f850d10eb87aeb22bbdcc8e64f79273c94af5a8226", size = 155580, upload-time = "2026-07-07T14:34:31.884Z" }, - { url = "https://files.pythonhosted.org/packages/80/3f/bd97d3d9c613013d07cb7733d299385b41df37f0471310f5a73dc359f0b8/charset_normalizer-3.4.9-cp314-cp314t-win_amd64.whl", hash = "sha256:9b8e0f3107e2200b76f6054de99016eac3ee6762713587b36baaa7e4bd2ae177", size = 167620, upload-time = "2026-07-07T14:34:33.438Z" }, - { url = "https://files.pythonhosted.org/packages/3d/c6/eee9dca4439b1061f76373f06ea855678cc4a64c1c3c90b50e479edbb8eb/charset_normalizer-3.4.9-cp314-cp314t-win_arm64.whl", hash = "sha256:19ac87f93086ce37b86e098888555c4b4bc48102279bae3350098c0ed664b501", size = 158037, upload-time = "2026-07-07T14:34:35.018Z" }, - { url = "https://files.pythonhosted.org/packages/98/2b/f97f1c193fb855c345d678f5077d6926034db0722df74c8f057020e05a25/charset_normalizer-3.4.9-py3-none-any.whl", hash = "sha256:68e5f26a1ad57ded6d1cfb85331d1c1a195314756471d97758c48498bb4dcdf5", size = 64538, upload-time = "2026-07-07T14:34:56.993Z" }, -] - -[[package]] -name = "colorama" -version = "0.4.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697, upload-time = "2022-10-25T02:36:22.414Z" } -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 = "cyclonedx-python-lib" -version = "11.11.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "license-expression" }, - { name = "packageurl-python" }, - { name = "py-serializable" }, - { name = "sortedcontainers" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/75/c9/5d0ccdd19bc7d8ab803b90695c1706aa2ea8529685d18e682dc2524d2630/cyclonedx_python_lib-11.11.0.tar.gz", hash = "sha256:4b3194db72b613717f2912447e67ab618c75ff7dcac6c4af3c0e9e1ac617c102", size = 1442983, upload-time = "2026-06-17T11:57:49.055Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/22/f3/56ccb2884aaa3db5622368e5191a3384b15f35392aa93df8b2f508c660d2/cyclonedx_python_lib-11.11.0-py3-none-any.whl", hash = "sha256:3049fc83e06a059b5c5907a527625a8ed5073caab10607ed4c9e5503b590fd44", size = 528689, upload-time = "2026-06-17T11:57:47.358Z" }, -] - -[[package]] -name = "defusedxml" -version = "0.7.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/0f/d5/c66da9b79e5bdb124974bfe172b4daf3c984ebd9c2a06e2b8a4dc7331c72/defusedxml-0.7.1.tar.gz", hash = "sha256:1bb3032db185915b62d7c6209c5a8792be6a32ab2fedacc84e01b52c51aa3e69", size = 75520, upload-time = "2021-03-08T10:59:26.269Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/07/6c/aa3f2f849e01cb6a001cd8554a88d4c77c5c1a31c95bdf1cf9301e6d9ef4/defusedxml-0.7.1-py2.py3-none-any.whl", hash = "sha256:a352e7e428770286cc899e2542b6cdaedb2b4953ff269a210103ec58f6198a61", size = 25604, upload-time = "2021-03-08T10:59:24.45Z" }, -] - -[[package]] -name = "egress-gate" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "grpcio" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "regex" }, - { name = "rich" }, - { name = "typer" }, - { name = "typing-extensions" }, -] - -[package.dev-dependencies] -dev = [ - { name = "pip-audit" }, - { name = "pytest" }, - { name = "pytest-asyncio" }, - { name = "ruff" }, - { name = "ty" }, -] - -[package.metadata] -requires-dist = [ - { name = "grpcio", specifier = ">=1.81.1,<2" }, - { name = "protobuf", specifier = ">=7.36,<8" }, - { name = "pydantic", specifier = ">=2.11,<3" }, - { name = "pyyaml", specifier = ">=6,<7" }, - { name = "regex", specifier = ">=2026.7.19,<2027" }, - { name = "rich", specifier = ">=14,<16" }, - { name = "typer", specifier = ">=0.16,<1" }, - { name = "typing-extensions", specifier = ">=4.12,<5" }, -] - -[package.metadata.requires-dev] -dev = [ - { name = "pip-audit", specifier = "==2.10.1" }, - { name = "pytest", specifier = ">=9.0.3,<10" }, - { name = "pytest-asyncio", specifier = ">=0.25,<2" }, - { name = "ruff", specifier = ">=0.12,<0.13" }, - { name = "ty", specifier = ">=0.0.1a16,<0.1" }, -] - -[[package]] -name = "filelock" -version = "3.32.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c0/80/8232b582c4b318b817cf1274ba74976b07b34d35ef439b3eb948f98645a1/filelock-3.32.0.tar.gz", hash = "sha256:7be2ad23a14607ccc71808e68fe30848aeace7058ace17852f68e2a68e310402", size = 213757, upload-time = "2026-07-21T13:17:42.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/06/79/b4c714bef36bc4ec2beeae1e0c124f0223888cd8c6feb1cdc56038116920/filelock-3.32.0-py3-none-any.whl", hash = "sha256:d396bea984af47333ef05e50eae7eff88c84256de6112aea0ec48a233c064fe3", size = 97732, upload-time = "2026-07-21T13:17:41.55Z" }, -] - -[[package]] -name = "grpcio" -version = "1.82.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/90/bc/656b89387d6f4ed7e0686c7b64c2ae7e554a759aa58122c8e5fb99392c32/grpcio-1.82.1.tar.gz", hash = "sha256:707b24abd90fcb1e45bcc080577da1dbf9971d107490589b9539af8e1e77b4b5", size = 13187300, upload-time = "2026-07-08T12:36:16.588Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/26/5b/e5092af97fa671ca279b3e373251af4bf87d5fbda7dc85f6a616899562a7/grpcio-1.82.1-cp311-cp311-linux_armv7l.whl", hash = "sha256:0ddb18a9a9e1f46692b3567ae4abb3f8d117ce6afea48650f8eca06d8ab5d06f", size = 6181472, upload-time = "2026-07-08T12:34:31.009Z" }, - { url = "https://files.pythonhosted.org/packages/c1/8f/18053a3a2ca03d0c2a1b8cc7271e705007a16aa5dae84bac00935c5b1a7f/grpcio-1.82.1-cp311-cp311-macosx_11_0_universal2.whl", hash = "sha256:cf855b1af246720f567b0ce5d0724d45dfa4188eecc3296a2a69257b11b9e94b", size = 11970995, upload-time = "2026-07-08T12:34:33.603Z" }, - { url = "https://files.pythonhosted.org/packages/5b/7e/21b1acb052876ad00959ec4d1b05fe08607d650bcfa282073bb164c2703c/grpcio-1.82.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:ddb30cb13e25bc13cea70ffc69d6d90c49d36ea6c1d4549e6912f70177834cac", size = 6760127, upload-time = "2026-07-08T12:34:36.122Z" }, - { url = "https://files.pythonhosted.org/packages/3e/12/25eef9c245c54f0061317d13a302357fe8ea03bac240b2b02ececcf54da4/grpcio-1.82.1-cp311-cp311-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1e822b2774f719c017cbe700b6e47173b6ae290fb84906f52a5a3c2c60b62e1e", size = 7484377, upload-time = "2026-07-08T12:34:38.368Z" }, - { url = "https://files.pythonhosted.org/packages/a0/41/1a348767eb9d9bd7765dc4fa8a01723d3bb386d67f981ee5c6f9c02b8b1c/grpcio-1.82.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:5dafb1ece8ed45dee7c738f166ec82e19673221ed5ab8967f72858a4685345b2", size = 6924269, upload-time = "2026-07-08T12:34:40.583Z" }, - { url = "https://files.pythonhosted.org/packages/e4/b9/3aae7a03d34c86ea27988db859a6087c186f6c3f53f9b551e07afd989bfa/grpcio-1.82.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:e06503106e7271e0a49fd5a1ac04747f1e47e87d900476db6fe45bc87ee411f4", size = 7531848, upload-time = "2026-07-08T12:34:43.277Z" }, - { url = "https://files.pythonhosted.org/packages/3c/2e/3c4afa625d0dac9090707966916284c035fc5b2fb3e2c51e156accee6735/grpcio-1.82.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:ff99bc8cafb6a952201c37b995f425e641c93ffa6e072258525feab57290141d", size = 8568217, upload-time = "2026-07-08T12:34:45.502Z" }, - { url = "https://files.pythonhosted.org/packages/20/9c/d8489c628e73e20a3d034e7f66912de7b1acb405f01d388f056a88e47924/grpcio-1.82.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:644ae1b94266ac785330f4590a69e52b6a7eb73029043a02209db81c81397d69", size = 7938771, upload-time = "2026-07-08T12:34:48.323Z" }, - { url = "https://files.pythonhosted.org/packages/4b/b7/0a92cfd1658f3a896d4aa12d4efeb7dd4ddfc723725ae22741a5241ea710/grpcio-1.82.1-cp311-cp311-win32.whl", hash = "sha256:e203d2e19d471630084a16c815616f8211dff21c268ab3c5f5bf38417832e074", size = 4256432, upload-time = "2026-07-08T12:34:50.432Z" }, - { url = "https://files.pythonhosted.org/packages/c7/6a/2872c761b025d9ec74386f22a4a7d59c5a5b00ebf718761b33739ffc45de/grpcio-1.82.1-cp311-cp311-win_amd64.whl", hash = "sha256:0d8299c285fe6cc6a1f56badf8d3bc5078c8d20273ee64bafa3783b4bc29a769", size = 5009633, upload-time = "2026-07-08T12:34:52.67Z" }, - { url = "https://files.pythonhosted.org/packages/dc/88/d1350bf3343a2ed87d801584e40609f6c6bd3087926eeca03de50348cf4a/grpcio-1.82.1-cp312-cp312-linux_armv7l.whl", hash = "sha256:c09bd5fa0d5b1fbd773ec349fe61441c3e4ebf168c229aa7538a820bdfad6a58", size = 6144689, upload-time = "2026-07-08T12:34:55.567Z" }, - { url = "https://files.pythonhosted.org/packages/e6/33/71875cdecd27c24ac1385d4783a09853f01b84a825a36aec2a2bc7d0d080/grpcio-1.82.1-cp312-cp312-macosx_11_0_universal2.whl", hash = "sha256:1eae24810720734598e3e6a1a528d5de0f265fe3fc86575e9ecce424b9ec7379", size = 11952034, upload-time = "2026-07-08T12:34:58.128Z" }, - { url = "https://files.pythonhosted.org/packages/82/b2/d9125df3d8a140dec12cc82c05b7deafedeababcff6496f28b2fd5634d10/grpcio-1.82.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a6bd5daf5bde7b24d7ad2cbaf8bf9eac620d96222016bb5e7ddde930dec0673f", size = 6710772, upload-time = "2026-07-08T12:35:01.33Z" }, - { url = "https://files.pythonhosted.org/packages/88/9b/69e2d1627398b964f34437dc476a5aff5a2cc8e7f247d26272b5674b5faf/grpcio-1.82.1-cp312-cp312-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:1ecfde669cb687ac020d31ff76debe5dc7a62213335f02262eb6625628da1c03", size = 7450677, upload-time = "2026-07-08T12:35:03.926Z" }, - { url = "https://files.pythonhosted.org/packages/e4/e7/8f855ca29c294956122a2a73023655b9b02602d5111dad2b9b00e7631c68/grpcio-1.82.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:011c8badee95734dee8bf05ce3464756a0ac3ebb8d443afd20c0e2b5e4640ad9", size = 6886855, upload-time = "2026-07-08T12:35:06.174Z" }, - { url = "https://files.pythonhosted.org/packages/5f/f0/fa87e85f49925f44c479d07e58b051e69bcfef6b6d5fbc6749d140f6730a/grpcio-1.82.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:b85f4564926fb23114d239392bdcae200db1e6179629edd7d7ab0ab89c96a197", size = 7501323, upload-time = "2026-07-08T12:35:08.49Z" }, - { url = "https://files.pythonhosted.org/packages/67/55/2e0b10ae1d3ef9dcc480b91dc2158f4931fc4675d3af0a2836e39b2a744f/grpcio-1.82.1-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:2c0c8270833395644c3fe6b6a806397955a2bc0538000a19a78b90c05a6c16e0", size = 8536899, upload-time = "2026-07-08T12:35:10.975Z" }, - { url = "https://files.pythonhosted.org/packages/bd/95/a3d8b0431fa221efc51ee39d73595ede74ba82a43b7c4313192e580face2/grpcio-1.82.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:2ba199205ff46c7778290fe1673c91ac8e7e45678dd5c86e9e56fa33ec8788f6", size = 7913892, upload-time = "2026-07-08T12:35:13.944Z" }, - { url = "https://files.pythonhosted.org/packages/b8/92/f2651ec704d9852a56faef394775038afba435b50ce82ab2404d119c3355/grpcio-1.82.1-cp312-cp312-win32.whl", hash = "sha256:06127691866e295c14e84a1fb86356dd962254f6abd0da4ca4b001eea9e89438", size = 4240985, upload-time = "2026-07-08T12:35:16.048Z" }, - { url = "https://files.pythonhosted.org/packages/96/4f/a5fe8bf0d0a1b24855f370293075c931f27de4eb55f0f158786095bf3c11/grpcio-1.82.1-cp312-cp312-win_amd64.whl", hash = "sha256:1fa3223a3a2e1db74f4c2b255189eb7ea875dfba56e221d252ee3fc7b204778e", size = 5001580, upload-time = "2026-07-08T12:35:18.689Z" }, - { url = "https://files.pythonhosted.org/packages/1b/3e/496992d08c0aaa11272eb6228dc8ab947da01fe835de243cd00521bce4c4/grpcio-1.82.1-cp313-cp313-linux_armv7l.whl", hash = "sha256:b454a2d97bfab7565683a02345f86bd182ab69fd7c2bdb7414171e7538f266b1", size = 6146068, upload-time = "2026-07-08T12:35:21.365Z" }, - { url = "https://files.pythonhosted.org/packages/e7/8f/f263d6f14fdba6b56cfadd91fd3e158a52682b72c6016d1f8723d435659f/grpcio-1.82.1-cp313-cp313-macosx_11_0_universal2.whl", hash = "sha256:3dde70abfc80b3be11de53ba0d601c439e7fb2afd3583ad1788d1146bec92fdc", size = 11948600, upload-time = "2026-07-08T12:35:24.312Z" }, - { url = "https://files.pythonhosted.org/packages/8c/14/3a02e6ee49c2d85bc15eaae321e0e11ab3542cad3c5b2de121ecce0c4296/grpcio-1.82.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f5523099c98c292ea1ae08e617249db760c56a78f8deae879027fe7d1ffbcbf6", size = 6714591, upload-time = "2026-07-08T12:35:27.027Z" }, - { url = "https://files.pythonhosted.org/packages/69/80/58e3738696f48ab7645347b98d8a7f93d10e00e6218388fbfcd6c9310e3d/grpcio-1.82.1-cp313-cp313-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:5e5c4dc0a59b0f8490a6bdfd6fc8395b9d8ad8a8407c7d67ca7b5bba15c0877f", size = 7454995, upload-time = "2026-07-08T12:35:29.599Z" }, - { url = "https://files.pythonhosted.org/packages/f3/6c/2557c1a889363072fbf2285ecd0e8c44860d4dbd60f017a32537c5b863e2/grpcio-1.82.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c40d94ba820329cc191981bc22fa6f6eed0799c6d921f3c6709521d59d4a2fd7", size = 6888621, upload-time = "2026-07-08T12:35:32.38Z" }, - { url = "https://files.pythonhosted.org/packages/d2/66/907706ccaff1223f1e10fd5b37fc16faead43392fccb4e786e7e390ac141/grpcio-1.82.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c816180e31e273caaec6f8bd86a8392499d5bbb26f41da44e3dce48bde69095", size = 7505069, upload-time = "2026-07-08T12:35:35.072Z" }, - { url = "https://files.pythonhosted.org/packages/b3/7c/ff97b0d0f635987ee5ec80dfedafa1aad629303745d48e8637d10eec5b80/grpcio-1.82.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e31fd780b261830720cb70b0fd8f0aa51d49e75a66d7464ad2e31d4b765f2580", size = 8535384, upload-time = "2026-07-08T12:35:37.954Z" }, - { url = "https://files.pythonhosted.org/packages/62/9e/a97fddd970a8d1588cade06eca20443761c1858b0ad6590a5c835aa18062/grpcio-1.82.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:9d76152d7c31d7210d4a106e5d8b64da5bba5d6abf11be30e2f7b0a0c59bbcbf", size = 7910707, upload-time = "2026-07-08T12:35:40.797Z" }, - { url = "https://files.pythonhosted.org/packages/20/e4/eaba1517888af483a88d449eb7566f0f7f63446d46f339c5891798435875/grpcio-1.82.1-cp313-cp313-win32.whl", hash = "sha256:38e9dcb5258226fb3282630b31b16a968df52c8c6ad514af540646e0a4578f8a", size = 4240363, upload-time = "2026-07-08T12:35:43.298Z" }, - { url = "https://files.pythonhosted.org/packages/b0/42/66a98d47732e35290bef722f6149fed3709cd4cf61166f6f53a12f417302/grpcio-1.82.1-cp313-cp313-win_amd64.whl", hash = "sha256:3dbfb52c36d9511ac2b8e6c94fdde837b393ae520cc321f52a333a2deedf5a90", size = 5000980, upload-time = "2026-07-08T12:35:46.262Z" }, - { url = "https://files.pythonhosted.org/packages/b4/cb/cf9ae9e164c6e6dc8a494faa9771763df9da150eefe19671009624d1559f/grpcio-1.82.1-cp314-cp314-linux_armv7l.whl", hash = "sha256:35f990f7784c8fd2872644f07f96ebb4d9e48e145a190ab80d0280af91a1bfb2", size = 6146901, upload-time = "2026-07-08T12:35:49.261Z" }, - { url = "https://files.pythonhosted.org/packages/3b/2a/eccf26dbcfb7f7cab8027c5490a16c8937c5aa7a2ec20a3eab2cf7a43165/grpcio-1.82.1-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:46536a4a1f4434df3c851b9254ff6fc7df5705b273681a15ca277d5921c178a0", size = 11954756, upload-time = "2026-07-08T12:35:52.196Z" }, - { url = "https://files.pythonhosted.org/packages/ad/75/3b3b4a3cc9f084b026af96e1d3e539b1af29ec7f41ed0dfff3cb99cc8626/grpcio-1.82.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:d6650a7c1ebb7921c70e12a385439a8118efb99e669fa9ed31cf25db1843937c", size = 6723087, upload-time = "2026-07-08T12:35:54.973Z" }, - { url = "https://files.pythonhosted.org/packages/9c/8b/b0f0c9b1400a99a4da4c09b114f101b192f8f11192e76f620b8962f5d90b/grpcio-1.82.1-cp314-cp314-manylinux2014_i686.manylinux_2_17_i686.whl", hash = "sha256:b8e110c66df5204c0506d6c8787b35d48b8b699ef5aa366d6c4d67325c67fe9a", size = 7454542, upload-time = "2026-07-08T12:35:57.586Z" }, - { url = "https://files.pythonhosted.org/packages/b7/bd/428e38868382aa193697a5aa53973f29c58e58ba4268aa0c86a2715ee58b/grpcio-1.82.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:f853eae07235a51a27bb5d6a9a175a59ca55dc9b99edc6ce2f76f07332d333ae", size = 6889588, upload-time = "2026-07-08T12:36:00.012Z" }, - { url = "https://files.pythonhosted.org/packages/49/ce/03e01d5e10259bf5c08ee50570cc94724e79c956f61fd2f09b341af0956c/grpcio-1.82.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:60b0f2c95337694fc094b77d9f60f50566c84b5677393e342eb98daeee242d98", size = 7514166, upload-time = "2026-07-08T12:36:02.693Z" }, - { url = "https://files.pythonhosted.org/packages/ff/59/278b4b600329e2ba3849f3c1ea3c820b3a01b38a7ad184ba09595e8d2733/grpcio-1.82.1-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:b064fc444812bdaa9825d33c26f8d732d63ee6a5d78557c1faf92c98687fed27", size = 8536166, upload-time = "2026-07-08T12:36:05.349Z" }, - { url = "https://files.pythonhosted.org/packages/44/27/7ccf2ef00f27a8e47a79d641c8ceaf7d3028c7a03d9a97b4c8a9a783c086/grpcio-1.82.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7d7ede11d747b4e1bd05e3bc0260e155b65a88735a895a10f6521f19b889511e", size = 7912572, upload-time = "2026-07-08T12:36:08.393Z" }, - { url = "https://files.pythonhosted.org/packages/0d/be/33742482d2753f2d3a1b7641664b6622262d44f2f3b609f13425dd86d36f/grpcio-1.82.1-cp314-cp314-win32.whl", hash = "sha256:3d21f19838dc255ecbb79321b15ae9b98fbddff4c3d4aedb0a81bdd7f4ab572a", size = 4321856, upload-time = "2026-07-08T12:36:10.899Z" }, - { url = "https://files.pythonhosted.org/packages/cc/67/03329c847172c78ddeb1eb9be6b444fdbc12775a84c958b27e427e7b926d/grpcio-1.82.1-cp314-cp314-win_amd64.whl", hash = "sha256:e20f1edbb15f99e3128ec86433f9785fd5a451d8f115e74fe0056134f092a9d5", size = 5141114, upload-time = "2026-07-08T12:36:13.595Z" }, -] - -[[package]] -name = "idna" -version = "3.18" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" }, -] - -[[package]] -name = "iniconfig" -version = "2.3.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503, upload-time = "2025-10-18T21:55:43.219Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484, upload-time = "2025-10-18T21:55:41.639Z" }, -] - -[[package]] -name = "license-expression" -version = "30.4.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "boolean-py" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/40/71/d89bb0e71b1415453980fd32315f2a037aad9f7f70f695c7cec7035feb13/license_expression-30.4.4.tar.gz", hash = "sha256:73448f0aacd8d0808895bdc4b2c8e01a8d67646e4188f887375398c761f340fd", size = 186402, upload-time = "2025-07-22T11:13:32.17Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/af/40/791891d4c0c4dab4c5e187c17261cedc26285fd41541577f900470a45a4d/license_expression-30.4.4-py3-none-any.whl", hash = "sha256:421788fdcadb41f049d2dc934ce666626265aeccefddd25e162a26f23bcbf8a4", size = 120615, upload-time = "2025-07-22T11:13:31.217Z" }, -] - -[[package]] -name = "markdown-it-py" -version = "4.2.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "mdurl" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, -] - -[[package]] -name = "mdurl" -version = "0.1.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/54/cfe61301667036ec958cb99bd3efefba235e65cdeb9c84d24a8293ba1d90/mdurl-0.1.2.tar.gz", hash = "sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba", size = 8729, upload-time = "2022-08-14T12:40:10.846Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b3/38/89ba8ad64ae25be8de66a6d463314cf1eb366222074cfda9ee839c56a4b4/mdurl-0.1.2-py3-none-any.whl", hash = "sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8", size = 9979, upload-time = "2022-08-14T12:40:09.779Z" }, -] - -[[package]] -name = "msgpack" -version = "1.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/31/f9/c0a1c127f9049db9155afc316952ea571720dd01833ff5e4d7e8e6352dbb/msgpack-1.2.1.tar.gz", hash = "sha256:04c721c2c7448767e9e3f2520a475663d8ee0f09c31890f6d2bd70fd636a9647", size = 183960, upload-time = "2026-06-18T16:13:52.594Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/6b/e9b1cdc042c4458801d2545ed782a95f3d6ba8e270cce8745b8603c7f748/msgpack-1.2.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:29a3f6e9667868429d8240dfd063ea5ffdc1321c13d783aa23827a38de0dcb22", size = 82812, upload-time = "2026-06-18T16:12:45.022Z" }, - { url = "https://files.pythonhosted.org/packages/0c/3a/dd518a1bf78ed1e9ad8afe57307c079a00eafe4b3068932a27ca1ea56b4f/msgpack-1.2.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:aded5bdf32609dc7987a49bbbd15a8ef096193f96dd8bbeb791de729e650acf5", size = 82739, upload-time = "2026-06-18T16:12:46.025Z" }, - { url = "https://files.pythonhosted.org/packages/70/e0/7ba9e1542bf0771a27b8b37c1316e3f95ae9d748fd765284655c476ad4ef/msgpack-1.2.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:146ee4e9ce80b365c6d4c47073da9da7bcec473e58194ceee5dd7620ace77e06", size = 414233, upload-time = "2026-06-18T16:12:47.029Z" }, - { url = "https://files.pythonhosted.org/packages/03/8d/671d81534ea0e2b0e8a121be100020da09eb78861fe3aa8f3ef7dcd3bed1/msgpack-1.2.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a28d076ca7c82b9c8728ad90b7147489449557038bed50e4241eb832395169b4", size = 423843, upload-time = "2026-06-18T16:12:48.19Z" }, - { url = "https://files.pythonhosted.org/packages/d2/b6/e5c737515ed1f166664b87601b532f58cbb73d8aa6a90b99f7c2c5037e8e/msgpack-1.2.1-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:7d31c0ac0c640f877804c67cb2bc9f4e23dc2db97e96c2e67fa27d38283b41f8", size = 390772, upload-time = "2026-06-18T16:12:49.624Z" }, - { url = "https://files.pythonhosted.org/packages/a8/46/62ed8c2e87d7021eab19921594d961ef3aa3794eec76c716dc30f3bfd433/msgpack-1.2.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:8ff92d7feeaf5bc26c51495b69e2f99ed97ab79346fb6555f44be7dd2ac6503b", size = 409559, upload-time = "2026-06-18T16:12:50.936Z" }, - { url = "https://files.pythonhosted.org/packages/70/ff/59aa3887b860bbf43532835e192b1c388a17590d6068ae4f8b2bc74c906e/msgpack-1.2.1-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:779197a6513bab3c3632265e3d0f7cb3227e62510841a6f34f1eaa37efbb345e", size = 387838, upload-time = "2026-06-18T16:12:52.161Z" }, - { url = "https://files.pythonhosted.org/packages/09/11/f8563e471093420cf6478cb3271a0175d8402b82d879783d4035d2d03360/msgpack-1.2.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:67f6dd22fa72a93752643f07889796d62739a13415ee630169a8ce764f86cf9f", size = 421732, upload-time = "2026-06-18T16:12:53.556Z" }, - { url = "https://files.pythonhosted.org/packages/57/cf/e673683c4c6c90c1022b24c65af4b03eda72b182a1176ef6449069d66acc/msgpack-1.2.1-cp311-cp311-win32.whl", hash = "sha256:91054a783328e0ea7954b8771095705c8d2243b814743fbaadf14552c9c52c5d", size = 64091, upload-time = "2026-06-18T16:12:54.821Z" }, - { url = "https://files.pythonhosted.org/packages/3f/07/ca212739d179f9083bff2c7c08c24101c3555a334fadc2b876b18768a3ae/msgpack-1.2.1-cp311-cp311-win_amd64.whl", hash = "sha256:2eda0b7ebb1283a98d3e4492ac933c8af6aff59fd3df1c3ed024f536af4b1dc8", size = 70462, upload-time = "2026-06-18T16:12:55.898Z" }, - { url = "https://files.pythonhosted.org/packages/6d/be/6798347b425e26f35db82e69dd83c09716c856a3714e7bffc4c0860fd830/msgpack-1.2.1-cp311-cp311-win_arm64.whl", hash = "sha256:6ee967f7c7e1df2890c671ff2ee51a28ded0efc95da3e507176dee881ce36c66", size = 65059, upload-time = "2026-06-18T16:12:57.053Z" }, - { url = "https://files.pythonhosted.org/packages/bc/dd/9e8cbd8f5582ca4b590336f2b91ee5662f6a6ca562b565abaf696a0f81ff/msgpack-1.2.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2ef59c659f289eddf8aa6623823f19fa2f40a4029266889eac7a2505dd210c35", size = 83531, upload-time = "2026-06-18T16:12:58.249Z" }, - { url = "https://files.pythonhosted.org/packages/50/2e/ebdb85a8da151397a2790363676b7ed7c125924fe618e4c6d8befb0cc62c/msgpack-1.2.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d3567748a5107cb40cdf66a275430c2f87c07777698f4bfd25c35f44d533258c", size = 82657, upload-time = "2026-06-18T16:12:59.396Z" }, - { url = "https://files.pythonhosted.org/packages/26/aa/753ad8b007b464e1d8aa0c8e650b9c5f4f725e658fc5ac8a7635c55b7f6e/msgpack-1.2.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:60926b75d00c8e816ef98f3034f484a8bc64242d66839cef4cf7e503142316a0", size = 410634, upload-time = "2026-06-18T16:13:00.383Z" }, - { url = "https://files.pythonhosted.org/packages/6a/fd/6adabd4f6d5e686f97dd02ce7fce3fe4cf672cbac36b8f67ff4040e8ad8b/msgpack-1.2.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:020e881a764b20d8d7ca1a54fc01b8175519d108e3c3f194fddc200bda95951a", size = 419989, upload-time = "2026-06-18T16:13:01.776Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cc/85039b7b0eb168aaad7383a23c97e291a11f08351cb45a606ce865e4e3f1/msgpack-1.2.1-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4202c74688ca06591f78cb18988228bd4cca2cc75d57b60008372892d2f1e6e6", size = 377544, upload-time = "2026-06-18T16:13:03.637Z" }, - { url = "https://files.pythonhosted.org/packages/ed/bf/35963899493b32030c85fc513b723ae66144ac70c11ebc52e889e16e3d99/msgpack-1.2.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8b267ce94efb76fbd1b3373511420074ee3187f0f7811bf394531de13294735a", size = 400842, upload-time = "2026-06-18T16:13:05.012Z" }, - { url = "https://files.pythonhosted.org/packages/a6/df/8e2ac970c8f99264cd9997d1c73df5466bc19da3301d7dc5500862a9b089/msgpack-1.2.1-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:e4f1d0f8f98ade9634e01fb704a408f9336c0a8f1117b369f5db83dc7551d8b1", size = 374108, upload-time = "2026-06-18T16:13:06.232Z" }, - { url = "https://files.pythonhosted.org/packages/17/dd/fa8bd265110dfa51c20cb529f9e6d240a16fafe7e645004c6af2d01353ba/msgpack-1.2.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f02cf17a6ca1abe29b5f980644f7551f94d71f2011509b26d8625ce038f0df64", size = 414939, upload-time = "2026-06-18T16:13:07.478Z" }, - { url = "https://files.pythonhosted.org/packages/2e/b9/8377a5ad8953fc0437c70cc98d9ae29f27fe5ac5109fbec0812085865735/msgpack-1.2.1-cp312-cp312-win32.whl", hash = "sha256:0c0d9802354507bcba62af19c17918e3eb437cc25e6f50657d511b5856a77aac", size = 64504, upload-time = "2026-06-18T16:13:08.822Z" }, - { url = "https://files.pythonhosted.org/packages/57/7f/ce1e377df7e62461fefd9eb23bfb93a4a523f40a517b377b8f844d836828/msgpack-1.2.1-cp312-cp312-win_amd64.whl", hash = "sha256:5c24aa15d5963051e1a5c62b12c50cd705992502b5ec1f3bece6046f33c9fc24", size = 71421, upload-time = "2026-06-18T16:13:09.828Z" }, - { url = "https://files.pythonhosted.org/packages/8f/32/ebfe84c9929f08f188d56c7a2fd913406a9ddad76a634697c1c43b8112e6/msgpack-1.2.1-cp312-cp312-win_arm64.whl", hash = "sha256:4227224aaec8f7fbcbfbd4272319347b2bb4030366502600f8c45588c5187b07", size = 64775, upload-time = "2026-06-18T16:13:11.056Z" }, - { url = "https://files.pythonhosted.org/packages/b0/ac/dcddcab6f6c20ecb387ca5e980371cdb3f87ff69aeca388be97eebc4c074/msgpack-1.2.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:0a70e3cf2804a300d921bb0940426e35f4e489a23adfb77a808892241db0a064", size = 83151, upload-time = "2026-06-18T16:13:12.173Z" }, - { url = "https://files.pythonhosted.org/packages/64/71/fbcfa83a1d6a9c6091942d1cfd070962244664b87427a9a49a6897b1b219/msgpack-1.2.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:491cc39455ca765fad51fb451bf2915eb2cf41192ab5801ce8d67c1d614fe056", size = 82351, upload-time = "2026-06-18T16:13:13.194Z" }, - { url = "https://files.pythonhosted.org/packages/e3/10/ddf7b06db879e8792d13934ddda09ff20bd2a583fd84c9b59aae9b0e650b/msgpack-1.2.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f310233ef7fb9c14e201c93639fe5f5260b005f56f0b29048e999c30935596cc", size = 407518, upload-time = "2026-06-18T16:13:14.233Z" }, - { url = "https://files.pythonhosted.org/packages/79/d3/36a46a8ed992b781acbc05928bd5bee3c810cb0c3563bf81a7b0c04a1a76/msgpack-1.2.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:787c9bebb5833e8f6fc8abca3c0597683d8d87f56a8842b6b89c75a5f3176e2d", size = 416405, upload-time = "2026-06-18T16:13:15.435Z" }, - { url = "https://files.pythonhosted.org/packages/f9/84/e8e9598b557c0ba6ddae901a73780a4c75ac667dddf59414b1e56a42fb34/msgpack-1.2.1-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:dc871b997a9370d855b7394465f2f350e847a5b806dd38dcc9c989e7d87da155", size = 376257, upload-time = "2026-06-18T16:13:17.022Z" }, - { url = "https://files.pythonhosted.org/packages/40/16/738fe6d875ad7e2a9429c165322a4ec088f4f273cdfae63d96a89c467961/msgpack-1.2.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:85f57e960d877f2977f6430896191b04a21f8901b3b4baf2e4604329f4db5402", size = 397469, upload-time = "2026-06-18T16:13:18.287Z" }, - { url = "https://files.pythonhosted.org/packages/ca/be/6d5952df75a7f24f35833af764c3a6860780364cb3a0030beb8099e1b2b4/msgpack-1.2.1-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:1233ee2dd0cefba127583de50ea654677277047d238303521db35def3d7b2e7c", size = 372802, upload-time = "2026-06-18T16:13:19.685Z" }, - { url = "https://files.pythonhosted.org/packages/e1/39/e2ef7dbf0473bcb8dc7c50bf782a892d67414877b63e47fc88eb189ef5e6/msgpack-1.2.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e3dc2feb0876209d9c38aa56cb1de169bd6c4348f1aa48271f241226590993e6", size = 411273, upload-time = "2026-06-18T16:13:21.028Z" }, - { url = "https://files.pythonhosted.org/packages/ef/c5/133f4512a56e983a93445c836c9d94d88f3bc2e0980ff4b9e577bd8416ce/msgpack-1.2.1-cp313-cp313-win32.whl", hash = "sha256:6d09badf350af2be9d189184e04e64cf54ad93569ab3d96fca58bd3e84aad707", size = 64471, upload-time = "2026-06-18T16:13:22.293Z" }, - { url = "https://files.pythonhosted.org/packages/e2/98/577e10b055096a7dd40732358cabaf7180a20c79ed1dcdbb618e4b9deac7/msgpack-1.2.1-cp313-cp313-win_amd64.whl", hash = "sha256:33f14fba63278b714efe6ad07e50ea5f03d91537aa6a1c5f1ceca4cf44013ca9", size = 71274, upload-time = "2026-06-18T16:13:23.455Z" }, - { url = "https://files.pythonhosted.org/packages/ba/ee/0c0048e7cfbef23c6a94791b8959ab28155232e7956de8a305b5ff588f05/msgpack-1.2.1-cp313-cp313-win_arm64.whl", hash = "sha256:afc5febcd4c99effbc02b528e49d6fd0760b2b7d48c05239e345a5fa6e743d9a", size = 64795, upload-time = "2026-06-18T16:13:24.687Z" }, - { url = "https://files.pythonhosted.org/packages/77/58/cce442852c6b9e1639c7c8ac8fd9143121cb32dab0f308df4d1426a8eb9c/msgpack-1.2.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:05f340e47e7e47d2da8db9b53e1bb1d294369e9ef45a747441309f6650b8351d", size = 83610, upload-time = "2026-06-18T16:13:25.724Z" }, - { url = "https://files.pythonhosted.org/packages/60/5c/15b4c7a0182f75ffa90751958ba36a9c01cafee367d49a3edc10ed140b01/msgpack-1.2.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:810b916696c86ef0deb3b74588480224df4c1b071136c34183e4a2a4284d7ac7", size = 83138, upload-time = "2026-06-18T16:13:26.781Z" }, - { url = "https://files.pythonhosted.org/packages/b8/a6/99e58722feaffc5f2fbcc0c8c0d1451ab9f84097f7af87291b46af2390f4/msgpack-1.2.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ca0dacff965c47afdc3749a8469d7302a8f801d6a28758d55120d75e66ce6889", size = 406090, upload-time = "2026-06-18T16:13:28.072Z" }, - { url = "https://files.pythonhosted.org/packages/19/03/8c63e8cf52958534ef688625965ab04c269a6cadd8caef16758b380a821a/msgpack-1.2.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0e2bf9280bceb5efca998435904b5d3e9fdbcc11d90dc9df30aec7973252b720", size = 412106, upload-time = "2026-06-18T16:13:29.427Z" }, - { url = "https://files.pythonhosted.org/packages/63/d2/155d9e71b40e41fd934bc0c48b9b2770f22263e1ac20aad8e29fdca7be3f/msgpack-1.2.1-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:aa6c4be5d1c02a42b066ca6ddb71adf36432868fdcdb6ee87e634e86e0674190", size = 374851, upload-time = "2026-06-18T16:13:30.631Z" }, - { url = "https://files.pythonhosted.org/packages/98/48/deaf2326262a8d5ea3295ce9649912ecd3f551ba7ec8e33c665d2ba583f3/msgpack-1.2.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:ec0e675d59150a6269ddc9139087c722292664a37d071a849c05c473350f1f2d", size = 396168, upload-time = "2026-06-18T16:13:31.977Z" }, - { url = "https://files.pythonhosted.org/packages/10/2a/b4410f906c2ec0008f1608d3ab5143afc3ad3f4e6da0fed3ea2231d0bef4/msgpack-1.2.1-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:dd3bfe82d53edfe4b7fc9a7ec9761e23a7a5b1dac22264505af428253c29ed24", size = 371959, upload-time = "2026-06-18T16:13:33.282Z" }, - { url = "https://files.pythonhosted.org/packages/59/86/1edc67270099a528fa2093ea60fe191233cd238e4bd30cfacf7db79fc959/msgpack-1.2.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5ad5467fc3f68b5468e06c5f788d712e9f8ffc8b0cd1bcb160c105c1ee92dae7", size = 408457, upload-time = "2026-06-18T16:13:34.567Z" }, - { url = "https://files.pythonhosted.org/packages/82/90/8b630fef07d8c5ab457b71ff2c217910c83d333c7a68472c186e87cc504a/msgpack-1.2.1-cp314-cp314-win32.whl", hash = "sha256:98b58bdb89c46190e4609bb36abe17c6d4105ad13f9c5f8f6f64d320f8ced3fb", size = 65942, upload-time = "2026-06-18T16:13:36.056Z" }, - { url = "https://files.pythonhosted.org/packages/16/f1/467b81e98b24dd3885d7b1857728797b4ffc76a7a7483af4fb321a07de3c/msgpack-1.2.1-cp314-cp314-win_amd64.whl", hash = "sha256:74847557e28ce71bd3c438a447ca90e4b507e997ddbdef8a12a7b283b86c156b", size = 72627, upload-time = "2026-06-18T16:13:37.079Z" }, - { url = "https://files.pythonhosted.org/packages/a7/1d/5d8c4c89985feb6acefb82a09e501c60392261856d2408d20bfe4f0360b1/msgpack-1.2.1-cp314-cp314-win_arm64.whl", hash = "sha256:b50b727bd652bdc37d950336c848ef20ec54a4cafc38dce19b1cd86ad625d0f7", size = 66908, upload-time = "2026-06-18T16:13:38.23Z" }, - { url = "https://files.pythonhosted.org/packages/1b/02/ad2afb678b4de94496cd432b581759b756a92c1192d8c767edd6b132efdc/msgpack-1.2.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:8d00f177ca88a77c1cf848d204a38f249751650b601cb6532acc68805d8a8273", size = 86000, upload-time = "2026-06-18T16:13:39.44Z" }, - { url = "https://files.pythonhosted.org/packages/54/74/0b797484013128837f3b1cbb6cea019277c4de4e377dc512b4d9a0f92940/msgpack-1.2.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5bb9c386f0a329c035ddbab4b72d1028bf9627add8dda41070288563d57ed1b1", size = 86544, upload-time = "2026-06-18T16:13:40.447Z" }, - { url = "https://files.pythonhosted.org/packages/a9/b4/b774d7eb95561739907fec675582f83203cf41c597a418c2589b4bfb8e9d/msgpack-1.2.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:20466cca18c49c7292a8984bc15d65857b171e7264bdcb5f96baf8be238791fc", size = 427661, upload-time = "2026-06-18T16:13:41.574Z" }, - { url = "https://files.pythonhosted.org/packages/b2/f9/3243191dc9937e00756c8bc1b0272fed8f23758e43df2a3b46f533e5090f/msgpack-1.2.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:196300e7e5d6e74d50f1607ab9c06c4a1484c383cd22defd727902591f7e8dde", size = 426375, upload-time = "2026-06-18T16:13:42.936Z" }, - { url = "https://files.pythonhosted.org/packages/23/c7/1693111db9944ba4ad4b67a1e788400d78a0b6af7a6523dc7e4e58f8274b/msgpack-1.2.1-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:575957e79cd51903a4e8495a242442949641e08f1efd5197b43bebd3ea7682b4", size = 380495, upload-time = "2026-06-18T16:13:44.306Z" }, - { url = "https://files.pythonhosted.org/packages/3e/2b/92f86956a0c13e8662f7e2ad630c4eb4db07497b967589bd5245e018b2c1/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8c2ed1e48cc0f460bf3c7780e7137ff21a4e18433451916f2442c1b21036cd7d", size = 410897, upload-time = "2026-06-18T16:13:45.629Z" }, - { url = "https://files.pythonhosted.org/packages/da/ea/1479f72d200313a76fc2f823a79d1e07ed052ab7b8a0280640aa7b95de42/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:5f6277e5f783c36786a145e0247fc189a03f35f84b251646e53592d2bc12b355", size = 378519, upload-time = "2026-06-18T16:13:46.998Z" }, - { url = "https://files.pythonhosted.org/packages/f5/4d/fa006060ffa1011d32bfae826fe766fe73e02982183601633b7121058ab3/msgpack-1.2.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:f9389552ecf4784886345ead0647e4edc96bee37cbab05b75540f542f766c48c", size = 419815, upload-time = "2026-06-18T16:13:48.205Z" }, - { url = "https://files.pythonhosted.org/packages/2f/e1/aab6c946570496b78e67804721f3d5e2d62a93081b9b37df77764ef56347/msgpack-1.2.1-cp314-cp314t-win32.whl", hash = "sha256:c1c79a604a2969a868a78b6ebd27a887e00c624f14f66b3038e0590cb23332d1", size = 70914, upload-time = "2026-06-18T16:13:49.385Z" }, - { url = "https://files.pythonhosted.org/packages/13/0a/e608956488a2af014cfe6e3d665e090b8ee42aa14b07f8f95b8880d66b09/msgpack-1.2.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f12038a35fabd52e56a3547bab42401af49a45caa6dd00b34c44de235bc93ee2", size = 77999, upload-time = "2026-06-18T16:13:50.467Z" }, - { url = "https://files.pythonhosted.org/packages/d2/8a/27e2e57055176e366a46b85d02d68e7a5bcfbdd8474c9706375d965f24d3/msgpack-1.2.1-cp314-cp314t-win_arm64.whl", hash = "sha256:0adcf06ffde0777c0e1a9b771a2b1c4226ba1bbf748c8efcc02fcdeca3299107", size = 71160, upload-time = "2026-06-18T16:13:51.498Z" }, -] - -[[package]] -name = "packageurl-python" -version = "0.17.6" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f5/d6/3b5a4e3cfaef7a53869a26ceb034d1ff5e5c27c814ce77260a96d50ab7bb/packageurl_python-0.17.6.tar.gz", hash = "sha256:1252ce3a102372ca6f86eb968e16f9014c4ba511c5c37d95a7f023e2ca6e5c25", size = 50618, upload-time = "2025-11-24T15:20:17.998Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/b1/2f/c7277b7615a93f51b5fbc1eacfc1b75e8103370e786fd8ce2abf6e5c04ab/packageurl_python-0.17.6-py3-none-any.whl", hash = "sha256:31a85c2717bc41dd818f3c62908685ff9eebcb68588213745b14a6ee9e7df7c9", size = 36776, upload-time = "2025-11-24T15:20:16.962Z" }, -] - -[[package]] -name = "packaging" -version = "26.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, -] - -[[package]] -name = "pip" -version = "26.2.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/ae/15/4500e320e6b101ec3b719ae85b697d9940b6cda672bc555bd6016fc60c6f/pip-26.2.1.tar.gz", hash = "sha256:f6ad667e89a1fe78046c8f13232b247200f5258d7828f3f7883d660878e0813f", size = 1848877, upload-time = "2026-08-04T22:51:14.148Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f3/6e/1736e5b4ae2b778ef2f81c47d797de9f891d4d8acb047a24ca37a60294dd/pip-26.2.1-py3-none-any.whl", hash = "sha256:71138adf1f4ca900cdb7d289c21b7494329f2332b6d85f0e1c42108c0384ed3e", size = 1816632, upload-time = "2026-08-04T22:51:12.472Z" }, -] - -[[package]] -name = "pip-api" -version = "0.0.34" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pip" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/b9/f1/ee85f8c7e82bccf90a3c7aad22863cc6e20057860a1361083cd2adacb92e/pip_api-0.0.34.tar.gz", hash = "sha256:9b75e958f14c5a2614bae415f2adf7eeb54d50a2cfbe7e24fd4826471bac3625", size = 123017, upload-time = "2024-07-09T20:32:30.641Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/91/f7/ebf5003e1065fd00b4cbef53bf0a65c3d3e1b599b676d5383ccb7a8b88ba/pip_api-0.0.34-py3-none-any.whl", hash = "sha256:8b2d7d7c37f2447373aa2cf8b1f60a2f2b27a84e1e9e0294a3f6ef10eb3ba6bb", size = 120369, upload-time = "2024-07-09T20:32:29.099Z" }, -] - -[[package]] -name = "pip-audit" -version = "2.10.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "cachecontrol", extra = ["filecache"] }, - { name = "cyclonedx-python-lib" }, - { name = "packaging" }, - { name = "pip-api" }, - { name = "pip-requirements-parser" }, - { name = "platformdirs" }, - { name = "requests" }, - { name = "rich" }, - { name = "tomli" }, - { name = "tomli-w" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/66/a4/f21d5f0a0edabcbce31560b73c7c5a6f72ae87af4236fd1069c8f59a353d/pip_audit-2.10.1.tar.gz", hash = "sha256:1eb4565d19ebe5d48996f4b770b4d2b32887e12cb12cfa637f1a064011b55ffc", size = 54275, upload-time = "2026-06-10T22:17:01.744Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a3/a7/b0c504148114047bd1bc9d97447453c6850ca176bb2f3c0038835994e8b7/pip_audit-2.10.1-py3-none-any.whl", hash = "sha256:99ef3f600a317c1945f1e89e227ef26e1c2d618429b8bd3fa6f4f7c440c4611a", size = 62023, upload-time = "2026-06-10T22:17:00.309Z" }, -] - -[[package]] -name = "pip-requirements-parser" -version = "32.0.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "packaging" }, - { name = "pyparsing" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/5e/2a/63b574101850e7f7b306ddbdb02cb294380d37948140eecd468fae392b54/pip-requirements-parser-32.0.1.tar.gz", hash = "sha256:b4fa3a7a0be38243123cf9d1f3518da10c51bdb165a2b2985566247f9155a7d3", size = 209359, upload-time = "2022-12-21T15:25:22.732Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/d0/d04f1d1e064ac901439699ee097f58688caadea42498ec9c4b4ad2ef84ab/pip_requirements_parser-32.0.1-py3-none-any.whl", hash = "sha256:4659bc2a667783e7a15d190f6fccf8b2486685b6dba4c19c3876314769c57526", size = 35648, upload-time = "2022-12-21T15:25:21.046Z" }, -] - -[[package]] -name = "platformdirs" -version = "4.11.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/78/9b/560e4be8e26f6fd133a03630a8df0c663b9e8d61b4ade152b72005aec83b/platformdirs-4.11.0.tar.gz", hash = "sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0", size = 31953, upload-time = "2026-07-21T13:09:36.565Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7d/68/d8d58938dfb1370b266a1a729e6d77a985be23689a0496498ee17b2cbf90/platformdirs-4.11.0-py3-none-any.whl", hash = "sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74", size = 23247, upload-time = "2026-07-21T13:09:35.422Z" }, -] - -[[package]] -name = "pluggy" -version = "1.6.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412, upload-time = "2025-05-15T12:30:07.975Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, -] - -[[package]] -name = "protobuf" -version = "7.36.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a7/e7/0553e21d25ca4d9f573135775348a372c3ec34a93a71d5f297c3bac38341/protobuf-7.36.0.tar.gz", hash = "sha256:e8e09cb0d794c6687926fa558a8a6e72aa10edb997d5ca61da0765f12a3e00ea", size = 510034, upload-time = "2026-08-20T16:34:01.071Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/ae/58e3ca96cb2e118cc546b677359b3c6659f79a140935c08dec94c7998585/protobuf-7.36.0-cp310-abi3-macosx_10_9_universal2.whl", hash = "sha256:9103532dffd80c6fab7e50c65a31007680a06eb57537d437bb1b35812c138a37", size = 453256, upload-time = "2026-08-20T16:33:53.945Z" }, - { url = "https://files.pythonhosted.org/packages/f0/15/5162230af4912697f0fe406f6800f80760945babcff0e2c2fe6c84ef2d5d/protobuf-7.36.0-cp310-abi3-manylinux2014_aarch64.whl", hash = "sha256:bf94a5917c71058262de683669bc0a797a7669d3de71f0b36d058e3194f47b44", size = 341436, upload-time = "2026-08-20T16:33:55.134Z" }, - { url = "https://files.pythonhosted.org/packages/d7/09/1670b2bfc9a45e807e520c3e9be36524db9ccc7dc05ea17af7681cabdc61/protobuf-7.36.0-cp310-abi3-manylinux2014_s390x.whl", hash = "sha256:3297e60abdff301e5f74393d87f6cc59dacab5f024a89548a6e8de1d26576b16", size = 354440, upload-time = "2026-08-20T16:33:56.077Z" }, - { url = "https://files.pythonhosted.org/packages/c7/f8/bd5804695ba400e423c33fd4d9f58c28d86633d5ba1945c36ff3967d98cb/protobuf-7.36.0-cp310-abi3-manylinux2014_x86_64.whl", hash = "sha256:70f5ec8eb0da81a44360c0dc0beac99a0d78071d21956a7076bae8bd2051841b", size = 340439, upload-time = "2026-08-20T16:33:56.992Z" }, - { url = "https://files.pythonhosted.org/packages/ef/9f/acd02338235a3e7d03168c4303478347b7624fc8189ff4e7f0d2654bbe86/protobuf-7.36.0-cp310-abi3-win32.whl", hash = "sha256:7326fd717bdc419162a735938d89d4032332bcc3408804012b24ff3a37086071", size = 440216, upload-time = "2026-08-20T16:33:57.99Z" }, - { url = "https://files.pythonhosted.org/packages/0e/4e/12cb93270967a2affff5b3f720694700d4d87712a67afd05c8cb3f6fa52c/protobuf-7.36.0-cp310-abi3-win_amd64.whl", hash = "sha256:1781cc1de61249b750848029bca452c0a8b7e990080316b9bbc2518b2117b488", size = 453731, upload-time = "2026-08-20T16:33:58.951Z" }, - { url = "https://files.pythonhosted.org/packages/01/c3/629999e78d46c1115c11886d51c6bd68c17ce4a944f1ea3e153a91316a33/protobuf-7.36.0-py3-none-any.whl", hash = "sha256:53374d53fc29a67f7dbbf0ade47d7526a0f0137bf0f9c90e48d8a60790ef748c", size = 177024, upload-time = "2026-08-20T16:34:00.053Z" }, -] - -[[package]] -name = "py-serializable" -version = "2.1.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "defusedxml" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/73/21/d250cfca8ff30c2e5a7447bc13861541126ce9bd4426cd5d0c9f08b5547d/py_serializable-2.1.0.tar.gz", hash = "sha256:9d5db56154a867a9b897c0163b33a793c804c80cee984116d02d49e4578fc103", size = 52368, upload-time = "2025-07-21T09:56:48.07Z" } -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 = "pydantic" -version = "2.13.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-types" }, - { name = "pydantic-core" }, - { name = "typing-extensions" }, - { name = "typing-inspection" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, -] - -[[package]] -name = "pydantic-core" -version = "2.46.4" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, - { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, - { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, - { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, - { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, - { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, - { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, - { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, - { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, - { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, - { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, - { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, - { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, - { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, - { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, - { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, - { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, - { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, - { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, - { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, - { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, - { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, - { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, - { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, - { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, - { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, - { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, - { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, - { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, - { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, - { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, - { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, - { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, - { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, - { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, - { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, - { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, - { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, - { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, - { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, - { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, - { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, - { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, - { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, - { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, - { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, - { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, - { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, - { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, - { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, - { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, - { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, - { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, - { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, - { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, - { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, - { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, - { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, - { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, - { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, - { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, - { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, - { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, - { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, - { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, - { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, - { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, - { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, - { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, - { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, - { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, - { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, - { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, - { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, - { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, - { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, - { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, - { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, - { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, - { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, - { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, - { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, - { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, - { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, - { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, - { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, -] - -[[package]] -name = "pygments" -version = "2.20.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991, upload-time = "2026-03-29T13:29:33.898Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151, upload-time = "2026-03-29T13:29:30.038Z" }, -] - -[[package]] -name = "pyparsing" -version = "3.3.2" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f3/91/9c6ee907786a473bf81c5f53cf703ba0957b23ab84c264080fb5a450416f/pyparsing-3.3.2.tar.gz", hash = "sha256:c777f4d763f140633dcb6d8a3eda953bf7a214dc4eff598413c070bcdc117cbc", size = 6851574, upload-time = "2026-01-21T03:57:59.36Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/bd/c038d7cc38edc1aa5bf91ab8068b63d4308c66c4c8bb3cbba7dfbc049f9c/pyparsing-3.3.2-py3-none-any.whl", hash = "sha256:850ba148bd908d7e2411587e247a1e4f0327839c40e2e5e6d05a007ecc69911d", size = 122781, upload-time = "2026-01-21T03:57:55.912Z" }, -] - -[[package]] -name = "pytest" -version = "9.1.1" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "iniconfig" }, - { name = "packaging" }, - { name = "pluggy" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/e4/47/b9efed96c114afcfa3c9d3fe98a76a1d14c74a9e266d397cf6eb64be5e01/pytest-9.1.1.tar.gz", hash = "sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313", size = 1636369, upload-time = "2026-06-19T10:58:32.857Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/24/25/1de2678b631f5a49215c6c96fff41ba892b0a34df68d6d80292b1b48aa7f/pytest-9.1.1-py3-none-any.whl", hash = "sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c", size = 386536, upload-time = "2026-06-19T10:58:31.347Z" }, -] - -[[package]] -name = "pytest-asyncio" -version = "1.4.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "pytest" }, - { name = "typing-extensions", marker = "python_full_version < '3.13'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/43/7c/d36d04db312ecf4298932ef77e6e4a9e8ad017906e24e34f0b0c361a2473/pytest_asyncio-1.4.0.tar.gz", hash = "sha256:c6c0d2259945122819f171a32ecea2c349ead889ee28176caaf492143424be42", size = 58514, upload-time = "2026-05-26T09:56:04.083Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/03/e2/08a497ef684b88559c9cc5f4ad53a37e7b99e727094a86d6ea32536d5d3c/pytest_asyncio-1.4.0-py3-none-any.whl", hash = "sha256:933ca923a23075a87fb7070c0ec272a6848489824d887c85c812670932835aa1", size = 16930, upload-time = "2026-05-26T09:56:02.576Z" }, -] - -[[package]] -name = "pyyaml" -version = "6.0.3" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/6d/16/a95b6757765b7b031c9374925bb718d55e0a9ba8a1b6a12d25962ea44347/pyyaml-6.0.3-cp311-cp311-macosx_10_13_x86_64.whl", hash = "sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e", size = 185826, upload-time = "2025-09-25T21:31:58.655Z" }, - { url = "https://files.pythonhosted.org/packages/16/19/13de8e4377ed53079ee996e1ab0a9c33ec2faf808a4647b7b4c0d46dd239/pyyaml-6.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824", size = 175577, upload-time = "2025-09-25T21:32:00.088Z" }, - { url = "https://files.pythonhosted.org/packages/0c/62/d2eb46264d4b157dae1275b573017abec435397aa59cbcdab6fc978a8af4/pyyaml-6.0.3-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c", size = 775556, upload-time = "2025-09-25T21:32:01.31Z" }, - { url = "https://files.pythonhosted.org/packages/10/cb/16c3f2cf3266edd25aaa00d6c4350381c8b012ed6f5276675b9eba8d9ff4/pyyaml-6.0.3-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00", size = 882114, upload-time = "2025-09-25T21:32:03.376Z" }, - { url = "https://files.pythonhosted.org/packages/71/60/917329f640924b18ff085ab889a11c763e0b573da888e8404ff486657602/pyyaml-6.0.3-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d", size = 806638, upload-time = "2025-09-25T21:32:04.553Z" }, - { url = "https://files.pythonhosted.org/packages/dd/6f/529b0f316a9fd167281a6c3826b5583e6192dba792dd55e3203d3f8e655a/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a", size = 767463, upload-time = "2025-09-25T21:32:06.152Z" }, - { url = "https://files.pythonhosted.org/packages/f2/6a/b627b4e0c1dd03718543519ffb2f1deea4a1e6d42fbab8021936a4d22589/pyyaml-6.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4", size = 794986, upload-time = "2025-09-25T21:32:07.367Z" }, - { url = "https://files.pythonhosted.org/packages/45/91/47a6e1c42d9ee337c4839208f30d9f09caa9f720ec7582917b264defc875/pyyaml-6.0.3-cp311-cp311-win32.whl", hash = "sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b", size = 142543, upload-time = "2025-09-25T21:32:08.95Z" }, - { url = "https://files.pythonhosted.org/packages/da/e3/ea007450a105ae919a72393cb06f122f288ef60bba2dc64b26e2646fa315/pyyaml-6.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf", size = 158763, upload-time = "2025-09-25T21:32:09.96Z" }, - { url = "https://files.pythonhosted.org/packages/d1/33/422b98d2195232ca1826284a76852ad5a86fe23e31b009c9886b2d0fb8b2/pyyaml-6.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196", size = 182063, upload-time = "2025-09-25T21:32:11.445Z" }, - { url = "https://files.pythonhosted.org/packages/89/a0/6cf41a19a1f2f3feab0e9c0b74134aa2ce6849093d5517a0c550fe37a648/pyyaml-6.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0", size = 173973, upload-time = "2025-09-25T21:32:12.492Z" }, - { url = "https://files.pythonhosted.org/packages/ed/23/7a778b6bd0b9a8039df8b1b1d80e2e2ad78aa04171592c8a5c43a56a6af4/pyyaml-6.0.3-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28", size = 775116, upload-time = "2025-09-25T21:32:13.652Z" }, - { url = "https://files.pythonhosted.org/packages/65/30/d7353c338e12baef4ecc1b09e877c1970bd3382789c159b4f89d6a70dc09/pyyaml-6.0.3-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c", size = 844011, upload-time = "2025-09-25T21:32:15.21Z" }, - { url = "https://files.pythonhosted.org/packages/8b/9d/b3589d3877982d4f2329302ef98a8026e7f4443c765c46cfecc8858c6b4b/pyyaml-6.0.3-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc", size = 807870, upload-time = "2025-09-25T21:32:16.431Z" }, - { url = "https://files.pythonhosted.org/packages/05/c0/b3be26a015601b822b97d9149ff8cb5ead58c66f981e04fedf4e762f4bd4/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e", size = 761089, upload-time = "2025-09-25T21:32:17.56Z" }, - { url = "https://files.pythonhosted.org/packages/be/8e/98435a21d1d4b46590d5459a22d88128103f8da4c2d4cb8f14f2a96504e1/pyyaml-6.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea", size = 790181, upload-time = "2025-09-25T21:32:18.834Z" }, - { url = "https://files.pythonhosted.org/packages/74/93/7baea19427dcfbe1e5a372d81473250b379f04b1bd3c4c5ff825e2327202/pyyaml-6.0.3-cp312-cp312-win32.whl", hash = "sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5", size = 137658, upload-time = "2025-09-25T21:32:20.209Z" }, - { url = "https://files.pythonhosted.org/packages/86/bf/899e81e4cce32febab4fb42bb97dcdf66bc135272882d1987881a4b519e9/pyyaml-6.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b", size = 154003, upload-time = "2025-09-25T21:32:21.167Z" }, - { url = "https://files.pythonhosted.org/packages/1a/08/67bd04656199bbb51dbed1439b7f27601dfb576fb864099c7ef0c3e55531/pyyaml-6.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd", size = 140344, upload-time = "2025-09-25T21:32:22.617Z" }, - { url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" }, - { url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" }, - { url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" }, - { url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" }, - { url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" }, - { url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" }, - { url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" }, - { url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" }, - { url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" }, - { url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" }, - { url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" }, - { url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" }, - { url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" }, - { url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" }, - { url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" }, - { url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" }, - { url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" }, - { url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" }, - { url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" }, - { url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" }, - { url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" }, - { url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" }, - { url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" }, - { url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" }, - { url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" }, - { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, -] - -[[package]] -name = "regex" -version = "2026.7.19" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/20/98/04b13f1ddfb63158025291c02e03eb42fbb7acb51d091d541050eb4e35e8/regex-2026.7.19.tar.gz", hash = "sha256:7e77b324909c1617cbb4c668677e2c6ae13f44d7c1de0d4f15f2e3c10f3315b5", size = 416440, upload-time = "2026-07-19T00:19:48.923Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/05/e5/cef4de2bac939280b68d32adc659478845238a8274f2f79c465063f590ad/regex-2026.7.19-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ac777001cdfc28b72477d93c8564bb7583081ea8fb45cdca3d568e0a4f87183c", size = 494012, upload-time = "2026-07-19T00:16:39.927Z" }, - { url = "https://files.pythonhosted.org/packages/ff/87/e86f51eb117457bb7803132ffe5cb6e2841e2b5bea4cc85d397f3c6e257d/regex-2026.7.19-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:59787bd5f8c70aa339084e961d2996b53fbdeab4d5393bba5c1fe1fc32e02bae", size = 295281, upload-time = "2026-07-19T00:16:41.433Z" }, - { url = "https://files.pythonhosted.org/packages/41/2e/2360c41d8080a3d9ec7e5c90fad6eab3b50192869d10e9a5609e48c8177b/regex-2026.7.19-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:90c633e7e8d6bf4e992b8b36ce69e018f834b641dd6de8cea6d78c06ffa119c5", size = 290615, upload-time = "2026-07-19T00:16:43.058Z" }, - { url = "https://files.pythonhosted.org/packages/cf/69/b65ba4344efbc771b28fe5dde84cbbb6c8f9551165952fe78def5b9dde6a/regex-2026.7.19-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:87ccab0db8d5f4fbb0272642113c1adb2ffc698c16d3a0944580222331fa7a20", size = 791804, upload-time = "2026-07-19T00:16:44.662Z" }, - { url = "https://files.pythonhosted.org/packages/81/b6/a40dfa0dc6224b36f620c00296eacc830489cbf8c2837b6750dfe6170375/regex-2026.7.19-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:9e50d748a32da622f256e8d505867f5d3c43a837c6a9f0efb149655fadd1042a", size = 861723, upload-time = "2026-07-19T00:16:46.412Z" }, - { url = "https://files.pythonhosted.org/packages/e3/02/735991dee71abd83196a7962f7ed8bf5aa05720ff06e2d3ff896a85e2bbb/regex-2026.7.19-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:bf1516fe58fc104f39b2d1dbe2d5e27d0cd45c4be2e42ba6ee0cc763701ec3c7", size = 905932, upload-time = "2026-07-19T00:16:47.956Z" }, - { url = "https://files.pythonhosted.org/packages/45/6c/e7098d8b846ccdbf431d8c081b61e496526a27a28094ed09e0dce21b3f54/regex-2026.7.19-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:09f3e5287f94f17b709dc9a9e70865855feee835c861613be144218ce4ca82cc", size = 801407, upload-time = "2026-07-19T00:16:49.43Z" }, - { url = "https://files.pythonhosted.org/packages/8a/18/34b69274e2649bcc7d9b089c2b2983fb2632d8ecf667e359593be9072e79/regex-2026.7.19-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:6383cd2ed53a646c659ba1fe65727db76437fdaa069e697a0b44a51d5843d864", size = 774448, upload-time = "2026-07-19T00:16:51.352Z" }, - { url = "https://files.pythonhosted.org/packages/bb/e6/0a72247d025585fd3800b98e040b84d562a88af6303347100484849f4f01/regex-2026.7.19-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:09d3007fc76249a83cdd33de160d50e6cb77f54e09d8fa9e7148e10607ce24af", size = 783297, upload-time = "2026-07-19T00:16:53.071Z" }, - { url = "https://files.pythonhosted.org/packages/b1/aa/c4f65ae7dd02a36b323a70c4cff326e1f3442361aaebc9311100a130d54f/regex-2026.7.19-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:6f8c6e7a1cfa3dc9d0ee2de0e65e834537fa29992cc3976ffec914afc35c5dd5", size = 854736, upload-time = "2026-07-19T00:16:54.607Z" }, - { url = "https://files.pythonhosted.org/packages/62/c3/668082bcc817b9e694189b84997aeba7385b7779faa6711788679c482e35/regex-2026.7.19-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:b2ea4a3e8357be8849e833beeae757ac3c7a6b3fc055c03c808a53c91ad30d82", size = 763298, upload-time = "2026-07-19T00:16:56.289Z" }, - { url = "https://files.pythonhosted.org/packages/4b/fb/2d07ad555e7af88aa5f867fdafa47a8d945ee237c20af3ebceb46a820835/regex-2026.7.19-cp311-cp311-musllinux_1_2_s390x.whl", hash = "sha256:80115dd39481fd3a4b4080220799dbcacb921a844de4b827264ececacbe17c78", size = 844430, upload-time = "2026-07-19T00:16:57.933Z" }, - { url = "https://files.pythonhosted.org/packages/51/15/c82a471fe3dce56f03745635b43aa456c40dc0db089e07ef148b331507d1/regex-2026.7.19-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:d6ce43a0269d68cee79a7d1ade7def53c20f8f2a047b92d7b5d5bcc73ae88327", size = 789683, upload-time = "2026-07-19T00:16:59.583Z" }, - { url = "https://files.pythonhosted.org/packages/b5/f4/7532a2c59d56f5398902c20de60f0c9a5d1cd364e42a051b48e1b210be7b/regex-2026.7.19-cp311-cp311-win32.whl", hash = "sha256:9be2a6647740dd3cca6acb24e87f03d7632cd280dbce9bbe40c26353a215a45d", size = 266778, upload-time = "2026-07-19T00:17:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/83/2b/cf1bc631db154eb95520d9d5dbc2371ff77a0f014bbf7d748fed8496aa63/regex-2026.7.19-cp311-cp311-win_amd64.whl", hash = "sha256:8d3469c91dd92ee41b7c95280edbd975ef1ba9195086686623a1c6e8935ce965", size = 277983, upload-time = "2026-07-19T00:17:02.571Z" }, - { url = "https://files.pythonhosted.org/packages/8d/bd/56ceaf170e875d5a6761bf2bfd0d040f1cacc896850d5e40cb29b11bbd06/regex-2026.7.19-cp311-cp311-win_arm64.whl", hash = "sha256:36aacfb15faaff3ced55afbf35ec72f50d4aee22082c4f7fe0573a33e2fca92e", size = 276961, upload-time = "2026-07-19T00:17:04.135Z" }, - { url = "https://files.pythonhosted.org/packages/3b/b9/d11d7e501ac8fd7d617684423ebb9561e0b998481c1e4cbc0cb212c5d74a/regex-2026.7.19-cp312-cp312-macosx_10_13_universal2.whl", hash = "sha256:2cc3460cedf7579948486eab03bc9ad7089df4d7281c0f47f4afe03e8d13f02d", size = 496778, upload-time = "2026-07-19T00:17:05.677Z" }, - { url = "https://files.pythonhosted.org/packages/3f/a9/a5ab6f312f24318019170dc485d5421fe4f89e43a98640da50d95a8a7041/regex-2026.7.19-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:0e9554c8785eac5cffe6300f69a91f58ba72bc88a5f8d661235ad7c6aa5b8ccd", size = 297122, upload-time = "2026-07-19T00:17:07.59Z" }, - { url = "https://files.pythonhosted.org/packages/b3/63/4cab4d7f2d384a144d420b763d97674cb70619c878ea6fcd7640d0e62143/regex-2026.7.19-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:d7da47a0f248977f08e2cb659ff3c17ddc13a4d39b3a7baa0a81bf5b415430f6", size = 292009, upload-time = "2026-07-19T00:17:09.648Z" }, - { url = "https://files.pythonhosted.org/packages/22/85/102a81b218298957d4ea7d2f084fae537a71add9d6ff93c8e67284c5f45e/regex-2026.7.19-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:93db40c8de0815baab96a06e08a984bac71f989d13bab789e382158c5d426797", size = 796708, upload-time = "2026-07-19T00:17:11.542Z" }, - { url = "https://files.pythonhosted.org/packages/78/b5/dc136af5629938a037cd2b304c12240e132ec92f38be8ff9cc89af2a1f2d/regex-2026.7.19-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:66bd62c59a5427746e8c44becae1d9b99d22fb13f30f492083dfb9ad7c45cc18", size = 865651, upload-time = "2026-07-19T00:17:13.312Z" }, - { url = "https://files.pythonhosted.org/packages/e0/75/67402ae3cd9c8c988a4c805d15ee3eef015e7ca4cb112cf3e640fc1f4153/regex-2026.7.19-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:1649eb39fcc9ea80c4d2f110fde2b8ab2aef3877b98f02ab9b14e961f418c511", size = 911756, upload-time = "2026-07-19T00:17:15.015Z" }, - { url = "https://files.pythonhosted.org/packages/2a/8e/096d00c7c480ef2ff4265349b14e2261d4ab787ba1f74e2e80d1c58079c3/regex-2026.7.19-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9dce8ec9695f531a1b8a6f314fd4b393adcccf2ea861db480cdf97a301d01a68", size = 801798, upload-time = "2026-07-19T00:17:17.208Z" }, - { url = "https://files.pythonhosted.org/packages/f0/41/e7ecac6edb5722417f85cc67eaf386322fbe8acf6918ec2fdc37c20dd9d0/regex-2026.7.19-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3080a7fd38ef049bd489e01c970c97dd84ff446a885b0f1f6b26d9b1ad13ce11", size = 776933, upload-time = "2026-07-19T00:17:19.347Z" }, - { url = "https://files.pythonhosted.org/packages/6f/69/03c9b3f058d66403e0ca2c938696e81d51cd4c6d47ec5265f02f96948d9a/regex-2026.7.19-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:1d793a7988e04fcb1e2e135567443d82173225d657419ec09414a9b5a145b986", size = 784338, upload-time = "2026-07-19T00:17:21.057Z" }, - { url = "https://files.pythonhosted.org/packages/f6/f7/b38ab3d43f284afbb618fcd15d0e77eb786ae461ce1f6bc7494619ddc0f2/regex-2026.7.19-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:e8b0abe7d870f53ca5143895fef7d1041a0c831a140d3dc2c760dd7ba25d4a8b", size = 860452, upload-time = "2026-07-19T00:17:23.119Z" }, - { url = "https://files.pythonhosted.org/packages/15/5c/ff60ef0571121714f3cf9920bc183071e384a10b556d042e0fdb06cc07a5/regex-2026.7.19-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:4e5413bd5f13d3a4e3539ca98f70f75e7fca92518dd7f117f030ebedd10b60cb", size = 765958, upload-time = "2026-07-19T00:17:24.81Z" }, - { url = "https://files.pythonhosted.org/packages/aa/0f/bd34021162c0ab47f9a315bd56cd5642e920c8e5668a75ef6c6a6fca590d/regex-2026.7.19-cp312-cp312-musllinux_1_2_s390x.whl", hash = "sha256:73b133a9e6fb512858e7f065e96f1180aa46646bc74a83aea62f1d314f3dd035", size = 851765, upload-time = "2026-07-19T00:17:26.993Z" }, - { url = "https://files.pythonhosted.org/packages/2a/20/a2ca43edade0595cccfdc98636739f536d9e26898e7dbddc2b9e98898953/regex-2026.7.19-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:dbe6493fbd27321b1d1f2dd4f5c7e5bd4d8b1d7cab7f32fd67db3d0b2ed8248a", size = 789714, upload-time = "2026-07-19T00:17:28.699Z" }, - { url = "https://files.pythonhosted.org/packages/5d/47/e02db4015d424fc83c00ea0ac8c5e5ec14397943de9abf909d5ce3a25931/regex-2026.7.19-cp312-cp312-win32.whl", hash = "sha256:ddd67571c10869f65a5d7dde536d1e066e306cc90de57d7de4d5f34802428bb5", size = 267157, upload-time = "2026-07-19T00:17:31.051Z" }, - { url = "https://files.pythonhosted.org/packages/08/8e/c780c131f79b42ed22d1bd7da4096c2c35f813e835acd02ef0f018bd892c/regex-2026.7.19-cp312-cp312-win_amd64.whl", hash = "sha256:e30d40268a28d54ce0437031750497004c22602b8e3ab891f759b795a003b312", size = 277777, upload-time = "2026-07-19T00:17:32.848Z" }, - { url = "https://files.pythonhosted.org/packages/3e/4c/e4d7e086449bdf379d89774bf1f89dc4a41943f3c5a6125a03905b34b5fb/regex-2026.7.19-cp312-cp312-win_arm64.whl", hash = "sha256:de9208bb427130c82a5dbfd104f92c8876fc9559278c880b3002755bbbe9c83d", size = 277136, upload-time = "2026-07-19T00:17:34.803Z" }, - { url = "https://files.pythonhosted.org/packages/5d/3d/84165e4299ff76f3a40fe1f2abf939e976f693383a08d2beea6af62bd2c1/regex-2026.7.19-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f035d9dc1d25eff9d361456572231c7d27b5ccd473ca7dc0adfce732bd006d40", size = 496552, upload-time = "2026-07-19T00:17:36.808Z" }, - { url = "https://files.pythonhosted.org/packages/02/a2/a65293e6e4cf28eb7ee1be5335a5386c40d6742e9f47fafc8fec785e16c7/regex-2026.7.19-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:c42572142ed0b9d5d261ba727157c426510da78e20828b66bbb855098b8a4e38", size = 296983, upload-time = "2026-07-19T00:17:38.816Z" }, - { url = "https://files.pythonhosted.org/packages/95/47/2d0564e93d87bc48618360ddca232a2ca612bbdf53ce8465d45ca5ce14ee/regex-2026.7.19-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:40b34dd88658e4fedd2fddbf0275ac970d00614b731357f425722a3ed1983d11", size = 291832, upload-time = "2026-07-19T00:17:40.726Z" }, - { url = "https://files.pythonhosted.org/packages/07/cd/42dfbabff3dfc9603c501c0e2e2c5adbb09d127b267bf5348de0af338c15/regex-2026.7.19-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0c41c63992bf1874cebb6e7f56fd7d3c007924659a604ae3d90e427d40d4fd13", size = 796775, upload-time = "2026-07-19T00:17:42.382Z" }, - { url = "https://files.pythonhosted.org/packages/df/5d/f6a4839f2b934e3eed5973fd07f5929ee97d4c98939fb275ea23c274ee16/regex-2026.7.19-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:1d3372064506b94dd2c67c845f2db8062e9e9ba84d04e33cb96d7d33c11fe1ae", size = 865687, upload-time = "2026-07-19T00:17:44.185Z" }, - { url = "https://files.pythonhosted.org/packages/14/b0/b47d6c36049bc59806a50bd4c86ced70bbe058d787f80281b1d7a9b0e024/regex-2026.7.19-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:fce7760bf283405b2c7999cab3da4e72f7deca6396013115e3f7a955db9760da", size = 911962, upload-time = "2026-07-19T00:17:46.442Z" }, - { url = "https://files.pythonhosted.org/packages/2a/be/ff61f28f9273658cfe23acbbac5217221f6519960ed401e61dfdab12bc35/regex-2026.7.19-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c0d702548d89d572b2929879bc883bb7a4c4709efafe4512cadee56c55c9bd15", size = 801817, upload-time = "2026-07-19T00:17:48.25Z" }, - { url = "https://files.pythonhosted.org/packages/c3/bb/8b4f7f26b333f9f79e1b453613c39bb4776f51d38ae66dd0ba31d6b354ca/regex-2026.7.19-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d446c6ac40bb6e05025ccee55b84d80fe9bf8e93010ffc4bb9484f13d498835f", size = 776908, upload-time = "2026-07-19T00:17:50.183Z" }, - { url = "https://files.pythonhosted.org/packages/09/13/610110fc5921d380516d03c26b652555f08aa0d23ea78a771231873c3638/regex-2026.7.19-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:4c3501bfa814ab07b5580741f9bf78dfdfe146a04057f82df9e2402d2a975939", size = 784426, upload-time = "2026-07-19T00:17:52.454Z" }, - { url = "https://files.pythonhosted.org/packages/ca/f5/1ef9e2a83a5947c57ebff0b377cb5727c3d5ec1992317a320d035cd0dbb6/regex-2026.7.19-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:c4585c3e64b4f9e583b4d2683f18f5d5d872b3d71dcf24594b74ecc23602fa96", size = 860600, upload-time = "2026-07-19T00:17:54.229Z" }, - { url = "https://files.pythonhosted.org/packages/a0/02/073af33a3ec149241d11c80acea91e722aa0adbf05addd50f251c4fe89c3/regex-2026.7.19-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:571fde9741eb0ccde23dd4e0c1d50fbae910e901fa7e629faf39b2dda740d220", size = 765950, upload-time = "2026-07-19T00:17:56.041Z" }, - { url = "https://files.pythonhosted.org/packages/81/a9/d1e9f819dc394a568ef370cd56cf25394e957a2235f8370f23b576e5a475/regex-2026.7.19-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:15b364b9b98d6d2fe1a85034c23a3180ff913f46caddc3895f6fd65186255ccc", size = 851794, upload-time = "2026-07-19T00:17:57.897Z" }, - { url = "https://files.pythonhosted.org/packages/03/3a/8ae83eda7579feacdf984e71fb9e70635fb6f832eeddca58427ec4fca926/regex-2026.7.19-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ffd8893ccc1c2fce6e0d6ca402d716fe1b29db70c7132609a05955e31b2aa8f2", size = 789845, upload-time = "2026-07-19T00:17:59.97Z" }, - { url = "https://files.pythonhosted.org/packages/4b/23/c195cbfe5a75fdec64d8f6554fd15237b837919d2c61bdc141d7c807b08b/regex-2026.7.19-cp313-cp313-win32.whl", hash = "sha256:f0fa4fa9c3632d708742baf2282f2055c11d888a790362670a403cbf48a2c404", size = 267135, upload-time = "2026-07-19T00:18:01.958Z" }, - { url = "https://files.pythonhosted.org/packages/b2/80/a11de8404b7272b70acb45c1c05987cce60b45d5693da2e176f0e390d564/regex-2026.7.19-cp313-cp313-win_amd64.whl", hash = "sha256:d51ffd3427640fa2da6ade574ceba932f210ad095f65fcc450a2b0a0d454868e", size = 277747, upload-time = "2026-07-19T00:18:04.121Z" }, - { url = "https://files.pythonhosted.org/packages/d1/29/0f5c8eff1b4f1f3d83276d365fccecf666afcc7d947420943bf394d07adb/regex-2026.7.19-cp313-cp313-win_arm64.whl", hash = "sha256:c670fe7be5b6020b76bc6e8d2196074657e1327595bca93a389e1a76ab130ad8", size = 277129, upload-time = "2026-07-19T00:18:05.821Z" }, - { url = "https://files.pythonhosted.org/packages/dc/4c/44b74742052cedda40f9ae469532a037112f7311a36669a891fba8984bb0/regex-2026.7.19-cp313-cp313t-macosx_10_13_universal2.whl", hash = "sha256:db47b561c9afd884baa1f96f797c9ca369872c4b65912bc691cfa99e68340af2", size = 501134, upload-time = "2026-07-19T00:18:07.567Z" }, - { url = "https://files.pythonhosted.org/packages/f0/45/bbd038b5e39ee5613a5a689290145b40058cc152c41de9cc23639d2b9734/regex-2026.7.19-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:65dcd28d3eba2ab7c2fd906485cc301392b47cc2234790d27d4e4814e02cdfda", size = 299418, upload-time = "2026-07-19T00:18:09.38Z" }, - { url = "https://files.pythonhosted.org/packages/65/38/c5bde94b4cedfd5850d64c3f08222d8e1600e84f6ee71d9b44b4b8163f74/regex-2026.7.19-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:f2e7f8e2ab6c2922be02c7ec45185aa5bd771e2e57b95455ee343a44d8130dff", size = 294486, upload-time = "2026-07-19T00:18:11.188Z" }, - { url = "https://files.pythonhosted.org/packages/d7/6a/2f5e107cb26c960b781967178899daf2787a7ab151844ed3c01d6fc95474/regex-2026.7.19-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fe31f28c94402043161876a258a9c6f757cb485905c7614ce8d6cd40e6b7bdc1", size = 811643, upload-time = "2026-07-19T00:18:12.975Z" }, - { url = "https://files.pythonhosted.org/packages/37/d4/a2f963406d7d73a62eed84ba05a258afb6cad1b21aa4517443ce40506b78/regex-2026.7.19-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:f8f6fa298bb4f7f58a33334406218ba74716e68feddf5e4e54cd5d8082705abf", size = 871081, upload-time = "2026-07-19T00:18:14.733Z" }, - { url = "https://files.pythonhosted.org/packages/45/a3/44be546340bedb15f13063f5e7fe16793ea4d9ea2e805d09bd174ac27724/regex-2026.7.19-cp313-cp313t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:cc1b2440423a851fad781309dd87843868f4f66a6bcd1ddb9225cf4ec2c84732", size = 917372, upload-time = "2026-07-19T00:18:16.724Z" }, - { url = "https://files.pythonhosted.org/packages/f8/f6/e0870b0fd2a40dba0074e4b76e514b21313d37946c9248453e34ec43923e/regex-2026.7.19-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ac59a0900474a52b7c04af8196affc22bd9842acb0950df12f7b813e983609a", size = 816089, upload-time = "2026-07-19T00:18:18.617Z" }, - { url = "https://files.pythonhosted.org/packages/ae/27/957e8e22690ad6634572b39b71f130a6105f4d0718bb16849eac00fff147/regex-2026.7.19-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:4896db1f4ce0576765b8272aa922df324e0f5b9bb2c3d03044ff32a7234a9aba", size = 785206, upload-time = "2026-07-19T00:18:20.464Z" }, - { url = "https://files.pythonhosted.org/packages/76/a4/186e410941e731037c01166069ab86da9f65e8f8110c18009ccf4bd623ee/regex-2026.7.19-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:4e6883a021db30511d9fb8cfb0f222ce1f2c369f7d4d8b0448f449a93ba0bdfc", size = 800431, upload-time = "2026-07-19T00:18:22.716Z" }, - { url = "https://files.pythonhosted.org/packages/73/9f/e4e10e023d291d64a33e246610b724493bf1ce98e0e59c9b7c837e5acfb7/regex-2026.7.19-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:09523a592938aa9f587fb74467c63ff0cf88fc3df14c82ab0f0517dcf76aaa62", size = 864906, upload-time = "2026-07-19T00:18:24.772Z" }, - { url = "https://files.pythonhosted.org/packages/24/57/ccb20b6be5f1f52a053d1ba2a8f7a077edb9d918248b8490d7506c6832b3/regex-2026.7.19-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:1ebac3474b8589fce2f9b225b650afd61448f7c73a5d0255a10cc6366471aed1", size = 773559, upload-time = "2026-07-19T00:18:27.008Z" }, - { url = "https://files.pythonhosted.org/packages/a3/82/f3b263cf8fad927dc102891da8502e718b7ff9d19af7a2a07c03865d7188/regex-2026.7.19-cp313-cp313t-musllinux_1_2_s390x.whl", hash = "sha256:4a0530bb1b8c1c985e7e2122e2b4d3aedd8a3c21c6bfddae6767c4405668b56e", size = 857739, upload-time = "2026-07-19T00:18:29.107Z" }, - { url = "https://files.pythonhosted.org/packages/47/2e/1687bd1b6c2aed5e672ccf845fc11557821fe7366d921b50889ea5ce57bf/regex-2026.7.19-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:2ef7eeb108c47ce7bcc9513e51bcb1bf57e8f483d52fce68a8642e3527141ae0", size = 804522, upload-time = "2026-07-19T00:18:31.362Z" }, - { url = "https://files.pythonhosted.org/packages/76/7c/cc4e7655181b2d9235b704f2c5e19d8eff002bbc437bae59baee0e381aca/regex-2026.7.19-cp313-cp313t-win32.whl", hash = "sha256:64b6ca7391a1395c2638dd5c7456d67bea44fc6c5e8e92c5dc8aa6a8f23292b4", size = 269141, upload-time = "2026-07-19T00:18:33.479Z" }, - { url = "https://files.pythonhosted.org/packages/bb/14/961b4c7b05a2391c32dbc85e27773076671ef8f97f36cec70fe414734c02/regex-2026.7.19-cp313-cp313t-win_amd64.whl", hash = "sha256:f04b9f56b0e0614c0126be12c2c2d9f8850c1e57af302bd0a63bed379d4af974", size = 280036, upload-time = "2026-07-19T00:18:35.419Z" }, - { url = "https://files.pythonhosted.org/packages/ce/67/795644550d788ddbb6dc458c95895f8009978ea6d6ea76b005eb3f45e8c9/regex-2026.7.19-cp313-cp313t-win_arm64.whl", hash = "sha256:fcee38cd8e5089d6d4f048ba1233b3ad76e5954f545382180889112ff5cb712d", size = 279394, upload-time = "2026-07-19T00:18:37.454Z" }, - { url = "https://files.pythonhosted.org/packages/d2/25/0c4c452f8ef3efe456745b2f33195f5904b573fb4c2ff3f0cb9ec188461e/regex-2026.7.19-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:a81758ed242b861b72e778ba34d41366441a2e10b16b472784c88da2dea7e2dd", size = 496750, upload-time = "2026-07-19T00:18:39.633Z" }, - { url = "https://files.pythonhosted.org/packages/24/9e/b70ca6c1704f6c7cd32a9e143c86cc5968d10981eca284bad670c245ea7d/regex-2026.7.19-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4aa5435cdb3eb6f55fe98a171b05e3fbcd95fadaa4aa32acf62afd9b0cfdbcac", size = 297093, upload-time = "2026-07-19T00:18:41.583Z" }, - { url = "https://files.pythonhosted.org/packages/87/74/0b692da2520d51fbff19c88b83d97e4c702909dd02386c585998b7e2dbed/regex-2026.7.19-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:60be8693a1dadc210bbcbc0db3e26da5f7d01d1d5a3da594e99b4fa42df404f5", size = 292043, upload-time = "2026-07-19T00:18:43.347Z" }, - { url = "https://files.pythonhosted.org/packages/e3/a7/1d478e614016045a33feae57446215f9fd65b665a5ceb2f891fb3183bc52/regex-2026.7.19-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d19662dbedbe783d323196312d38f5ba53cf56296378252171985da6899887d3", size = 797214, upload-time = "2026-07-19T00:18:45.362Z" }, - { url = "https://files.pythonhosted.org/packages/aa/ae/11b9c9411d92c30e3d2db32df5a31133e4a99a8fc397a604fd08f6c4bffb/regex-2026.7.19-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d15df07081d91b76ff20d43f94592ee110330152d617b730fdbe5ef9fb680053", size = 866433, upload-time = "2026-07-19T00:18:47.315Z" }, - { url = "https://files.pythonhosted.org/packages/b1/62/2b2efc4992f91d6d204b24c647c9f9412e85379d92b7c0ab9fdae622327e/regex-2026.7.19-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:56ad4d9f77df871a99e25c37091052a02528ec0eb059de928ee33956b854b45b", size = 911360, upload-time = "2026-07-19T00:18:49.588Z" }, - { url = "https://files.pythonhosted.org/packages/14/71/986ceea9aa3da548bf1357cad89b63915ec6d21ec957c8113b29ece567df/regex-2026.7.19-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7322ec6cc9fba9d49ab888bb82d67ac5625627aa168f0165139b17018df3fb8a", size = 801275, upload-time = "2026-07-19T00:18:51.767Z" }, - { url = "https://files.pythonhosted.org/packages/15/be/ce9d9534b2cda96eab32c548261224b9b4e220a4126f098f60f42ae7b4cd/regex-2026.7.19-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:9c7472192ebfad53a6be7c4a8bfb2d64b81c0e93a1fc8c57e1dd0b638297b5d1", size = 777131, upload-time = "2026-07-19T00:18:54.053Z" }, - { url = "https://files.pythonhosted.org/packages/61/2b/58b5c710f2c3929515a25f3a1ca0dad0dcd4518d4fff3cf23bc7adb8dcd2/regex-2026.7.19-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:c10b82c2634df08dfb13b1f04e38fe310d086ee092f4f69c0c8da234251e556e", size = 785020, upload-time = "2026-07-19T00:18:56.579Z" }, - { url = "https://files.pythonhosted.org/packages/84/03/5fe091935b74f15fe0f97998c215cae418d1c0413f6258c7d4d2e83aa37f/regex-2026.7.19-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:17ed5692f6acc4183e98331101a5f9e4f64d72fe58b753da4d444a2c77d05b12", size = 861263, upload-time = "2026-07-19T00:18:58.64Z" }, - { url = "https://files.pythonhosted.org/packages/d8/fa/d60bf82e10841eef62a9e32aac401468f05fddfbcb2942e342b1ba3d2433/regex-2026.7.19-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:22a992de9a0d91bda927bf02b94351d737a0302905432c88a53de7c4b9ce62e2", size = 766199, upload-time = "2026-07-19T00:19:00.705Z" }, - { url = "https://files.pythonhosted.org/packages/bf/5d/11e64d151b0662b81d6bf644c74dc118d461df85bdf2577fadbbf751788a/regex-2026.7.19-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:618a0aed532be87294c4477b0481f3aa0f1520f4014a4374dd4cf789b4cd2c97", size = 851317, upload-time = "2026-07-19T00:19:03.015Z" }, - { url = "https://files.pythonhosted.org/packages/7c/34/532efb87488d90807bae6a443d357ee5e2728a478c597619c8aaa17cc0bd/regex-2026.7.19-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:2ce9e679f776649746729b6c86382da519ef649c8e34cc41df0d2e5e0f6c36d4", size = 789557, upload-time = "2026-07-19T00:19:05.338Z" }, - { url = "https://files.pythonhosted.org/packages/d6/90/3a8d5ca977171ec3ae21a71207d2228b2663bde14d7f7ef0e6363ecf9290/regex-2026.7.19-cp314-cp314-win32.whl", hash = "sha256:73f272fba87b8ccfe70a137d02a54af386f6d27aa509fbffdd978f5947aae1aa", size = 272531, upload-time = "2026-07-19T00:19:07.487Z" }, - { url = "https://files.pythonhosted.org/packages/96/e1/8862885e70409de70e8c005f57fb2e7be8d9ef0317250d60f4c9660a300d/regex-2026.7.19-cp314-cp314-win_amd64.whl", hash = "sha256:d721e53758b2cca74990185eb0671dd466d7a388a1a45d0c6f4c13cef41a68ac", size = 280831, upload-time = "2026-07-19T00:19:09.46Z" }, - { url = "https://files.pythonhosted.org/packages/08/82/2693e53e29f9104d9de95d37ce4dd826bd32d5f9c0085d3aa6ac042675c4/regex-2026.7.19-cp314-cp314-win_arm64.whl", hash = "sha256:65fa6cb38ed5e9c3637e68e544f598b39c3b86b808ed0627a67b68320384b459", size = 281099, upload-time = "2026-07-19T00:19:11.398Z" }, - { url = "https://files.pythonhosted.org/packages/92/b7/9a01aa16461a18cde9d7b9c3ab21e501db2ce33725f53014342b91df2b0a/regex-2026.7.19-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:5a2721c8720e2cb3c209925dfb9200199b4b07361c9e01d321719404b21458b3", size = 501121, upload-time = "2026-07-19T00:19:13.425Z" }, - { url = "https://files.pythonhosted.org/packages/f3/5e/bbaeca815dc9191c424c94a4fdc5c87c75748a64a6271821212ebdd4e1a3/regex-2026.7.19-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:199535629f25caf89698039af3d1ad5fcae7f933e2112c73f1cdf49165c99518", size = 299415, upload-time = "2026-07-19T00:19:15.43Z" }, - { url = "https://files.pythonhosted.org/packages/cd/d6/0dd1a321afaab95eb7ff44aa0f637301786f1dc71c6b797b9ed236ed8890/regex-2026.7.19-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:9b60d7814174f059e5de4ab98271cc5ba9259cfea55273a81544dceea32dc8d9", size = 294483, upload-time = "2026-07-19T00:19:17.879Z" }, - { url = "https://files.pythonhosted.org/packages/92/5f/40bacf91d0904f812e13bbbab3864604c463eced8afdc54aeaa50492ea95/regex-2026.7.19-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dbece16025afda5e3031af0c4059207e61dcf73ef13af844964f57f387d1c435", size = 811833, upload-time = "2026-07-19T00:19:20.102Z" }, - { url = "https://files.pythonhosted.org/packages/94/7c/4902744261f775aeede8b5627314b38482da29cf49a57b66a6fb753246c5/regex-2026.7.19-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:d24ecb4f5e009ea0bd275ee37ad9953b32005e2e5e60f8bbae16da0dbbf0d3a0", size = 871270, upload-time = "2026-07-19T00:19:22.365Z" }, - { url = "https://files.pythonhosted.org/packages/16/70/6980c9be6bf21c0a60ed3e0aea39cf419ecf3b08d1d9947bc56e196ef186/regex-2026.7.19-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:8cae6fd77a5b72dae505084b1a2ee0360139faf72fedbab667cd7cc65aae7a6a", size = 917534, upload-time = "2026-07-19T00:19:24.529Z" }, - { url = "https://files.pythonhosted.org/packages/52/92/8b2bd872782ce8c42691e39acb38eb8efe014e5ddb78ad7d943d6f197ce9/regex-2026.7.19-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9724e6cb5e478cd7d8cabf027826178739cb18cf0e117d0e32814d479fa02276", size = 816135, upload-time = "2026-07-19T00:19:26.919Z" }, - { url = "https://files.pythonhosted.org/packages/de/2d/33a602f657bdc4041f17d79f92ab18261d255d91a06117a6e29df023e5e2/regex-2026.7.19-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:572fc57b0009c735ee56c175ea021b637a15551a312f56734277f923d6fd0f6c", size = 785492, upload-time = "2026-07-19T00:19:29.192Z" }, - { url = "https://files.pythonhosted.org/packages/9e/36/0987cf4cb271680064a70d24a475873775a151d0b7058698a006cb0cae4a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:20568e182eb82d39a6bf7cff3fd58566f14c75c6f74b2c8c96537eecf9010e3a", size = 800658, upload-time = "2026-07-19T00:19:31.392Z" }, - { url = "https://files.pythonhosted.org/packages/a8/24/c14f31c135e1ba55fa4f9a58ca98d0842512bf6188230763c31c8f449e3b/regex-2026.7.19-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:1d58561843f0ff7dc78b4c28b5e2dc388f3eff94ebc8a232a3adba961fc00009", size = 865073, upload-time = "2026-07-19T00:19:33.485Z" }, - { url = "https://files.pythonhosted.org/packages/14/85/181a12211f22469f24d2de1ebddfe397d2396e2c29013b9a58134a91069a/regex-2026.7.19-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:61bb1bd45520aacd56dd80943bd34991fb5350afdd1f36f2282230fd5154a218", size = 773684, upload-time = "2026-07-19T00:19:35.599Z" }, - { url = "https://files.pythonhosted.org/packages/23/58/bd1a0c1a62251366f8d21f41b1ea3c76994962071b8b6ea42f72d505c0f0/regex-2026.7.19-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:cd3584591ea4429026cdb931b054342c2bcf189b44ff367f8d5c15bc092a2966", size = 857769, upload-time = "2026-07-19T00:19:37.738Z" }, - { url = "https://files.pythonhosted.org/packages/e4/4f/f7e2dad6756b2fe1fe75dd90a628c3b45f249d39f948dd90cd2476325417/regex-2026.7.19-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:5cc26a66e212fa5d6c6170c3a40d99d888db3020c6fdab1523250d4341382e44", size = 804546, upload-time = "2026-07-19T00:19:40.229Z" }, - { url = "https://files.pythonhosted.org/packages/2b/d7/01d31d5bdb09bc026fab77f59a371fdf8f9b292e4810546c56182ca70498/regex-2026.7.19-cp314-cp314t-win32.whl", hash = "sha256:2c4e61e2e1be56f63ec3cc618aa9e0de81ef6f43d177205451840022e24f5b78", size = 274526, upload-time = "2026-07-19T00:19:42.398Z" }, - { url = "https://files.pythonhosted.org/packages/52/0e/cea4ce73bc0a8247a0748228ae6669984c7e1f8134b6fa66e59c0572e0ea/regex-2026.7.19-cp314-cp314t-win_amd64.whl", hash = "sha256:c639ea314df70a7b2811e8020448c75af8c9445f5a60f8a4ced81c306a9380c2", size = 283763, upload-time = "2026-07-19T00:19:44.644Z" }, - { url = "https://files.pythonhosted.org/packages/6f/b6/26e41975febae63b7a6e3e02f32cff6cff2e4f10d19c929082f56aebf7c6/regex-2026.7.19-cp314-cp314t-win_arm64.whl", hash = "sha256:9a15e785f244f3e07847b984ce8773fc3da10a9f3c131cc49a4c5b4d672b4547", size = 283451, upload-time = "2026-07-19T00:19:46.639Z" }, -] - -[[package]] -name = "requests" -version = "2.34.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "certifi" }, - { name = "charset-normalizer" }, - { name = "idna" }, - { name = "urllib3" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" }, -] - -[[package]] -name = "rich" -version = "15.0.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "markdown-it-py" }, - { name = "pygments" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/c0/8f/0722ca900cc807c13a6a0c696dacf35430f72e0ec571c4275d2371fca3e9/rich-15.0.0.tar.gz", hash = "sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36", size = 230680, upload-time = "2026-04-12T08:24:00.75Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/82/3b/64d4899d73f91ba49a8c18a8ff3f0ea8f1c1d75481760df8c68ef5235bf5/rich-15.0.0-py3-none-any.whl", hash = "sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb", size = 310654, upload-time = "2026-04-12T08:24:02.83Z" }, -] - -[[package]] -name = "ruff" -version = "0.12.12" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/a8/f0/e0965dd709b8cabe6356811c0ee8c096806bb57d20b5019eb4e48a117410/ruff-0.12.12.tar.gz", hash = "sha256:b86cd3415dbe31b3b46a71c598f4c4b2f550346d1ccf6326b347cc0c8fd063d6", size = 5359915, upload-time = "2025-09-04T16:50:18.273Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/09/79/8d3d687224d88367b51c7974cec1040c4b015772bfbeffac95face14c04a/ruff-0.12.12-py3-none-linux_armv6l.whl", hash = "sha256:de1c4b916d98ab289818e55ce481e2cacfaad7710b01d1f990c497edf217dafc", size = 12116602, upload-time = "2025-09-04T16:49:18.892Z" }, - { url = "https://files.pythonhosted.org/packages/c3/c3/6e599657fe192462f94861a09aae935b869aea8a1da07f47d6eae471397c/ruff-0.12.12-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7acd6045e87fac75a0b0cdedacf9ab3e1ad9d929d149785903cff9bb69ad9727", size = 12868393, upload-time = "2025-09-04T16:49:23.043Z" }, - { url = "https://files.pythonhosted.org/packages/e8/d2/9e3e40d399abc95336b1843f52fc0daaceb672d0e3c9290a28ff1a96f79d/ruff-0.12.12-py3-none-macosx_11_0_arm64.whl", hash = "sha256:abf4073688d7d6da16611f2f126be86523a8ec4343d15d276c614bda8ec44edb", size = 12036967, upload-time = "2025-09-04T16:49:26.04Z" }, - { url = "https://files.pythonhosted.org/packages/e9/03/6816b2ed08836be272e87107d905f0908be5b4a40c14bfc91043e76631b8/ruff-0.12.12-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:968e77094b1d7a576992ac078557d1439df678a34c6fe02fd979f973af167577", size = 12276038, upload-time = "2025-09-04T16:49:29.056Z" }, - { url = "https://files.pythonhosted.org/packages/9f/d5/707b92a61310edf358a389477eabd8af68f375c0ef858194be97ca5b6069/ruff-0.12.12-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:42a67d16e5b1ffc6d21c5f67851e0e769517fb57a8ebad1d0781b30888aa704e", size = 11901110, upload-time = "2025-09-04T16:49:32.07Z" }, - { url = "https://files.pythonhosted.org/packages/9d/3d/f8b1038f4b9822e26ec3d5b49cf2bc313e3c1564cceb4c1a42820bf74853/ruff-0.12.12-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b216ec0a0674e4b1214dcc998a5088e54eaf39417327b19ffefba1c4a1e4971e", size = 13668352, upload-time = "2025-09-04T16:49:35.148Z" }, - { url = "https://files.pythonhosted.org/packages/98/0e/91421368ae6c4f3765dd41a150f760c5f725516028a6be30e58255e3c668/ruff-0.12.12-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:59f909c0fdd8f1dcdbfed0b9569b8bf428cf144bec87d9de298dcd4723f5bee8", size = 14638365, upload-time = "2025-09-04T16:49:38.892Z" }, - { url = "https://files.pythonhosted.org/packages/74/5d/88f3f06a142f58ecc8ecb0c2fe0b82343e2a2b04dcd098809f717cf74b6c/ruff-0.12.12-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9ac93d87047e765336f0c18eacad51dad0c1c33c9df7484c40f98e1d773876f5", size = 14060812, upload-time = "2025-09-04T16:49:42.732Z" }, - { url = "https://files.pythonhosted.org/packages/13/fc/8962e7ddd2e81863d5c92400820f650b86f97ff919c59836fbc4c1a6d84c/ruff-0.12.12-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:01543c137fd3650d322922e8b14cc133b8ea734617c4891c5a9fccf4bfc9aa92", size = 13050208, upload-time = "2025-09-04T16:49:46.434Z" }, - { url = "https://files.pythonhosted.org/packages/53/06/8deb52d48a9a624fd37390555d9589e719eac568c020b27e96eed671f25f/ruff-0.12.12-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:2afc2fa864197634e549d87fb1e7b6feb01df0a80fd510d6489e1ce8c0b1cc45", size = 13311444, upload-time = "2025-09-04T16:49:49.931Z" }, - { url = "https://files.pythonhosted.org/packages/2a/81/de5a29af7eb8f341f8140867ffb93f82e4fde7256dadee79016ac87c2716/ruff-0.12.12-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:0c0945246f5ad776cb8925e36af2438e66188d2b57d9cf2eed2c382c58b371e5", size = 13279474, upload-time = "2025-09-04T16:49:53.465Z" }, - { url = "https://files.pythonhosted.org/packages/7f/14/d9577fdeaf791737ada1b4f5c6b59c21c3326f3f683229096cccd7674e0c/ruff-0.12.12-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:a0fbafe8c58e37aae28b84a80ba1817f2ea552e9450156018a478bf1fa80f4e4", size = 12070204, upload-time = "2025-09-04T16:49:56.882Z" }, - { url = "https://files.pythonhosted.org/packages/77/04/a910078284b47fad54506dc0af13839c418ff704e341c176f64e1127e461/ruff-0.12.12-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b9c456fb2fc8e1282affa932c9e40f5ec31ec9cbb66751a316bd131273b57c23", size = 11880347, upload-time = "2025-09-04T16:49:59.729Z" }, - { url = "https://files.pythonhosted.org/packages/df/58/30185fcb0e89f05e7ea82e5817b47798f7fa7179863f9d9ba6fd4fe1b098/ruff-0.12.12-py3-none-musllinux_1_2_i686.whl", hash = "sha256:5f12856123b0ad0147d90b3961f5c90e7427f9acd4b40050705499c98983f489", size = 12891844, upload-time = "2025-09-04T16:50:02.591Z" }, - { url = "https://files.pythonhosted.org/packages/21/9c/28a8dacce4855e6703dcb8cdf6c1705d0b23dd01d60150786cd55aa93b16/ruff-0.12.12-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:26a1b5a2bf7dd2c47e3b46d077cd9c0fc3b93e6c6cc9ed750bd312ae9dc302ee", size = 13360687, upload-time = "2025-09-04T16:50:05.8Z" }, - { url = "https://files.pythonhosted.org/packages/c8/fa/05b6428a008e60f79546c943e54068316f32ec8ab5c4f73e4563934fbdc7/ruff-0.12.12-py3-none-win32.whl", hash = "sha256:173be2bfc142af07a01e3a759aba6f7791aa47acf3604f610b1c36db888df7b1", size = 12052870, upload-time = "2025-09-04T16:50:09.121Z" }, - { url = "https://files.pythonhosted.org/packages/85/60/d1e335417804df452589271818749d061b22772b87efda88354cf35cdb7a/ruff-0.12.12-py3-none-win_amd64.whl", hash = "sha256:e99620bf01884e5f38611934c09dd194eb665b0109104acae3ba6102b600fd0d", size = 13178016, upload-time = "2025-09-04T16:50:12.559Z" }, - { url = "https://files.pythonhosted.org/packages/28/7e/61c42657f6e4614a4258f1c3b0c5b93adc4d1f8575f5229d1906b483099b/ruff-0.12.12-py3-none-win_arm64.whl", hash = "sha256:2a8199cab4ce4d72d158319b63370abf60991495fb733db96cd923a34c52d093", size = 12256762, upload-time = "2025-09-04T16:50:15.737Z" }, -] - -[[package]] -name = "shellingham" -version = "1.5.4" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/58/15/8b3609fd3830ef7b27b655beb4b4e9c62313a4e8da8c676e142cc210d58e/shellingham-1.5.4.tar.gz", hash = "sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de", size = 10310, upload-time = "2023-10-24T04:13:40.426Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/e0/f9/0595336914c5619e5f28a1fb793285925a8cd4b432c9da0a987836c7f822/shellingham-1.5.4-py2.py3-none-any.whl", hash = "sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686", size = 9755, upload-time = "2023-10-24T04:13:38.866Z" }, -] - -[[package]] -name = "sortedcontainers" -version = "2.4.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e8/c4/ba2f8066cceb6f23394729afe52f3bf7adec04bf9ed2c820b39e19299111/sortedcontainers-2.4.0.tar.gz", hash = "sha256:25caa5a06cc30b6b83d11423433f65d1f9d76c4c6a0c90e3379eaa43b9bfdb88", size = 30594, upload-time = "2021-05-16T22:03:42.897Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/32/46/9cb0e58b2deb7f82b84065f37f3bffeb12413f947f9388e4cac22c4621ce/sortedcontainers-2.4.0-py2.py3-none-any.whl", hash = "sha256:a163dcaede0f1c021485e957a39245190e74249897e2ae4b2aa38595db237ee0", size = 29575, upload-time = "2021-05-16T22:03:41.177Z" }, -] - -[[package]] -name = "tomli" -version = "2.4.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543, upload-time = "2026-03-25T20:22:03.828Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704, upload-time = "2026-03-25T20:21:10.473Z" }, - { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454, upload-time = "2026-03-25T20:21:12.036Z" }, - { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561, upload-time = "2026-03-25T20:21:13.098Z" }, - { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824, upload-time = "2026-03-25T20:21:14.569Z" }, - { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227, upload-time = "2026-03-25T20:21:15.712Z" }, - { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859, upload-time = "2026-03-25T20:21:17.001Z" }, - { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204, upload-time = "2026-03-25T20:21:18.079Z" }, - { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084, upload-time = "2026-03-25T20:21:18.978Z" }, - { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285, upload-time = "2026-03-25T20:21:20.309Z" }, - { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924, upload-time = "2026-03-25T20:21:21.626Z" }, - { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018, upload-time = "2026-03-25T20:21:23.002Z" }, - { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948, upload-time = "2026-03-25T20:21:24.04Z" }, - { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341, upload-time = "2026-03-25T20:21:25.177Z" }, - { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159, upload-time = "2026-03-25T20:21:26.364Z" }, - { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290, upload-time = "2026-03-25T20:21:27.46Z" }, - { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141, upload-time = "2026-03-25T20:21:28.492Z" }, - { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847, upload-time = "2026-03-25T20:21:29.386Z" }, - { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088, upload-time = "2026-03-25T20:21:30.677Z" }, - { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866, upload-time = "2026-03-25T20:21:31.65Z" }, - { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887, upload-time = "2026-03-25T20:21:33.028Z" }, - { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704, upload-time = "2026-03-25T20:21:34.51Z" }, - { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628, upload-time = "2026-03-25T20:21:36.012Z" }, - { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180, upload-time = "2026-03-25T20:21:37.136Z" }, - { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674, upload-time = "2026-03-25T20:21:38.298Z" }, - { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976, upload-time = "2026-03-25T20:21:39.316Z" }, - { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755, upload-time = "2026-03-25T20:21:40.248Z" }, - { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265, upload-time = "2026-03-25T20:21:41.219Z" }, - { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726, upload-time = "2026-03-25T20:21:42.23Z" }, - { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859, upload-time = "2026-03-25T20:21:43.386Z" }, - { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713, upload-time = "2026-03-25T20:21:44.474Z" }, - { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084, upload-time = "2026-03-25T20:21:45.62Z" }, - { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973, upload-time = "2026-03-25T20:21:46.937Z" }, - { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223, upload-time = "2026-03-25T20:21:48.467Z" }, - { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973, upload-time = "2026-03-25T20:21:49.526Z" }, - { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082, upload-time = "2026-03-25T20:21:50.506Z" }, - { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490, upload-time = "2026-03-25T20:21:51.474Z" }, - { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263, upload-time = "2026-03-25T20:21:52.543Z" }, - { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736, upload-time = "2026-03-25T20:21:53.674Z" }, - { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717, upload-time = "2026-03-25T20:21:55.129Z" }, - { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461, upload-time = "2026-03-25T20:21:56.228Z" }, - { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855, upload-time = "2026-03-25T20:21:57.653Z" }, - { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144, upload-time = "2026-03-25T20:21:59.089Z" }, - { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683, upload-time = "2026-03-25T20:22:00.214Z" }, - { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196, upload-time = "2026-03-25T20:22:01.169Z" }, - { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393, upload-time = "2026-03-25T20:22:02.137Z" }, - { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583, upload-time = "2026-03-25T20:22:03.012Z" }, -] - -[[package]] -name = "tomli-w" -version = "1.2.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/75/241269d1da26b624c0d5e110e8149093c759b7a286138f4efd61a60e75fe/tomli_w-1.2.0.tar.gz", hash = "sha256:2dd14fac5a47c27be9cd4c976af5a12d87fb1f0b4512f81d69cce3b35ae25021", size = 7184, upload-time = "2025-01-15T12:07:24.262Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/c7/18/c86eb8e0202e32dd3df50d43d7ff9854f8e0603945ff398974c1d91ac1ef/tomli_w-1.2.0-py3-none-any.whl", hash = "sha256:188306098d013b691fcadc011abd66727d3c414c571bb01b1a174ba8c983cf90", size = 6675, upload-time = "2025-01-15T12:07:22.074Z" }, -] - -[[package]] -name = "ty" -version = "0.0.61" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/92/63/6944925d0fe9a4bb9cc744e6c045a42bbd2ee4654c103190674577a36c3f/ty-0.0.61.tar.gz", hash = "sha256:acbf0d914cc7e2e57ccc440036af36114819e2a604a5ffb554e72e4ca7dd65a2", size = 6234957, upload-time = "2026-07-18T01:39:54.696Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/cf/044f31523e2768e3e64b0ca2ec32f70b3a731d4a2caa6ea110baf26e251c/ty-0.0.61-py3-none-linux_armv6l.whl", hash = "sha256:148779b8675eac93f40ec58bd70037fe67537117f20a23272264f8f136d41336", size = 11891448, upload-time = "2026-07-18T01:39:18.449Z" }, - { url = "https://files.pythonhosted.org/packages/d2/55/558cfe76b65d91d1854bbfac336020bd42fd887caa632d845d13c0c539eb/ty-0.0.61-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:08217382b3385808ee7288501ea3214b32631b08d1fd091ece6799b0c95264c5", size = 11602442, upload-time = "2026-07-18T01:39:20.914Z" }, - { url = "https://files.pythonhosted.org/packages/27/be/78c0ae6634cd606a68e5b46b338db427a48a1800c96a749b2d2f7a702e03/ty-0.0.61-py3-none-macosx_11_0_arm64.whl", hash = "sha256:6d99c729011b47dec20e78a32ac9c8f6defd4cf62f7bb851bbccf70dde6cee50", size = 11125286, upload-time = "2026-07-18T01:39:22.893Z" }, - { url = "https://files.pythonhosted.org/packages/a4/18/a40793962f1b6337938ddb0bca7496b54e70879e23b4d2cc8dfd7e5d1af3/ty-0.0.61-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5cda607978ae271b77e51c947663218bce635c3507e256865444b10c37cdb60d", size = 11663403, upload-time = "2026-07-18T01:39:25.017Z" }, - { url = "https://files.pythonhosted.org/packages/98/c1/7879244da5b30407dc368946d36be5024380073408b079f144ffe034030e/ty-0.0.61-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d78f160a0f9434d570cdcdbc4dafba1f6aac3c47a32f9f63995b3cb55ffe4b6", size = 11715250, upload-time = "2026-07-18T01:39:27.045Z" }, - { url = "https://files.pythonhosted.org/packages/35/c4/8a4637cd58abd37f315dd515e24c582986cb1bfdf2edc4786882f5a4f69a/ty-0.0.61-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09aeab4800b36e93e4ce918699004da642d74988cac920b7592a6a2b9be6611c", size = 12393876, upload-time = "2026-07-18T01:39:29.197Z" }, - { url = "https://files.pythonhosted.org/packages/27/4b/27e7c640b1272743503229aa17ae2167a538040c4716a2fa1777c2b34fea/ty-0.0.61-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:dccc8136df44142a109953a168be17b4915c99876b047d0b6672c31dae939bdf", size = 12958187, upload-time = "2026-07-18T01:39:31.308Z" }, - { url = "https://files.pythonhosted.org/packages/3a/f5/70eaaefb6081fb0a8115cff66fbfaa20dafac8c646df2477adad95a59de2/ty-0.0.61-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:220760c2d13a887d027ee1093172c24ac35b6e634805329c93a30908ae4d3f5c", size = 12560101, upload-time = "2026-07-18T01:39:33.35Z" }, - { url = "https://files.pythonhosted.org/packages/e3/5a/17bae3b6429b5c479dc6c1e344d34e1f79efbc27531f15f3ee5b5da63745/ty-0.0.61-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:effefbb89da7128d18059529d1c2ea390fe7f1f3882690d257ca2143d49a0c34", size = 12225389, upload-time = "2026-07-18T01:39:35.436Z" }, - { url = "https://files.pythonhosted.org/packages/d2/0e/2ac380ba20d6395542c8df1d6fa4f00e2aead784c2e6aaefa1e02ed0610c/ty-0.0.61-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:ba8b28a5ef811d5bb6461e37d76110c06fd20487474865c323d3d18b08b972b2", size = 12548403, upload-time = "2026-07-18T01:39:37.556Z" }, - { url = "https://files.pythonhosted.org/packages/b0/e5/7da4b73e825e1a9808c26d68b0156e9a37aede1846191210dfffb8c64042/ty-0.0.61-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:88ecd6d9b05e8174b1860dac9bd3e188d6cef5702b0d3239fd9f94f6ac73a29d", size = 11621813, upload-time = "2026-07-18T01:39:39.919Z" }, - { url = "https://files.pythonhosted.org/packages/9b/3c/5b58015e998cd0d89b17a463b6321421457d86d987574e8dac65ddfceba3/ty-0.0.61-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb0cdfe4c48542ffb9a1139825dfa3d4aae49e96e966682ef7da762ab97831ff", size = 11734101, upload-time = "2026-07-18T01:39:42.097Z" }, - { url = "https://files.pythonhosted.org/packages/a6/21/294f4cc819b7b12ed659fd860e5cdfbd592d4c768c8f23596685dbc43e6b/ty-0.0.61-py3-none-musllinux_1_2_i686.whl", hash = "sha256:dff03873c0c3d0b44738f8b6d403b0756a31cf54c65136397df7624c6159b1f0", size = 11988401, upload-time = "2026-07-18T01:39:44.183Z" }, - { url = "https://files.pythonhosted.org/packages/2e/26/0f96f79fdac118521a9771e9eef3f9b3f447d647b2c77953e80a1715c7e8/ty-0.0.61-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a9210e80e3d41c1dfc751e9e8e0980272f475031fafd0fb0f48aee233c78da03", size = 12330624, upload-time = "2026-07-18T01:39:46.662Z" }, - { url = "https://files.pythonhosted.org/packages/e3/08/1e62d1bca5c0cebdc7a34db1f4b61557aab85961cedd56953dd2c32d3e66/ty-0.0.61-py3-none-win32.whl", hash = "sha256:e3e1fe06f49a5492a922a5df2739834aa5ee978c7dd10414119dc8755cc40c9c", size = 11313991, upload-time = "2026-07-18T01:39:48.761Z" }, - { url = "https://files.pythonhosted.org/packages/26/f1/d8e33b3aeb36b73d81ae34d10e46ec4abf506d68f4e0a1491a76a593dd42/ty-0.0.61-py3-none-win_amd64.whl", hash = "sha256:25f2291169e0298fcdbba1b1fea64f8207a6c1908dddef32346fd5e3e6ac9221", size = 12311717, upload-time = "2026-07-18T01:39:50.881Z" }, - { url = "https://files.pythonhosted.org/packages/e1/14/7caec26d93a943c0e7d15eb7374644508d08cbd387d112b722b12d14e044/ty-0.0.61-py3-none-win_arm64.whl", hash = "sha256:3e496f7698bc4b5bbb1eb66d8b5799ba87596d88d36604ca359083893fa2fc49", size = 11693485, upload-time = "2026-07-18T01:39:52.73Z" }, -] - -[[package]] -name = "typer" -version = "0.27.0" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "annotated-doc" }, - { name = "colorama", marker = "sys_platform == 'win32'" }, - { name = "rich" }, - { name = "shellingham" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/37/78/fda3361b56efc27944f24225f6ecd13d96d6fcfe37bd0eb34e2f4c63f9fc/typer-0.27.0.tar.gz", hash = "sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5", size = 203430, upload-time = "2026-07-15T19:21:07.007Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/40/03/26a383c9e58c213199d1aad1c3d353cfc22d4444ec6d2c0bf8ad02523843/typer-0.27.0-py3-none-any.whl", hash = "sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1", size = 122716, upload-time = "2026-07-15T19:21:05.553Z" }, -] - -[[package]] -name = "typing-extensions" -version = "4.16.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/f6/cc/6253133b5bb138fc3306cebfbda2c520f545d36b5be2c7255cc528bb45d6/typing_extensions-4.16.0.tar.gz", hash = "sha256:dc983d19a509c94dba722ee6abd33940f7c05a89e243c47e907eb4db6f1a43e5", size = 113555, upload-time = "2026-07-02T08:40:05.92Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, -] - -[[package]] -name = "typing-inspection" -version = "0.4.2" -source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "typing-extensions" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, -] - -[[package]] -name = "urllib3" -version = "2.7.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" }, -] diff --git a/projects/openshell-middleware-manager/README.md b/projects/openshell-middleware-manager/README.md index d17c1736..ae93e618 100644 --- a/projects/openshell-middleware-manager/README.md +++ b/projects/openshell-middleware-manager/README.md @@ -85,7 +85,7 @@ builds. Use `--openshell-version latest` when you want the newest release. The starter templates still use the older `max_body_bytes` binding field, so `omm create` does not yet support v0.0.116. Validation stops without publishing that starter. `omm update` can refresh an existing service already compatible -with the newer contract, such as Egress Gate. +with the newer contract. Run `omm --help` for all options and `omm --version` for the installed OMM version. By default, `omm` derives the Python package name from the project @@ -116,7 +116,7 @@ Python updates run `uv sync` and then `uv run pytest` by default. If a project needs additional build steps, supply its normal validation command: ```sh -omm update /path/to/egress-gate \ +omm update /path/to/my-middleware \ --openshell-version v0.0.116 --check-command 'make check' ``` diff --git a/scripts/stage-project-docs.py b/scripts/stage-project-docs.py index df686a07..210ee3d0 100644 --- a/scripts/stage-project-docs.py +++ b/scripts/stage-project-docs.py @@ -13,7 +13,6 @@ ROOT = Path(__file__).resolve().parents[1] PROJECT_DOCUMENTATION = { - "egress-gate": ROOT / "projects" / "egress-gate" / "docs", "openshell-agent-runner": ROOT / "projects" / "openshell-agent-runner" / "docs", } DOCUMENTATION_ROOT = ROOT / "docs" / "documentation" diff --git a/tests/test_page_navigation.py b/tests/test_page_navigation.py index 15aaae02..22ebe826 100644 --- a/tests/test_page_navigation.py +++ b/tests/test_page_navigation.py @@ -11,12 +11,14 @@ CONFIG = ROOT / "zensical.toml" STYLES = ROOT / "docs" / "stylesheets" / "dev-notes.css" DOCUMENTATION_LANDING = ROOT / "site" / "documentation" / "index.html" -EGRESS_GATE_LANDING = ROOT / "site" / "documentation" / "egress-gate" / "index.html" -CONFIGURATION_GUIDE = ( - ROOT / "site" / "documentation" / "egress-gate" / "configuration" / "index.html" +AGENT_RUNNER_LANDING = ( + ROOT / "site" / "documentation" / "openshell-agent-runner" / "index.html" ) -REQUEST_CONTENT_GUIDE = ( - ROOT / "site" / "documentation" / "egress-gate" / "request-content" / "index.html" +REVIEWS_GUIDE = ( + ROOT / "site" / "documentation" / "openshell-agent-runner" / "reviews" / "index.html" +) +PROFILES_GUIDE = ( + ROOT / "site" / "documentation" / "openshell-agent-runner" / "profiles" / "index.html" ) @@ -50,19 +52,19 @@ def test_rendered_links_follow_the_reading_path(self) -> None: self.skipTest("rendered output is checked after the documentation build") documentation = DOCUMENTATION_LANDING.read_text(encoding="utf-8") - egress_gate = EGRESS_GATE_LANDING.read_text(encoding="utf-8") - configuration = CONFIGURATION_GUIDE.read_text(encoding="utf-8") - request_content = REQUEST_CONTENT_GUIDE.read_text(encoding="utf-8") + agent_runner = AGENT_RUNNER_LANDING.read_text(encoding="utf-8") + reviews = REVIEWS_GUIDE.read_text(encoding="utf-8") + profiles = PROFILES_GUIDE.read_text(encoding="utf-8") self.assertIn("Back to OpenShell Research", documentation) - self.assertIn("Next: Egress Gate", documentation) + self.assertIn("Next: OpenShell Agent Runner", documentation) self.assertNotIn("Previous: Bringing Privacy", documentation) - self.assertIn("Previous: Documentation", egress_gate) - self.assertIn("Next: Configure policies", egress_gate) - self.assertIn("Previous: Egress Gate", configuration) - self.assertIn("Next: Parse request content", configuration) - self.assertIn("Previous: Configure policies", request_content) - self.assertIn("Next: Test policies offline", request_content) + self.assertIn("Previous: Documentation", agent_runner) + self.assertIn("Next: Run reviews", agent_runner) + self.assertIn("Previous: OpenShell Agent Runner", reviews) + self.assertIn("Next: Customize profiles", reviews) + self.assertIn("Previous: Run reviews", profiles) + self.assertIn("Next: Command reference", profiles) if __name__ == "__main__": diff --git a/tests/test_stage_project_docs.py b/tests/test_stage_project_docs.py index 2ceb75e6..8292a0e0 100644 --- a/tests/test_stage_project_docs.py +++ b/tests/test_stage_project_docs.py @@ -23,7 +23,7 @@ class StageProjectDocsTests(unittest.TestCase): def test_configures_every_published_project(self) -> None: self.assertEqual( set(STAGER.PROJECT_DOCUMENTATION), - {"egress-gate", "openshell-agent-runner"}, + {"openshell-agent-runner"}, ) for source in STAGER.PROJECT_DOCUMENTATION.values(): self.assertTrue((source / "index.md").is_file()) diff --git a/zensical.toml b/zensical.toml index c455ab7d..eea105e2 100644 --- a/zensical.toml +++ b/zensical.toml @@ -29,28 +29,6 @@ nav = [ ]}, {"Documentation" = [ "documentation/index.md", - {"Egress Gate" = [ - "documentation/egress-gate/index.md", - {"Guides" = [ - {"Configure policies" = "documentation/egress-gate/configuration.md"}, - {"Parse request content" = "documentation/egress-gate/request-content.md"}, - {"Test policies offline" = "documentation/egress-gate/evaluation.md"}, - {"Run and operate Egress Gate" = "documentation/egress-gate/operations.md"} - ]}, - {"Gates" = [ - "documentation/egress-gate/gates/index.md", - {"Regex gate" = "documentation/egress-gate/gates/regex.md"}, - {"Add a custom gate" = "documentation/egress-gate/gates/custom.md"} - ]}, - {"Architecture" = [ - "documentation/egress-gate/architecture/index.md", - {"Request lifecycle" = "documentation/egress-gate/architecture/request-lifecycle.md"}, - {"Service boundary" = "documentation/egress-gate/architecture/service-boundary.md"} - ]}, - {"Reference" = [ - {"Limits and failure behavior" = "documentation/egress-gate/reference/limits-and-failures.md"} - ]} - ]}, {"OpenShell Agent Runner" = [ "documentation/openshell-agent-runner/index.md", {"Run reviews" = "documentation/openshell-agent-runner/reviews.md"},