From 7dc94879e65517e245827574d7652f55beaf4bbc Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Mon, 3 Aug 2026 22:01:35 +0000 Subject: [PATCH 01/46] Rename Privacy Guard to Egress Gate and add domain models --- .github/workflows/docs-preview-deploy.yml | 6 +- .github/workflows/docs-preview.yml | 6 +- .../{privacy-guard.yml => egress-gate.yml} | 12 +- .gitignore | 2 +- docs/development/index.md | 4 +- docs/documentation/index.md | 2 +- projects/README.md | 2 +- .../.openshell-middleware-manifest.json | 2 +- .../{privacy-guard => egress-gate}/AGENTS.md | 32 +- .../{privacy-guard => egress-gate}/LICENSE | 0 .../{privacy-guard => egress-gate}/Makefile | 2 +- .../{privacy-guard => egress-gate}/README.md | 46 +-- .../analysis/README.md | 22 +- .../analysis/egress-gate-latency.csv} | 2 +- .../analysis/render_latency_plot.py | 30 +- .../docs/architecture/index.md | 16 +- .../docs/architecture/request-lifecycle.md | 18 +- .../docs/architecture/service-boundary.md | 28 +- .../egress-gate-latency-vs-prompt-size.svg} | 8 +- .../diagrams/component-architecture.svg | 2 +- .../assets/diagrams/processing-pipeline.svg | 4 +- .../assets/diagrams/request-lifecycle.svg | 6 +- .../docs/assets/diagrams/request-path.svg | 6 +- .../docs/configuration.md | 44 +-- .../docs/engines/custom.md | 32 +- .../docs/engines/index.md | 8 +- .../docs/engines/regex.md | 14 +- .../docs/index.md | 52 ++-- .../docs/operations.md | 72 ++--- .../docs/reference/limits-and-failures.md | 36 +-- .../examples/custom-engine/.gitignore | 0 .../examples/custom-engine/README.md | 42 +-- .../examples/custom-engine/custom_engine.py | 6 +- .../custom-engine/egress-gate-config.yaml} | 0 .../examples/custom-engine/policy.yaml | 4 +- .../examples/regex-engine/.gitignore | 0 .../examples/regex-engine/README.md | 32 +- .../regex-engine/egress-gate-config.yaml} | 0 .../examples/regex-engine/patterns.yaml | 0 .../examples/regex-engine/policy.yaml | 4 +- .../proto/supervisor_middleware.proto | 0 .../pyproject.toml | 8 +- .../scripts/check.sh | 2 +- .../egress-gate/src/egress_gate/__init__.py | 1 + .../src/egress_gate}/base.py | 0 .../src/egress_gate}/bindings/__init__.py | 0 .../bindings/supervisor_middleware_pb2.py | 0 .../bindings/supervisor_middleware_pb2.pyi | 0 .../supervisor_middleware_pb2_grpc.py | 0 .../src/egress_gate}/cli.py | 43 ++- .../src/egress_gate}/config.py | 14 +- .../src/egress_gate}/constants.py | 26 +- .../src/egress_gate}/engines/__init__.py | 6 +- .../src/egress_gate}/engines/base.py | 10 +- .../src/egress_gate}/engines/regex.py | 18 +- .../src/egress_gate}/engines/registry.py | 38 ++- .../src/egress_gate}/errors.py | 12 +- .../src/egress_gate}/gateway_config.py | 8 +- .../src/egress_gate}/logging.py | 28 +- .../egress-gate/src/egress_gate/request.py | 197 ++++++++++++ .../src/egress_gate}/request_processor.py | 44 +-- .../egress-gate/src/egress_gate/result.py | 282 ++++++++++++++++++ .../src/egress_gate/service/__init__.py | 6 + .../src/egress_gate}/service/server.py | 38 +-- .../src/egress_gate}/service/servicer.py | 70 ++--- .../src/egress_gate}/string_validators.py | 2 +- .../src/egress_gate}/timeout.py | 6 +- projects/egress-gate/tests/__init__.py | 1 + .../tests/engines/test_base.py | 8 +- .../tests/engines/test_regex.py | 16 +- .../tests/engines/test_registry.py | 16 +- .../tests/examples/test_custom_engine.py | 30 +- .../tests/examples/test_regex_engine.py | 32 +- .../egress-gate/tests/service/__init__.py | 1 + .../tests/service/test_grpc_integration.py | 34 +-- .../tests/service/test_server.py | 112 +++---- .../tests/service/test_servicer.py | 100 +++---- .../tests/test_cli.py | 53 ++-- .../tests/test_config.py | 40 +-- .../tests/test_errors.py | 24 +- .../tests/test_gateway_config.py | 46 +-- .../tests/test_logging.py | 32 +- projects/egress-gate/tests/test_request.py | 171 +++++++++++ .../tests/test_request_processor.py | 36 +-- projects/egress-gate/tests/test_result.py | 157 ++++++++++ .../tests/test_timeout.py | 6 +- .../tests/test_typing_policy.py | 4 +- .../{privacy-guard => egress-gate}/uv.lock | 2 +- .../src/privacy_guard/__init__.py | 1 - .../src/privacy_guard/service/__init__.py | 6 - projects/privacy-guard/tests/__init__.py | 1 - .../privacy-guard/tests/service/__init__.py | 1 - scripts/build-docs.sh | 2 +- ...uard-docs.py => stage-egress-gate-docs.py} | 12 +- ...docs.py => test_stage_egress_gate_docs.py} | 14 +- zensical.toml | 22 +- 96 files changed, 1621 insertions(+), 822 deletions(-) rename .github/workflows/{privacy-guard.yml => egress-gate.yml} (69%) rename projects/{privacy-guard => egress-gate}/.openshell-middleware-manifest.json (90%) rename projects/{privacy-guard => egress-gate}/AGENTS.md (80%) rename projects/{privacy-guard => egress-gate}/LICENSE (100%) rename projects/{privacy-guard => egress-gate}/Makefile (97%) rename projects/{privacy-guard => egress-gate}/README.md (80%) rename projects/{privacy-guard => egress-gate}/analysis/README.md (74%) rename projects/{privacy-guard/analysis/privacy-guard-latency.csv => egress-gate/analysis/egress-gate-latency.csv} (97%) rename projects/{privacy-guard => egress-gate}/analysis/render_latency_plot.py (92%) rename projects/{privacy-guard => egress-gate}/docs/architecture/index.md (90%) rename projects/{privacy-guard => egress-gate}/docs/architecture/request-lifecycle.md (88%) rename projects/{privacy-guard => egress-gate}/docs/architecture/service-boundary.md (86%) rename projects/{privacy-guard/docs/assets/analysis/privacy-guard-latency-vs-prompt-size.svg => egress-gate/docs/assets/analysis/egress-gate-latency-vs-prompt-size.svg} (97%) rename projects/{privacy-guard => egress-gate}/docs/assets/diagrams/component-architecture.svg (98%) rename projects/{privacy-guard => egress-gate}/docs/assets/diagrams/processing-pipeline.svg (96%) rename projects/{privacy-guard => egress-gate}/docs/assets/diagrams/request-lifecycle.svg (96%) rename projects/{privacy-guard => egress-gate}/docs/assets/diagrams/request-path.svg (91%) rename projects/{privacy-guard => egress-gate}/docs/configuration.md (84%) rename projects/{privacy-guard => egress-gate}/docs/engines/custom.md (89%) rename projects/{privacy-guard => egress-gate}/docs/engines/index.md (75%) rename projects/{privacy-guard => egress-gate}/docs/engines/regex.md (91%) rename projects/{privacy-guard => egress-gate}/docs/index.md (79%) rename projects/{privacy-guard => egress-gate}/docs/operations.md (74%) rename projects/{privacy-guard => egress-gate}/docs/reference/limits-and-failures.md (81%) rename projects/{privacy-guard => egress-gate}/examples/custom-engine/.gitignore (100%) rename projects/{privacy-guard => egress-gate}/examples/custom-engine/README.md (85%) rename projects/{privacy-guard => egress-gate}/examples/custom-engine/custom_engine.py (92%) rename projects/{privacy-guard/examples/custom-engine/privacy-guard-config.yaml => egress-gate/examples/custom-engine/egress-gate-config.yaml} (100%) rename projects/{privacy-guard => egress-gate}/examples/custom-engine/policy.yaml (94%) rename projects/{privacy-guard => egress-gate}/examples/regex-engine/.gitignore (100%) rename projects/{privacy-guard => egress-gate}/examples/regex-engine/README.md (83%) rename projects/{privacy-guard/examples/regex-engine/privacy-guard-config.yaml => egress-gate/examples/regex-engine/egress-gate-config.yaml} (100%) rename projects/{privacy-guard => egress-gate}/examples/regex-engine/patterns.yaml (100%) rename projects/{privacy-guard => egress-gate}/examples/regex-engine/policy.yaml (95%) rename projects/{privacy-guard => egress-gate}/proto/supervisor_middleware.proto (100%) rename projects/{privacy-guard => egress-gate}/pyproject.toml (91%) rename projects/{privacy-guard => egress-gate}/scripts/check.sh (90%) create mode 100644 projects/egress-gate/src/egress_gate/__init__.py rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/base.py (100%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/bindings/__init__.py (100%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/bindings/supervisor_middleware_pb2.py (100%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/bindings/supervisor_middleware_pb2.pyi (100%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/bindings/supervisor_middleware_pb2_grpc.py (100%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/cli.py (89%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/config.py (90%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/constants.py (72%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/engines/__init__.py (90%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/engines/base.py (98%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/engines/regex.py (98%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/engines/registry.py (91%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/errors.py (93%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/gateway_config.py (97%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/logging.py (77%) create mode 100644 projects/egress-gate/src/egress_gate/request.py rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/request_processor.py (82%) create mode 100644 projects/egress-gate/src/egress_gate/result.py create mode 100644 projects/egress-gate/src/egress_gate/service/__init__.py rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/service/server.py (72%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/service/servicer.py (87%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/string_validators.py (95%) rename projects/{privacy-guard/src/privacy_guard => egress-gate/src/egress_gate}/timeout.py (92%) create mode 100644 projects/egress-gate/tests/__init__.py rename projects/{privacy-guard => egress-gate}/tests/engines/test_base.py (97%) rename projects/{privacy-guard => egress-gate}/tests/engines/test_regex.py (96%) rename projects/{privacy-guard => egress-gate}/tests/engines/test_registry.py (96%) rename projects/{privacy-guard => egress-gate}/tests/examples/test_custom_engine.py (81%) rename projects/{privacy-guard => egress-gate}/tests/examples/test_regex_engine.py (77%) create mode 100644 projects/egress-gate/tests/service/__init__.py rename projects/{privacy-guard => egress-gate}/tests/service/test_grpc_integration.py (91%) rename projects/{privacy-guard => egress-gate}/tests/service/test_server.py (74%) rename projects/{privacy-guard => egress-gate}/tests/service/test_servicer.py (88%) rename projects/{privacy-guard => egress-gate}/tests/test_cli.py (88%) rename projects/{privacy-guard => egress-gate}/tests/test_config.py (94%) rename projects/{privacy-guard => egress-gate}/tests/test_errors.py (53%) rename projects/{privacy-guard => egress-gate}/tests/test_gateway_config.py (90%) rename projects/{privacy-guard => egress-gate}/tests/test_logging.py (77%) create mode 100644 projects/egress-gate/tests/test_request.py rename projects/{privacy-guard => egress-gate}/tests/test_request_processor.py (86%) create mode 100644 projects/egress-gate/tests/test_result.py rename projects/{privacy-guard => egress-gate}/tests/test_timeout.py (86%) rename projects/{privacy-guard => egress-gate}/tests/test_typing_policy.py (99%) rename projects/{privacy-guard => egress-gate}/uv.lock (99%) delete mode 100644 projects/privacy-guard/src/privacy_guard/__init__.py delete mode 100644 projects/privacy-guard/src/privacy_guard/service/__init__.py delete mode 100644 projects/privacy-guard/tests/__init__.py delete mode 100644 projects/privacy-guard/tests/service/__init__.py rename scripts/{stage-privacy-guard-docs.py => stage-egress-gate-docs.py} (78%) rename tests/{test_stage_privacy_guard_docs.py => test_stage_egress_gate_docs.py} (84%) diff --git a/.github/workflows/docs-preview-deploy.yml b/.github/workflows/docs-preview-deploy.yml index ef587cf0..113ae8ea 100644 --- a/.github/workflows/docs-preview-deploy.yml +++ b/.github/workflows/docs-preview-deploy.yml @@ -99,17 +99,17 @@ jobs: 'scripts/build-docs.sh', 'scripts/publish-agent-markdown.py', 'scripts/render-dev-notes.py', - 'scripts/stage-privacy-guard-docs.py', + 'scripts/stage-egress-gate-docs.py', 'tests/test_agent_markdown.py', 'tests/test_docs_404.py', 'tests/test_render_dev_notes.py', - 'tests/test_stage_privacy_guard_docs.py', + 'tests/test_stage_egress_gate_docs.py', 'zensical.toml', ]); const docsChanged = files.some( ({ filename }) => filename.startsWith('docs/') || filename.startsWith('overrides/') || - filename.startsWith('projects/privacy-guard/docs/') || + filename.startsWith('projects/egress-gate/docs/') || exactInputs.has(filename), ); operation = docsChanged ? 'deploy' : 'remove'; diff --git a/.github/workflows/docs-preview.yml b/.github/workflows/docs-preview.yml index 6f54a221..a90d3f84 100644 --- a/.github/workflows/docs-preview.yml +++ b/.github/workflows/docs-preview.yml @@ -47,17 +47,17 @@ jobs: 'scripts/build-docs.sh', 'scripts/publish-agent-markdown.py', 'scripts/render-dev-notes.py', - 'scripts/stage-privacy-guard-docs.py', + 'scripts/stage-egress-gate-docs.py', 'tests/test_agent_markdown.py', 'tests/test_docs_404.py', 'tests/test_render_dev_notes.py', - 'tests/test_stage_privacy_guard_docs.py', + 'tests/test_stage_egress_gate_docs.py', 'zensical.toml', ]); const docsChanged = files.some( ({ filename }) => filename.startsWith('docs/') || filename.startsWith('overrides/') || - filename.startsWith('projects/privacy-guard/docs/') || + filename.startsWith('projects/egress-gate/docs/') || exactInputs.has(filename), ); core.setOutput('operation', docsChanged ? 'deploy' : 'remove'); diff --git a/.github/workflows/privacy-guard.yml b/.github/workflows/egress-gate.yml similarity index 69% rename from .github/workflows/privacy-guard.yml rename to .github/workflows/egress-gate.yml index 07a9c3bb..b16f6c91 100644 --- a/.github/workflows/privacy-guard.yml +++ b/.github/workflows/egress-gate.yml @@ -1,4 +1,4 @@ -name: Privacy Guard +name: Egress Gate "on": pull_request: @@ -11,12 +11,12 @@ permissions: contents: read concurrency: - group: privacy-guard-${{ github.workflow }}-${{ github.ref }} + group: egress-gate-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true jobs: check: - name: Check Privacy Guard (Python ${{ matrix.python-version }}) + name: Check Egress Gate (Python ${{ matrix.python-version }}) runs-on: ubuntu-latest strategy: fail-fast: false @@ -26,7 +26,7 @@ jobs: - "3.14" defaults: run: - working-directory: projects/privacy-guard + working-directory: projects/egress-gate steps: - name: Checkout uses: actions/checkout@v7 @@ -41,8 +41,8 @@ jobs: - name: Configure isolated uv paths run: | - echo "UV_CACHE_DIR=$RUNNER_TEMP/privacy-guard-uv-cache" >> "$GITHUB_ENV" - echo "UV_PROJECT_ENVIRONMENT=$RUNNER_TEMP/privacy-guard-venv" >> "$GITHUB_ENV" + 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 diff --git a/.gitignore b/.gitignore index ee6c962b..8e2e0357 100644 --- a/.gitignore +++ b/.gitignore @@ -121,7 +121,7 @@ lib/ # Static site and docs output public/ site/ -docs/documentation/privacy-guard/ +docs/documentation/egress-gate/ .docusaurus/ .vitepress/cache/ .vitepress/dist/ diff --git a/docs/development/index.md b/docs/development/index.md index ceef7e54..c1380883 100644 --- a/docs/development/index.md +++ b/docs/development/index.md @@ -92,8 +92,8 @@ scripts/build-docs.sh ``` `scripts/build-docs.sh` recreates `.venv-docs`, installs the pinned toolchain, -stages canonical Privacy Guard documentation from -`projects/privacy-guard/docs/`, renders Dev Notes metadata, and runs +stages canonical Egress Gate documentation from +`projects/egress-gate/docs/`, renders Dev Notes metadata, and runs `zensical build --clean --strict`. Do not report success unless it completes without issues. diff --git a/docs/documentation/index.md b/docs/documentation/index.md index 8f2d1e54..e1e8360b 100644 --- a/docs/documentation/index.md +++ b/docs/documentation/index.md @@ -9,4 +9,4 @@ agent_markdown: true Technical documentation and references for installing, using, and extending OpenShell Research projects. -- [Privacy Guard](privacy-guard/index.md): middleware for protecting sensitive data in OpenShell. +- [Egress Gate](egress-gate/index.md): middleware for protecting sensitive data in OpenShell. diff --git a/projects/README.md b/projects/README.md index 051e2d23..05e4aaa6 100644 --- a/projects/README.md +++ b/projects/README.md @@ -8,7 +8,7 @@ Current projects: - `openshell-middleware-manager`: `omm` CLI that creates and updates version-matched Python and Rust OpenShell supervisor middleware projects. -- `privacy-guard`: OpenShell supervisor middleware for inspecting and enforcing +- `egress-gate`: OpenShell supervisor middleware for inspecting and enforcing policy on provider-bound requests before credentials are attached. - `python-project-template`: Minimal, production-ready Python project scaffold managed with uv. diff --git a/projects/privacy-guard/.openshell-middleware-manifest.json b/projects/egress-gate/.openshell-middleware-manifest.json similarity index 90% rename from projects/privacy-guard/.openshell-middleware-manifest.json rename to projects/egress-gate/.openshell-middleware-manifest.json index 2356631e..24290a96 100644 --- a/projects/privacy-guard/.openshell-middleware-manifest.json +++ b/projects/egress-gate/.openshell-middleware-manifest.json @@ -5,7 +5,7 @@ "languages": [ "python" ], - "python_package": "privacy_guard", + "python_package": "egress_gate", "generator": { "name": "openshell-middleware-manager", "version": "0.1.0" diff --git a/projects/privacy-guard/AGENTS.md b/projects/egress-gate/AGENTS.md similarity index 80% rename from projects/privacy-guard/AGENTS.md rename to projects/egress-gate/AGENTS.md index 5d7c5f20..e583de09 100644 --- a/projects/privacy-guard/AGENTS.md +++ b/projects/egress-gate/AGENTS.md @@ -1,12 +1,12 @@ -# Privacy Guard +# Egress Gate -Privacy Guard is OpenShell middleware that runs an ordered pipeline of +Egress Gate is OpenShell middleware that runs an ordered pipeline of entity-processing engines over one UTF-8 request body and applies a user-facing detect, block, or replace action. ## Development commands -Run commands from `projects/privacy-guard/`. +Run commands from `projects/egress-gate/`. - List targets: `make help` - Run all checks: `make check` @@ -30,20 +30,20 @@ Run focused tests while working and `make check` before handoff. ## Project map -- `src/privacy_guard/engines/`: engine contract, registry, and built-in implementations -- `src/privacy_guard/config.py`: policy action and ordered stage configuration -- `src/privacy_guard/request_processor.py`: stage execution and policy disposition -- `src/privacy_guard/cli.py`: command parsing, discovery, gateway registration +- `src/egress_gate/engines/`: engine contract, registry, and built-in implementations +- `src/egress_gate/config.py`: policy action and ordered stage configuration +- `src/egress_gate/request_processor.py`: stage execution and policy disposition +- `src/egress_gate/cli.py`: command parsing, discovery, gateway registration management, configuration-schema output, and server adapter -- `src/privacy_guard/gateway_config.py`: safe OpenShell gateway TOML +- `src/egress_gate/gateway_config.py`: safe OpenShell gateway TOML registration management -- `src/privacy_guard/logging.py`: package-scoped standard-library logging configuration -- `src/privacy_guard/base.py`: package-wide strict immutable domain-model base -- `src/privacy_guard/string_validators.py`: shared string validators and field types -- `src/privacy_guard/service/`: gRPC lifecycle and protobuf adapter -- `src/privacy_guard/bindings/`: generated protobuf files; never hand-edit +- `src/egress_gate/logging.py`: package-scoped standard-library logging configuration +- `src/egress_gate/base.py`: package-wide strict immutable domain-model base +- `src/egress_gate/string_validators.py`: shared string validators and field types +- `src/egress_gate/service/`: gRPC lifecycle and protobuf adapter +- `src/egress_gate/bindings/`: generated protobuf files; never hand-edit - `docs/`: canonical user and architecture documentation; the repository docs - build stages this tree at the public Privacy Guard documentation route + build stages this tree at the public Egress Gate documentation route - `tests/`: tests that mirror source boundaries - `examples/`: copyable policy-authoring examples @@ -86,13 +86,13 @@ behavior or per-request state. ```python from typing import Literal -from privacy_guard.engines import ( +from egress_gate.engines import ( EngineConfig, EntityProcessingEngine, EntityProcessingStrategy, TextProcessingResult, ) -from privacy_guard.timeout import Timeout +from egress_gate.timeout import Timeout class KeywordConfig(EngineConfig): diff --git a/projects/privacy-guard/LICENSE b/projects/egress-gate/LICENSE similarity index 100% rename from projects/privacy-guard/LICENSE rename to projects/egress-gate/LICENSE diff --git a/projects/privacy-guard/Makefile b/projects/egress-gate/Makefile similarity index 97% rename from projects/privacy-guard/Makefile rename to projects/egress-gate/Makefile index 95223f16..ebe240b3 100644 --- a/projects/privacy-guard/Makefile +++ b/projects/egress-gate/Makefile @@ -47,7 +47,7 @@ fix: ## Apply Ruff fixes, then format. $(UV_RUN) ruff format . import-check: ## Smoke-test the installed package import. - $(UV_RUN) python -c "import privacy_guard" + $(UV_RUN) python -c "import egress_gate" build: ## Build the source distribution and wheel. $(UV) build diff --git a/projects/privacy-guard/README.md b/projects/egress-gate/README.md similarity index 80% rename from projects/privacy-guard/README.md rename to projects/egress-gate/README.md index 21af0c8f..637d511a 100644 --- a/projects/privacy-guard/README.md +++ b/projects/egress-gate/README.md @@ -1,21 +1,21 @@ -# Privacy Guard +# Egress Gate -Privacy Guard is an OpenShell supervisor middleware that detects, blocks, or +Egress Gate is an OpenShell supervisor middleware that detects, blocks, or replaces configured entities in provider-bound HTTP request bodies before OpenShell attaches provider credentials. It processes the complete request body as UTF-8 text through an ordered pipeline of entity-processing engines. -> **Experimental:** Privacy Guard is a proof of concept. It reduces exposure on +> **Experimental:** Egress Gate is a proof of concept. It reduces exposure on > provider-bound network requests that OpenShell routes through the middleware; > it does not guarantee that sensitive data cannot leak. -Privacy Guard does not intercept data before a harness writes it to disk. +Egress Gate does not intercept data before a harness writes it to disk. Prompts, tool output, transcripts, and session histories may therefore retain raw sensitive values even when the provider-bound request is later replaced or blocked. Use harness persistence controls and appropriate storage isolation, -retention, and cleanup in addition to Privacy Guard. +retention, and cleanup in addition to Egress Gate. ## What it does @@ -41,14 +41,14 @@ From this directory: ```bash uv sync --locked -uv run privacy-guard engines -uv run privacy-guard configuration-schema +uv run egress-gate engines +uv run egress-gate configuration-schema ``` Start the built-in `RegexEngine` service locally: ```bash -uv run privacy-guard serve \ +uv run egress-gate serve \ --listen 127.0.0.1:50051 ``` @@ -89,23 +89,23 @@ the detection-only strategy. pattern_catalog: patterns.yaml ``` -Relative paths resolve beneath Privacy Guard's working directory. Absolute +Relative paths resolve beneath Egress Gate's working directory. Absolute paths, traversal, symlinks, unsafe YAML tags, aliases, duplicate keys, invalid UTF-8, and oversized catalogs are rejected. ## CLI ```bash -uv run privacy-guard engines -uv run privacy-guard configuration-schema -uv run privacy-guard add-gateway-registration --host-ip YOUR_HOST_IPV4 -uv run privacy-guard remove-gateway-registration --name privacy-guard -uv run privacy-guard serve \ +uv run egress-gate engines +uv run egress-gate configuration-schema +uv run egress-gate add-gateway-registration --host-ip YOUR_HOST_IPV4 +uv run egress-gate remove-gateway-registration --name egress-gate +uv run egress-gate serve \ --listen 0.0.0.0:50051 \ --timeout-seconds 4 ``` -`add-gateway-registration` adds or updates a Privacy Guard registration in the +`add-gateway-registration` adds or updates a Egress Gate registration in the OpenShell gateway TOML. Its registration name must match the policy's `middleware` field. Restart the gateway after changing registrations. `remove-gateway-registration` removes one registration by name while preserving @@ -120,11 +120,11 @@ the five-second value. Use a trusted registry factory for custom engines: ```bash -uv run privacy-guard \ +uv run egress-gate \ --registry-factory my_engines:create_registry \ engines -uv run privacy-guard \ +uv run egress-gate \ --registry-factory my_engines:create_registry \ serve ``` @@ -132,10 +132,10 @@ uv run privacy-guard \ ## Python server API ```python -from privacy_guard.engines.registry import create_builtin_registry -from privacy_guard.service import PrivacyGuardServer +from egress_gate.engines.registry import create_builtin_registry +from egress_gate.service import EgressGateServer -server = PrivacyGuardServer( +server = EgressGateServer( create_builtin_registry(), timeout_seconds=5, ) @@ -152,7 +152,7 @@ await server.serve_async("127.0.0.1:50051") - [Overview and end-to-end quickstart](docs/index.md) - [Configure policies](docs/configuration.md) -- [Run and operate Privacy Guard](docs/operations.md) +- [Run and operate Egress Gate](docs/operations.md) - [Use RegexEngine](docs/engines/regex.md) - [Add a custom engine](docs/engines/custom.md) - [System architecture](docs/architecture/index.md) @@ -172,8 +172,8 @@ await server.serve_async("127.0.0.1:50051") `--debug-log-content` logs complete input and processed text. Use it only in a controlled development environment. -Imported applications can configure the standard `privacy_guard` logger -themselves or use `privacy_guard.logging.configure_logging()`. +Imported applications can configure the standard `egress_gate` logger +themselves or use `egress_gate.logging.configure_logging()`. ## Development diff --git a/projects/privacy-guard/analysis/README.md b/projects/egress-gate/analysis/README.md similarity index 74% rename from projects/privacy-guard/analysis/README.md rename to projects/egress-gate/analysis/README.md index 0e6d1767..6edb19eb 100644 --- a/projects/privacy-guard/analysis/README.md +++ b/projects/egress-gate/analysis/README.md @@ -1,13 +1,13 @@ -# Privacy Guard latency analysis +# Egress Gate latency analysis This directory contains the source data, deterministic renderer, and generated -Privacy Guard latency-versus-prompt-size figure. It is the reusable starting +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/privacy-guard/`: +Run from `projects/egress-gate/`: ```sh uv run python analysis/render_latency_plot.py @@ -16,7 +16,7 @@ uv run python analysis/render_latency_plot.py The command writes the documentation asset: ```text -docs/assets/analysis/privacy-guard-latency-vs-prompt-size.svg +docs/assets/analysis/egress-gate-latency-vs-prompt-size.svg ``` Verify that the committed figure matches the data and renderer: @@ -30,15 +30,15 @@ dependency. ## Data -`privacy-guard-latency.csv` contains 96 joined observations from the synthetic +`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 | -| `privacy_guard_latency_ms` | `duration_ms` emitted by the `privacy_guard_evaluation` service log | -| `entity_count` | Aggregate Privacy Guard finding count | +| `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 | @@ -50,8 +50,8 @@ 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 -`privacy_guard_latency_ms / turn_elapsed_ms` across those 12 completed turns. -It is not Privacy Guard's share of the short OpenShell middleware interval. +`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 @@ -63,9 +63,9 @@ 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, Privacy Guard configuration, RegexEngine +- the run used one host, sandbox, Egress Gate configuration, RegexEngine policy, and Claude Code session -- baseline and large-context observations span a Privacy Guard process restart +- 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 diff --git a/projects/privacy-guard/analysis/privacy-guard-latency.csv b/projects/egress-gate/analysis/egress-gate-latency.csv similarity index 97% rename from projects/privacy-guard/analysis/privacy-guard-latency.csv rename to projects/egress-gate/analysis/egress-gate-latency.csv index d123e0b5..feee325c 100644 --- a/projects/privacy-guard/analysis/privacy-guard-latency.csv +++ b/projects/egress-gate/analysis/egress-gate-latency.csv @@ -1,4 +1,4 @@ -observed_at_utc,prompt_tokens,privacy_guard_latency_ms,entity_count,phase,openshell_observed_ms,first_output_elapsed_ms,turn_elapsed_ms +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,,, diff --git a/projects/privacy-guard/analysis/render_latency_plot.py b/projects/egress-gate/analysis/render_latency_plot.py similarity index 92% rename from projects/privacy-guard/analysis/render_latency_plot.py rename to projects/egress-gate/analysis/render_latency_plot.py index da4ff2b4..1cec7ffc 100644 --- a/projects/privacy-guard/analysis/render_latency_plot.py +++ b/projects/egress-gate/analysis/render_latency_plot.py @@ -1,4 +1,4 @@ -"""Render the Privacy Guard latency proof-of-concept figure as deterministic SVG.""" +"""Render the Egress Gate latency proof-of-concept figure as deterministic SVG.""" from __future__ import annotations @@ -10,13 +10,13 @@ from pathlib import Path _ANALYSIS_DIR = Path(__file__).resolve().parent -_DEFAULT_DATA = _ANALYSIS_DIR / "privacy-guard-latency.csv" +_DEFAULT_DATA = _ANALYSIS_DIR / "egress-gate-latency.csv" _DEFAULT_OUTPUT = ( _ANALYSIS_DIR.parent / "docs" / "assets" / "analysis" - / "privacy-guard-latency-vs-prompt-size.svg" + / "egress-gate-latency-vs-prompt-size.svg" ) _WIDTH = 1200 @@ -47,7 +47,7 @@ class Measurement: observed_at_utc: str prompt_tokens: int - privacy_guard_latency_ms: float + egress_gate_latency_ms: float entity_count: int phase: str openshell_observed_ms: float | None @@ -97,7 +97,7 @@ def main() -> None: completed_turns = [row for row in measurements if row.turn_elapsed_ms is not None] mean_turn_share = statistics.fmean( - row.privacy_guard_latency_ms / row.turn_elapsed_ms + row.egress_gate_latency_ms / row.turn_elapsed_ms for row in completed_turns if row.turn_elapsed_ms is not None ) @@ -118,7 +118,7 @@ def _load_measurements(path: Path) -> list[Measurement]: Measurement( observed_at_utc=row["observed_at_utc"], prompt_tokens=int(row["prompt_tokens"]), - privacy_guard_latency_ms=float(row["privacy_guard_latency_ms"]), + 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"]), @@ -139,7 +139,7 @@ def _optional_float(value: str) -> float | None: def _linear_fit(measurements: list[Measurement]) -> LinearFit: x_values = [row.prompt_tokens / 100_000.0 for row in measurements] - y_values = [row.privacy_guard_latency_ms 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) @@ -173,7 +173,7 @@ def _render_svg(measurements: list[Measurement], fit: LinearFit) -> str: 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.privacy_guard_latency_ms / row.turn_elapsed_ms + row.egress_gate_latency_ms / row.turn_elapsed_ms for row in completed_turns if row.turn_elapsed_ms is not None ) @@ -184,12 +184,12 @@ def _render_svg(measurements: list[Measurement], fit: LinearFit) -> str: f'viewBox="0 0 {_WIDTH} {_HEIGHT}" role="img" ' f'aria-labelledby="title description">' ), - 'Privacy Guard latency versus prompt size', + 'Egress Gate latency versus prompt size', ( - 'Scatter plot of 96 Privacy Guard service ' + '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"Privacy Guard averaged {100.0 * mean_turn_share:.2f} percent of " + f"Egress Gate averaged {100.0 * mean_turn_share:.2f} percent of " "end-to-end time across 12 completed turns." ), "", @@ -223,7 +223,7 @@ def _render_svg(measurements: list[Measurement], fit: LinearFit) -> str: "", ( f'' - "Privacy Guard latency (ms) · log scale" + "Egress Gate latency (ms) · log scale" ), ] @@ -280,10 +280,10 @@ def _render_svg(measurements: list[Measurement], fit: LinearFit) -> str: ) parts.append( f'' f"{row.prompt_tokens:,} tokens; " - f"{row.privacy_guard_latency_ms:.1f} ms; " + f"{row.egress_gate_latency_ms:.1f} ms; " f"{row.entity_count} entities detected" ) @@ -293,7 +293,7 @@ def _render_svg(measurements: list[Measurement], fit: LinearFit) -> str: [ ( f'' - f"Privacy Guard averaged {100.0 * mean_turn_share:.2f}%" + f"Egress Gate averaged {100.0 * mean_turn_share:.2f}%" ), ( f' -Privacy Guard latency versus prompt size -Scatter plot of 96 Privacy Guard service latency measurements from 18 thousand to 1.141 million prompt tokens, with one linear fit and a one-million-token threshold. Privacy Guard averaged 0.56 percent of end-to-end time across 12 completed turns. +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. @@ -20,7 +20,7 @@ text{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Ar .threshold{stroke:#20262d;stroke-width:2;stroke-dasharray:8 7} @media(prefers-color-scheme:dark){text{fill:#edf2f7}.axis,.annotation{fill:#aeb8c5}.grid{stroke:#52606d}.threshold{stroke:#d8dee5}.point{stroke:#e5e9ee}} -Privacy Guard latency (ms) · log scale +Egress Gate latency (ms) · log scale 10k @@ -142,7 +142,7 @@ text{font-family:-apple-system,BlinkMacSystemFont,"Segoe UI",Roboto,Helvetica,Ar 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 -Privacy Guard averaged 0.56% +Egress Gate averaged 0.56% of end-to-end turn time across 12 completed turns Entities detected diff --git a/projects/privacy-guard/docs/assets/diagrams/component-architecture.svg b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg similarity index 98% rename from projects/privacy-guard/docs/assets/diagrams/component-architecture.svg rename to projects/egress-gate/docs/assets/diagrams/component-architecture.svg index e1013014..4f7dba25 100644 --- a/projects/privacy-guard/docs/assets/diagrams/component-architecture.svg +++ b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg @@ -1,5 +1,5 @@ - Privacy Guard component architecture + Egress Gate component architecture Four architecture layers show transport and startup adapters, typed configuration and registry, request processing, and entity-processing engines. Downward arrows show configuration flowing into the request processor and the processor invoking concrete engines through their shared wrapper. Egress Gate has separate transport, policy, request-processing, and request-gate layers. +
Transport code stays outside the protobuf-free runtime and gate contract.
+ ## Component ownership @@ -38,6 +33,13 @@ does not add a second execution path or import the transport adapter. Only `service/` imports generated protobuf/gRPC bindings. The processor and gates receive domain values and can be tested offline. +## Pipeline execution + +
+ A request moves through runtime controls and an ordered gate pipeline before Egress Gate returns a result. +
Each gate sees the current request. A proceed result makes a validated patch visible to the next gate.
+
+ ## Trust and state Registry factories and custom gate modules are trusted deployment code. diff --git a/projects/egress-gate/docs/architecture/request-lifecycle.md b/projects/egress-gate/docs/architecture/request-lifecycle.md index 7dc6f1d7..5ad1c26b 100644 --- a/projects/egress-gate/docs/architecture/request-lifecycle.md +++ b/projects/egress-gate/docs/architecture/request-lifecycle.md @@ -6,6 +6,11 @@ agent_markdown: true # Request lifecycle +
+ The request lifecycle validates the transport and policy, runs the gate pipeline, and returns either a result or an RPC failure. +
Input failures end the RPC. Policy decisions and runtime-limit denials return normal middleware results.
+
+ ## 1. Validate the transport The service checks the pre-credentials phase, exact protobuf configuration, diff --git a/projects/egress-gate/docs/assets/diagrams/component-architecture.svg b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg index 1df58abc..9a235756 100644 --- a/projects/egress-gate/docs/assets/diagrams/component-architecture.svg +++ b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg @@ -14,6 +14,7 @@ .implementation{font-size:18px;font-weight:700} .subtitle{font-size:15px;fill:#5f6b76} .arrow{fill:none;stroke:#718096;stroke-width:2.4;marker-end:url(#arrow)} + .arrow-head{fill:#718096} .dependency{fill:none;stroke:#a0aec0;stroke-width:2;stroke-dasharray:7 6;marker-end:url(#arrow)} .arrow-label{font-size:14px;fill:#5f6b76} @media(prefers-color-scheme:dark){ @@ -25,11 +26,12 @@ .gate{fill:#2d203a;stroke:#b794d4} .layer-label,.subtitle,.arrow-label{fill:#aeb8c5} .arrow{stroke:#8b98a5} + .arrow-head{fill:#8b98a5} .dependency{stroke:#71808e} } - + diff --git a/projects/egress-gate/docs/assets/diagrams/processing-pipeline.svg b/projects/egress-gate/docs/assets/diagrams/processing-pipeline.svg index 2eac86ba..df0f1d32 100644 --- a/projects/egress-gate/docs/assets/diagrams/processing-pipeline.svg +++ b/projects/egress-gate/docs/assets/diagrams/processing-pipeline.svg @@ -1,6 +1,6 @@ Egress Gate processing pipeline - A validated policy and immutable byte-oriented request share one timeout. Ordered gates run and validate their evaluations, applying patches to the current request. Egress Gate aggregates findings and applies an explicit terminal or default decision. + A validated policy and immutable byte-oriented request share one timeout. Ordered gates run and validate their evaluations. Egress Gate applies each patch returned with a proceed result to the current request, then returns a decision, an optional request patch, and findings. - + @@ -48,30 +50,30 @@ 3 - First gate + Evaluate gate gate.evaluate(current request) 4 - Validate result + Validate output Control · patch · findings · limits - proceed + validated 5 - Next gate - Repeat in configured order + Apply control + Apply patch or stop 6 - Aggregate - Patches · findings · provenance + Finalize + Findings · provenance · default @@ -80,15 +82,15 @@ Return the explicit result - allow - terminal or default + Decision + allow or deny - deny - stable reason code + Request patch + body + headers - replace - validated request patch + Findings + bounded + sourced - repeat until terminal or default + 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 index 769cb131..49a898ff 100644 --- a/projects/egress-gate/docs/assets/diagrams/request-lifecycle.svg +++ b/projects/egress-gate/docs/assets/diagrams/request-lifecycle.svg @@ -14,6 +14,7 @@ .title{font-size:19px;font-weight:700} .step{font-size:15px;fill:#5f6b76} .arrow{fill:none;stroke:#718096;stroke-width:2.5;marker-end:url(#arrow)} + .arrow-head{fill:#718096} .branch{fill:none;stroke:#a0aec0;stroke-width:2;stroke-dasharray:7 6;marker-end:url(#arrow)} .branch-label{font-size:14px;font-weight:600;fill:#5f6b76} @media(prefers-color-scheme:dark){ @@ -26,11 +27,12 @@ .failure{fill:#3b2026;stroke:#f17b8c} .phase-label,.step,.branch-label{fill:#aeb8c5} .arrow{stroke:#8b98a5} + .arrow-head{fill:#8b98a5} .branch{stroke:#71808e} } - + @@ -54,7 +56,7 @@ Validate policy typed union · gates - resources · action + resources · default Resolve processor @@ -66,9 +68,9 @@ 3 · PROCESSING - Decode text - strict UTF-8 - empty body bypass + Build request + transport-free model + target · headers · bytes Run pipeline @@ -81,15 +83,15 @@ 4 · RESULT - Apply - detect - block - replace + Finalize + decision + request patch + findings Serialize allow or deny - body + findings + patch + findings RPC FAILURE @@ -101,8 +103,8 @@ SUCCESSFUL MIDDLEWARE RESULT - Allow, policy deny, or limit deny - egress_gate_blocked - egress_gate_limit_exceeded + Return an allow or deny decision + Validated request patch and findings + Stable reason code for each denial diff --git a/projects/egress-gate/docs/assets/diagrams/request-path.svg b/projects/egress-gate/docs/assets/diagrams/request-path.svg index eab21f5c..04cf2792 100644 --- a/projects/egress-gate/docs/assets/diagrams/request-path.svg +++ b/projects/egress-gate/docs/assets/diagrams/request-path.svg @@ -5,32 +5,46 @@ - + - - + + Sandbox application Creates a provider-bound HTTP request @@ -38,37 +52,37 @@ request - - + + OpenShell supervisor Routes the request before credentials are attached - body + policy over gRPC + request + policy over gRPC - + PRE-CREDENTIALS - - - + + + Egress Gate - Detects entities and applies the configured action + Runs the configured request gates in order - allow original, replacement, or deny + allow, validated mutation, or deny - - + + OpenShell supervisor - Stops denied requests; attaches credentials after allow + Stops denials · attaches credentials after allow authorized request - - + + Provider diff --git a/projects/egress-gate/docs/index.md b/projects/egress-gate/docs/index.md index 8ff1438e..bf9e29e5 100644 --- a/projects/egress-gate/docs/index.md +++ b/projects/egress-gate/docs/index.md @@ -18,17 +18,12 @@ It is not a forward proxy, TLS interceptor, or response filter. It does not protect content that a harness already wrote to disk. Configure storage and retention controls separately. -## Runtime shape - -```text -OpenShell protobuf/gRPC - -> service adapter - -> HttpRequest + strict pipeline config - -> RequestProcessor + shared Timeout - -> GateEvaluation sequence - -> EgressResult - -> OpenShell protobuf/gRPC -``` +## Request path + +
+ A provider-bound request moves from a sandbox application through OpenShell and Egress Gate before it reaches the provider. +
OpenShell calls Egress Gate before it attaches provider credentials.
+
Only `service/` imports generated bindings. Gate and processor code is protobuf-free and can be evaluated offline. From 6cda9045a1ebdd7e6c7232134d767fa0924f7a6a Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 14:43:01 +0000 Subject: [PATCH 20/46] docs(egress-gate): highlight code examples --- projects/egress-gate/docs/configuration.md | 6 +++--- projects/egress-gate/docs/evaluation.md | 6 +++--- projects/egress-gate/docs/gates/custom.md | 4 ++-- projects/egress-gate/docs/gates/regex.md | 2 +- projects/egress-gate/docs/index.md | 2 +- projects/egress-gate/docs/operations.md | 8 ++++---- zensical.toml | 10 ++++++++++ 7 files changed, 24 insertions(+), 14 deletions(-) diff --git a/projects/egress-gate/docs/configuration.md b/projects/egress-gate/docs/configuration.md index beeea308..4557b34c 100644 --- a/projects/egress-gate/docs/configuration.md +++ b/projects/egress-gate/docs/configuration.md @@ -10,7 +10,7 @@ OpenShell embeds the Egress Gate policy in a `network_middlewares` entry. The registry validates the complete strict configuration before preparing a processor. -```yaml +```yaml title="OpenShell policy" network_middlewares: egress_gate: name: Inspect provider requests @@ -57,7 +57,7 @@ registry factory supplies other behavior. ## Inspect the installed registry -```bash +```bash title="Inspect the default registry" uv run egress-gate gates uv run egress-gate configuration-schema uv run egress-gate validate --policy path/to/policy.yaml @@ -65,7 +65,7 @@ uv run egress-gate validate --policy path/to/policy.yaml Custom registries use the same factory for inspection and serving: -```bash +```bash title="Inspect a custom registry" uv run egress-gate \ --registry-factory my_gates:create_registry gates uv run egress-gate \ diff --git a/projects/egress-gate/docs/evaluation.md b/projects/egress-gate/docs/evaluation.md index bf5dbb4c..0955a086 100644 --- a/projects/egress-gate/docs/evaluation.md +++ b/projects/egress-gate/docs/evaluation.md @@ -10,7 +10,7 @@ agent_markdown: true bounded corpus locally through the production `RequestProcessor`. It does not start gRPC, attach credentials, contact an upstream, or persist request data. -```bash +```bash title="Evaluate a request corpus" uv run egress-gate evaluate \ --policy examples/regex-redaction/egress-gate-config.yaml \ --cases examples/regex-redaction/cases.yaml \ @@ -25,7 +25,7 @@ The service and discovery commands accept the same finalized registry factory. The corpus is strict, bounded YAML. The parser rejects aliases, duplicate keys, unknown fields, invalid base64, oversized requests, and duplicate case names. -```yaml +```yaml title="cases.yaml" version: 1 cases: - name: ordinary-request @@ -65,7 +65,7 @@ Each case gets a new `Timeout`. Policy preparation gets a separate timeout. The evaluator reuses one prepared processor for all cases. Output is content-safe and stable: -```text +```text title="Evaluation output" PASS case="ordinary-request" SUMMARY total=1 passed=1 failed=0 ``` diff --git a/projects/egress-gate/docs/gates/custom.md b/projects/egress-gate/docs/gates/custom.md index 149ca61f..998d28ec 100644 --- a/projects/egress-gate/docs/gates/custom.md +++ b/projects/egress-gate/docs/gates/custom.md @@ -15,7 +15,7 @@ The repository includes a runnable that pairs the implementation below with a policy and two offline evaluation cases. From `projects/egress-gate/`, run it with: -```bash +```bash title="Run the custom-gate example" uv run python -m egress_gate.cli \ --registry-factory examples.custom_gate.keyword_gate:create_registry \ evaluate \ @@ -23,7 +23,7 @@ uv run python -m egress_gate.cli \ --cases examples/custom_gate/cases.yaml ``` -```python +```python title="examples/custom_gate/keyword_gate.py" from typing import Literal from egress_gate.gates import Gate, GateCapabilities, GateConfig, GateRegistry diff --git a/projects/egress-gate/docs/gates/regex.md b/projects/egress-gate/docs/gates/regex.md index 0a2c083c..3c017cb1 100644 --- a/projects/egress-gate/docs/gates/regex.md +++ b/projects/egress-gate/docs/gates/regex.md @@ -12,7 +12,7 @@ A catalog can be inline or in a relative `.yaml` or `.yml` file. The gate rejects absolute paths, path traversal, symlinks, YAML aliases, duplicate keys, invalid UTF-8, unsafe patterns, and oversized catalogs. -```yaml +```yaml title="Inline regex catalog" gate: regex-body pattern_catalog: entities: diff --git a/projects/egress-gate/docs/index.md b/projects/egress-gate/docs/index.md index bf9e29e5..e3d9e6fd 100644 --- a/projects/egress-gate/docs/index.md +++ b/projects/egress-gate/docs/index.md @@ -32,7 +32,7 @@ protobuf-free and can be evaluated offline. From `projects/egress-gate/`: -```bash +```bash title="Install, inspect, validate, and serve" uv sync --frozen uv run egress-gate gates uv run egress-gate configuration-schema diff --git a/projects/egress-gate/docs/operations.md b/projects/egress-gate/docs/operations.md index 6adcdb0b..dde908da 100644 --- a/projects/egress-gate/docs/operations.md +++ b/projects/egress-gate/docs/operations.md @@ -9,7 +9,7 @@ agent_markdown: true The OpenShell gateway and sandbox supervisors call Egress Gate through gRPC. Install and run the service from `projects/egress-gate`: -```bash +```bash title="Start Egress Gate" uv sync --frozen uv run egress-gate gates uv run egress-gate configuration-schema @@ -22,7 +22,7 @@ trusted network. Do not expose the port to an untrusted network. ## OpenShell registration -```bash +```bash title="Register Egress Gate" uv run egress-gate add-gateway-registration \ --host-ip YOUR_HOST_IPV4 --name egress-gate --port 50051 ``` @@ -32,7 +32,7 @@ The command updates `OPENSHELL_GATEWAY_CONFIG`, then `~/.config/openshell/gateway.toml`. Use `--config PATH` for another file. Restart the OpenShell gateway after changing registrations. Remove one with: -```bash +```bash title="Remove the registration" uv run egress-gate remove-gateway-registration --name egress-gate ``` @@ -72,7 +72,7 @@ time. Inspect a finite OpenShell log window: -```bash +```bash title="Inspect recent sandbox logs" openshell status openshell logs SANDBOX_NAME -n 100 --source sandbox ``` diff --git a/zensical.toml b/zensical.toml index eb03ba96..ed882324 100644 --- a/zensical.toml +++ b/zensical.toml @@ -55,12 +55,22 @@ generator = false [project.markdown_extensions.admonition] +[project.markdown_extensions."pymdownx.highlight"] +anchor_linenums = true +line_spans = "__span" +pygments_lang_class = true + +[project.markdown_extensions."pymdownx.inlinehilite"] + +[project.markdown_extensions."pymdownx.superfences"] + [project.theme] custom_dir = "overrides" favicon = "assets/brand/favicon.svg" logo = "assets/brand/openshell-mark.svg" icon.repo = "fontawesome/brands/github" features = [ + "content.code.copy", "navigation.sections", "navigation.indexes", "navigation.path", From 460a036342c87f50e6f369d0be81c218e38d78ab Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 14:51:53 +0000 Subject: [PATCH 21/46] docs(egress-gate): refine diagram routing --- .../docs/assets/diagrams/component-architecture.svg | 6 +++--- .../docs/assets/diagrams/processing-pipeline.svg | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/projects/egress-gate/docs/assets/diagrams/component-architecture.svg b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg index 9a235756..08ef7ef1 100644 --- a/projects/egress-gate/docs/assets/diagrams/component-architecture.svg +++ b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg @@ -70,13 +70,13 @@ Custom gates trusted integrations - - validate config + + validate config validated policy construct gates - run 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 index df0f1d32..3d946eec 100644 --- a/projects/egress-gate/docs/assets/diagrams/processing-pipeline.svg +++ b/projects/egress-gate/docs/assets/diagrams/processing-pipeline.svg @@ -91,6 +91,6 @@ Findings bounded + sourced - - proceed · next gate + + proceed · next gate From 253325bc6f5d4ee9c6d5561de9f6080a11834049 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 15:03:40 +0000 Subject: [PATCH 22/46] docs(egress-gate): polish request lifecycle diagram --- .../assets/diagrams/request-lifecycle.svg | 132 +++++++++--------- 1 file changed, 67 insertions(+), 65 deletions(-) diff --git a/projects/egress-gate/docs/assets/diagrams/request-lifecycle.svg b/projects/egress-gate/docs/assets/diagrams/request-lifecycle.svg index 49a898ff..ccfb78ba 100644 --- a/projects/egress-gate/docs/assets/diagrams/request-lifecycle.svg +++ b/projects/egress-gate/docs/assets/diagrams/request-lifecycle.svg @@ -1,6 +1,6 @@ - + Egress Gate request lifecycle - The request lifecycle has four phases: validate the OpenShell transport, validate and activate configuration, adapt and process the current request, and serialize the result. Invalid input fails the RPC, while policy and limit decisions return successful allow or deny results. + The request lifecycle has four phases: validate the OpenShell request, validate and prepare the policy, run the configured gates, and serialize the result. Validation or execution failures end the RPC. Policy decisions and runtime-limit denials return normal middleware results. - + 1 · TRANSPORT - - Receive - HttpRequestEvaluation - pre-credentials phase - - - Validate bounds - context · config - target · headers · body - phase + + Receive request + OpenShell evaluation RPC + pre-credentials phase + + + Validate input + phase · request context + target · headers · body + size · encoding bounds - + - - 2 · CONFIGURATION - - Validate policy - typed union · gates - resources · default - - - Resolve processor - reuse equal config - or prepare + activate + + 2 · CONFIGURATION + + Validate policy + strict policy schema + gates · default decision + + + Prepare pipeline + reuse unchanged policy + prepare changed policy + activate when complete - + - - 3 · PROCESSING - - Build request - transport-free model - target · headers · bytes - - - Run pipeline - ordered gates - one shared timeout - aggregate findings + + 3 · PROCESSING + + Build request + immutable HTTP model + body remains bytes + + + Run gates + evaluate in policy order + share one deadline + apply validated patches - + - - 4 · RESULT - - Finalize - decision - request patch - findings - - - Serialize - allow or deny - patch + findings + + 4 · RESULT + + Build result + allow or deny + optional patch + bounded findings + + + Serialize + OpenShell wire result + five-field findings - + RPC FAILURE - Invalid input or internal failure - INVALID_ARGUMENT or INTERNAL - OpenShell applies middleware on_error - - + Return a gRPC error + INVALID_ARGUMENT · invalid input + INTERNAL · gate or service failure + + + - - SUCCESSFUL MIDDLEWARE RESULT - Return an allow or deny decision - Validated request patch and findings - Stable reason code for each denial - + + SUCCESSFUL MIDDLEWARE RESULT + Return allow or deny + Optional validated patch and findings + Stable reason code for every denial + From 92b7eff1838c8f4c677d4d5f02a09f4e1ff224a4 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 15:15:34 +0000 Subject: [PATCH 23/46] docs(egress-gate): teach offline policy testing --- projects/egress-gate/README.md | 2 +- projects/egress-gate/docs/configuration.md | 2 +- projects/egress-gate/docs/evaluation.md | 148 +++++++++++++++------ projects/egress-gate/docs/index.md | 8 +- zensical.toml | 2 +- 5 files changed, 116 insertions(+), 46 deletions(-) diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 690664ce..4106520e 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -78,7 +78,7 @@ through slot acquisition, policy preparation, and `RequestProcessor.process`. - [Overview](docs/index.md) - [Configuration](docs/configuration.md) -- [Offline evaluation](docs/evaluation.md) +- [Test policies offline](docs/evaluation.md) - [Operations](docs/operations.md) - [Gate authoring](docs/gates/custom.md) - [Regex-body](docs/gates/regex.md) diff --git a/projects/egress-gate/docs/configuration.md b/projects/egress-gate/docs/configuration.md index 4557b34c..2bd57e02 100644 --- a/projects/egress-gate/docs/configuration.md +++ b/projects/egress-gate/docs/configuration.md @@ -85,4 +85,4 @@ 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 processor path and does not start the gRPC service. See -[Offline evaluation](evaluation.md). +[Test policies offline](evaluation.md). diff --git a/projects/egress-gate/docs/evaluation.md b/projects/egress-gate/docs/evaluation.md index 0955a086..ceb1c85a 100644 --- a/projects/egress-gate/docs/evaluation.md +++ b/projects/egress-gate/docs/evaluation.md @@ -1,76 +1,146 @@ --- -title: Offline policy evaluation -description: Run bounded request corpora through the production processor. +title: Test policies offline +description: Test policy decisions and findings before deployment. agent_markdown: true --- -# Offline policy evaluation +# Test policies offline -`egress-gate evaluate` prepares a validated pipeline once and evaluates a -bounded corpus locally through the production `RequestProcessor`. It does not -start gRPC, attach credentials, contact an upstream, or persist request data. +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. -```bash title="Evaluate a request corpus" +`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/`: + +```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-seconds 1 ``` -Use the global `--registry-factory` option for trusted application-owned gates. -The service and discovery commands accept the same finalized registry factory. +The command prepares the policy once, runs each case with a fresh timeout, and +prints a short result: + +```text title="Evaluation output" +PASS case="email-is-detected-and-request-is-allowed" +PASS case="ordinary-body-is-allowed" +SUMMARY total=2 passed=2 failed=0 +``` + +No request goes to an upstream service. The command does not start gRPC, +attach credentials, or persist request data. + +## Write one test case -## Corpus v1 +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. -The corpus is strict, bounded YAML. The parser rejects aliases, duplicate keys, -unknown fields, invalid base64, oversized requests, and duplicate case names. +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: ordinary-request - tags: [smoke] + - name: email-is-detected provenance: - kind: synthetic # synthetic or captured - redacted: true + kind: synthetic + redacted: false request: context: - request_id: corpus-1 - sandbox_id: sandbox-1 + request_id: test-email + sandbox_id: test-sandbox target: scheme: https host: api.example.com port: 443 method: POST - path: /v1/items + path: /v1/messages query: "" headers: [] body: - encoding: utf8 # utf8 or base64 - value: '{"item":"ordinary"}' + encoding: utf8 + value: "send alice@example.com" expected: decision: allow - decision_source_kind: pipeline_default - finding_types: [] + finding_types: [sensitive_entity] ``` -`request.context`, `request.target`, and `request.headers` use the existing -protobuf-free domain fields. The evaluator decodes `request.body` into the -bounded `HttpRequest` model that the service uses. `expected.decision` is -required. Source kind, gate name, gate type, and ordered finding types are -optional projections. Gate name and gate type require a `gate` source kind. -The evaluator does not compare omitted projections. +Each case has three parts: -Each case gets a new `Timeout`. Policy preparation gets a separate timeout. -The evaluator reuses one prepared processor for all cases. Output is -content-safe and stable: +- `provenance` records whether the request is synthetic or captured and + whether its content is redacted. +- `request` contains the immutable HTTP request that the gates will evaluate. +- `expected` contains the result fields that must match. -```text title="Evaluation output" -PASS case="ordinary-request" -SUMMARY total=1 passed=1 failed=0 +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 runtime 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 a request patch. + +## 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 runtime limits. These checks keep tests +repeatable and ensure that test requests follow the same bounds as service +requests. + +Use `--registry-factory` when the policy contains application-owned custom +gates: + +```bash title="Test a custom gate" +uv run 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 ``` -Exit status `0` means that every case matches. Status `1` means that one or -more cases do not match. Status `2` means that input, preparation, or execution -failed. Mismatch lines contain only decision metadata and finding types. The -command does not print request bodies or raw exception text. +## 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/index.md b/projects/egress-gate/docs/index.md index e3d9e6fd..82b49e9e 100644 --- a/projects/egress-gate/docs/index.md +++ b/projects/egress-gate/docs/index.md @@ -44,9 +44,9 @@ uv run egress-gate serve --listen 127.0.0.1:50051 Use the [regex-body guide](gates/regex.md) for an OpenShell policy and a file-backed catalog. -Use [offline evaluation](evaluation.md) to run bounded request corpora through -the same prepared `RequestProcessor` used by the service, without starting -gRPC or contacting an upstream provider. +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. ## Core rules @@ -66,7 +66,7 @@ gRPC or contacting an upstream provider. ## Further reading - [Configuration](configuration.md) -- [Offline evaluation](evaluation.md) +- [Test policies offline](evaluation.md) - [Operations](operations.md) - [Gate authoring](gates/custom.md) - [Regex-body](gates/regex.md) diff --git a/zensical.toml b/zensical.toml index ed882324..bb763742 100644 --- a/zensical.toml +++ b/zensical.toml @@ -29,7 +29,7 @@ nav = [ "documentation/egress-gate/index.md", {"Guides" = [ {"Configure policies" = "documentation/egress-gate/configuration.md"}, - {"Offline policy evaluation" = "documentation/egress-gate/evaluation.md"}, + {"Test policies offline" = "documentation/egress-gate/evaluation.md"}, {"Run and operate Egress Gate" = "documentation/egress-gate/operations.md"} ]}, {"Gates" = [ From c6b592450556068095c0bde5dd8a7ba633c6716c Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 19:06:37 +0000 Subject: [PATCH 24/46] Polish documentation navigation --- .github/workflows/docs.yml | 3 + docs/javascripts/navigation-drawer.js | 123 +++++++---- docs/stylesheets/dev-notes.css | 211 ++++++++++++++++++- overrides/main.html | 10 + overrides/partials/footer.html | 57 ++++++ overrides/partials/header.html | 70 +++++++ scripts/build-docs.sh | 2 + tests/navigation-drawer.test.js | 283 ++++++++++++++++++++++++++ tests/test_navigation_drawer.py | 57 ++++++ tests/test_page_navigation.py | 53 +++++ zensical.toml | 1 + 11 files changed, 830 insertions(+), 40 deletions(-) create mode 100644 overrides/partials/footer.html create mode 100644 overrides/partials/header.html create mode 100644 tests/navigation-drawer.test.js create mode 100644 tests/test_navigation_drawer.py create mode 100644 tests/test_page_navigation.py diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index 2f98ced6..047fbea3 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -50,6 +50,9 @@ jobs: - name: Check navigation JavaScript syntax run: node --check docs/javascripts/navigation-drawer.js + - name: Test navigation JavaScript behavior + run: node tests/navigation-drawer.test.js + - name: Build documentation run: scripts/build-docs.sh diff --git a/docs/javascripts/navigation-drawer.js b/docs/javascripts/navigation-drawer.js index 83ebaea0..06e89d0e 100644 --- a/docs/javascripts/navigation-drawer.js +++ b/docs/javascripts/navigation-drawer.js @@ -1,4 +1,6 @@ (() => { + const drawerStateKey = "openshell.navigationDrawerOpen"; + const modalDrawerQuery = "(max-width: 63.99rem)"; let cleanup = () => {}; function enhanceNavigationDrawer() { @@ -7,32 +9,19 @@ const toggle = document.querySelector("#__drawer"); const sidebar = document.querySelector(".md-sidebar--primary"); const overlay = document.querySelector('.md-overlay[for="__drawer"]'); - const legacyControl = document.querySelector( - '.md-header__button[for="__drawer"]', - ); - - if (!(toggle instanceof HTMLInputElement) || !(sidebar instanceof HTMLElement)) { - cleanup = () => {}; - return; - } - - let button = document.querySelector(".openshell-drawer-button"); - if (!(button instanceof HTMLButtonElement) && legacyControl instanceof HTMLElement) { - button = document.createElement("button"); - button.type = "button"; - button.className = legacyControl.className; - button.classList.add("openshell-drawer-button"); - button.innerHTML = legacyControl.innerHTML; - legacyControl.replaceWith(button); - } - - if (!(button instanceof HTMLButtonElement)) { + const modalDrawer = window.matchMedia(modalDrawerQuery); + const button = document.querySelector(".openshell-drawer-button"); + + if ( + !(toggle instanceof HTMLInputElement) || + !(sidebar instanceof HTMLElement) || + !(button instanceof HTMLElement) + ) { cleanup = () => {}; return; } sidebar.id = "primary-navigation"; - sidebar.setAttribute("role", "dialog"); sidebar.setAttribute("aria-label", "Primary navigation"); button.setAttribute("aria-controls", sidebar.id); @@ -61,26 +50,42 @@ const focusableElements = () => Array.from( sidebar.querySelectorAll( - 'a[href], button:not([disabled]), input:not([disabled]), [tabindex]:not([tabindex="-1"])', + 'a[href], button:not([disabled]), input:not([disabled]):not(.md-toggle), [tabindex]:not([tabindex="-1"])', ), - ).filter((element) => element instanceof HTMLElement && !element.hidden); + ).filter( + (element) => + element instanceof HTMLElement && + element.tabIndex >= 0 && + element.getClientRects().length > 0 && + window.getComputedStyle(element).visibility === "visible" && + !element.closest("[inert]"), + ); const synchronize = ({ moveFocus = false, restoreFocus = false } = {}) => { const isOpen = toggle.checked; + const isModal = modalDrawer.matches; + document.documentElement.dataset.navigationDrawer = isOpen ? "open" : "closed"; button.setAttribute("aria-expanded", String(isOpen)); button.setAttribute("aria-label", isOpen ? "Close navigation" : "Open navigation"); sidebar.setAttribute("aria-hidden", String(!isOpen)); - if (isOpen) { + if (isModal) { + sidebar.setAttribute("role", "dialog"); + sidebar.setAttribute("aria-label", "Primary navigation"); + } else { + sidebar.removeAttribute("role"); + sidebar.removeAttribute("aria-label"); + } + if (isOpen && isModal) { sidebar.setAttribute("aria-modal", "true"); } else { sidebar.removeAttribute("aria-modal"); } sidebar.inert = !isOpen; backgroundElements.forEach((wasInert, element) => { - element.inert = isOpen || wasInert; + element.inert = (isOpen && isModal) || wasInert; }); - if (isOpen && moveFocus) { + if (isOpen && isModal && moveFocus) { focusableElements()[0]?.focus(); } else if (!isOpen && restoreFocus) { returnFocus.focus(); @@ -92,27 +97,42 @@ returnFocus = button; } toggle.checked = isOpen; + writeDrawerState(isOpen); synchronize(options); }; - const onButtonClick = () => { + const onButtonClick = (event) => { + event.preventDefault(); setOpen(!toggle.checked, { moveFocus: !toggle.checked, restoreFocus: toggle.checked, }); }; - const onToggleChange = () => synchronize(); - const onOverlayClick = (event) => { - event.preventDefault(); - setOpen(false, { restoreFocus: true }); + const onToggleChange = () => { + writeDrawerState(toggle.checked); + synchronize(); }; const onSidebarClick = (event) => { - if (event.target instanceof Element && event.target.closest("a[href]")) { + const link = event.target instanceof Element && event.target.closest("a[href]"); + if (link && modalDrawer.matches) { setOpen(false); } }; + const onOverlayClick = (event) => { + event.preventDefault(); + setOpen(false, { restoreFocus: true }); + }; const onKeyDown = (event) => { + if ( + document.activeElement === button && + event.key === " " + ) { + event.preventDefault(); + onButtonClick(event); + return; + } + if (!toggle.checked) return; if (event.key === "Escape") { @@ -121,7 +141,7 @@ return; } - if (event.key !== "Tab") return; + if (event.key !== "Tab" || !modalDrawer.matches) return; const focusable = focusableElements(); if (!focusable.length) { @@ -142,23 +162,54 @@ button.addEventListener("click", onButtonClick); toggle.addEventListener("change", onToggleChange); - overlay?.addEventListener("click", onOverlayClick); sidebar.addEventListener("click", onSidebarClick); + overlay?.addEventListener("click", onOverlayClick); + const onDrawerModeChange = () => { + const shouldMoveFocus = + toggle.checked && modalDrawer.matches && !sidebar.contains(document.activeElement); + synchronize({ moveFocus: shouldMoveFocus }); + }; + modalDrawer.addEventListener("change", onDrawerModeChange); document.addEventListener("keydown", onKeyDown); - synchronize(); + document.documentElement.classList.add("openshell-drawer-restoring"); + toggle.checked = readDrawerState(); + synchronize({ moveFocus: toggle.checked && modalDrawer.matches }); + window.requestAnimationFrame(() => { + window.requestAnimationFrame(() => { + document.documentElement.classList.remove("openshell-drawer-restoring"); + }); + }); cleanup = () => { button.removeEventListener("click", onButtonClick); toggle.removeEventListener("change", onToggleChange); - overlay?.removeEventListener("click", onOverlayClick); sidebar.removeEventListener("click", onSidebarClick); + overlay?.removeEventListener("click", onOverlayClick); + modalDrawer.removeEventListener("change", onDrawerModeChange); document.removeEventListener("keydown", onKeyDown); + document.documentElement.classList.remove("openshell-drawer-restoring"); backgroundElements.forEach((wasInert, element) => { element.inert = wasInert; }); }; } + const readDrawerState = () => { + try { + return window.sessionStorage.getItem(drawerStateKey) === "true"; + } catch { + return false; + } + }; + + const writeDrawerState = (isOpen) => { + try { + window.sessionStorage.setItem(drawerStateKey, String(isOpen)); + } catch { + // Keep the drawer usable when browser storage is unavailable. + } + }; + if (window.document$?.subscribe) { window.document$.subscribe(enhanceNavigationDrawer); } else if (document.readyState === "loading") { diff --git a/docs/stylesheets/dev-notes.css b/docs/stylesheets/dev-notes.css index b2e9e37a..896c2064 100644 --- a/docs/stylesheets/dev-notes.css +++ b/docs/stylesheets/dev-notes.css @@ -9,6 +9,7 @@ */ :root { + --openshell-sidebar-width: 15.25rem; --openshell-green: #76b900; --openshell-green-soft: #8dc63f; --openshell-accent: #3c626b; @@ -109,6 +110,34 @@ body { outline-offset: 0.15rem; } +.openshell-drawer-button svg { + width: 1.35rem; + height: 1.35rem; + fill: none; + stroke: currentColor; + stroke-width: 1.7; + stroke-linecap: round; + stroke-linejoin: round; +} + +.openshell-drawer-icon-collapse, +:root[data-navigation-drawer="open"] .openshell-drawer-icon-expand, +#__drawer:checked ~ .md-header .openshell-drawer-icon-expand { + display: none; +} + +:root[data-navigation-drawer="open"] .openshell-drawer-icon-collapse, +#__drawer:checked ~ .md-header .openshell-drawer-icon-collapse { + display: inline; +} + +.openshell-drawer-restoring .openshell-drawer-button, +.openshell-drawer-restoring .md-main, +.openshell-drawer-restoring .md-sidebar--primary, +.openshell-drawer-restoring .md-footer { + transition: none !important; +} + .md-header__inner > .md-logo { order: -1; } @@ -183,6 +212,7 @@ body { transition: opacity 180ms ease; } +:root[data-navigation-drawer="open"] .md-overlay, #__drawer:checked ~ .md-overlay { opacity: 1; pointer-events: auto; @@ -195,7 +225,7 @@ body { bottom: auto !important; left: 0.25rem !important; display: block; - width: 15.25rem; + width: var(--openshell-sidebar-width); height: calc(100vh - 1rem) !important; padding: 0; border: 1px solid var(--openshell-rule); @@ -207,12 +237,82 @@ body { transition: transform 200ms ease, visibility 0s linear 200ms !important; } +:root[data-navigation-drawer="open"] .md-sidebar--primary, #__drawer:checked ~ .md-container .md-sidebar--primary { transform: translateX(0) !important; visibility: visible; transition-delay: 0s !important; } +@media (min-width: 64rem) { + .md-header { + z-index: 6; + } + + .md-header__inner > .openshell-drawer-button { + position: fixed; + z-index: 7; + top: 0.675rem; + left: 0.85rem; + width: 2.35rem; + height: 2.35rem; + margin: 0; + color: var(--md-default-fg-color--light); + background: transparent; + border: 0; + border-radius: 0.35rem; + transition: color 120ms ease, background-color 120ms ease; + } + + .md-header__inner > .openshell-drawer-button svg { + width: 1.1rem; + height: 1.1rem; + } + + .md-header__inner > .openshell-drawer-button:hover { + color: var(--md-default-fg-color); + background: color-mix(in srgb, var(--md-default-fg-color) 8%, transparent); + } + + .md-overlay, + :root[data-navigation-drawer="open"] .md-overlay, + #__drawer:checked ~ .md-overlay { + opacity: 0; + pointer-events: none; + } + + .md-main { + transition: padding-left 200ms ease; + } + + :root[data-navigation-drawer="open"] .md-main { + padding-left: var(--openshell-sidebar-width); + } + + :root[data-navigation-drawer="open"] .md-footer { + padding-left: var(--openshell-sidebar-width); + } + + .md-sidebar--primary { + top: 3.7rem !important; + bottom: 0 !important; + left: 0 !important; + height: calc(100vh - 3.7rem) !important; + border-width: 0 1px 0 0; + border-radius: 0; + box-shadow: none; + transform: translateX(calc(-100% - 1px)) !important; + visibility: hidden; + transition: transform 200ms ease, visibility 0s linear 200ms !important; + } + + :root[data-navigation-drawer="open"] .md-sidebar--primary { + transform: translateX(0) !important; + visibility: visible; + transition-delay: 0s !important; + } +} + .md-sidebar--primary .md-sidebar__scrollwrap { height: 100%; margin: 0; @@ -277,9 +377,7 @@ body { } .md-main { - background: - linear-gradient(90deg, transparent 0, transparent calc(50% - 36rem), color-mix(in srgb, var(--openshell-rule) 20%, transparent) calc(50% - 36rem), transparent calc(50% - 35.95rem)), - var(--openshell-paper); + background: var(--openshell-paper); } .md-main__inner { @@ -326,8 +424,108 @@ body { } .md-footer { + box-sizing: border-box; border-top: 0; background: var(--openshell-paper); + transition: padding-left 200ms ease; +} + +.md-footer__inner { + gap: 2rem; + width: min(calc(100% - 3rem), 60rem); + max-width: none; + margin: 3.5rem auto 0; + padding: 1.25rem 0 2.25rem; + border-top: 1px solid var(--openshell-rule); +} + +.md-footer__link { + align-items: center; + flex: 1 1 0; + gap: 0.75rem; + min-width: 0; + max-width: calc(50% - 1rem); + margin: 0; + padding: 0.65rem 0; + color: var(--openshell-muted); + opacity: 1; +} + +.md-footer__link:hover, +.md-footer__link:focus-visible { + color: var(--openshell-ink); + opacity: 1; +} + +.md-footer__link--next { + margin-left: auto; + text-align: right; +} + +.md-footer__button { + flex: 0 0 auto; + width: 1.2rem; + height: 1.2rem; + margin: 0; +} + +.md-footer__button svg { + width: 1rem; + height: 1rem; +} + +.md-footer__title { + flex: 1 1 auto; + min-width: 0; + max-width: none; + padding: 0; + font-family: var(--openshell-serif); + font-size: 1rem; + line-height: 1.3; + white-space: normal; +} + +.md-footer__title .md-ellipsis { + display: -webkit-box; + overflow: hidden; + white-space: normal; + text-overflow: clip; + -webkit-box-orient: vertical; + -webkit-line-clamp: 2; +} + +.md-footer__direction { + display: block; + margin-bottom: 0.2rem; + color: var(--openshell-muted); + font-family: var(--openshell-mono); + font-size: 0.58rem; + font-weight: 600; + letter-spacing: 0.1em; + line-height: 1.4; + opacity: 1; + text-transform: uppercase; +} + +.md-footer__link:hover .md-footer__direction, +.md-footer__link:focus-visible .md-footer__direction { + color: var(--openshell-accent); +} + +@media screen and (max-width: 44.984375em) { + .md-footer__inner { + flex-direction: column; + gap: 0.25rem; + } + + .md-footer__link { + width: 100%; + max-width: none; + } + + .md-footer__link--prev .md-footer__title { + display: block; + } } .md-footer-meta { @@ -1072,6 +1270,11 @@ body[data-md-color-scheme="slate"] .openshell-home-brand__dark { width: calc(100% - 2rem); } + .md-footer__inner { + width: calc(100% - 2rem); + margin-top: 2.75rem; + } + .research-masthead { padding-top: 2.5rem; } diff --git a/overrides/main.html b/overrides/main.html index f922ff93..adeeb5fa 100644 --- a/overrides/main.html +++ b/overrides/main.html @@ -3,6 +3,16 @@ {% block extrahead %} {{ super() }} + {% if page.meta and page.meta.agent_markdown %} {% endif %} diff --git a/overrides/partials/footer.html b/overrides/partials/footer.html new file mode 100644 index 00000000..2d4d6e02 --- /dev/null +++ b/overrides/partials/footer.html @@ -0,0 +1,57 @@ +{% set area_landing = page.url == "dev-notes/" or page.url == "documentation/" %} +
+ {% if "navigation.footer" in features %} + {% if area_landing or page.previous_page or page.next_page %} + {% if page.meta and page.meta.hide %} + {% set hidden = "hidden" if "footer" in page.meta.hide %} + {% endif %} +
+ + + {% elif page.previous_page %} + {% set direction = lang.t("footer.previous") %} + + + + + {% endif %} + {% if page.next_page %} + {% set direction = lang.t("footer.next") %} + + + + + {% endif %} + + {% endif %} + {% endif %} + + diff --git a/overrides/partials/header.html b/overrides/partials/header.html new file mode 100644 index 00000000..7d373ad0 --- /dev/null +++ b/overrides/partials/header.html @@ -0,0 +1,70 @@ +{% set class = "md-header" %} +{% if "navigation.tabs.sticky" in features %} + {% set class = class ~ " md-header--shadow md-header--lifted" %} +{% elif "navigation.tabs" not in features %} + {% set class = class ~ " md-header--shadow" %} +{% endif %} +
+ + {% if "navigation.tabs.sticky" in features %} + {% if "navigation.tabs" in features %} + {% include "partials/tabs.html" %} + {% endif %} + {% endif %} +
diff --git a/scripts/build-docs.sh b/scripts/build-docs.sh index 4fb2168a..18c0662a 100755 --- a/scripts/build-docs.sh +++ b/scripts/build-docs.sh @@ -36,3 +36,5 @@ zensical build --clean --strict python scripts/publish-agent-markdown.py REQUIRE_RENDERED_AGENT_MARKDOWN=1 python tests/test_agent_markdown.py REQUIRE_RENDERED_404=1 python tests/test_docs_404.py +REQUIRE_RENDERED_NAVIGATION=1 python tests/test_navigation_drawer.py +REQUIRE_RENDERED_PAGE_NAVIGATION=1 python tests/test_page_navigation.py diff --git a/tests/navigation-drawer.test.js b/tests/navigation-drawer.test.js new file mode 100644 index 00000000..2710f2b6 --- /dev/null +++ b/tests/navigation-drawer.test.js @@ -0,0 +1,283 @@ +// SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +// SPDX-License-Identifier: Apache-2.0 + +const assert = require("node:assert/strict"); +const fs = require("node:fs"); +const path = require("node:path"); +const test = require("node:test"); +const vm = require("node:vm"); + +const script = fs.readFileSync( + path.join(__dirname, "..", "docs", "javascripts", "navigation-drawer.js"), + "utf8", +); + +class TestEvent { + constructor(type, options = {}) { + this.type = type; + Object.assign(this, options); + this.defaultPrevented = false; + } + + preventDefault() { + this.defaultPrevented = true; + } +} + +class TestElement { + constructor(tagName, document) { + this.tagName = tagName.toUpperCase(); + this.ownerDocument = document; + this.attributes = new Map(); + this.children = []; + this.listeners = new Map(); + this.focusables = []; + this.parentElement = null; + this.hidden = false; + this.inert = false; + this.tabIndex = 0; + this.visible = true; + } + + addEventListener(type, listener) { + const listeners = this.listeners.get(type) ?? []; + listeners.push(listener); + this.listeners.set(type, listeners); + } + + removeEventListener(type, listener) { + this.listeners.set( + type, + (this.listeners.get(type) ?? []).filter((candidate) => candidate !== listener), + ); + } + + dispatchEvent(event) { + event.target ??= this; + for (const listener of this.listeners.get(event.type) ?? []) listener(event); + } + + append(...children) { + for (const child of children) { + child.parentElement = this; + this.children.push(child); + } + } + + setAttribute(name, value) { + this.attributes.set(name, String(value)); + } + + getAttribute(name) { + return this.attributes.get(name) ?? null; + } + + removeAttribute(name) { + this.attributes.delete(name); + } + + querySelectorAll() { + return this.focusables; + } + + contains(element) { + for (let current = element; current; current = current.parentElement) { + if (current === this) return true; + } + return false; + } + + closest(selector) { + for (let current = this; current; current = current.parentElement) { + if (selector === "[inert]" && current.inert) return current; + if ( + selector === "a[href]" && + current.tagName === "A" && + current.attributes.has("href") + ) { + return current; + } + } + return null; + } + + getClientRects() { + return this.visible ? [{}] : []; + } + + focus() { + this.ownerDocument.activeElement = this; + } +} + +class TestInput extends TestElement { + constructor(document) { + super("input", document); + this.checked = false; + } +} + +class TestDocument extends TestElement { + constructor() { + super("document", null); + this.ownerDocument = this; + this.activeElement = null; + this.readyState = "complete"; + this.elements = new Map(); + this.documentElement = new TestElement("html", this); + this.documentElement.dataset = {}; + this.documentElement.classList = { + add() {}, + remove() {}, + }; + } + + querySelector(selector) { + return this.elements.get(selector) ?? null; + } + + querySelectorAll() { + return []; + } +} + +class TestMediaQuery extends TestElement { + constructor(document, matches) { + super("media-query", document); + this.matches = matches; + } +} + +function createFixture({ modal = false, storedOpen = false } = {}) { + const document = new TestDocument(); + const toggle = new TestInput(document); + const sidebar = new TestElement("aside", document); + const overlay = new TestElement("label", document); + const button = new TestElement("label", document); + const container = new TestElement("div", document); + const header = new TestElement("header", document); + const main = new TestElement("main", document); + const firstLink = new TestElement("a", document); + const hiddenLink = new TestElement("a", document); + const lastLink = new TestElement("a", document); + const outside = new TestElement("a", document); + const media = new TestMediaQuery(document, modal); + const storage = new Map([ + ["openshell.navigationDrawerOpen", String(storedOpen)], + ]); + + firstLink.setAttribute("href", "/first/"); + hiddenLink.setAttribute("href", "/hidden/"); + hiddenLink.visible = false; + lastLink.setAttribute("href", "/last/"); + sidebar.append(firstLink, hiddenLink, lastLink); + sidebar.focusables = [firstLink, hiddenLink, lastLink]; + header.append(button); + main.append(sidebar); + + document.elements.set("#__drawer", toggle); + document.elements.set(".md-sidebar--primary", sidebar); + document.elements.set('.md-overlay[for="__drawer"]', overlay); + document.elements.set(".openshell-drawer-button", button); + document.elements.set(".md-container", container); + + const window = { + document$: undefined, + getComputedStyle(element) { + return { visibility: element.visible ? "visible" : "hidden" }; + }, + matchMedia() { + return media; + }, + requestAnimationFrame(callback) { + callback(); + return 1; + }, + sessionStorage: { + getItem(key) { + return storage.get(key) ?? null; + }, + setItem(key, value) { + storage.set(key, value); + }, + }, + }; + + vm.runInNewContext(script, { + document, + Element: TestElement, + HTMLElement: TestElement, + HTMLInputElement: TestInput, + window, + }); + + return { + button, + document, + firstLink, + hiddenLink, + lastLink, + media, + outside, + sidebar, + storage, + toggle, + }; +} + +test("desktop restores state without adding a duplicate navigation landmark", () => { + const fixture = createFixture({ storedOpen: true }); + + assert.equal(fixture.toggle.checked, true); + assert.equal(fixture.document.documentElement.dataset.navigationDrawer, "open"); + assert.equal(fixture.sidebar.getAttribute("role"), null); + assert.equal(fixture.button.getAttribute("aria-expanded"), "true"); +}); + +test("mobile navigation closes the modal and clears saved state", () => { + const fixture = createFixture({ modal: true, storedOpen: true }); + + assert.equal(fixture.document.activeElement, fixture.firstLink); + assert.equal(fixture.sidebar.getAttribute("role"), "dialog"); + assert.equal(fixture.sidebar.getAttribute("aria-modal"), "true"); + + fixture.sidebar.dispatchEvent( + new TestEvent("click", { target: fixture.lastLink }), + ); + + assert.equal(fixture.toggle.checked, false); + assert.equal(fixture.storage.get("openshell.navigationDrawerOpen"), "false"); + assert.equal(fixture.sidebar.inert, true); +}); + +test("keyboard control, Escape, and visible focus endpoints work", () => { + const fixture = createFixture({ modal: true }); + fixture.button.focus(); + + fixture.document.dispatchEvent(new TestEvent("keydown", { key: "Enter" })); + assert.equal(fixture.toggle.checked, false, "Zensical owns Enter activation"); + + fixture.document.dispatchEvent(new TestEvent("keydown", { key: " " })); + assert.equal(fixture.toggle.checked, true); + + fixture.firstLink.focus(); + fixture.document.dispatchEvent( + new TestEvent("keydown", { key: "Tab", shiftKey: true }), + ); + assert.equal(fixture.document.activeElement, fixture.lastLink); + + fixture.document.dispatchEvent(new TestEvent("keydown", { key: "Escape" })); + assert.equal(fixture.toggle.checked, false); + assert.equal(fixture.document.activeElement, fixture.button); +}); + +test("entering modal mode repairs focus", () => { + const fixture = createFixture({ storedOpen: true }); + fixture.outside.focus(); + fixture.media.matches = true; + + fixture.media.dispatchEvent(new TestEvent("change")); + + assert.equal(fixture.sidebar.getAttribute("role"), "dialog"); + assert.equal(fixture.document.activeElement, fixture.firstLink); +}); diff --git a/tests/test_navigation_drawer.py b/tests/test_navigation_drawer.py new file mode 100644 index 00000000..f60a9549 --- /dev/null +++ b/tests/test_navigation_drawer.py @@ -0,0 +1,57 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +from pathlib import Path +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +HEADER_TEMPLATE = ROOT / "overrides" / "partials" / "header.html" +MAIN_TEMPLATE = ROOT / "overrides" / "main.html" +DRAWER_SCRIPT = ROOT / "docs" / "javascripts" / "navigation-drawer.js" +DRAWER_STYLES = ROOT / "docs" / "stylesheets" / "dev-notes.css" +RENDERED_PAGE = ROOT / "site" / "documentation" / "index.html" + + +class NavigationDrawerTests(unittest.TestCase): + def test_header_renders_the_final_control(self) -> None: + header = HEADER_TEMPLATE.read_text(encoding="utf-8") + script = DRAWER_SCRIPT.read_text(encoding="utf-8") + + self.assertEqual(header.count("openshell-drawer-button"), 1) + self.assertIn("openshell-drawer-icon-expand", header) + self.assertIn("openshell-drawer-icon-collapse", header) + self.assertNotIn("material/menu", header) + self.assertNotIn(".innerHTML", script) + self.assertNotIn("replaceWith", script) + + def test_saved_state_is_available_before_first_render(self) -> None: + main = MAIN_TEMPLATE.read_text(encoding="utf-8") + styles = DRAWER_STYLES.read_text(encoding="utf-8") + + self.assertIn("document.documentElement.dataset.navigationDrawer", main) + self.assertIn(':root[data-navigation-drawer="open"] .md-main', styles) + self.assertIn( + ':root[data-navigation-drawer="open"] .openshell-drawer-icon-collapse', + styles, + ) + self.assertNotIn("calc(50% - 36rem)", styles) + + def test_rendered_page_contains_one_stable_control(self) -> None: + if os.environ.get("REQUIRE_RENDERED_NAVIGATION") != "1": + self.skipTest("rendered output is checked after the documentation build") + if not RENDERED_PAGE.exists(): + self.fail("the rendered documentation page does not exist") + + html = RENDERED_PAGE.read_text(encoding="utf-8") + head = html[: html.index("")] + + self.assertEqual(html.count("openshell-drawer-button"), 1) + self.assertIn("openshell-drawer-icon-expand", html) + self.assertIn("openshell-drawer-icon-collapse", html) + self.assertIn("document.documentElement.dataset.navigationDrawer", head) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_page_navigation.py b/tests/test_page_navigation.py new file mode 100644 index 00000000..8295fd12 --- /dev/null +++ b/tests/test_page_navigation.py @@ -0,0 +1,53 @@ +# SPDX-FileCopyrightText: Copyright (c) 2026 NVIDIA CORPORATION & AFFILIATES. All rights reserved. +# SPDX-License-Identifier: Apache-2.0 + +import os +from pathlib import Path +import re +import unittest + + +ROOT = Path(__file__).resolve().parents[1] +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" +) + + +class PageNavigationTests(unittest.TestCase): + def test_footer_navigation_is_enabled(self) -> None: + config = CONFIG.read_text(encoding="utf-8") + styles = STYLES.read_text(encoding="utf-8") + + self.assertIn('"navigation.footer"', config) + self.assertRegex( + styles, + re.compile( + r':root\[data-navigation-drawer="open"\] \.md-footer\s*\{' + r"[^}]*padding-left: var\(--openshell-sidebar-width\)", + re.DOTALL, + ), + ) + + def test_rendered_links_follow_the_reading_path(self) -> None: + if os.environ.get("REQUIRE_RENDERED_PAGE_NAVIGATION") != "1": + 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") + + self.assertIn("Back to OpenShell Research", documentation) + self.assertIn("Next: Egress Gate", 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: Test policies offline", configuration) + + +if __name__ == "__main__": + unittest.main() diff --git a/zensical.toml b/zensical.toml index bb763742..daf43577 100644 --- a/zensical.toml +++ b/zensical.toml @@ -74,6 +74,7 @@ features = [ "navigation.sections", "navigation.indexes", "navigation.path", + "navigation.footer", "navigation.top", "search.highlight", "toc.follow" From a4d017b905e8ec6cb29b9497f84bdfdb61948e3f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 21:59:01 +0000 Subject: [PATCH 25/46] Generalize Egress Gate request scanning --- projects/egress-gate/AGENTS.md | 20 +- projects/egress-gate/README.md | 38 +-- projects/egress-gate/analysis/README.md | 2 +- .../egress-gate/docs/architecture/index.md | 2 +- .../docs/architecture/request-lifecycle.md | 13 +- .../docs/architecture/service-boundary.md | 7 +- .../diagrams/component-architecture.svg | 4 +- projects/egress-gate/docs/configuration.md | 36 +-- projects/egress-gate/docs/evaluation.md | 13 +- projects/egress-gate/docs/gates/custom.md | 16 +- projects/egress-gate/docs/gates/index.md | 17 +- projects/egress-gate/docs/gates/regex.md | 88 ++++-- projects/egress-gate/docs/index.md | 21 +- projects/egress-gate/docs/operations.md | 11 +- .../examples/custom_gate/README.md | 16 +- .../custom_gate/egress-gate-config.yaml | 2 +- .../examples/custom_gate/keyword_gate.py | 2 +- .../examples/regex-redaction/README.md | 25 +- .../examples/regex-redaction/cases.yaml | 2 +- .../regex-redaction/egress-gate-config.yaml | 11 +- .../examples/regex-redaction/policy.yaml | 11 +- projects/egress-gate/src/egress_gate/cli.py | 17 +- .../src/egress_gate/gates/__init__.py | 34 ++- .../egress-gate/src/egress_gate/gates/base.py | 2 +- .../gates/{regex_body.py => regex.py} | 254 +++++++++++------ .../src/egress_gate/gates/registry.py | 48 ++-- .../egress-gate/src/egress_gate/request.py | 6 +- .../src/egress_gate/request_processor.py | 26 +- .../egress-gate/src/egress_gate/result.py | 55 ++-- projects/egress-gate/tests/gates/test_base.py | 26 +- .../{test_regex_body.py => test_regex.py} | 258 ++++++++++++++---- .../egress-gate/tests/gates/test_registry.py | 77 ++++-- .../tests/service/test_grpc_integration.py | 31 +-- .../tests/service/test_servicer.py | 69 +++-- projects/egress-gate/tests/test_cli.py | 31 ++- projects/egress-gate/tests/test_config.py | 36 +-- projects/egress-gate/tests/test_request.py | 23 +- .../tests/test_request_processor.py | 99 +++++-- projects/egress-gate/tests/test_result.py | 94 +++++-- zensical.toml | 2 +- 40 files changed, 1038 insertions(+), 507 deletions(-) rename projects/egress-gate/src/egress_gate/gates/{regex_body.py => regex.py} (80%) rename projects/egress-gate/tests/gates/{test_regex_body.py => test_regex.py} (69%) diff --git a/projects/egress-gate/AGENTS.md b/projects/egress-gate/AGENTS.md index 7d7db059..66098eed 100644 --- a/projects/egress-gate/AGENTS.md +++ b/projects/egress-gate/AGENTS.md @@ -36,7 +36,7 @@ Run focused tests while working and `make check` before handoff. ## Project map -- `src/egress_gate/gates/`: `Gate`, helper bases, registry, and regex-body +- `src/egress_gate/gates/`: `Gate`, helper bases, registry, and the regex gate - `src/egress_gate/config.py`: strict `pipeline.gates` and `default_decision` policy models - `src/egress_gate/request.py`: protobuf-free request and ordered patch models @@ -56,12 +56,17 @@ architecture overview and matching topic page under `docs/architecture/`. ## Gate contract -Every gate declares a strict `GateConfig` with a literal `gate` discriminator, +Every gate declares a strict `GateConfig` with a literal `kind` discriminator, an optional typed `GateResources` bundle, `GateCapabilities`, and its `FindingTypeDefinition` declarations. `GateRegistry.finalize()` creates the exact discriminated pipeline schema for the installed gates and prepares validated gate instances from trusted application-owned resources. +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. A proceeding patch is applied before the @@ -76,10 +81,11 @@ dependencies and no request state or policy behavior. ## Current built-ins and boundaries -This slice ships exactly one built-in. `regex-body` preserves bounded catalog -loading, regex matching, overlap resolution, UTF-8 body handling, and -detect/deny/replace modes. Deterministic network request policy belongs to -OpenShell. Do not add more built-ins speculatively. +This slice ships exactly one built-in. `regex` selects one typed body, path, +query, or header scan and preserves bounded catalog loading, matching, +overlap resolution, and detect/deny actions. Body scans also support strict +UTF-8 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 is @@ -95,7 +101,7 @@ middleware phase. ## Plan boundaries The current implementation covers the gate contract, strict pipeline -configuration, finalized registry, regex-body behavior, request processing, +configuration, finalized 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 diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 4106520e..2e91dd6b 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -15,12 +15,13 @@ Requirements: Python 3.11+ and `uv` 0.11+. ```bash uv sync --frozen -uv run egress-gate gates -uv run egress-gate configuration-schema -uv run egress-gate validate \ +source .venv/bin/activate +egress-gate gates +egress-gate configuration-schema +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 \ +egress-gate serve --listen 127.0.0.1:50051 +egress-gate evaluate \ --policy examples/regex-redaction/egress-gate-config.yaml \ --cases examples/regex-redaction/cases.yaml ``` @@ -38,23 +39,26 @@ pipeline: gates: - name: identifiers config: - gate: regex-body + kind: regex + scan: + kind: body + action: + kind: replace + template: "[{entity}]" pattern_catalog: patterns.yaml - mode: replace - replacement: - strategy: template - template: "[{entity}]" default_decision: allow ``` -The shipped registry contains exactly `regex-body`. It supports `detect`, -`deny`, and `replace`. Replacement mode preserves an explicit body-replacement -intent even when the resulting bytes equal the input. Add custom trusted gates -through `--registry-factory`. +The shipped registry contains exactly `regex`. Its `scan` selects the body, +path, query, or selected request headers. Each scan contains its `action`. +Every scan supports `detect` and `deny`. A body scan also supports `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-factory`. ```bash -uv run egress-gate --registry-factory my_gates:create_registry gates -uv run egress-gate --registry-factory my_gates:create_registry serve +egress-gate --registry-factory my_gates:create_registry gates +egress-gate --registry-factory my_gates:create_registry serve ``` OpenShell owns interception, routing, and credential attachment. Egress Gate @@ -81,7 +85,7 @@ through slot acquisition, policy preparation, and `RequestProcessor.process`. - [Test policies offline](docs/evaluation.md) - [Operations](docs/operations.md) - [Gate authoring](docs/gates/custom.md) -- [Regex-body](docs/gates/regex.md) +- [Regex gate](docs/gates/regex.md) - [Architecture](docs/architecture/index.md) - [Limits and failures](docs/reference/limits-and-failures.md) - [Regex redaction composition](examples/regex-redaction/README.md) diff --git a/projects/egress-gate/analysis/README.md b/projects/egress-gate/analysis/README.md index f354a384..be048d2c 100644 --- a/projects/egress-gate/analysis/README.md +++ b/projects/egress-gate/analysis/README.md @@ -63,7 +63,7 @@ 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-body gate +- 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 diff --git a/projects/egress-gate/docs/architecture/index.md b/projects/egress-gate/docs/architecture/index.md index 62ee4ec2..3805f6bb 100644 --- a/projects/egress-gate/docs/architecture/index.md +++ b/projects/egress-gate/docs/architecture/index.md @@ -21,7 +21,7 @@ Egress Gate has one transport adapter and one protobuf-free runtime. | `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_body.py` | Bounded catalog loading, matching, overlap handling, caching, and replacement | +| `gates/regex.py` | Typed scan and action selection, bounded matching, overlap handling, caching, and body replacement | | `config.py` | Strict `pipeline.gates` and required default decision | | `request_processor.py` | Shared deadline, current-request mutation, control flow, aggregation, and provenance | | `service/` | Protobuf validation/conversion, worker slots, lifecycle, and wire serialization | diff --git a/projects/egress-gate/docs/architecture/request-lifecycle.md b/projects/egress-gate/docs/architecture/request-lifecycle.md index 5ad1c26b..77d277a3 100644 --- a/projects/egress-gate/docs/architecture/request-lifecycle.md +++ b/projects/egress-gate/docs/architecture/request-lifecycle.md @@ -30,15 +30,18 @@ candidate only after a final deadline check. For each configured gate: 1. Check the shared deadline. -2. Evaluate the current immutable `HttpRequest`. +2. Pass the current read-only `HttpRequest` snapshot to the gate. 3. Reconstruct and validate the returned `GateEvaluation`. 4. Add a content-safe `GateTrace` and runtime-owned `SourcedFinding` values. -5. On `proceed`, apply the patch to form the next current request. +5. On `proceed`, validate the patch and construct the next request snapshot. 6. On terminal `allow` or `deny`, stop without invoking later gates. -The processor keeps the original request private. The final allowed patch -combines preceding patches in order. A denied result always has an empty patch. -Body replacement `None` and `b""` remain distinct. +The processor never changes a request object in place. It keeps the original +request private, constructs a new snapshot after each validated patch, and +passes that snapshot to the next gate. The final allowed patch combines these +changes in order for the service to return to OpenShell. A denied result always +has an empty patch. Body replacement `None` and `b""` remain distinct. Header +mutation variants use the required `kind` values `write` and `remove`. If every gate proceeds, `default_decision` controls the result. Default deny uses `egress_gate_default_deny`. Default allow has no reason code. diff --git a/projects/egress-gate/docs/architecture/service-boundary.md b/projects/egress-gate/docs/architecture/service-boundary.md index 66587faf..06ac15ec 100644 --- a/projects/egress-gate/docs/architecture/service-boundary.md +++ b/projects/egress-gate/docs/architecture/service-boundary.md @@ -36,9 +36,10 @@ 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 source, -and traces are runtime values and are not serialized. The adapter rechecks -protobuf finding and header sizes before returning a response. +`confidence`, and `severity`. `SourcedFinding.source_gate`, decision sources, +and traces are runtime values and are not serialized. Decision sources use a +strict `kind`-discriminated union. The adapter rechecks protobuf finding and +header sizes before returning a response. `RequestPatch` operations serialize in their validated order. `None` means no replacement, while empty bytes are emitted with `has_body=true`. diff --git a/projects/egress-gate/docs/assets/diagrams/component-architecture.svg b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg index 08ef7ef1..7d69b2bd 100644 --- a/projects/egress-gate/docs/assets/diagrams/component-architecture.svg +++ b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg @@ -64,8 +64,8 @@ Gate wrapper · contract and bounds - regex-body - built-in implementation + regex + typed scan · explicit action Custom gates trusted integrations diff --git a/projects/egress-gate/docs/configuration.md b/projects/egress-gate/docs/configuration.md index 2bd57e02..6f75fcd1 100644 --- a/projects/egress-gate/docs/configuration.md +++ b/projects/egress-gate/docs/configuration.md @@ -21,17 +21,18 @@ network_middlewares: gates: - name: identifiers config: - gate: regex-body + kind: regex + scan: + kind: body + action: + kind: replace + template: '[{entity}]' pattern_catalog: entities: - name: email rules: - pattern: '[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}' confidence: high - mode: replace - replacement: - strategy: template - template: '[{entity}]' default_decision: allow on_error: fail_closed endpoints: @@ -43,32 +44,35 @@ The top-level policy has one `pipeline`. The pipeline 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 `gate` field selects +Each gate entry has a unique, bounded `name`. Its literal `kind` field selects the exact configuration type. The registry rejects unknown fields, unknown gate types, missing defaults, and duplicate names. ## Built-in gates -The shipped registry contains only `regex-body`. See -[Regex-body](gates/regex.md) for catalogs and replacement templates. Its -`mode` is required and is one of `detect`, `deny`, or `replace`. A replacement -recipe is required only when the mode is `replace`. A trusted application -registry factory supplies other behavior. +The shipped registry contains only `regex`. See +[Regex gate](gates/regex.md) for scans, actions, catalogs, and replacement +templates. `scan.kind` selects the body, path, query, or named headers. +`scan.action.kind` selects `detect` or `deny`; a body scan can also select +`replace`. The schema does not permit `replace` for another scan kind. A +trusted application registry factory supplies other behavior. ## Inspect the installed registry +Run these commands with the Egress Gate environment active: + ```bash title="Inspect the default registry" -uv run egress-gate gates -uv run egress-gate configuration-schema -uv run egress-gate validate --policy path/to/policy.yaml +egress-gate gates +egress-gate configuration-schema +egress-gate validate --policy path/to/policy.yaml ``` Custom registries use the same factory for inspection and serving: ```bash title="Inspect a custom registry" -uv run egress-gate \ +egress-gate \ --registry-factory my_gates:create_registry gates -uv run egress-gate \ +egress-gate \ --registry-factory my_gates:create_registry configuration-schema ``` diff --git a/projects/egress-gate/docs/evaluation.md b/projects/egress-gate/docs/evaluation.md index ceb1c85a..6b5f96c2 100644 --- a/projects/egress-gate/docs/evaluation.md +++ b/projects/egress-gate/docs/evaluation.md @@ -29,11 +29,11 @@ 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/`: +The repository includes a regex policy and two request cases. Activate the +installed project environment, then run them from `projects/egress-gate/`: ```bash title="Run the example policy tests" -uv run egress-gate evaluate \ +egress-gate evaluate \ --policy examples/regex-redaction/egress-gate-config.yaml \ --cases examples/regex-redaction/cases.yaml \ --timeout-seconds 1 @@ -84,14 +84,15 @@ cases: value: "send alice@example.com" expected: decision: allow - finding_types: [sensitive_entity] + 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 immutable HTTP request that the gates will evaluate. +- `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 @@ -124,7 +125,7 @@ Use `--registry-factory` when the policy contains application-owned custom gates: ```bash title="Test a custom gate" -uv run egress-gate \ +egress-gate \ --registry-factory examples.custom_gate.keyword_gate:create_registry \ evaluate \ --policy examples/custom_gate/egress-gate-config.yaml \ diff --git a/projects/egress-gate/docs/gates/custom.md b/projects/egress-gate/docs/gates/custom.md index 998d28ec..818183ba 100644 --- a/projects/egress-gate/docs/gates/custom.md +++ b/projects/egress-gate/docs/gates/custom.md @@ -13,16 +13,22 @@ protobuf, or `RequestProcessor` internals. The repository includes a runnable [minimal custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom_gate) that pairs the implementation below with a policy and two offline evaluation -cases. From `projects/egress-gate/`, run it with: +cases. From `projects/egress-gate/`, activate the installed project environment +and run it with: ```bash title="Run the custom-gate example" -uv run python -m egress_gate.cli \ +source .venv/bin/activate +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 ``` +The executable resolves the explicit `module:factory` reference from the +working directory. 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 @@ -33,7 +39,7 @@ from egress_gate.timeout import Timeout class KeywordDenyConfig(GateConfig): - gate: Literal["keyword-deny"] + kind: Literal["keyword-deny"] keyword: str @@ -61,6 +67,10 @@ schema from the registered config types. Registry factories supply typed `GateResources` objects for deployment-owned clients or profiles. Policy configuration cannot construct or replace those resources. +Every serialized variant uses a required `kind` field. A gate config declares +one literal gate kind. Nested unions follow the same rule. This gives policy +parsers and generated schemas one consistent way to select an exact model. + Declare output 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 diff --git a/projects/egress-gate/docs/gates/index.md b/projects/egress-gate/docs/gates/index.md index 4d9d1e4c..6605b2ad 100644 --- a/projects/egress-gate/docs/gates/index.md +++ b/projects/egress-gate/docs/gates/index.md @@ -1,17 +1,22 @@ --- title: Gates -description: Built-in body inspection and trusted custom gates. +description: Built-in request matching and trusted custom gates. agent_markdown: true --- # Gates -A gate receives the current immutable `HttpRequest`, one shared `Timeout`, and -its exact typed configuration. It returns a `GateEvaluation` with `proceed`, -terminal `allow`, or terminal `deny` control. +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 `RequestPatch`. -The default registry ships exactly `regex-body`. Application registries can add +The runtime validates the patch and constructs a new read-only snapshot for the +next gate. It also accumulates the patch that the service will return to +OpenShell. 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. The runtime does not isolate trusted Python gate code. -- [Regex-body](regex.md) +- [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 index 3c017cb1..a63c9fa9 100644 --- a/projects/egress-gate/docs/gates/regex.md +++ b/projects/egress-gate/docs/gates/regex.md @@ -1,19 +1,25 @@ --- -title: Regex-body gate -description: Configure bounded regex matching, denial, and replacement. +title: Regex gate +description: Scan one configured part of a request with bounded regular expressions. agent_markdown: true --- -# Regex-body gate +# Regex gate -`regex-body` strictly decodes the current request body as UTF-8. It matches a -bounded catalog and returns audit-safe findings with type `sensitive_entity`. -A catalog can be inline or in a relative `.yaml` or `.yml` file. The gate -rejects absolute paths, path traversal, symlinks, YAML aliases, duplicate keys, -invalid UTF-8, unsafe patterns, and oversized catalogs. +The `regex` gate matches one configured part of the current request. It can +inspect the body, 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" -gate: regex-body +kind: regex +scan: + kind: body + action: + kind: replace + template: '[{entity}]' pattern_catalog: entities: - name: customer-id @@ -21,31 +27,71 @@ pattern_catalog: - name: customer-id-rule pattern: '\bCUST-[0-9]{8}\b' confidence: high -mode: replace -replacement: - strategy: template - template: '[{entity}]' ``` +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" +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 +patches from earlier gates. The regex gate does not return header mutations. +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 +`mutates_headers` capability. + +A catalog can be inline or in a relative `.yaml` or `.yml` file. 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. -## Modes +## Actions -| Mode | Match result | +| `scan.action.kind` | Match result | | --- | --- | -| `detect` | `proceed`, findings, no body replacement | +| `detect` | `proceed`, findings, no request mutation | | `deny` | terminal `deny`, findings, `egress_gate_regex_denied` | -| `replace` | `proceed`, findings, explicit replacement bytes | +| `replace` | `proceed`, findings, explicit body replacement | + +`detect` and `deny` work with every scan kind. `replace` exists only in the +body scan schema. 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 replacement recipe is required exactly for `replace`. In replacement mode -the gate returns a replacement even when there is no match, preserving the -operator's intent to replace the current body. Invalid input UTF-8 is a stable -`body_encoding_invalid` service failure. +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. 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` | +| `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 index 82b49e9e..ca7fa547 100644 --- a/projects/egress-gate/docs/index.md +++ b/projects/egress-gate/docs/index.md @@ -34,14 +34,15 @@ From `projects/egress-gate/`: ```bash title="Install, inspect, validate, and serve" uv sync --frozen -uv run egress-gate gates -uv run egress-gate configuration-schema -uv run egress-gate validate \ +source .venv/bin/activate +egress-gate gates +egress-gate configuration-schema +egress-gate validate \ --policy examples/regex-redaction/egress-gate-config.yaml -uv run egress-gate serve --listen 127.0.0.1:50051 +egress-gate serve --listen 127.0.0.1:50051 ``` -Use the [regex-body guide](gates/regex.md) for an OpenShell policy and a +Use the [regex guide](gates/regex.md) for an OpenShell policy and a file-backed catalog. Use [offline policy tests](evaluation.md) to check saved request examples with @@ -51,9 +52,11 @@ upstream provider. ## Core rules - A policy has one through ten named gates and a required `default_decision`. -- Each gate sees the request after Egress Gate applies patches from earlier - gates. -- `proceed` applies a patch. Terminal `allow` and `deny` require empty patches. +- Each gate receives a read-only request snapshot that includes validated + patches from earlier gates. +- A `proceed` result can propose a patch. The runtime validates it and creates + the snapshot for the next gate. Terminal `allow` and `deny` require empty + patches. - `None` body replacement means no replacement. `b""` is an explicit empty replacement. - When a runtime safety limit occurs, Egress Gate denies the request. The result @@ -69,7 +72,7 @@ upstream provider. - [Test policies offline](evaluation.md) - [Operations](operations.md) - [Gate authoring](gates/custom.md) -- [Regex-body](gates/regex.md) +- [Regex gate](gates/regex.md) - [Architecture](architecture/index.md) - [Request lifecycle](architecture/request-lifecycle.md) - [Service boundary](architecture/service-boundary.md) diff --git a/projects/egress-gate/docs/operations.md b/projects/egress-gate/docs/operations.md index dde908da..ff8c1f96 100644 --- a/projects/egress-gate/docs/operations.md +++ b/projects/egress-gate/docs/operations.md @@ -11,9 +11,10 @@ Install and run the service from `projects/egress-gate`: ```bash title="Start Egress Gate" uv sync --frozen -uv run egress-gate gates -uv run egress-gate configuration-schema -uv run egress-gate serve --listen 0.0.0.0:50051 --timeout-seconds 4 +source .venv/bin/activate +egress-gate gates +egress-gate configuration-schema +egress-gate serve --listen 0.0.0.0:50051 --timeout-seconds 4 ``` Use a reachable non-loopback address only when the supervisor is outside the @@ -23,7 +24,7 @@ trusted network. Do not expose the port to an untrusted network. ## OpenShell registration ```bash title="Register Egress Gate" -uv run egress-gate add-gateway-registration \ +egress-gate add-gateway-registration \ --host-ip YOUR_HOST_IPV4 --name egress-gate --port 50051 ``` @@ -33,7 +34,7 @@ The command updates `OPENSHELL_GATEWAY_CONFIG`, then Restart the OpenShell gateway after changing registrations. Remove one with: ```bash title="Remove the registration" -uv run egress-gate remove-gateway-registration --name egress-gate +egress-gate remove-gateway-registration --name egress-gate ``` The generated OpenShell middleware timeout is five seconds. Keep the Egress diff --git a/projects/egress-gate/examples/custom_gate/README.md b/projects/egress-gate/examples/custom_gate/README.md index cc73eaa3..7464a41f 100644 --- a/projects/egress-gate/examples/custom_gate/README.md +++ b/projects/egress-gate/examples/custom_gate/README.md @@ -7,32 +7,34 @@ returns `proceed`, and the pipeline continues. The implementation has three pieces: 1. `KeywordDenyConfig` defines the exact policy fields and the stable - `keyword-deny` discriminator. + `kind: keyword-deny` discriminator. 2. `KeywordDenyGate` declares what it reads and may return, then implements `_evaluate`. 3. `create_registry` registers the trusted Python class and finalizes the configuration schema. -Run the example from `projects/egress-gate/`: +Run the example from `projects/egress-gate/`. Activate the installed project +environment once, then use the CLI executable directly: ```bash -uv run python -m egress_gate.cli \ +source .venv/bin/activate +egress-gate \ --registry-factory examples.custom_gate.keyword_gate:create_registry \ gates -uv run python -m egress_gate.cli \ +egress-gate \ --registry-factory examples.custom_gate.keyword_gate:create_registry \ validate --policy examples/custom_gate/egress-gate-config.yaml -uv run python -m egress_gate.cli \ +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 ``` -Using `python -m` keeps the repository root importable for this local example. -An installed custom-gate package can use the regular `egress-gate` executable. +The executable resolves the explicit `module:factory` reference from the +working directory. An installed custom-gate package works the same way. The `block-secret-keyword` gate denies the first corpus case. The second gate evaluation proceeds. The explicit `default_decision: allow` then determines diff --git a/projects/egress-gate/examples/custom_gate/egress-gate-config.yaml b/projects/egress-gate/examples/custom_gate/egress-gate-config.yaml index ef2660bf..fbf8f726 100644 --- a/projects/egress-gate/examples/custom_gate/egress-gate-config.yaml +++ b/projects/egress-gate/examples/custom_gate/egress-gate-config.yaml @@ -2,6 +2,6 @@ pipeline: gates: - name: block-secret-keyword config: - gate: keyword-deny + 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 index c5038bac..8e97fe37 100644 --- a/projects/egress-gate/examples/custom_gate/keyword_gate.py +++ b/projects/egress-gate/examples/custom_gate/keyword_gate.py @@ -11,7 +11,7 @@ class KeywordDenyConfig(GateConfig): """Policy fields accepted by the custom gate.""" - gate: Literal["keyword-deny"] + kind: Literal["keyword-deny"] keyword: str diff --git a/projects/egress-gate/examples/regex-redaction/README.md b/projects/egress-gate/examples/regex-redaction/README.md index 5722ff84..fcb89935 100644 --- a/projects/egress-gate/examples/regex-redaction/README.md +++ b/projects/egress-gate/examples/regex-redaction/README.md @@ -1,24 +1,25 @@ # Regex redaction composition -This example runs the built-in `regex-body` gate in `replace` mode. The -standalone configuration contains a small email catalog. You can validate or -evaluate it from any working directory. The OpenShell `policy.yaml` shows the -equivalent file-backed catalog with email and customer-ID patterns. Both keep -request-derived content out of findings. +This example runs the built-in `regex` gate with a body scan and a replace +action. The standalone configuration contains a small email catalog. You can +validate or evaluate it from any working directory. The OpenShell `policy.yaml` +shows the equivalent file-backed catalog with email and customer-ID patterns. +Both keep request-derived content out of findings. Inspect the installed gate and exact policy schema: ```bash cd projects/egress-gate -uv run egress-gate gates -uv run egress-gate configuration-schema +source .venv/bin/activate +egress-gate gates +egress-gate configuration-schema ``` Start the middleware: ```bash cd projects/egress-gate/examples/regex-redaction -uv run egress-gate serve --listen 0.0.0.0:50051 --timeout-seconds 4 +egress-gate serve --listen 0.0.0.0:50051 --timeout-seconds 4 ``` Register that address with the OpenShell gateway using a reachable host IPv4 @@ -26,6 +27,8 @@ address, then create a sandbox with `policy.yaml`. The policy embeds the `pipeline.gates` configuration and uses `egress-gate-redaction` as the middleware registration name. -The `regex-body` gate receives bytes from the runtime. The gate strictly -decodes these bytes as UTF-8. Its `detect`, `deny`, and `replace` modes are -independent policy choices. There is no global detection action. +This composition selects `scan.kind: body` and +`scan.action.kind: replace`. The gate strictly decodes the body bytes as UTF-8 +before it finds and replaces matches. A body scan also supports `detect` and +`deny` actions. The same built-in can detect or deny matches in a 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 index 074e16ac..e9e568c7 100644 --- a/projects/egress-gate/examples/regex-redaction/cases.yaml +++ b/projects/egress-gate/examples/regex-redaction/cases.yaml @@ -23,7 +23,7 @@ cases: expected: decision: allow decision_source_kind: pipeline_default - finding_types: [sensitive_entity] + finding_types: [regex_match] - name: ordinary-body-is-allowed tags: [regex] provenance: diff --git a/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml b/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml index 3c737d7f..d2c9837b 100644 --- a/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml +++ b/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml @@ -2,15 +2,16 @@ pipeline: gates: - name: identifiers config: - gate: regex-body + kind: regex + scan: + kind: body + action: + kind: replace + template: "[{entity}]" pattern_catalog: entities: - name: email rules: - pattern: '(? tuple[_FieldDifference, ...]: + source = result.decision_source actual: dict[str, object] = { "decision": result.decision.value, - "decision_source_kind": result.decision_source.kind.value, - "gate_name": result.decision_source.gate_name, - "gate_type": result.decision_source.gate_type, + "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} @@ -851,6 +857,9 @@ def _load_registry(factory_reference: str | None) -> GateRegistry: "Use module:factory, for example my_gates:create_registry.", param_hint="--registry-factory", ) + 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: diff --git a/projects/egress-gate/src/egress_gate/gates/__init__.py b/projects/egress-gate/src/egress_gate/gates/__init__.py index 14bc0afb..2ffe8581 100644 --- a/projects/egress-gate/src/egress_gate/gates/__init__.py +++ b/projects/egress-gate/src/egress_gate/gates/__init__.py @@ -7,15 +7,23 @@ GateResources, Utf8BodyGate, ) -from egress_gate.gates.regex_body import ( +from egress_gate.gates.regex import ( ConfidenceLevel, - RegexBodyConfig, - RegexBodyGate, - RegexBodyMode, + RegexBodyAction, + RegexBodyScan, + RegexConfig, + RegexDenyAction, + RegexDetectAction, RegexEntity, + RegexGate, + RegexHeaderScan, + RegexPathScan, RegexPatternCatalog, - RegexReplacement, + RegexQueryScan, + RegexReadOnlyAction, + RegexReplaceAction, RegexRule, + RegexScan, ) from egress_gate.gates.registry import ( GateDescription, @@ -33,13 +41,21 @@ "GateRegistry", "GateResources", "FindingTypeDefinition", - "RegexBodyConfig", - "RegexBodyGate", - "RegexBodyMode", + "RegexBodyAction", + "RegexBodyScan", + "RegexConfig", + "RegexDenyAction", + "RegexDetectAction", "RegexEntity", + "RegexGate", + "RegexHeaderScan", "RegexPatternCatalog", - "RegexReplacement", + "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 index cbcc715f..4e219c27 100644 --- a/projects/egress-gate/src/egress_gate/gates/base.py +++ b/projects/egress-gate/src/egress_gate/gates/base.py @@ -27,7 +27,7 @@ class GateConfig(StrictDomainModel): - """Nominal base for one gate's exact policy configuration.""" + """Base for an exact gate config with one required literal ``kind``.""" class GateResources: diff --git a/projects/egress-gate/src/egress_gate/gates/regex_body.py b/projects/egress-gate/src/egress_gate/gates/regex.py similarity index 80% rename from projects/egress-gate/src/egress_gate/gates/regex_body.py rename to projects/egress-gate/src/egress_gate/gates/regex.py index 2f2607e3..9298d42d 100644 --- a/projects/egress-gate/src/egress_gate/gates/regex_body.py +++ b/projects/egress-gate/src/egress_gate/gates/regex.py @@ -1,4 +1,4 @@ -"""Bounded regular-expression request-body detection and replacement.""" +"""Typed, bounded regular-expression scans and actions for HTTP requests.""" from __future__ import annotations @@ -13,7 +13,7 @@ from stat import S_ISREG from string import Formatter from threading import RLock -from typing import Literal, Protocol, Self +from typing import Annotated, Literal, Protocol, Self, TypeAlias import regex import yaml @@ -29,6 +29,7 @@ MAX_DETECTIONS_PER_GATE, MAX_DIAGNOSTIC_TEXT_BYTES, MAX_PROTO_FINDING_GROUPS, + MAX_PROTO_HEADERS, MAX_REGEX_CATALOG_FILE_BYTES, MAX_REGEX_CATALOG_PATH_BYTES, MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES, @@ -41,19 +42,20 @@ from egress_gate.errors import ( GateConfigurationError, GateContractError, + GateInputError, GateLimitExceededError, TimeoutExpiredError, ) -from egress_gate.gates.base import GateCapabilities, GateConfig, Utf8BodyGate +from egress_gate.gates.base import Gate, GateCapabilities, GateConfig from egress_gate.logging import get_logger -from egress_gate.request import RequestPatch +from egress_gate.request import HeaderName, HttpRequest, RequestPatch 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-body gate.""" + """Categorical certainty reported by the regex gate.""" LOW = "low" MEDIUM = "medium" @@ -120,7 +122,7 @@ def _supplied_rule_names_are_unique(self) -> Self: class RegexPatternCatalog(StrictDomainModel): - """The complete ordered entity catalog for one regex-body gate.""" + """The complete ordered entity catalog for one regex gate.""" entities: tuple[RegexEntity, ...] = Field(repr=False) @@ -146,10 +148,22 @@ def _catalog_is_bounded_and_unambiguous(self) -> Self: return self -class RegexReplacement(StrictDomainModel): - """A constrained template replacement recipe.""" +class RegexDetectAction(StrictDomainModel): + """Report matches and continue without changing the request.""" - strategy: Literal["template"] = "template" + 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") @@ -168,33 +182,76 @@ def _template_is_safe_and_bounded(cls, value: str) -> str: return value -class RegexBodyMode(StrEnum): - """The disposition applied when the regex-body gate finds a match.""" +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 + - DETECT = "detect" - DENY = "deny" - REPLACE = "replace" +RegexScan: TypeAlias = Annotated[ + RegexBodyScan | RegexPathScan | RegexQueryScan | RegexHeaderScan, + Field(discriminator="kind"), +] -class RegexBodyConfig(GateConfig): - """Exact policy configuration owned by ``RegexBodyGate``.""" +class RegexConfig(GateConfig): + """Exact policy configuration owned by ``RegexGate``.""" - gate: Literal["regex-body"] + kind: Literal["regex"] + scan: RegexScan pattern_catalog: RegexPatternCatalog = Field( repr=False, description=( "Complete structured catalog or relative path to a complete YAML catalog." ), ) - mode: RegexBodyMode - replacement: RegexReplacement | None = None - - @field_validator("mode", mode="before") - @classmethod - def _parse_mode(cls, value: object) -> RegexBodyMode: - if isinstance(value, RegexBodyMode): - return value - return RegexBodyMode(validate_scalar_string(value)) @field_validator( "pattern_catalog", @@ -211,28 +268,28 @@ def _load_pattern_catalog( return value @model_validator(mode="after") - def _rules_are_valid(self) -> Self: + 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") - if (self.mode is RegexBodyMode.REPLACE) != (self.replacement is not None): - raise ValueError("regex replacement is required only when mode is replace") return self -class RegexBodyGate(Utf8BodyGate[RegexBodyConfig, None]): - """Detect every regex match, including matches that share input characters.""" +class RegexGate(Gate[RegexConfig, None]): + """Run one typed request scan, including overlapping matches.""" capabilities = GateCapabilities( + reads_target=True, + reads_headers=True, reads_body=True, replaces_body=True, produces_findings=True, may_deny=True, ) - finding_types = (FindingTypeDefinition(type="sensitive_entity"),) + finding_types = (FindingTypeDefinition(type="regex_match"),) def _initialize(self, *, timeout: Timeout | None = None) -> None: try: @@ -242,16 +299,73 @@ def _initialize(self, *, timeout: Timeout | None = None) -> None: ) except (RecursionError, ValueError, regex.error): raise GateConfigurationError( - "regex-body gate configuration is invalid" + "regex gate configuration is invalid" ) from None - def _evaluate_text( + def _evaluate( self, - text: str, + request: HttpRequest, *, timeout: Timeout, ) -> GateEvaluation: + scan_texts = self._scan_texts(request) detections_with_identity: list[tuple[_RegexDetection, str]] = [] + for text in scan_texts: + detections_with_identity.extend(self._match_text(text, timeout=timeout)) + 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) + + body_text = scan_texts[0] + output_text = body_text + if detections: + winners = _resolve_overlaps(detections_with_identity) + output_text = _render_bounded_replacement( + body_text, + winners, + action.template, + ) + return GateEvaluation.proceed( + patch=RequestPatch(replacement_body=output_text.encode("utf-8")), + findings=findings, + ) + + def _scan_texts(self, request: HttpRequest) -> tuple[str, ...]: + scan = self.config.scan + if isinstance(scan, RegexBodyScan): + try: + return (request.body.decode("utf-8", errors="strict"),) + except UnicodeDecodeError: + raise GateInputError("regex body scan is not valid UTF-8") from None + if isinstance(scan, RegexPathScan): + return (request.target.path,) + if isinstance(scan, RegexQueryScan): + return (request.target.query,) + selected_names = frozenset(name.casefold() for name in scan.names) + return tuple( + header.value + for header in request.headers + if header.name.casefold() in selected_names + ) + + 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): @@ -266,26 +380,29 @@ def _evaluate_text( start, end = match.span() if start == end: raise GateConfigurationError( - "regex-body configuration matches an empty span" + "regex configuration matches an empty span" ) if match.span(rule.marker) != (end, end): raise GateConfigurationError( - "regex-body configuration marker is invalid" + "regex configuration marker is invalid" + ) + detections.append( + ( + _RegexDetection( + entity=rule.entity, + start=start, + end=end, + confidence=rule.confidence, + ), + rule.rule_identity, ) - detection = _RegexDetection( - entity=rule.entity, - start=start, - end=end, - confidence=rule.confidence, ) - detections_with_identity.append((detection, rule.rule_identity)) - if len(detections_with_identity) > MAX_DETECTIONS_PER_GATE: + if len(detections) > MAX_DETECTIONS_PER_GATE: raise GateLimitExceededError( "regex detection count exceeds the limit" ) next_position = start + 1 - - detections_with_identity.sort( + detections.sort( key=lambda item: ( item[0].start, item[0].end, @@ -293,32 +410,7 @@ def _evaluate_text( item[1], ) ) - 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") - output_text = text - if self.config.mode is RegexBodyMode.REPLACE and detections: - replacement = self.config.replacement - if replacement is None: - raise GateConfigurationError("regex replacement is missing") - winners = _resolve_overlaps(detections_with_identity) - output_text = _render_bounded_replacement( - text, - winners, - replacement.template, - ) - if self.config.mode is RegexBodyMode.DENY and detections: - return GateEvaluation.deny( - "egress_gate_regex_denied", - findings=findings, - ) - if self.config.mode is RegexBodyMode.REPLACE: - return GateEvaluation.proceed( - patch=RequestPatch(replacement_body=output_text.encode("utf-8")), - findings=findings, - ) - return GateEvaluation.proceed(findings=findings) + return detections @dataclass(frozen=True) @@ -347,7 +439,7 @@ def _aggregate_findings( counts[key] = counts.get(key, 0) + 1 return tuple( Finding( - type="sensitive_entity", + type="regex_match", label=entity, count=count, confidence=confidence.value, @@ -803,11 +895,19 @@ def _rendered_template_size(template: str, entity: str) -> int: __all__ = [ "ConfidenceLevel", - "RegexBodyConfig", - "RegexBodyGate", - "RegexBodyMode", + "RegexBodyAction", + "RegexBodyScan", + "RegexConfig", + "RegexDenyAction", + "RegexDetectAction", "RegexEntity", + "RegexGate", + "RegexHeaderScan", "RegexPatternCatalog", - "RegexReplacement", + "RegexPathScan", + "RegexQueryScan", + "RegexReadOnlyAction", + "RegexReplaceAction", "RegexRule", + "RegexScan", ] diff --git a/projects/egress-gate/src/egress_gate/gates/registry.py b/projects/egress-gate/src/egress_gate/gates/registry.py index 08e8d277..9a8e9731 100644 --- a/projects/egress-gate/src/egress_gate/gates/registry.py +++ b/projects/egress-gate/src/egress_gate/gates/registry.py @@ -34,7 +34,7 @@ GateConfig, GateResources, ) -from egress_gate.gates.regex_body import RegexBodyGate +from egress_gate.gates.regex import RegexGate from egress_gate.result import FindingTypeDefinition from egress_gate.timeout import Timeout @@ -62,7 +62,7 @@ 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(RegexBodyGate) + self.register(RegexGate) @property def is_finalized(self) -> bool: @@ -97,9 +97,9 @@ def register( 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") - gate_name = _gate_discriminator(config_type) - if gate_name in self._registrations: - raise GateRegistryError("gate discriminator is already registered") + 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() @@ -112,7 +112,7 @@ def register( elif resources is None or not isinstance(resources, resources_type): raise GateRegistryError("gate resources do not match their declared type") - self._registrations[gate_name] = _Registration( + self._registrations[gate_kind] = _Registration( gate_type=gate_type, config_type=config_type, resources=resources, @@ -196,7 +196,7 @@ def prepare_processor( prepared: list[tuple[str, str, Gate[GateConfig, GateResources | None]]] = [] for configured_gate in validated_config.pipeline.gates: timeout.raise_if_expired() - gate_type = getattr(configured_gate.config, "gate", None) + gate_type = getattr(configured_gate.config, "kind", None) if not isinstance(gate_type, str): raise GateRegistryError("gate config discriminator is invalid") prepared.append( @@ -222,7 +222,7 @@ def describe_gates(self) -> tuple[GateDescription, ...]: """Return safe gate metadata without constructing runtime gates.""" return tuple( GateDescription( - gate_type=gate_name, + gate_type=gate_kind, description=_gate_description(registration.gate_type), capabilities=registration.gate_type.capabilities, finding_types=registration.gate_type.finding_types, @@ -234,7 +234,7 @@ def describe_gates(self) -> tuple[GateDescription, ...]: ), config_type=registration.config_type.__name__, ) - for gate_name, registration in self._registrations.items() + for gate_kind, registration in self._registrations.items() ) @staticmethod @@ -253,10 +253,10 @@ def _resolve_registration(self, config: GateConfig) -> _Registration: if not self.is_finalized: raise GateRegistryError("gate registry is not finalized") try: - gate_name = getattr(config, "gate") - if not isinstance(gate_name, str): + gate_kind = getattr(config, "kind") + if not isinstance(gate_kind, str): raise AttributeError - return self._registrations[gate_name] + return self._registrations[gate_kind] except (AttributeError, KeyError): raise GateRegistryError("gate config is not registered") from None @@ -288,7 +288,7 @@ def _build_egress_gate_config_type( registered_union = reduce(or_, config_types) registered_config = getitem( Annotated, - (registered_union, Field(discriminator="gate")), + (registered_union, Field(discriminator="kind")), ) pipeline_type: object = getattr(EgressGateConfig, "__class_getitem__")( registered_config @@ -320,21 +320,21 @@ def _is_egress_gate_config_type( return isinstance(value, type) and issubclass(value, EgressGateConfig) -def _gate_discriminator(config_type: type[GateConfig]) -> str: - field = config_type.model_fields.get("gate") +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 gate discriminator") + raise GateRegistryError("gate config lacks a kind discriminator") if get_origin(field.annotation) is not Literal: - raise GateRegistryError("gate discriminator must be one string 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 discriminator must be one string Literal") - gate_name = values[0] - if _GATE_NAME.fullmatch(gate_name) is None: - raise GateRegistryError("gate discriminator is invalid") + 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 discriminator must be required") - return gate_name + raise GateRegistryError("gate kind must be required") + return gate_kind def _gate_description(gate_type: type[object]) -> str: @@ -345,7 +345,7 @@ def _gate_description(gate_type: type[object]) -> str: return first_line -_GATE_NAME = re.compile(r"[a-z][a-z0-9-]{0,127}\Z") +_GATE_KIND_PATTERN = re.compile(r"[a-z][a-z0-9-]{0,127}\Z") __all__ = [ diff --git a/projects/egress-gate/src/egress_gate/request.py b/projects/egress-gate/src/egress_gate/request.py index 8a36c145..f36f1ad6 100644 --- a/projects/egress-gate/src/egress_gate/request.py +++ b/projects/egress-gate/src/egress_gate/request.py @@ -121,7 +121,7 @@ class ExistingHeaderAction(StrEnum): class WriteHeaderMutation(StrictDomainModel): """One ordered write operation proposed by a gate.""" - operation: Literal["write"] = "write" + kind: Literal["write"] name: HeaderName value: HeaderValue on_existing: ExistingHeaderAction @@ -130,13 +130,13 @@ class WriteHeaderMutation(StrictDomainModel): class RemoveHeaderMutation(StrictDomainModel): """One ordered removal operation proposed by a gate.""" - operation: Literal["remove"] = "remove" + kind: Literal["remove"] name: HeaderName HeaderMutation: TypeAlias = Annotated[ WriteHeaderMutation | RemoveHeaderMutation, - Field(discriminator="operation"), + Field(discriminator="kind"), ] diff --git a/projects/egress-gate/src/egress_gate/request_processor.py b/projects/egress-gate/src/egress_gate/request_processor.py index 12574ea0..0d4efb33 100644 --- a/projects/egress-gate/src/egress_gate/request_processor.py +++ b/projects/egress-gate/src/egress_gate/request_processor.py @@ -37,12 +37,16 @@ ) 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 @@ -67,7 +71,7 @@ def __init__( configured_types = tuple(gate_type for _, gate_type, _ in gates) policy_names = tuple(item.name for item in config.pipeline.gates) policy_types = tuple( - getattr(item.config, "gate", None) for item in config.pipeline.gates + getattr(item.config, "kind", None) for item in config.pipeline.gates ) if configured_names != policy_names or configured_types != policy_types: raise ValueError("configured gates do not match the policy") @@ -140,8 +144,9 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: if evaluation.control is GateControl.DENY: return _result( decision=EgressDecision.DENY, - source=DecisionSource.gate( - name=gate_name, + source=GateDecisionSource( + kind=DecisionSourceKind.GATE, + gate_name=gate_name, gate_type=gate_type, ), findings=sourced_findings, @@ -152,8 +157,9 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: if evaluation.control is GateControl.ALLOW: return _result( decision=EgressDecision.ALLOW, - source=DecisionSource.gate( - name=gate_name, + source=GateDecisionSource( + kind=DecisionSourceKind.GATE, + gate_name=gate_name, gate_type=gate_type, ), patch=accumulated_patch, @@ -197,7 +203,9 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: if self._config.pipeline.default_decision is DefaultDecision.ALLOW: result = _result( decision=EgressDecision.ALLOW, - source=DecisionSource.pipeline_default(), + source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), patch=accumulated_patch, findings=sourced_findings, fingerprint=self._policy_fingerprint, @@ -206,7 +214,9 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: else: result = _result( decision=EgressDecision.DENY, - source=DecisionSource.pipeline_default(), + source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), findings=sourced_findings, reason_code=DEFAULT_DENY_REASON_CODE, fingerprint=self._policy_fingerprint, @@ -339,7 +349,7 @@ def _result( def _runtime_limit_result(fingerprint: str | None) -> EgressResult: return _result( decision=EgressDecision.DENY, - source=DecisionSource.runtime_limit(), + source=RuntimeLimitDecisionSource(kind=DecisionSourceKind.RUNTIME_LIMIT), reason_code=LIMIT_REASON_CODE, fingerprint=fingerprint, ) diff --git a/projects/egress-gate/src/egress_gate/result.py b/projects/egress-gate/src/egress_gate/result.py index 811126c2..8175d656 100644 --- a/projects/egress-gate/src/egress_gate/result.py +++ b/projects/egress-gate/src/egress_gate/result.py @@ -8,7 +8,7 @@ from __future__ import annotations from enum import StrEnum -from typing import Annotated, Self, TypeAlias +from typing import Annotated, Literal, Self, TypeAlias from pydantic import ( Field, @@ -107,42 +107,30 @@ class SourcedFinding(StrictDomainModel): finding: Finding -class DecisionSource(StrictDomainModel): - """Runtime-owned attribution for a final decision.""" +class GateDecisionSource(StrictDomainModel): + """A final decision made by one configured gate.""" - kind: DecisionSourceKind - gate_name: GateName | None = None - gate_type: GateType | None = None + kind: Literal[DecisionSourceKind.GATE] + gate_name: GateName + gate_type: GateType - @model_validator(mode="after") - def _gate_fields_match_kind(self) -> Self: - has_gate = self.gate_name is not None or self.gate_type is not None - if self.kind is DecisionSourceKind.GATE and not ( - self.gate_name is not None and self.gate_type is not None - ): - raise ValueError("gate decision sources require gate name and type") - if self.kind is not DecisionSourceKind.GATE and has_gate: - raise ValueError("non-gate decision sources cannot name a gate") - return self - @classmethod - def gate(cls, *, name: str, gate_type: str) -> Self: - """Create a source attributed to one configured gate.""" - return cls( - kind=DecisionSourceKind.GATE, - gate_name=name, - gate_type=gate_type, - ) +class PipelineDefaultDecisionSource(StrictDomainModel): + """A final decision made by the pipeline default.""" - @classmethod - def pipeline_default(cls) -> Self: - """Create a source attributed to the pipeline default.""" - return cls(kind=DecisionSourceKind.PIPELINE_DEFAULT) + kind: Literal[DecisionSourceKind.PIPELINE_DEFAULT] - @classmethod - def runtime_limit(cls) -> Self: - """Create a source attributed to a runtime safety limit.""" - return cls(kind=DecisionSourceKind.RUNTIME_LIMIT) + +class RuntimeLimitDecisionSource(StrictDomainModel): + """A fail-closed decision caused by a runtime safety limit.""" + + kind: Literal[DecisionSourceKind.RUNTIME_LIMIT] + + +DecisionSource: TypeAlias = Annotated[ + GateDecisionSource | PipelineDefaultDecisionSource | RuntimeLimitDecisionSource, + Field(discriminator="kind"), +] class GateEvaluation(StrictDomainModel): @@ -300,12 +288,15 @@ def _encoded_string_field_size(value: str) -> int: "FindingLabel", "FindingType", "GateControl", + "GateDecisionSource", "GateEvaluation", "GateName", "GateTrace", "GateType", "MutationKind", + "PipelineDefaultDecisionSource", "ReasonCode", "ResultMetadata", + "RuntimeLimitDecisionSource", "SourcedFinding", ] diff --git a/projects/egress-gate/tests/gates/test_base.py b/projects/egress-gate/tests/gates/test_base.py index 33c69bce..87e58891 100644 --- a/projects/egress-gate/tests/gates/test_base.py +++ b/projects/egress-gate/tests/gates/test_base.py @@ -14,8 +14,8 @@ GateCapabilities, GateConfig, GateResources, - RegexBodyConfig, - RegexBodyGate, + RegexConfig, + RegexGate, ) from egress_gate.request import HttpRequest, HttpTarget, RequestContext from egress_gate.result import Finding, GateControl, GateEvaluation @@ -23,7 +23,7 @@ class _RequestConfig(GateConfig): - gate: Literal["test-request"] + kind: Literal["test-request"] class _RequestGate(Gate[_RequestConfig, None]): @@ -51,7 +51,7 @@ def __init__(self) -> None: class _CounterConfig(GateConfig): - gate: Literal["test-counter"] + kind: Literal["test-counter"] class _CounterGate(Gate[_CounterConfig, _CounterResources]): @@ -124,7 +124,7 @@ def _request(*, body: bytes = b"payload", host: str = "example.com") -> HttpRequ def test_gate_uses_exact_config_and_resource_types() -> None: - config = _RequestConfig(gate="test-request") + config = _RequestConfig(kind="test-request") gate = _RequestGate(config, None) assert gate.config is config @@ -141,13 +141,13 @@ def test_gate_uses_exact_config_and_resource_types() -> None: def test_gate_public_wrapper_enforces_declared_output_capabilities() -> None: with pytest.raises(GateContractError, match="undeclared finding"): - _UndeclaredOutputGate(_RequestConfig(gate="test-request"), None).evaluate( + _UndeclaredOutputGate(_RequestConfig(kind="test-request"), None).evaluate( _request(), timeout=Timeout.from_seconds(1) ) with pytest.raises(GateContractError, match="undeclared finding"): _CapabilityBypassGate( - _RequestConfig(gate="test-request"), + _RequestConfig(kind="test-request"), None, ).evaluate(_request(), timeout=Timeout.from_seconds(1)) @@ -155,15 +155,16 @@ def test_gate_public_wrapper_enforces_declared_output_capabilities() -> None: def test_gate_public_wrapper_classifies_invalid_models_as_contract_errors() -> None: with pytest.raises(GateContractError, match="gate output is invalid"): _InvalidEvaluationGate( - _RequestConfig(gate="test-request"), + _RequestConfig(kind="test-request"), None, ).evaluate(_request(), timeout=Timeout.from_seconds(1)) def test_gate_rejects_invalid_utf8_as_gate_input() -> None: - config = RegexBodyConfig.model_validate( + config = RegexConfig.model_validate( { - "gate": "regex-body", + "kind": "regex", + "scan": {"kind": "body", "action": {"kind": "detect"}}, "pattern_catalog": { "entities": [ { @@ -172,19 +173,18 @@ def test_gate_rejects_invalid_utf8_as_gate_input() -> None: } ] }, - "mode": "detect", } ) with pytest.raises(GateInputError, match="valid UTF-8"): - RegexBodyGate(config, None).evaluate( + 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(gate="test-counter"), resources) + gate = _CounterGate(_CounterConfig(kind="test-counter"), resources) def evaluate(_: int) -> GateEvaluation: return gate.evaluate(_request(), timeout=Timeout.from_seconds(1)) diff --git a/projects/egress-gate/tests/gates/test_regex_body.py b/projects/egress-gate/tests/gates/test_regex.py similarity index 69% rename from projects/egress-gate/tests/gates/test_regex_body.py rename to projects/egress-gate/tests/gates/test_regex.py index fb952749..5a4669ed 100644 --- a/projects/egress-gate/tests/gates/test_regex_body.py +++ b/projects/egress-gate/tests/gates/test_regex.py @@ -1,4 +1,4 @@ -"""Behavior, safety, cache, file-loading, and concurrency tests for regex-body.""" +"""Behavior, scans, actions, safety, caching, and concurrency tests for regex.""" from __future__ import annotations @@ -10,14 +10,14 @@ import pytest from pydantic import ValidationError -import egress_gate.gates.regex_body as regex_module +import egress_gate.gates.regex as regex_module from egress_gate.errors import ( GateConfigurationError, GateLimitExceededError, TimeoutExpiredError, ) -from egress_gate.gates import RegexBodyConfig, RegexBodyGate, RegexPatternCatalog -from egress_gate.request import HttpRequest, HttpTarget, RequestContext +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 @@ -25,22 +25,32 @@ def _config( rules: list[dict[str, object]], *, - mode: str = "detect", - replacement: dict[str, object] | None = None, -) -> RegexBodyConfig: + 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] = { - "gate": "regex-body", + "kind": "regex", + "scan": scan_values, "pattern_catalog": { "entities": [{"name": "token", "rules": rules}], }, - "mode": mode, } - if replacement is not None: - values["replacement"] = replacement - return RegexBodyConfig.model_validate(values) + return RegexConfig.model_validate(values) -def _request(body: bytes) -> HttpRequest: +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( @@ -48,16 +58,16 @@ def _request(body: bytes) -> HttpRequest: host="example.com", port=443, method="POST", - path="/", - query="", + path=path, + query=query, ), - headers=(), + headers=headers, body=body, ) -def _run(config: RegexBodyConfig, text: str) -> GateEvaluation: - return RegexBodyGate(config, None).evaluate( +def _run(config: RegexConfig, text: str) -> GateEvaluation: + return RegexGate(config, None).evaluate( _request(text.encode("utf-8")), timeout=Timeout.from_seconds(1) ) @@ -75,7 +85,7 @@ def _catalog(pattern: str) -> RegexPatternCatalog: ) -def test_detect_mode_reports_overlaps_without_mutating_the_body() -> None: +def test_detect_action_reports_overlaps_without_mutating_the_body() -> None: evaluation = _run( _config( [ @@ -101,7 +111,7 @@ def test_equivalent_detections_are_aggregated_before_evaluation_bounds() -> None assert evaluation.findings == ( regex_module.Finding( - type="sensitive_entity", + type="regex_match", label="token", count=33, confidence="high", @@ -109,9 +119,12 @@ def test_equivalent_detections_are_aggregated_before_evaluation_bounds() -> None ) -def test_deny_mode_is_terminal_and_uses_the_stable_gate_reason() -> None: +def test_deny_action_is_terminal_and_uses_the_stable_gate_reason() -> None: evaluation = _run( - _config([{"pattern": "secret", "confidence": "high"}], mode="deny"), + _config( + [{"pattern": "secret", "confidence": "high"}], + action_kind="deny", + ), "contains secret", ) @@ -121,11 +134,11 @@ def test_deny_mode_is_terminal_and_uses_the_stable_gate_reason() -> None: assert len(evaluation.findings) == 1 -def test_replace_mode_preserves_explicit_replacement_intent() -> None: +def test_replace_action_preserves_explicit_replacement_intent() -> None: config = _config( [{"pattern": "secret", "confidence": "high"}], - mode="replace", - replacement={"strategy": "template", "template": "[{entity}]"}, + action_kind="replace", + template="[{entity}]", ) changed = _run(config, "contains secret") @@ -136,15 +149,138 @@ def test_replace_mode_preserves_explicit_replacement_intent() -> None: assert not unchanged.patch.is_empty -def test_replacement_recipe_is_required_only_for_replace_mode() -> None: +@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.patch.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" + + +@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): - _config([{"pattern": "x", "confidence": "high"}], mode="replace") + RegexConfig.model_validate( + { + "kind": "regex", + "scan": {"kind": "body", "action": {}}, + "pattern_catalog": _catalog("x"), + } + ) with pytest.raises(ValidationError): _config( [{"pattern": "x", "confidence": "high"}], - mode="detect", - replacement={"strategy": "template", "template": "[{entity}]"}, + action_kind="detect", + template="[{entity}]", + ) + + +def test_retired_flat_source_and_mode_shape_is_rejected() -> None: + with pytest.raises(ValidationError): + RegexConfig.model_validate( + { + "kind": "regex", + "source": {"kind": "body"}, + "pattern_catalog": _catalog("x"), + "mode": "detect", + } ) @@ -191,7 +327,7 @@ def test_compile_dependent_pattern_errors_are_rejected_during_preparation( config = _config([{"pattern": pattern, "confidence": "high"}]) with pytest.raises(GateConfigurationError) as exception_info: - RegexBodyGate(config, None, timeout=Timeout.from_seconds(1)) + RegexGate(config, None, timeout=Timeout.from_seconds(1)) assert pattern not in str(exception_info.value) @@ -214,7 +350,7 @@ def test_contextual_zero_width_matches_fail_during_evaluation( with pytest.raises( GateConfigurationError, - match="regex-body configuration matches an empty span", + match="regex configuration matches an empty span", ) as exception_info: _run(config, text) @@ -269,8 +405,8 @@ def test_replacement_selects_ranked_non_overlapping_winners() -> None: {"name": "long-low", "pattern": "abc", "confidence": "low"}, {"name": "short-high", "pattern": "bc", "confidence": "high"}, ], - mode="replace", - replacement={"strategy": "template", "template": "<{entity}>"}, + action_kind="replace", + template="<{entity}>", ), "abc", ) @@ -280,23 +416,23 @@ def test_replacement_selects_ranked_non_overlapping_winners() -> None: @pytest.mark.parametrize( - "replacement", + "template", [ - {"strategy": "template", "template": "{unknown}"}, - {"strategy": "template", "template": "{entity.attr}"}, - {"strategy": "template", "template": "{entity!r}"}, - {"strategy": "template", "template": "{entity:>10}"}, - {"strategy": "template", "template": "{"}, + "{unknown}", + "{entity.attr}", + "{entity!r}", + "{entity:>10}", + "{", ], ) def test_replacement_template_language_is_constrained( - replacement: dict[str, object], + template: str, ) -> None: with pytest.raises(ValidationError): _config( [{"pattern": "x", "confidence": "high"}], - mode="replace", - replacement=replacement, + action_kind="replace", + template=template, ) @@ -306,8 +442,8 @@ def test_replacement_size_is_projected_before_rendering( monkeypatch.setattr(regex_module, "MAX_BODY_BYTES", 4) config = _config( [{"pattern": "x", "confidence": "high"}], - mode="replace", - replacement={"strategy": "template", "template": "[{entity}]"}, + action_kind="replace", + template="[{entity}]", ) with pytest.raises(GateLimitExceededError): @@ -318,7 +454,7 @@ def test_pattern_search_has_an_enforceable_timeout() -> None: config = _config([{"pattern": "(a+)+$", "confidence": "high"}]) with pytest.raises(TimeoutExpiredError): - RegexBodyGate(config, None).evaluate( + RegexGate(config, None).evaluate( _request((b"a" * 100_000) + b"!"), timeout=Timeout.from_seconds(0.001), ) @@ -339,7 +475,7 @@ def recording_compile(pattern: str, flags: int = 0) -> object: monkeypatch.setattr(regex_module.regex, "compile", recording_compile) config = _config([{"pattern": "x", "confidence": "high"}]) assert compile_count == 0 - RegexBodyGate(config, None, timeout=Timeout.from_seconds(1)) + RegexGate(config, None, timeout=Timeout.from_seconds(1)) prepared_count = compile_count _run(config, "x") @@ -353,7 +489,7 @@ def test_gate_preparation_honors_an_expired_timeout() -> None: config = _config([{"pattern": "x", "confidence": "high"}]) with pytest.raises(TimeoutExpiredError): - RegexBodyGate(config, None, timeout=Timeout(deadline=0)) + RegexGate(config, None, timeout=Timeout(deadline=0)) def test_compiled_catalog_cache_wait_honors_preparation_timeout() -> None: @@ -391,7 +527,7 @@ def test_compiled_catalog_cache_evicts_least_recently_used_entry( ) with caplog.at_level( logging.DEBUG, - logger="egress_gate.gates.regex_body", + logger="egress_gate.gates.regex", ): regex_module._compile_pattern_catalog(catalogs[1]) assert regex_module._compile_pattern_catalog(catalogs[0]) is first_rules @@ -423,7 +559,7 @@ def test_compiled_catalog_cache_skips_oversized_valid_entry( try: with caplog.at_level( logging.DEBUG, - logger="egress_gate.gates.regex_body", + logger="egress_gate.gates.regex", ): first = regex_module._compile_pattern_catalog(catalog) second = regex_module._compile_pattern_catalog(catalog) @@ -507,8 +643,8 @@ def synchronized_compile( regex_module._clear_compiled_pattern_cache() -def test_regex_body_gate_is_safe_for_concurrent_runs() -> None: - gate = RegexBodyGate( +def test_regex_gate_is_safe_for_concurrent_runs() -> None: + gate = RegexGate( _config([{"pattern": "x", "confidence": "high"}]), None, ) @@ -540,17 +676,21 @@ def test_relative_yaml_catalog_loading_rejects_aliases_and_traversal( " confidence: high\n" ) - config = RegexBodyConfig.model_validate( - {"gate": "regex-body", "pattern_catalog": "patterns.yaml", "mode": "detect"} + config = RegexConfig.model_validate( + { + "kind": "regex", + "scan": {"kind": "body", "action": {"kind": "detect"}}, + "pattern_catalog": "patterns.yaml", + } ) assert len(_run(config, "secret").findings) == 1 with pytest.raises(ValidationError): - RegexBodyConfig.model_validate( + RegexConfig.model_validate( { - "gate": "regex-body", + "kind": "regex", + "scan": {"kind": "body", "action": {"kind": "detect"}}, "pattern_catalog": "../patterns.yaml", - "mode": "detect", } ) (directory / "aliases.yaml").write_text( @@ -563,10 +703,10 @@ def test_relative_yaml_catalog_loading_rejects_aliases_and_traversal( " - *shared\n" ) with pytest.raises(ValidationError): - RegexBodyConfig.model_validate( + RegexConfig.model_validate( { - "gate": "regex-body", + "kind": "regex", + "scan": {"kind": "body", "action": {"kind": "detect"}}, "pattern_catalog": "aliases.yaml", - "mode": "detect", } ) diff --git a/projects/egress-gate/tests/gates/test_registry.py b/projects/egress-gate/tests/gates/test_registry.py index 3650cc0a..2cf00fad 100644 --- a/projects/egress-gate/tests/gates/test_registry.py +++ b/projects/egress-gate/tests/gates/test_registry.py @@ -24,7 +24,7 @@ class _RegistryConfig(GateConfig): - gate: Literal["registry-test"] + kind: Literal["registry-test"] answer: int @@ -49,7 +49,7 @@ def _evaluate( class _ResourceConfig(GateConfig): - gate: Literal["resource-test"] + kind: Literal["resource-test"] class _ResourceBundle(GateResources): @@ -83,24 +83,32 @@ def _pipeline(config: dict[str, object]) -> dict[str, object]: } -def test_builtin_registry_is_finalized_and_contains_only_regex_body() -> None: +def test_builtin_registry_is_finalized_and_contains_only_regex() -> None: registry = create_builtin_registry() assert registry.is_finalized - assert tuple(item.gate_type for item in registry.describe_gates()) == ( - "regex-body", - ) + assert tuple(item.gate_type for item in registry.describe_gates()) == ("regex",) schema = registry.configuration_json_schema() + assert _discriminator_names(schema) == {"kind"} assert "pipeline" in str(schema.get("properties")) definitions = schema["$defs"] - assert isinstance(definitions, Mapping) + assert isinstance(definitions, dict) regex_schema = next( - value for key, value in definitions.items() if key == "RegexBodyConfig" + value for key, value in definitions.items() if key == "RegexConfig" ) assert isinstance(regex_schema, Mapping) required = next(value for key, value in regex_schema.items() if key == "required") assert isinstance(required, list) - assert "gate" in required + assert "kind" in required + assert "scan" in required + 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) with pytest.raises(EgressGateError): registry.validate_config( @@ -114,7 +122,7 @@ def test_builtin_registry_is_finalized_and_contains_only_regex_body() -> None: } ] }, - "mode": "detect", + "scan": {"kind": "body", "action": {"kind": "detect"}}, } ) ) @@ -126,7 +134,7 @@ def test_registry_validates_exact_pipeline_and_gate_config() -> None: registry.finalize() config = registry.validate_config( - _pipeline({"gate": "registry-test", "answer": 42}) + _pipeline({"kind": "registry-test", "answer": 42}) ) assert config.pipeline.default_decision.value == "allow" @@ -138,7 +146,7 @@ def test_registry_validates_exact_pipeline_and_gate_config() -> None: def test_registry_requires_an_explicit_gate_discriminator() -> None: class DefaultedConfig(GateConfig): - gate: Literal["defaulted"] = "defaulted" + kind: Literal["defaulted"] = "defaulted" class DefaultedGate(Gate[DefaultedConfig, None]): capabilities = GateCapabilities() @@ -153,11 +161,11 @@ def _evaluate( del request, timeout return GateEvaluation.proceed() - with pytest.raises(GateRegistryError, match="discriminator must be required"): + with pytest.raises(GateRegistryError, match="gate kind must be required"): GateRegistry().register(DefaultedGate) class FactoryDefaultedConfig(GateConfig): - gate: Literal["factory-defaulted"] = Field( + kind: Literal["factory-defaulted"] = Field( default_factory=lambda: "factory-defaulted" ) @@ -174,7 +182,7 @@ def _evaluate( del request, timeout return GateEvaluation.proceed() - with pytest.raises(GateRegistryError, match="discriminator must be required"): + with pytest.raises(GateRegistryError, match="gate kind must be required"): GateRegistry().register(FactoryDefaultedGate) @@ -183,7 +191,7 @@ def test_registry_forwards_the_shared_preparation_timeout() -> None: registry.register(_RegistryGate) registry.finalize() config = registry.validate_config( - _pipeline({"gate": "registry-test", "answer": 42}) + _pipeline({"kind": "registry-test", "answer": 42}) ) timeout = Timeout.from_seconds(1) @@ -198,7 +206,7 @@ def test_registry_prepares_the_production_processor_from_validated_config() -> N registry.register(_RegistryGate) registry.finalize() config = registry.validate_config( - _pipeline({"gate": "registry-test", "answer": 42}) + _pipeline({"kind": "registry-test", "answer": 42}) ) processor = registry.prepare_processor( @@ -215,7 +223,7 @@ def test_registry_injects_typed_application_resources() -> None: registry.register(_ResourceGate, resources=resources) registry.finalize() - config = registry.validate_config(_pipeline({"gate": "resource-test"})) + config = registry.validate_config(_pipeline({"kind": "resource-test"})) gate = registry.create_gate(config.pipeline.gates[0].config) assert gate.resources is resources @@ -233,12 +241,13 @@ def test_registry_rejects_unknown_policy_shapes() -> None: for values in ( {"unexpected": {}}, - _pipeline({"gate": "missing", "answer": 1}), - _pipeline({"gate": "registry-test", "answer": 1, "extra": True}), + _pipeline({"gate": "registry-test", "answer": 1}), + _pipeline({"kind": "missing", "answer": 1}), + _pipeline({"kind": "registry-test", "answer": 1, "extra": True}), { "pipeline": { "gates": [ - {"name": "one", "config": {"gate": "registry-test", "answer": 1}} + {"name": "one", "config": {"kind": "registry-test", "answer": 1}} ], } }, @@ -257,8 +266,8 @@ def test_registry_lifecycle_and_fingerprint_are_deterministic() -> None: with pytest.raises(GateRegistryError): registry.register(_ResourceGate) - first = registry.validate_config(_pipeline({"gate": "registry-test", "answer": 1})) - second = registry.validate_config(_pipeline({"gate": "registry-test", "answer": 2})) + first = registry.validate_config(_pipeline({"kind": "registry-test", "answer": 1})) + second = registry.validate_config(_pipeline({"kind": "registry-test", "answer": 2})) assert registry.policy_fingerprint(first) != registry.policy_fingerprint(second) assert registry.policy_fingerprint(first) == registry.policy_fingerprint(first) @@ -270,8 +279,8 @@ def test_registry_rejects_duplicate_gate_names_before_preparation() -> None: values = { "pipeline": { "gates": [ - {"name": "same", "config": {"gate": "registry-test", "answer": 1}}, - {"name": "same", "config": {"gate": "registry-test", "answer": 2}}, + {"name": "same", "config": {"kind": "registry-test", "answer": 1}}, + {"name": "same", "config": {"kind": "registry-test", "answer": 2}}, ], "default_decision": "allow", } @@ -279,3 +288,21 @@ def test_registry_rejects_duplicate_gate_names_before_preparation() -> None: 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() diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index bb47565c..06124482 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -17,14 +17,18 @@ from egress_gate.service.servicer import EgressGateMiddleware -def _config(*, mode: str = "replace") -> Message: +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] = { "pipeline": { "gates": [ { "name": "identifiers", "config": { - "gate": "regex-body", + "kind": "regex", + "scan": {"kind": "body", "action": action}, "pattern_catalog": { "entities": [ { @@ -38,17 +42,6 @@ def _config(*, mode: str = "replace") -> Message: } ] }, - "mode": mode, - **( - { - "replacement": { - "strategy": "template", - "template": "[{entity}]", - } - } - if mode == "replace" - else {} - ), }, } ], @@ -63,12 +56,12 @@ def _config(*, mode: str = "replace") -> Message: def _evaluation( body: bytes, *, - mode: str = "replace", + 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(mode=mode), + config=_config(action_kind=action_kind), target=pb2.HttpRequestTarget( scheme="https", host="example.com", @@ -100,7 +93,7 @@ async def _running_stub( @pytest.mark.asyncio -async def test_generated_stub_round_trip_covers_manifest_and_gate_modes() -> None: +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( @@ -110,10 +103,10 @@ async def test_generated_stub_round_trip_covers_manifest_and_gate_modes() -> Non 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", mode="detect") + _evaluation(b"contact a@b.com", action_kind="detect") ) - denied_config = _config(mode="deny") - denied_request = _evaluation(b"contact a@b.com", mode="deny") + 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) diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 5bfcd001..6895254d 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -29,18 +29,24 @@ MAX_PROTO_TARGET_BYTES, ) from egress_gate.errors import EgressGateError, ErrorCode, GateRegistryError -from egress_gate.gates import GateConfig, create_builtin_registry +from egress_gate.gates import ( + GateConfig, + RegexConfig, + RegexReplaceAction, + create_builtin_registry, +) from egress_gate.request import ( ExistingHeaderAction, RequestPatch, WriteHeaderMutation, ) from egress_gate.result import ( - DecisionSource, DecisionSourceKind, EgressDecision, EgressResult, Finding, + PipelineDefaultDecisionSource, + RuntimeLimitDecisionSource, SourcedFinding, ) from egress_gate.service import servicer as servicer_module @@ -49,10 +55,14 @@ def _values( - *, mode: str = "detect", default_decision: str = "allow" + *, action_kind: str = "detect", default_decision: str = "allow" ) -> dict[str, object]: + action: dict[str, object] = {"kind": action_kind} + if action_kind == "replace": + action["template"] = "[{entity}]" config: dict[str, object] = { - "gate": "regex-body", + "kind": "regex", + "scan": {"kind": "body", "action": action}, "pattern_catalog": { "entities": [ { @@ -61,13 +71,7 @@ def _values( } ] }, - "mode": mode, } - if mode == "replace": - config["replacement"] = { - "strategy": "template", - "template": "[{entity}]", - } return { "pipeline": { "gates": [{"name": "body", "config": config}], @@ -217,7 +221,9 @@ def test_result_adapter_serializes_only_five_finding_fields_and_empty_body_inten ) result = EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), patch=RequestPatch(replacement_body=b""), findings=(SourcedFinding(source_gate="body", finding=finding),), ) @@ -240,10 +246,13 @@ def test_result_adapter_serializes_only_five_finding_fields_and_empty_body_inten def test_result_adapter_preserves_ordered_header_mutations_and_deny_reason() -> None: allowed = EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), patch=RequestPatch( header_mutations=( WriteHeaderMutation( + kind="write", name="x-openshell-middleware-reviewed", value="true", on_existing=ExistingHeaderAction.OVERWRITE, @@ -253,7 +262,9 @@ def test_result_adapter_preserves_ordered_header_mutations_and_deny_reason() -> ) denied = EgressResult( decision=EgressDecision.DENY, - decision_source=DecisionSource.runtime_limit(), + decision_source=RuntimeLimitDecisionSource( + kind=DecisionSourceKind.RUNTIME_LIMIT + ), reason_code=LIMIT_REASON_CODE, ) @@ -277,7 +288,7 @@ def test_processor_preparation_reuses_only_the_current_validated_policy() -> Non _values(), timeout=Timeout.from_seconds(1) ) changed = middleware._policy.processor_for( - _values(mode="replace"), timeout=Timeout.from_seconds(1) + _values(action_kind="replace"), timeout=Timeout.from_seconds(1) ) finally: asyncio.run(middleware.close()) @@ -346,7 +357,9 @@ def fail_changed_candidate( *, timeout: Timeout | None = None, ) -> object: - if getattr(config, "mode", None) == "replace": + if isinstance(config, RegexConfig) and isinstance( + config.scan.action, RegexReplaceAction + ): raise GateRegistryError("candidate preparation failed") return original_create_gate(config, timeout=timeout) @@ -358,7 +371,7 @@ def fail_changed_candidate( try: with pytest.raises(EgressGateError) as error: middleware._policy.processor_for( - _values(mode="replace"), + _values(action_kind="replace"), timeout=Timeout.from_seconds(1), ) active = middleware._policy.processor_for( @@ -375,7 +388,7 @@ 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(mode="replace"))) + invalid.config.CopyFrom(_proto_config(_values(action_kind="replace"))) invalid.headers[0].name = "" try: @@ -395,7 +408,7 @@ def test_in_flight_processor_reference_survives_policy_replacement() -> None: _values(), timeout=Timeout.from_seconds(1) ) replacement = middleware._policy.processor_for( - _values(mode="replace"), timeout=Timeout.from_seconds(1) + _values(action_kind="replace"), timeout=Timeout.from_seconds(1) ) domain_request = servicer_module._request_from_proto(_request()) @@ -435,7 +448,7 @@ def blocked_build( monkeypatch.setattr(middleware._policy, "_build_processor", blocked_build) changed_request = _request() - changed_request.config.CopyFrom(_proto_config(_values(mode="replace"))) + changed_request.config.CopyFrom(_proto_config(_values(action_kind="replace"))) task = asyncio.create_task( middleware._evaluate_http_request( changed_request, @@ -469,7 +482,9 @@ async def test_result_serialization_is_bracketed_by_the_shared_timeout( middleware = EgressGateMiddleware(create_builtin_registry()) result = EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), ) events: list[str] = [] original_serialize = servicer_module._result_to_proto_with_source @@ -508,17 +523,17 @@ def record_serialization( @pytest.mark.asyncio @pytest.mark.parametrize( - ("mode", "expected_source"), + ("action_kind", "expected_source"), (("detect", "pipeline_default"), ("deny", "gate")), ) async def test_evaluation_log_records_decision_source( - mode: str, + action_kind: str, expected_source: str, caplog: pytest.LogCaptureFixture, ) -> None: middleware = EgressGateMiddleware(create_builtin_registry()) request = _request() - request.config.CopyFrom(_proto_config(_values(mode=mode))) + 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()) @@ -569,7 +584,9 @@ async def return_limit( def test_serialized_limit_result_reports_runtime_limit_source() -> None: result = EgressResult( decision=EgressDecision.DENY, - decision_source=DecisionSource.runtime_limit(), + decision_source=RuntimeLimitDecisionSource( + kind=DecisionSourceKind.RUNTIME_LIMIT + ), reason_code=LIMIT_REASON_CODE, ) @@ -616,7 +633,9 @@ def test_service_request_body_limit_is_checked_before_worker_execution() -> None def test_default_deny_reason_is_wire_safe() -> None: result = EgressResult( decision=EgressDecision.DENY, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), reason_code=DEFAULT_DENY_REASON_CODE, ) response = servicer_module._result_to_proto(result) diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index ab7dfcdc..d9f6bc3e 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import subprocess import sys from pathlib import Path from types import ModuleType @@ -18,11 +19,12 @@ def test_cli_gates_describes_the_request_level_builtin() -> None: result = CliRunner().invoke(app, ["gates"]) assert result.exit_code == 0 - assert result.stdout.startswith("regex-body\tfindings=sensitive_entity\t") - assert "capabilities=reads_body,replaces_body,produces_findings,may_deny" in ( - result.stdout + assert result.stdout.startswith("regex\tfindings=regex_match\t") + assert ( + "capabilities=reads_target,reads_headers,reads_body,replaces_body," + "produces_findings,may_deny" in result.stdout ) - assert "resources=-\tconfig=RegexBodyConfig" in result.stdout + assert "resources=-\tconfig=RegexConfig" in result.stdout def test_cli_configuration_schema_exposes_pipeline_only() -> None: @@ -99,6 +101,27 @@ def test_cli_evaluate_runs_the_custom_gate_example() -> None: assert "SUMMARY total=2 passed=2 failed=0" in result.stdout +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-factory", + "examples.custom_gate.keyword_gate:create_registry", + "gates", + ], + cwd=project_dir, + capture_output=True, + check=False, + text=True, + ) + + assert result.returncode == 0, result.stderr + assert "\nkeyword-deny\t" in result.stdout + + def test_cli_validate_checks_policy_without_preparing_gates( monkeypatch: pytest.MonkeyPatch, ) -> None: diff --git a/projects/egress-gate/tests/test_config.py b/projects/egress-gate/tests/test_config.py index f190e2e7..a5ad8d1d 100644 --- a/projects/egress-gate/tests/test_config.py +++ b/projects/egress-gate/tests/test_config.py @@ -11,12 +11,13 @@ EgressGateConfig, ) from egress_gate.constants import MAX_PIPELINE_GATES -from egress_gate.gates import RegexBodyConfig +from egress_gate.gates import RegexConfig def _regex_config() -> dict[str, object]: return { - "gate": "regex-body", + "kind": "regex", + "scan": {"kind": "body", "action": {"kind": "detect"}}, "pattern_catalog": { "entities": [ { @@ -25,7 +26,6 @@ def _regex_config() -> dict[str, object]: } ] }, - "mode": "detect", } @@ -38,16 +38,16 @@ def _values(*, default_decision: str = "allow") -> dict[str, object]: def test_pipeline_uses_required_default_and_exact_gate_entries() -> None: - config = EgressGateConfig[RegexBodyConfig].model_validate(_values()) + config = EgressGateConfig[RegexConfig].model_validate(_values()) assert config.pipeline.default_decision is DefaultDecision.ALLOW assert config.pipeline.gates[0].name == "body" - assert type(config.pipeline.gates[0].config) is RegexBodyConfig + assert type(config.pipeline.gates[0].config) is RegexConfig assert ConfiguredGate.model_fields["config"].is_required() def test_pipeline_default_deny_is_explicit() -> None: - config = EgressGateConfig[RegexBodyConfig].model_validate( + config = EgressGateConfig[RegexConfig].model_validate( _values(default_decision="deny") ) assert config.pipeline.default_decision is DefaultDecision.DENY @@ -58,7 +58,7 @@ def test_pipeline_default_deny_is_explicit() -> None: } } with pytest.raises(ValidationError): - EgressGateConfig[RegexBodyConfig].model_validate(missing_default) + EgressGateConfig[RegexConfig].model_validate(missing_default) def test_pipeline_rejects_unknown_fields_and_duplicate_names() -> None: @@ -70,7 +70,7 @@ def test_pipeline_rejects_unknown_fields_and_duplicate_names() -> None: } } with pytest.raises(ValidationError): - EgressGateConfig[RegexBodyConfig].model_validate(unknown) + EgressGateConfig[RegexConfig].model_validate(unknown) duplicate = { "pipeline": { @@ -82,7 +82,7 @@ def test_pipeline_rejects_unknown_fields_and_duplicate_names() -> None: } } with pytest.raises(ValidationError): - EgressGateConfig[RegexBodyConfig].model_validate(duplicate) + EgressGateConfig[RegexConfig].model_validate(duplicate) def test_pipeline_gate_count_has_an_exact_boundary() -> None: @@ -96,7 +96,7 @@ def test_pipeline_gate_count_has_an_exact_boundary() -> None: "default_decision": "allow", } } - config = EgressGateConfig[RegexBodyConfig].model_validate(exact) + config = EgressGateConfig[RegexConfig].model_validate(exact) assert len(config.pipeline.gates) == MAX_PIPELINE_GATES too_many_gates = [ @@ -110,17 +110,21 @@ def test_pipeline_gate_count_has_an_exact_boundary() -> None: } } with pytest.raises(ValidationError): - EgressGateConfig[RegexBodyConfig].model_validate(too_many) + EgressGateConfig[RegexConfig].model_validate(too_many) -def test_regex_mode_requires_replacement_only_when_replacing() -> None: - replace = _regex_config() - replace["mode"] = "replace" +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[RegexBodyConfig].model_validate( + EgressGateConfig[RegexConfig].model_validate( { "pipeline": { - "gates": [{"name": "body", "config": replace}], + "gates": [{"name": "header", "config": invalid}], "default_decision": "allow", } } diff --git a/projects/egress-gate/tests/test_request.py b/projects/egress-gate/tests/test_request.py index 7cd4cf26..0462db8f 100644 --- a/projects/egress-gate/tests/test_request.py +++ b/projects/egress-gate/tests/test_request.py @@ -3,7 +3,7 @@ from __future__ import annotations import pytest -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from egress_gate.constants import ( MAX_BODY_BYTES, @@ -16,6 +16,7 @@ ) from egress_gate.request import ( ExistingHeaderAction, + HeaderMutation, HttpHeader, HttpRequest, HttpTarget, @@ -105,23 +106,34 @@ def test_request_patch_distinguishes_no_replacement_from_empty_body() -> None: def test_request_patch_preserves_ordered_discriminated_header_mutations() -> None: + adapter = TypeAdapter(HeaderMutation) + discriminator = adapter.json_schema().get("discriminator") + assert isinstance(discriminator, dict) + assert discriminator.get("propertyName") == "kind" + patch = RequestPatch( header_mutations=( WriteHeaderMutation( + kind="write", name="x-test", value="one", on_existing=ExistingHeaderAction.APPEND, ), - RemoveHeaderMutation(name="x-old"), + RemoveHeaderMutation(kind="remove", name="x-old"), ) ) - assert patch.header_mutations[0].operation == "write" - assert patch.header_mutations[1].operation == "remove" + assert patch.header_mutations[0].kind == "write" + assert patch.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_patch_rejects_invalid_mutation_bounds() -> None: - mutation = RemoveHeaderMutation(name="x-test") + mutation = RemoveHeaderMutation(kind="remove", name="x-test") with pytest.raises(ValidationError): RequestPatch( header_mutations=tuple(mutation for _ in range(MAX_HEADER_MUTATIONS + 1)) @@ -131,6 +143,7 @@ def test_request_patch_rejects_invalid_mutation_bounds() -> None: RequestPatch( header_mutations=( WriteHeaderMutation( + kind="write", name="x", value="x" * MAX_HEADER_MUTATION_DATA_BYTES, on_existing=ExistingHeaderAction.OVERWRITE, diff --git a/projects/egress-gate/tests/test_request_processor.py b/projects/egress-gate/tests/test_request_processor.py index 4e1736ca..2c5e5ca4 100644 --- a/projects/egress-gate/tests/test_request_processor.py +++ b/projects/egress-gate/tests/test_request_processor.py @@ -43,13 +43,14 @@ EgressDecision, Finding, FindingTypeDefinition, + GateDecisionSource, GateEvaluation, ) from egress_gate.timeout import Timeout class _ControlConfig(GateConfig): - gate: Literal["test-control"] + kind: Literal["test-control"] control: Literal["proceed", "allow", "deny"] = "proceed" replacement: str | None = None expected_body: str | None = None @@ -103,6 +104,7 @@ def _evaluate( return GateEvaluation.allow(findings=findings) mutations: tuple[WriteHeaderMutation, ...] = tuple( WriteHeaderMutation( + kind="write", name=f"x-openshell-middleware-test-{index}", value=self.config.header_value or "true", on_existing=ExistingHeaderAction.OVERWRITE, @@ -112,6 +114,7 @@ def _evaluate( if self.config.header_value is not None and not mutations: mutations = ( WriteHeaderMutation( + kind="write", name="x-openshell-middleware-test", value=self.config.header_value, on_existing=ExistingHeaderAction.OVERWRITE, @@ -148,9 +151,19 @@ def _request( ) -def _regex_config(mode: str = "detect") -> dict[str, object]: +def _regex_config( + action_kind: str = "detect", + *, + scan: dict[str, object] | None = None, +) -> 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"] = "[{entity}]" + scan_values["action"] = action return { - "gate": "regex-body", + "kind": "regex", + "scan": scan_values, "pattern_catalog": { "entities": [ { @@ -159,12 +172,6 @@ def _regex_config(mode: str = "detect") -> dict[str, object]: } ] }, - "mode": mode, - **( - {"replacement": {"strategy": "template", "template": "[{entity}]"}} - if mode == "replace" - else {} - ), } @@ -186,7 +193,7 @@ def _processor( config = registry.validate_config(values) prepared_items = [] for entry in config.pipeline.gates: - gate_type = getattr(entry.config, "gate", None) + gate_type = getattr(entry.config, "kind", None) if not isinstance(gate_type, str): raise AssertionError("test gate config has no discriminator") prepared_items.append( @@ -207,7 +214,7 @@ def test_processor_process_requires_the_service_created_timeout() -> None: process_signature.parameters["timeout"].kind is inspect.Parameter.KEYWORD_ONLY ) - processor = _processor((("one", {"gate": "test-control", "control": "proceed"}),)) + processor = _processor((("one", {"kind": "test-control", "control": "proceed"}),)) result = processor.process(_request(), timeout=Timeout.from_seconds(1)) assert result.decision is EgressDecision.ALLOW @@ -220,7 +227,7 @@ def test_processor_applies_patches_to_the_current_request_and_preserves_intent() ( "redact", { - "gate": "test-control", + "kind": "test-control", "replacement": "redacted", "finding_label": "secret", }, @@ -228,7 +235,7 @@ def test_processor_applies_patches_to_the_current_request_and_preserves_intent() ( "observe", { - "gate": "test-control", + "kind": "test-control", "expected_body": "redacted", "header_value": "true", "finding_label": "observed", @@ -256,12 +263,44 @@ def test_processor_applies_patches_to_the_current_request_and_preserves_intent() assert result.policy_fingerprint == "policy-fingerprint" +def test_regex_gate_sees_header_patches_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.patch.is_empty + + def test_processor_aggregates_equivalent_findings_by_gate_provenance() -> None: processor = _processor( ( ( "one", - {"gate": "test-control", "finding_label": "same", "emit_twice": True}, + {"kind": "test-control", "finding_label": "same", "emit_twice": True}, ), ) ) @@ -278,7 +317,7 @@ def test_terminal_decisions_skip_later_gates() -> None: ( "deny", { - "gate": "test-control", + "kind": "test-control", "control": "deny", "reason_code": "policy_denied", }, @@ -286,7 +325,7 @@ def test_terminal_decisions_skip_later_gates() -> None: ( "never", { - "gate": "test-control", + "kind": "test-control", "expected_body": "this gate must not run", }, ), @@ -294,11 +333,11 @@ def test_terminal_decisions_skip_later_gates() -> None: ) allow = _processor( ( - ("allow", {"gate": "test-control", "control": "allow"}), + ("allow", {"kind": "test-control", "control": "allow"}), ( "never", { - "gate": "test-control", + "kind": "test-control", "expected_body": "this gate must not run", }, ), @@ -310,15 +349,17 @@ def test_terminal_decisions_skip_later_gates() -> None: 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_patch() -> None: processor = _processor( - (("redact", {"gate": "test-control", "replacement": "redacted"}),), + (("redact", {"kind": "test-control", "replacement": "redacted"}),), default_decision=DefaultDecision.DENY, ) @@ -331,7 +372,7 @@ def test_default_deny_owns_its_reason_and_discards_accumulated_patch() -> None: def test_expired_shared_timeout_returns_atomic_runtime_limit_result() -> None: - processor = _processor((("one", {"gate": "test-control", "control": "proceed"}),)) + processor = _processor((("one", {"kind": "test-control", "control": "proceed"}),)) result = processor.process( _request(), @@ -350,7 +391,8 @@ def test_regex_finding_group_overflow_is_an_atomic_runtime_limit() -> None: ( "regex", { - "gate": "regex-body", + "kind": "regex", + "scan": {"kind": "body", "action": {"kind": "detect"}}, "pattern_catalog": { "entities": [ { @@ -360,7 +402,6 @@ def test_regex_finding_group_overflow_is_an_atomic_runtime_limit() -> None: for index in range(MAX_PROTO_FINDING_GROUPS + 1) ] }, - "mode": "detect", }, ), ), @@ -384,12 +425,12 @@ def test_composed_header_mutation_overflow_is_an_atomic_runtime_limit() -> None: ( ( "first", - {"gate": "test-control", "header_count": MAX_HEADER_MUTATIONS // 2}, + {"kind": "test-control", "header_count": MAX_HEADER_MUTATIONS // 2}, ), ( "second", { - "gate": "test-control", + "kind": "test-control", "header_count": MAX_HEADER_MUTATIONS // 2 + 1, }, ), @@ -412,7 +453,7 @@ def test_trace_finding_count_overflow_is_an_atomic_runtime_limit() -> None: ( "observations", { - "gate": "test-control", + "kind": "test-control", "finding_label": "same", "finding_count": MAX_FINDING_COUNT, "emit_twice": True, @@ -444,7 +485,7 @@ def test_invalid_utf8_is_translated_to_the_stable_input_error() -> None: def test_prepared_gate_type_is_part_of_the_processor_contract() -> None: - processor = _processor((("one", {"gate": "test-control", "control": "proceed"}),)) + processor = _processor((("one", {"kind": "test-control", "control": "proceed"}),)) config = processor._config gate = processor._gates[0][2] @@ -465,21 +506,24 @@ def test_header_patch_operations_are_ordered_and_protected() -> None: patch = RequestPatch( 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(name="x-other"), + RemoveHeaderMutation(kind="remove", name="x-other"), ) ) updated = apply_request_patch(original, patch) @@ -495,6 +539,7 @@ def test_header_patch_operations_are_ordered_and_protected() -> None: RequestPatch( 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 index 6344b646..1ab13d89 100644 --- a/projects/egress-gate/tests/test_result.py +++ b/projects/egress-gate/tests/test_result.py @@ -5,7 +5,7 @@ import math import pytest -from pydantic import ValidationError +from pydantic import TypeAdapter, ValidationError from egress_gate.constants import ( DEFAULT_DENY_REASON_CODE, @@ -26,10 +26,13 @@ EgressResult, Finding, GateControl, + GateDecisionSource, GateEvaluation, GateTrace, MutationKind, + PipelineDefaultDecisionSource, ResultMetadata, + RuntimeLimitDecisionSource, SourcedFinding, ) @@ -108,24 +111,37 @@ def test_reason_codes_use_stable_identifier_format(reason_code: str) -> None: def test_decision_source_keeps_gate_provenance_outside_finding() -> None: - source = DecisionSource.gate(name="identifiers", gate_type="regex") + 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): - DecisionSource(kind=DecisionSourceKind.GATE, gate_name="identifiers") + adapter.validate_python({"kind": "gate", "gate_name": "identifiers"}) with pytest.raises(ValidationError): - DecisionSource(kind=DecisionSourceKind.RUNTIME_LIMIT, gate_name="identifiers") + 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=DecisionSource.gate(name="identifiers", gate_type="regex"), + decision_source=GateDecisionSource( + kind=DecisionSourceKind.GATE, gate_name="identifiers", gate_type="regex" + ), patch=RequestPatch(replacement_body=b"redacted"), findings=(finding,), ) @@ -134,19 +150,25 @@ def test_egress_result_suppresses_mutations_on_deny_by_rejecting_them() -> None: with pytest.raises(ValidationError): EgressResult( decision=EgressDecision.DENY, - decision_source=DecisionSource.runtime_limit(), + decision_source=RuntimeLimitDecisionSource( + kind=DecisionSourceKind.RUNTIME_LIMIT + ), patch=RequestPatch(replacement_body=b"must-not-leak"), reason_code="egress_gate_limit_exceeded", ) with pytest.raises(ValidationError): EgressResult( decision=EgressDecision.DENY, - decision_source=DecisionSource.runtime_limit(), + decision_source=RuntimeLimitDecisionSource( + kind=DecisionSourceKind.RUNTIME_LIMIT + ), ) with pytest.raises(ValidationError): EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), reason_code="not-allowed-on-allow", ) @@ -161,7 +183,9 @@ def test_egress_result_limits_finding_groups_and_trace_values() -> None: ) result = EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), findings=findings, ) assert len(result.findings) == MAX_PROTO_FINDING_GROUPS @@ -170,7 +194,9 @@ def test_egress_result_limits_finding_groups_and_trace_values() -> None: with pytest.raises(ValidationError): EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), findings=findings + (finding,), ) @@ -209,14 +235,18 @@ def test_gate_evaluation_and_result_group_limits_have_exact_boundaries() -> None ) result = EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.pipeline_default(), + 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=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), findings=sourced + (SourcedFinding(source_gate="over", finding=_finding(label="over")),), ) @@ -232,7 +262,9 @@ def test_metadata_count_and_aggregate_byte_limits_have_exact_boundaries() -> Non ) result = EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), metadata=entries, ) assert len(result.metadata) == MAX_RESULT_METADATA_ENTRIES @@ -244,7 +276,9 @@ def test_metadata_count_and_aggregate_byte_limits_have_exact_boundaries() -> Non with pytest.raises(ValidationError): EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), metadata=entries[:-1] + ( ResultMetadata( @@ -256,7 +290,9 @@ def test_metadata_count_and_aggregate_byte_limits_have_exact_boundaries() -> Non with pytest.raises(ValidationError): EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), metadata=entries + (ResultMetadata(key="over", value="v"),), ) @@ -291,14 +327,18 @@ def test_trace_count_and_mutation_kind_limits_have_exact_boundaries() -> None: ) result = EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.pipeline_default(), + 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=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), traces=traces + (trace,), ) @@ -306,12 +346,16 @@ def test_trace_count_and_mutation_kind_limits_have_exact_boundaries() -> None: def test_decision_source_reason_code_ownership_is_strict() -> None: default_deny = EgressResult( decision=EgressDecision.DENY, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), reason_code=DEFAULT_DENY_REASON_CODE, ) runtime_limit = EgressResult( decision=EgressDecision.DENY, - decision_source=DecisionSource.runtime_limit(), + decision_source=RuntimeLimitDecisionSource( + kind=DecisionSourceKind.RUNTIME_LIMIT + ), reason_code=LIMIT_REASON_CODE, ) assert default_deny.reason_code == DEFAULT_DENY_REASON_CODE @@ -320,17 +364,23 @@ def test_decision_source_reason_code_ownership_is_strict() -> None: with pytest.raises(ValidationError): EgressResult( decision=EgressDecision.ALLOW, - decision_source=DecisionSource.runtime_limit(), + decision_source=RuntimeLimitDecisionSource( + kind=DecisionSourceKind.RUNTIME_LIMIT + ), ) with pytest.raises(ValidationError): EgressResult( decision=EgressDecision.DENY, - decision_source=DecisionSource.runtime_limit(), + decision_source=RuntimeLimitDecisionSource( + kind=DecisionSourceKind.RUNTIME_LIMIT + ), reason_code=DEFAULT_DENY_REASON_CODE, ) with pytest.raises(ValidationError): EgressResult( decision=EgressDecision.DENY, - decision_source=DecisionSource.pipeline_default(), + decision_source=PipelineDefaultDecisionSource( + kind=DecisionSourceKind.PIPELINE_DEFAULT + ), reason_code=LIMIT_REASON_CODE, ) diff --git a/zensical.toml b/zensical.toml index daf43577..d9c7e499 100644 --- a/zensical.toml +++ b/zensical.toml @@ -34,7 +34,7 @@ nav = [ ]}, {"Gates" = [ "documentation/egress-gate/gates/index.md", - {"Regex-body gate" = "documentation/egress-gate/gates/regex.md"}, + {"Regex gate" = "documentation/egress-gate/gates/regex.md"}, {"Add a custom gate" = "documentation/egress-gate/gates/custom.md"} ]}, {"Architecture" = [ From 31736563d1857ecdd1db93e09ece91277f2e60d1 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 22:40:44 +0000 Subject: [PATCH 26/46] Polish Egress Gate CLI and examples --- projects/egress-gate/README.md | 20 +- projects/egress-gate/docs/configuration.md | 17 +- projects/egress-gate/docs/evaluation.md | 32 +- projects/egress-gate/docs/gates/custom.md | 17 +- projects/egress-gate/docs/index.md | 14 +- projects/egress-gate/docs/operations.md | 17 +- .../{custom_gate => custom-gate}/README.md | 25 +- .../{custom_gate => custom-gate}/cases.yaml | 0 .../egress-gate-config.yaml | 0 .../keyword_gate.py | 0 .../examples/regex-redaction/README.md | 7 +- projects/egress-gate/pyproject.toml | 1 + projects/egress-gate/src/egress_gate/cli.py | 435 +++++++++++++----- .../egress-gate/src/egress_gate/errors.py | 2 +- .../src/egress_gate/gates/regex.py | 2 +- projects/egress-gate/tests/test_cli.py | 114 +++-- projects/egress-gate/uv.lock | 88 ++-- 17 files changed, 521 insertions(+), 270 deletions(-) rename projects/egress-gate/examples/{custom_gate => custom-gate}/README.md (70%) rename projects/egress-gate/examples/{custom_gate => custom-gate}/cases.yaml (100%) rename projects/egress-gate/examples/{custom_gate => custom-gate}/egress-gate-config.yaml (100%) rename projects/egress-gate/examples/{custom_gate => custom-gate}/keyword_gate.py (100%) diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 2e91dd6b..fcd320cc 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -13,15 +13,15 @@ inside the runtime. Egress Gate does not add provenance to findings or labels. Requirements: Python 3.11+ and `uv` 0.11+. +`uv run` prepares the project environment before it starts the command. + ```bash -uv sync --frozen -source .venv/bin/activate -egress-gate gates -egress-gate configuration-schema -egress-gate validate \ +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 -egress-gate serve --listen 127.0.0.1:50051 -egress-gate evaluate \ +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 ``` @@ -57,8 +57,8 @@ preserves an explicit body-replacement intent even when the resulting bytes equal the input. Add custom trusted gates through `--registry-factory`. ```bash -egress-gate --registry-factory my_gates:create_registry gates -egress-gate --registry-factory my_gates:create_registry serve +uv run egress-gate --registry-factory my_gates:create_registry gates list +uv run egress-gate --registry-factory my_gates:create_registry serve ``` OpenShell owns interception, routing, and credential attachment. Egress Gate @@ -89,7 +89,7 @@ through slot acquisition, policy preparation, and `RequestProcessor.process`. - [Architecture](docs/architecture/index.md) - [Limits and failures](docs/reference/limits-and-failures.md) - [Regex redaction composition](examples/regex-redaction/README.md) -- [Minimal custom gate](examples/custom_gate/README.md) +- [Minimal custom gate](examples/custom-gate/README.md) ## Development diff --git a/projects/egress-gate/docs/configuration.md b/projects/egress-gate/docs/configuration.md index 6f75fcd1..35a8b811 100644 --- a/projects/egress-gate/docs/configuration.md +++ b/projects/egress-gate/docs/configuration.md @@ -59,21 +59,22 @@ trusted application registry factory supplies other behavior. ## Inspect the installed registry -Run these commands with the Egress Gate environment active: +Run these commands from `projects/egress-gate/`. `uv` prepares the locked +environment automatically: ```bash title="Inspect the default registry" -egress-gate gates -egress-gate configuration-schema -egress-gate validate --policy path/to/policy.yaml +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 factory for inspection and serving: ```bash title="Inspect a custom registry" -egress-gate \ - --registry-factory my_gates:create_registry gates -egress-gate \ - --registry-factory my_gates:create_registry configuration-schema +uv run egress-gate \ + --registry-factory my_gates:create_registry gates list +uv run egress-gate \ + --registry-factory my_gates:create_registry gates schema ``` The factory must return a finalized `GateRegistry`. It owns trusted gate diff --git a/projects/egress-gate/docs/evaluation.md b/projects/egress-gate/docs/evaluation.md index 6b5f96c2..15464b3c 100644 --- a/projects/egress-gate/docs/evaluation.md +++ b/projects/egress-gate/docs/evaluation.md @@ -29,25 +29,35 @@ 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. Activate the -installed project environment, then run them from `projects/egress-gate/`: +The repository includes a regex policy and two request cases. Run them from +`projects/egress-gate/`; `uv` prepares the locked environment automatically: ```bash title="Run the example policy tests" -egress-gate evaluate \ +uv run egress-gate evaluate \ --policy examples/regex-redaction/egress-gate-config.yaml \ --cases examples/regex-redaction/cases.yaml \ --timeout-seconds 1 ``` The command prepares the policy once, runs each case with a fresh timeout, and -prints a short result: +shows whether each request produced its expected result: ```text title="Evaluation output" -PASS case="email-is-detected-and-request-is-allowed" -PASS case="ordinary-body-is-allowed" -SUMMARY total=2 passed=2 failed=0 + 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. @@ -125,11 +135,11 @@ Use `--registry-factory` when the policy contains application-owned custom gates: ```bash title="Test a custom gate" -egress-gate \ - --registry-factory examples.custom_gate.keyword_gate:create_registry \ +uv run 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 + --policy examples/custom-gate/egress-gate-config.yaml \ + --cases examples/custom-gate/cases.yaml ``` ## Use the result in automation diff --git a/projects/egress-gate/docs/gates/custom.md b/projects/egress-gate/docs/gates/custom.md index 818183ba..2e7f6b0f 100644 --- a/projects/egress-gate/docs/gates/custom.md +++ b/projects/egress-gate/docs/gates/custom.md @@ -11,25 +11,24 @@ Custom gates are trusted application code. They target the protobuf-free protobuf, or `RequestProcessor` internals. The repository includes a runnable -[minimal custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom_gate) +[minimal custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom-gate) that pairs the implementation below with a policy and two offline evaluation -cases. From `projects/egress-gate/`, activate the installed project environment -and run it with: +cases. Run it from `projects/egress-gate/`; `uv` prepares the locked environment +automatically: ```bash title="Run the custom-gate example" -source .venv/bin/activate -egress-gate \ - --registry-factory examples.custom_gate.keyword_gate:create_registry \ +uv run 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 + --policy examples/custom-gate/egress-gate-config.yaml \ + --cases examples/custom-gate/cases.yaml ``` The executable resolves the explicit `module:factory` reference from the working directory. A packaged deployment can resolve the same reference from an installed custom-gate package. -```python title="examples/custom_gate/keyword_gate.py" +```python title="examples/custom-gate/keyword_gate.py" from typing import Literal from egress_gate.gates import Gate, GateCapabilities, GateConfig, GateRegistry diff --git a/projects/egress-gate/docs/index.md b/projects/egress-gate/docs/index.md index ca7fa547..20b99678 100644 --- a/projects/egress-gate/docs/index.md +++ b/projects/egress-gate/docs/index.md @@ -32,14 +32,14 @@ protobuf-free and can be evaluated offline. From `projects/egress-gate/`: -```bash title="Install, inspect, validate, and serve" -uv sync --frozen -source .venv/bin/activate -egress-gate gates -egress-gate configuration-schema -egress-gate validate \ +`uv run` prepares the project environment before each command. + +```bash title="Inspect, validate, and serve" +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 -egress-gate serve --listen 127.0.0.1:50051 +uv run egress-gate serve --listen 127.0.0.1:50051 ``` Use the [regex guide](gates/regex.md) for an OpenShell policy and a diff --git a/projects/egress-gate/docs/operations.md b/projects/egress-gate/docs/operations.md index ff8c1f96..d2bc685b 100644 --- a/projects/egress-gate/docs/operations.md +++ b/projects/egress-gate/docs/operations.md @@ -7,14 +7,13 @@ agent_markdown: true # Run and operate Egress Gate The OpenShell gateway and sandbox supervisors call Egress Gate through gRPC. -Install and run the service from `projects/egress-gate`: +Run the service from `projects/egress-gate`. `uv run` prepares the project +environment as needed. ```bash title="Start Egress Gate" -uv sync --frozen -source .venv/bin/activate -egress-gate gates -egress-gate configuration-schema -egress-gate serve --listen 0.0.0.0:50051 --timeout-seconds 4 +uv run egress-gate gates list +uv run egress-gate gates schema +uv run egress-gate serve --listen 0.0.0.0:50051 --timeout-seconds 4 ``` Use a reachable non-loopback address only when the supervisor is outside the @@ -24,7 +23,7 @@ trusted network. Do not expose the port to an untrusted network. ## OpenShell registration ```bash title="Register Egress Gate" -egress-gate add-gateway-registration \ +uv run egress-gate add-gateway-registration \ --host-ip YOUR_HOST_IPV4 --name egress-gate --port 50051 ``` @@ -34,7 +33,7 @@ The command updates `OPENSHELL_GATEWAY_CONFIG`, then Restart the OpenShell gateway after changing registrations. Remove one with: ```bash title="Remove the registration" -egress-gate remove-gateway-registration --name egress-gate +uv run egress-gate remove-gateway-registration --name egress-gate ``` The generated OpenShell middleware timeout is five seconds. Keep the Egress @@ -80,5 +79,5 @@ 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 `configuration-schema` +limit reason is returned. Check the exact schema with `gates schema` when validation fails. diff --git a/projects/egress-gate/examples/custom_gate/README.md b/projects/egress-gate/examples/custom-gate/README.md similarity index 70% rename from projects/egress-gate/examples/custom_gate/README.md rename to projects/egress-gate/examples/custom-gate/README.md index 7464a41f..18e6d4c5 100644 --- a/projects/egress-gate/examples/custom_gate/README.md +++ b/projects/egress-gate/examples/custom-gate/README.md @@ -13,24 +13,23 @@ The implementation has three pieces: 3. `create_registry` registers the trusted Python class and finalizes the configuration schema. -Run the example from `projects/egress-gate/`. Activate the installed project -environment once, then use the CLI executable directly: +Run the example from `projects/egress-gate/`. `uv run` prepares the project +environment before each command: ```bash -source .venv/bin/activate -egress-gate \ - --registry-factory examples.custom_gate.keyword_gate:create_registry \ - gates +uv run egress-gate \ + --registry-factory examples.custom-gate.keyword_gate:create_registry \ + gates list -egress-gate \ - --registry-factory examples.custom_gate.keyword_gate:create_registry \ - validate --policy examples/custom_gate/egress-gate-config.yaml +uv run egress-gate \ + --registry-factory examples.custom-gate.keyword_gate:create_registry \ + validate --policy examples/custom-gate/egress-gate-config.yaml -egress-gate \ - --registry-factory examples.custom_gate.keyword_gate:create_registry \ +uv run 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 + --policy examples/custom-gate/egress-gate-config.yaml \ + --cases examples/custom-gate/cases.yaml ``` The executable resolves the explicit `module:factory` reference from the diff --git a/projects/egress-gate/examples/custom_gate/cases.yaml b/projects/egress-gate/examples/custom-gate/cases.yaml similarity index 100% rename from projects/egress-gate/examples/custom_gate/cases.yaml rename to projects/egress-gate/examples/custom-gate/cases.yaml diff --git a/projects/egress-gate/examples/custom_gate/egress-gate-config.yaml b/projects/egress-gate/examples/custom-gate/egress-gate-config.yaml similarity index 100% rename from projects/egress-gate/examples/custom_gate/egress-gate-config.yaml rename to projects/egress-gate/examples/custom-gate/egress-gate-config.yaml diff --git a/projects/egress-gate/examples/custom_gate/keyword_gate.py b/projects/egress-gate/examples/custom-gate/keyword_gate.py similarity index 100% rename from projects/egress-gate/examples/custom_gate/keyword_gate.py rename to projects/egress-gate/examples/custom-gate/keyword_gate.py diff --git a/projects/egress-gate/examples/regex-redaction/README.md b/projects/egress-gate/examples/regex-redaction/README.md index fcb89935..dc370060 100644 --- a/projects/egress-gate/examples/regex-redaction/README.md +++ b/projects/egress-gate/examples/regex-redaction/README.md @@ -10,16 +10,15 @@ Inspect the installed gate and exact policy schema: ```bash cd projects/egress-gate -source .venv/bin/activate -egress-gate gates -egress-gate configuration-schema +uv run egress-gate gates list +uv run egress-gate gates schema ``` Start the middleware: ```bash cd projects/egress-gate/examples/regex-redaction -egress-gate serve --listen 0.0.0.0:50051 --timeout-seconds 4 +uv run egress-gate serve --listen 0.0.0.0:50051 --timeout-seconds 4 ``` Register that address with the OpenShell gateway using a reachable host IPv4 diff --git a/projects/egress-gate/pyproject.toml b/projects/egress-gate/pyproject.toml index 44d25bae..056e45ab 100644 --- a/projects/egress-gate/pyproject.toml +++ b/projects/egress-gate/pyproject.toml @@ -15,6 +15,7 @@ dependencies = [ "pydantic>=2.11,<3", "pyyaml>=6,<7", "regex>=2026.7.19,<2027", + "rich>=14,<16", "typer>=0.16,<1", "typing-extensions>=4.12,<5", ] diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 894e8456..7cbf50e9 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -16,6 +16,12 @@ import typer import yaml from pydantic import ValidationError, field_validator, model_validator +from rich import box +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 @@ -53,12 +59,17 @@ app = typer.Typer( name="egress-gate", help=( - "Run Egress Gate, manage local OpenShell gateway registrations, and " - "inspect installed request-level gates." + "Run the OpenShell middleware, test policies offline, manage the OpenShell " + "gateway registration, and inspect installed gates." ), no_args_is_help=True, add_completion=False, ) +gates_app = typer.Typer( + help="Inspect installed gates and the policy schema they accept.", + no_args_is_help=True, +) +app.add_typer(gates_app, name="gates") @app.callback() @@ -68,8 +79,8 @@ def configure_cli( str | None, typer.Option( help=( - "Load gates from a trusted Python callable, formatted as " - "module:factory. The callable must return a finalized GateRegistry." + "Load a trusted MODULE:FACTORY callable that returns a finalized " + "GateRegistry. This option applies to every command." ), ), ] = None, @@ -77,9 +88,7 @@ def configure_cli( bool, typer.Option( "--debug", - help=( - "Log content-safe diagnostic details for startup and request handling." - ), + help="Log content-safe startup and request diagnostics.", ), ] = False, debug_log_content: Annotated[ @@ -87,8 +96,8 @@ def configure_cli( typer.Option( "--debug-log-content", help=( - "DANGEROUS: log complete request and processed text, which may " - "contain secrets or personal data." + "DANGEROUS: log original and replacement request bodies. Bodies " + "can contain credentials, secrets, or personal data." ), ), ] = False, @@ -115,8 +124,8 @@ def serve( str, typer.Option( help=( - "Host and port on which Egress Gate listens, formatted as " - "host:port. Use 0.0.0.0 when sandbox supervisors must reach it." + "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", @@ -124,13 +133,13 @@ def serve( float, typer.Option( help=( - "Maximum seconds shared by all processing gates in one request; " - f"must be greater than 0 and at most {MAX_TIMEOUT_SECONDS:g}." + "Total processing time available to all gates for one request. " + f"The value must be greater than 0 and at most {MAX_TIMEOUT_SECONDS:g}." ), ), ] = DEFAULT_TIMEOUT_SECONDS, ) -> None: - """Run Egress Gate until the process receives a shutdown signal.""" + """Start the Egress Gate gRPC service and run until shutdown.""" options = _command_options(context) from egress_gate.service.server import EgressGateServer @@ -148,7 +157,7 @@ def serve( log_request_content=options.log_request_content, ).serve_sync(listen) except EgressGateError as error: - typer.echo(str(error), err=True) + _render_egress_error("Egress Gate could not start", error) raise typer.Exit(code=1) from None @@ -158,8 +167,8 @@ def add_gateway_registration( str, typer.Option( help=( - "Non-loopback IPv4 address of this host that both the OpenShell " - "gateway and sandbox supervisors can reach." + "Non-loopback IPv4 address that the OpenShell gateway and sandbox " + "supervisors can use to reach this Egress Gate service." ), ), ], @@ -167,9 +176,8 @@ def add_gateway_registration( Path | None, typer.Option( help=( - "Gateway TOML to update. Defaults to " - "`$OPENSHELL_GATEWAY_CONFIG` when set, otherwise `gateway.toml` " - "under `$XDG_CONFIG_HOME/openshell`." + "OpenShell gateway TOML file to update. By default, use " + "OPENSHELL_GATEWAY_CONFIG, then the standard per-user file." ), ), ] = None, @@ -177,8 +185,8 @@ def add_gateway_registration( str, typer.Option( help=( - "Gateway registration name referenced by the policy's middleware " - "field. OpenShell allows " + "Registration name used by an OpenShell policy's middleware field. " + "OpenShell allows " f"1-{MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES} ASCII bytes." ), ), @@ -189,7 +197,8 @@ def add_gateway_registration( min=1, max=65535, help=( - "Egress Gate port. Use the same port in `egress-gate serve --listen`." + "Port to advertise to OpenShell. It must match the port in the " + "egress-gate serve --listen address." ), ), ] = 50051, @@ -225,23 +234,29 @@ def add_gateway_registration( port=port, ) except GatewayConfigError as error: - typer.echo( - f"Could not add or update the OpenShell gateway registration: {error}", - err=True, + _render_cli_error( + "Gateway registration could not be saved", + code="gateway_config_error", + message=str(error), ) raise typer.Exit(code=1) from None - action = { - GatewayConfigUpdate.CREATED: "Created", - GatewayConfigUpdate.ADDED: "Added the registration to", - GatewayConfigUpdate.UPDATED: "Updated", - GatewayConfigUpdate.UNCHANGED: "No changes needed in", + 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] - typer.echo(f"{action} {config_path}") - typer.echo(f"Registered {validated_name} at http://{address}:{port}") - typer.echo( - "Next: start Egress Gate, then restart the OpenShell gateway so it " - "loads this registration." + _render_registration( + title="Gateway registration is ready", + config_path=config_path, + name=validated_name, + endpoint=f"http://{address}:{port}", + change=change, + next_step=( + "Start Egress Gate, then restart the OpenShell gateway to load this " + "registration." + ), ) @@ -251,7 +266,7 @@ def remove_gateway_registration( str, typer.Option( help=( - "Gateway registration name to remove. OpenShell allows " + "Registration name to remove. OpenShell allows " f"1-{MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES} ASCII bytes." ), ), @@ -260,9 +275,8 @@ def remove_gateway_registration( Path | None, typer.Option( help=( - "Gateway TOML to update. Defaults to " - "`$OPENSHELL_GATEWAY_CONFIG` when set, otherwise `gateway.toml` " - "under `$XDG_CONFIG_HOME/openshell`." + "OpenShell gateway TOML file to update. By default, use " + "OPENSHELL_GATEWAY_CONFIG, then the standard per-user file." ), ), ] = None, @@ -283,31 +297,52 @@ def remove_gateway_registration( middleware_name=validated_name, ) except GatewayConfigError as error: - typer.echo( - f"Could not remove the OpenShell gateway registration: {error}", - err=True, + _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: - typer.echo(f"Removed {validated_name} from {config_path}") - typer.echo( - "Next: restart the OpenShell gateway so it unloads this registration." + _render_registration( + title="Gateway registration was removed", + config_path=config_path, + name=validated_name, + next_step=("Restart the OpenShell gateway to unload this registration."), ) else: - typer.echo(f"No registration named {validated_name} found in {config_path}") - - -@app.command("configuration-schema") -def configuration_schema(context: typer.Context) -> None: - """Print the policy configuration JSON Schema for the installed gates.""" - typer.echo( - json.dumps( - _command_options(context).registry.configuration_json_schema(), - indent=2, - ensure_ascii=False, - sort_keys=True, + _render_registration( + title="Gateway registration was not found", + config_path=config_path, + name=validated_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, ) @@ -318,22 +353,34 @@ def validate_policy( Path, typer.Option( "--policy", - help="Strict YAML pipeline policy to validate without preparing gates.", + help="Path to the YAML policy to check.", ), ], ) -> None: - """Validate policy configuration and registered resources without side effects.""" + """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: - typer.echo("VALIDATE_ERROR invalid_input", err=True) + _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 EgressGateError: - typer.echo("VALIDATE_ERROR config_invalid", err=True) + _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 pipeline, gate kinds, " + "required fields, and pattern catalog." + ), + ) raise typer.Exit(code=1) from None - typer.echo("VALID") + _CONSOLE.print("[bold green]✓[/bold green] Policy is valid") @app.command("evaluate") @@ -343,75 +390,84 @@ def evaluate( Path, typer.Option( "--policy", - help="Strict YAML pipeline policy to prepare and evaluate.", + help="Path to the YAML policy to test.", ), ], cases: Annotated[ Path, typer.Option( "--cases", - help="Strict YAML version-one evaluation corpus.", + help="Path to the YAML file of saved request cases and expected results.", ), ], timeout_seconds: Annotated[ float, typer.Option( help=( - "Maximum seconds for preparation and each case; " - f"must be greater than 0 and at most {MAX_TIMEOUT_SECONDS:g}." + "Maximum seconds for policy preparation and, separately, each case. " + f"The value must be greater than 0 and at most {MAX_TIMEOUT_SECONDS:g}." ), ), ] = DEFAULT_TIMEOUT_SECONDS, ) -> None: - """Evaluate a policy corpus offline through the production processor.""" + """Test saved requests against a policy without starting the service.""" options = _command_options(context) try: validated_timeout_seconds = validate_timeout_seconds(timeout_seconds) + except ValueError as error: + raise typer.BadParameter( + str(error), + param_hint="--timeout-seconds", + ) 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=validated_timeout_seconds, ) - except _EvaluationCorpusError: - typer.echo("EVALUATE_ERROR invalid_input", err=True) - raise typer.Exit(code=2) from None - except EgressGateError: - typer.echo("EVALUATE_ERROR egress_gate_failure", err=True) + except EgressGateError as error: + _render_egress_error("Evaluation failed", error) raise typer.Exit(code=2) from None except Exception: - typer.echo("EVALUATE_ERROR execution_failed", err=True) + _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 - for case in summary.cases: - for line in _format_case_evaluation(case): - typer.echo(line) - typer.echo(_format_summary(summary)) + _render_evaluation(summary) if summary.failed: raise typer.Exit(code=1) -@app.command("gates") -def gates(context: typer.Context) -> None: - """List installed gates, capabilities, and declared finding types.""" - for description in _command_options(context).registry.describe_gates(): - finding_types = ",".join(item.type for item in description.finding_types) - capabilities = ",".join( - name - for name, enabled in description.capabilities.model_dump().items() - if enabled - ) - typer.echo( - f"{description.gate_type}\tfindings={finding_types or '-'}\t" - f"capabilities={capabilities or '-'}\t" - f"resources={description.resource_type or '-'}\t" - f"config={description.config_type}\t{description.description}" - ) - - _LOGGER = get_logger(__name__) +_CONSOLE = Console() +_ERROR_CONSOLE = Console(stderr=True) @dataclass(frozen=True) @@ -748,27 +804,160 @@ def _run_corpus( return _EvaluationSummary(cases=tuple(evaluations)) -def _format_case_evaluation(evaluation: _CaseEvaluation) -> tuple[str, ...]: - """Render one content-safe case result as stable text lines.""" - if evaluation.matched: - return (f"PASS case={_format_value(evaluation.name)}",) - return tuple( - "FAIL " - f"case={_format_value(evaluation.name)} " - f"field={difference.field} " - f"expected={_format_value(difference.expected)} " - f"actual={_format_value(difference.actual)}" - for difference in evaluation.differences +def _render_evaluation(summary: _EvaluationSummary) -> None: + """Render content-safe case results and their aggregate.""" + table = Table( + title="Policy evaluation", + box=box.ROUNDED, + header_style="bold cyan", + title_style="bold", + show_lines=True, + ) + 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 _format_summary(summary: _EvaluationSummary) -> str: - """Render the stable aggregate line for one corpus run.""" - return ( - f"SUMMARY total={summary.total} passed={summary.passed} failed={summary.failed}" +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" + ) + capability_values = description.capabilities.model_dump() + request_access = ", ".join( + label + for name, label in _REQUEST_ACCESS_LABELS.items() + if capability_values[name] + ) + possible_results = ", ".join( + label + for name, label in _RESULT_CAPABILITY_LABELS.items() + if capability_values[name] + ) + 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, + 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 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_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 = { + "reads_target": "target", + "reads_context": "request context", + "reads_headers": "headers", + "reads_body": "body", +} +_RESULT_CAPABILITY_LABELS = { + "replaces_body": "body replacement", + "mutates_headers": "header changes", + "produces_findings": "findings", + "may_allow": "allow decision", + "may_deny": "deny decision", +} + + def _load_yaml(path: Path) -> object: try: with path.open("rb") as source: @@ -854,7 +1043,7 @@ def _load_registry(factory_reference: str | None) -> GateRegistry: module_name, separator, factory_name = factory_reference.partition(":") if not separator or not module_name or not factory_name: raise typer.BadParameter( - "Use module:factory, for example my_gates:create_registry.", + "Use MODULE:FACTORY, for example my_gates:create_registry.", param_hint="--registry-factory", ) working_directory = str(Path.cwd()) @@ -864,43 +1053,39 @@ def _load_registry(factory_reference: str | None) -> GateRegistry: module = importlib.import_module(module_name) except Exception: raise typer.BadParameter( - "Registry module could not be imported. Verify the module:factory " - "reference, then import the module directly with content-safe " - "diagnostics to find missing dependencies or startup failures.", + "Could not import the registry module. Check MODULE:FACTORY and the " + "module's dependencies.", param_hint="--registry-factory", ) from None try: factory = getattr(module, factory_name) except Exception: raise typer.BadParameter( - "Registry factory could not be resolved. Verify the module:factory " - "reference and exported callable, then access it directly with " - "content-safe diagnostics.", + "Could not find the registry factory. Check the callable name in " + "MODULE:FACTORY.", param_hint="--registry-factory", ) from None if not callable(factory): raise typer.BadParameter( - "Registry factory is not callable. Export a callable that returns a " - "finalized GateRegistry.", + "The registry factory must be callable.", param_hint="--registry-factory", ) try: registry = factory() except Exception: raise typer.BadParameter( - "Registry factory failed. Run the factory directly with content-safe " - "diagnostics and fix its startup error.", + "The registry factory raised an exception. Run it directly to inspect " + "the startup failure.", param_hint="--registry-factory", ) from None if not isinstance(registry, GateRegistry): raise typer.BadParameter( - "Registry factory returned an invalid object. Return a GateRegistry.", + "The registry factory must return a GateRegistry.", param_hint="--registry-factory", ) if not registry.is_finalized: raise typer.BadParameter( - "Registry factory returned an unfinalized registry. Call finalize() " - "before returning it.", + "The registry factory must call finalize() before returning.", param_hint="--registry-factory", ) return registry diff --git a/projects/egress-gate/src/egress_gate/errors.py b/projects/egress-gate/src/egress_gate/errors.py index d9714ecb..54b848f9 100644 --- a/projects/egress-gate/src/egress_gate/errors.py +++ b/projects/egress-gate/src/egress_gate/errors.py @@ -132,7 +132,7 @@ class _ErrorSpec: "Policy configuration is invalid.", "Keep the encoded configuration at or below " f"{MAX_PROTO_CONFIG_BYTES // 1024} KiB, compare it with " - "`egress-gate configuration-schema`, then check the pipeline, gates, " + "`egress-gate gates schema`, then check the pipeline, gates, " "pattern catalogs, replacements, and default decision.", ), ErrorCode.REQUEST_PHASE_INVALID: _ErrorSpec( diff --git a/projects/egress-gate/src/egress_gate/gates/regex.py b/projects/egress-gate/src/egress_gate/gates/regex.py index 9298d42d..b44b2a75 100644 --- a/projects/egress-gate/src/egress_gate/gates/regex.py +++ b/projects/egress-gate/src/egress_gate/gates/regex.py @@ -279,7 +279,7 @@ def _patterns_are_valid(self) -> Self: class RegexGate(Gate[RegexConfig, None]): - """Run one typed request scan, including overlapping matches.""" + """Scan the request body, path, query, or selected headers with regex rules.""" capabilities = GateCapabilities( reads_target=True, diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index d9f6bc3e..afaecb32 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -2,6 +2,7 @@ from __future__ import annotations +import json import subprocess import sys from pathlib import Path @@ -16,23 +17,27 @@ def test_cli_gates_describes_the_request_level_builtin() -> None: - result = CliRunner().invoke(app, ["gates"]) + result = CliRunner().invoke(app, ["gates", "list"]) assert result.exit_code == 0 - assert result.stdout.startswith("regex\tfindings=regex_match\t") - assert ( - "capabilities=reads_target,reads_headers,reads_body,replaces_body," - "produces_findings,may_deny" in result.stdout - ) - assert "resources=-\tconfig=RegexConfig" in result.stdout + 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_pipeline_only() -> None: - result = CliRunner().invoke(app, ["configuration-schema"]) + result = CliRunner().invoke(app, ["gates", "schema"]) assert result.exit_code == 0 - assert '"pipeline"' in result.stdout - assert '"default_decision"' in result.stdout + schema = json.loads(result.stdout) + assert "pipeline" in schema["properties"] + assert "default_decision" in str(schema) def test_registry_factory_loader_requires_a_finalized_gate_registry( @@ -44,7 +49,7 @@ def test_registry_factory_loader_requires_a_finalized_gate_registry( monkeypatch.setitem(sys.modules, module.__name__, module) assert _load_registry("test_registry_factory:create_registry").is_finalized - with pytest.raises(Exception, match="unfinalized registry"): + with pytest.raises(Exception, match=r"call finalize\(\)"): _load_registry("test_registry_factory:unfinished") @@ -76,8 +81,10 @@ def test_cli_evaluate_runs_the_builtin_policy_corpus() -> None: ) assert result.exit_code == 0, result.output - assert 'PASS case="email-is-detected-and-request-is-allowed"' in result.stdout - assert "SUMMARY total=2 passed=2 failed=0" in result.stdout + 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 def test_cli_evaluate_runs_the_custom_gate_example() -> None: @@ -86,19 +93,19 @@ def test_cli_evaluate_runs_the_custom_gate_example() -> None: app, [ "--registry-factory", - "examples.custom_gate.keyword_gate:create_registry", + "examples.custom-gate.keyword_gate:create_registry", "evaluate", "--policy", - str(project_dir / "examples/custom_gate/egress-gate-config.yaml"), + str(project_dir / "examples/custom-gate/egress-gate-config.yaml"), "--cases", - str(project_dir / "examples/custom_gate/cases.yaml"), + str(project_dir / "examples/custom-gate/cases.yaml"), ], ) assert result.exit_code == 0, result.output - assert 'PASS case="configured-keyword-is-denied"' in result.stdout - assert 'PASS case="other-bodies-proceed-to-the-default"' in result.stdout - assert "SUMMARY total=2 passed=2 failed=0" in result.stdout + 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 def test_installed_executable_loads_a_registry_from_the_working_directory() -> None: @@ -109,8 +116,9 @@ def test_installed_executable_loads_a_registry_from_the_working_directory() -> N [ executable, "--registry-factory", - "examples.custom_gate.keyword_gate:create_registry", + "examples.custom-gate.keyword_gate:create_registry", "gates", + "list", ], cwd=project_dir, capture_output=True, @@ -119,7 +127,8 @@ def test_installed_executable_loads_a_registry_from_the_working_directory() -> N ) assert result.returncode == 0, result.stderr - assert "\nkeyword-deny\t" in result.stdout + assert "Installed gates" in result.stdout + assert "keyword-deny" in result.stdout def test_cli_validate_checks_policy_without_preparing_gates( @@ -142,7 +151,7 @@ def unexpected_preparation(*args: object, **kwargs: object) -> object: ) assert result.exit_code == 0, result.output - assert result.stdout == "VALID\n" + assert result.stdout == "✓ Policy is valid\n" def test_cli_validate_rejects_invalid_policy(tmp_path: Path) -> None: @@ -155,7 +164,32 @@ def test_cli_validate_rejects_invalid_policy(tmp_path: Path) -> None: ) assert result.exit_code == 1 - assert result.stderr == "VALIDATE_ERROR config_invalid\n" + assert "Policy validation failed [config_invalid]" in result.stderr + assert "does not match the schema for the installed gates" in result.stderr + assert "egress-gate gates schema" in result.stderr + + +def test_cli_add_gateway_registration_reports_the_result(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), + ], + ) + + assert result.exit_code == 0, result.output + assert "Gateway registration is ready" in result.stdout + assert "Gateway file" in result.stdout + assert "gateway.toml" in result.stdout + assert "Registration egress-gate" in result.stdout + assert "Endpoint http://192.0.2.10:50051" in result.stdout + assert "Created the gateway configuration file" in result.stdout + assert "Next: Start Egress Gate" in result.stdout def test_cli_evaluate_reports_content_safe_mismatch_status(tmp_path: Path) -> None: @@ -176,11 +210,12 @@ def test_cli_evaluate_reports_content_safe_mismatch_status(tmp_path: Path) -> No ) assert result.exit_code == 1 - assert ( - 'FAIL case="email-is-detected-and-request-is-allowed" field=decision' - in result.stdout - ) - assert "SUMMARY total=2 passed=1 failed=1" in result.stdout + 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 @@ -211,5 +246,26 @@ def test_cli_evaluate_rejects_non_strict_corpus_yaml( ) assert result.exit_code == 2 - assert "EVALUATE_ERROR invalid_input" in result.stderr + 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-seconds", + "0", + ], + ) + + assert result.exit_code == 2 + assert "Invalid value for --timeout-seconds" in result.stderr + assert "greater than 0" in result.stderr diff --git a/projects/egress-gate/uv.lock b/projects/egress-gate/uv.lock index 14e311d3..f2ecd66e 100644 --- a/projects/egress-gate/uv.lock +++ b/projects/egress-gate/uv.lock @@ -164,6 +164,51 @@ 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 = ">=6.33.5,<7" }, + { 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" @@ -429,49 +474,6 @@ 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 = "egress-gate" -version = "0.1.0" -source = { editable = "." } -dependencies = [ - { name = "grpcio" }, - { name = "protobuf" }, - { name = "pydantic" }, - { name = "pyyaml" }, - { name = "regex" }, - { 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 = ">=6.33.5,<7" }, - { name = "pydantic", specifier = ">=2.11,<3" }, - { name = "pyyaml", specifier = ">=6,<7" }, - { name = "regex", specifier = ">=2026.7.19,<2027" }, - { 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 = "protobuf" version = "6.33.6" From 399f9cec3c4d951c2249658ebfbbb60586f1aa78 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 22:45:45 +0000 Subject: [PATCH 27/46] Simplify Egress Gate example setup --- projects/egress-gate/docs/configuration.md | 2 +- projects/egress-gate/docs/evaluation.md | 2 +- projects/egress-gate/docs/gates/custom.md | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/projects/egress-gate/docs/configuration.md b/projects/egress-gate/docs/configuration.md index 35a8b811..eb3bed6c 100644 --- a/projects/egress-gate/docs/configuration.md +++ b/projects/egress-gate/docs/configuration.md @@ -59,7 +59,7 @@ trusted application registry factory supplies other behavior. ## Inspect the installed registry -Run these commands from `projects/egress-gate/`. `uv` prepares the locked +Run these commands from `projects/egress-gate/`. `uv` prepares the project environment automatically: ```bash title="Inspect the default registry" diff --git a/projects/egress-gate/docs/evaluation.md b/projects/egress-gate/docs/evaluation.md index 15464b3c..9becd4e3 100644 --- a/projects/egress-gate/docs/evaluation.md +++ b/projects/egress-gate/docs/evaluation.md @@ -30,7 +30,7 @@ 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 locked environment automatically: +`projects/egress-gate/`; `uv` prepares the project environment automatically: ```bash title="Run the example policy tests" uv run egress-gate evaluate \ diff --git a/projects/egress-gate/docs/gates/custom.md b/projects/egress-gate/docs/gates/custom.md index 2e7f6b0f..4ef1c394 100644 --- a/projects/egress-gate/docs/gates/custom.md +++ b/projects/egress-gate/docs/gates/custom.md @@ -13,7 +13,7 @@ protobuf, or `RequestProcessor` internals. The repository includes a runnable [minimal custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom-gate) that pairs the implementation below with a policy and two offline evaluation -cases. Run it from `projects/egress-gate/`; `uv` prepares the locked environment +cases. Run it from `projects/egress-gate/`; `uv` prepares the project environment automatically: ```bash title="Run the custom-gate example" From b05f413f8f9b51339cb6ce099b6a539d5acce567 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 22:49:10 +0000 Subject: [PATCH 28/46] Make CLI tests terminal-independent --- projects/egress-gate/tests/test_cli.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index afaecb32..abc49973 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -185,7 +185,7 @@ def test_cli_add_gateway_registration_reports_the_result(tmp_path: Path) -> None assert result.exit_code == 0, result.output assert "Gateway registration is ready" in result.stdout assert "Gateway file" in result.stdout - assert "gateway.toml" in result.stdout + assert str(config) in "".join(result.stdout.split()) assert "Registration egress-gate" in result.stdout assert "Endpoint http://192.0.2.10:50051" in result.stdout assert "Created the gateway configuration file" in result.stdout @@ -267,5 +267,5 @@ def test_cli_evaluate_explains_an_invalid_timeout() -> None: ) assert result.exit_code == 2 - assert "Invalid value for --timeout-seconds" in result.stderr + assert "--timeout-seconds" in result.stderr assert "greater than 0" in result.stderr From 6a19baaafba514ac357b43d9122a37db6fc4d81f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 22:51:25 +0000 Subject: [PATCH 29/46] Normalize styled CLI test output --- projects/egress-gate/tests/test_cli.py | 7 +++++-- 1 file changed, 5 insertions(+), 2 deletions(-) diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index abc49973..8adbaded 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -9,6 +9,7 @@ from types import ModuleType import pytest +from rich.text import Text from typer.testing import CliRunner from egress_gate.cli import _load_registry, app @@ -264,8 +265,10 @@ def test_cli_evaluate_explains_an_invalid_timeout() -> None: "--timeout-seconds", "0", ], + color=True, ) assert result.exit_code == 2 - assert "--timeout-seconds" in result.stderr - assert "greater than 0" in result.stderr + error_output = Text.from_ansi(result.stderr).plain + assert "Invalid value for --timeout-seconds" in error_output + assert "greater than 0" in error_output From 7b352cdc9ec06666f485f13f6c90861c57787c2d Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 22:58:14 +0000 Subject: [PATCH 30/46] Remove request-content logging --- projects/egress-gate/docs/operations.md | 5 ++-- projects/egress-gate/src/egress_gate/cli.py | 30 ++----------------- .../src/egress_gate/gates/registry.py | 2 -- .../src/egress_gate/request_processor.py | 20 ------------- .../src/egress_gate/service/server.py | 2 -- .../src/egress_gate/service/servicer.py | 15 ++-------- projects/egress-gate/tests/test_cli.py | 8 +++++ 7 files changed, 15 insertions(+), 67 deletions(-) diff --git a/projects/egress-gate/docs/operations.md b/projects/egress-gate/docs/operations.md index d2bc685b..b4a503d4 100644 --- a/projects/egress-gate/docs/operations.md +++ b/projects/egress-gate/docs/operations.md @@ -42,9 +42,8 @@ headroom. ## Logging and decisions -`--debug` enables content-safe diagnostics. `--debug-log-content` is an -explicit development-only option that logs complete request and replacement -body content. +`--debug` enables content-safe diagnostics. Egress Gate does not log request or +replacement bodies. Successful policy outcomes are distinct from gRPC failures: diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 7cbf50e9..98ffc9e9 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -50,7 +50,7 @@ update_gateway_config, validate_middleware_name, ) -from egress_gate.logging import LoggingConfig, configure_logging, get_logger +from egress_gate.logging import LoggingConfig, configure_logging from egress_gate.request import HttpHeader, HttpRequest, HttpTarget, RequestContext from egress_gate.result import EgressResult, GateDecisionSource from egress_gate.string_validators import BoundedMetadataString @@ -91,30 +91,10 @@ def configure_cli( help="Log content-safe startup and request diagnostics.", ), ] = False, - debug_log_content: Annotated[ - bool, - typer.Option( - "--debug-log-content", - help=( - "DANGEROUS: log original and replacement request bodies. Bodies " - "can contain credentials, secrets, or personal data." - ), - ), - ] = False, ) -> None: """Configure the command application and its gate inventory.""" - configure_logging( - LoggingConfig(level="DEBUG" if debug or debug_log_content else "INFO") - ) - context.obj = _CommandOptions( - registry=_load_registry(registry_factory), - log_request_content=debug_log_content, - ) - if debug_log_content: - _LOGGER.warning( - "egress_gate_request_content_logging_enabled " - "complete_request_text_may_contain_secrets" - ) + configure_logging(LoggingConfig(level="DEBUG" if debug else "INFO")) + context.obj = _CommandOptions(registry=_load_registry(registry_factory)) @app.command("serve") @@ -154,7 +134,6 @@ def serve( EgressGateServer( options.registry, timeout_seconds=validated_timeout_seconds, - log_request_content=options.log_request_content, ).serve_sync(listen) except EgressGateError as error: _render_egress_error("Egress Gate could not start", error) @@ -465,7 +444,6 @@ def evaluate( raise typer.Exit(code=1) -_LOGGER = get_logger(__name__) _CONSOLE = Console() _ERROR_CONSOLE = Console(stderr=True) @@ -473,7 +451,6 @@ def evaluate( @dataclass(frozen=True) class _CommandOptions: registry: GateRegistry - log_request_content: bool class _EvaluationCorpusError(Exception): @@ -787,7 +764,6 @@ def _run_corpus( processor = registry.prepare_processor( validated_config, timeout=Timeout.from_seconds(validated_timeout), - log_request_content=False, ) evaluations: list[_CaseEvaluation] = [] for case in corpus.cases: diff --git a/projects/egress-gate/src/egress_gate/gates/registry.py b/projects/egress-gate/src/egress_gate/gates/registry.py index 9a8e9731..039b2244 100644 --- a/projects/egress-gate/src/egress_gate/gates/registry.py +++ b/projects/egress-gate/src/egress_gate/gates/registry.py @@ -177,7 +177,6 @@ def prepare_processor( validated_config: EgressGateConfig[GateConfig], *, timeout: Timeout, - log_request_content: bool = False, ) -> RequestProcessor: """Prepare one processor from a validated policy configuration. @@ -211,7 +210,6 @@ def prepare_processor( validated_config, tuple(prepared), policy_fingerprint=self.policy_fingerprint(validated_config), - log_request_content=log_request_content, ) def configuration_json_schema(self) -> dict[str, object]: diff --git a/projects/egress-gate/src/egress_gate/request_processor.py b/projects/egress-gate/src/egress_gate/request_processor.py index 0d4efb33..a4a1b180 100644 --- a/projects/egress-gate/src/egress_gate/request_processor.py +++ b/projects/egress-gate/src/egress_gate/request_processor.py @@ -64,7 +64,6 @@ def __init__( ], *, policy_fingerprint: str | None = None, - log_request_content: bool = False, ) -> None: gates = tuple(configured_gates) configured_names = tuple(name for name, _, _ in gates) @@ -89,7 +88,6 @@ def __init__( self._config = config self._gates = gates self._policy_fingerprint = policy_fingerprint - self._log_request_content = log_request_content def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: """Evaluate one request and return an atomic final domain result.""" @@ -102,12 +100,6 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: sourced_findings: list[SourcedFinding] = [] traces: list[GateTrace] = [] - if self._log_request_content: - _LOGGER.debug( - "egress_gate_body_input body=%r", - _decode_for_debug(request.body), - ) - try: for gate_name, gate_type, gate in self._gates: timeout.raise_if_expired() @@ -222,11 +214,6 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: fingerprint=self._policy_fingerprint, traces=traces, ) - if self._log_request_content and result.patch.replacement_body is not None: - _LOGGER.debug( - "egress_gate_body_output body=%r", - _decode_for_debug(result.patch.replacement_body), - ) return result @@ -372,13 +359,6 @@ def _validate_remove_mutation(mutation: RemoveHeaderMutation) -> None: raise GateContractError("protected headers cannot be removed") -def _decode_for_debug(body: bytes) -> str: - try: - return body.decode("utf-8", errors="strict") - except UnicodeDecodeError: - return "" - - _PROTECTED_HEADER_NAMES = frozenset( { "authorization", diff --git a/projects/egress-gate/src/egress_gate/service/server.py b/projects/egress-gate/src/egress_gate/service/server.py index 61315eb5..f5256a62 100644 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ b/projects/egress-gate/src/egress_gate/service/server.py @@ -28,12 +28,10 @@ def __init__( registry: GateRegistry, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, - log_request_content: bool = False, ) -> None: self._middleware = EgressGateMiddleware( registry, timeout_seconds=timeout_seconds, - log_request_content=log_request_content, ) def serve_sync(self, listen: str = DEFAULT_LISTEN_ADDRESS) -> None: diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index 5564c274..78b51cef 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -76,16 +76,12 @@ def __init__( registry: GateRegistry, *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, - log_request_content: bool = False, ) -> None: if not registry.is_finalized: raise GateRegistryError("middleware requires a finalized gate registry") self._registry = registry self._timeout_seconds = validate_timeout_seconds(timeout_seconds) - self._policy = _ActivePolicy( - registry, - log_request_content=log_request_content, - ) + self._policy = _ActivePolicy(registry) self._processing_slots = asyncio.Semaphore(MAX_CONCURRENT_PROCESSING) self._processing_executor = ThreadPoolExecutor( max_workers=MAX_CONCURRENT_PROCESSING, @@ -300,14 +296,8 @@ def _worker_finished(self, future: asyncio.Future[object]) -> None: class _ActivePolicy: """Own one active validated policy and its prepared immutable gates.""" - def __init__( - self, - registry: GateRegistry, - *, - log_request_content: bool, - ) -> None: + def __init__(self, registry: GateRegistry) -> None: self._registry = registry - self._log_request_content = log_request_content self._config: EgressGateConfig[GateConfig] | None = None self._processor: RequestProcessor | None = None self._lock = Lock() @@ -350,7 +340,6 @@ def _build_processor( return self._registry.prepare_processor( config, timeout=timeout, - log_request_content=self._log_request_content, ) def clear(self) -> None: diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 8adbaded..e7ea9807 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -17,6 +17,14 @@ from egress_gate.gates import GateRegistry, create_builtin_registry +def test_cli_does_not_offer_request_content_logging() -> None: + result = CliRunner().invoke(app, ["--help"]) + + assert result.exit_code == 0 + assert "--debug" in result.stdout + assert "--debug-log-content" not in result.stdout + + def test_cli_gates_describes_the_request_level_builtin() -> None: result = CliRunner().invoke(app, ["gates", "list"]) From f80a2f6a0da88ef12b94c1ce5de7ae0ec46342c1 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Tue, 4 Aug 2026 23:01:12 +0000 Subject: [PATCH 31/46] Normalize CLI help test output --- projects/egress-gate/tests/test_cli.py | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index e7ea9807..4ac1e716 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -18,11 +18,12 @@ def test_cli_does_not_offer_request_content_logging() -> None: - result = CliRunner().invoke(app, ["--help"]) + result = CliRunner().invoke(app, ["--help"], color=True) assert result.exit_code == 0 - assert "--debug" in result.stdout - assert "--debug-log-content" not in result.stdout + help_output = Text.from_ansi(result.stdout).plain + assert "--debug" in help_output + assert "--debug-log-content" not in help_output def test_cli_gates_describes_the_request_level_builtin() -> None: From fc82923306dbce14aa7ef6bcac9a64f29569b707 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 5 Aug 2026 00:48:53 +0000 Subject: [PATCH 32/46] Polish Egress Gate presentation --- docs/documentation/index.md | 4 ++-- projects/README.md | 4 ++-- projects/egress-gate/docs/evaluation.md | 12 ++++-------- projects/egress-gate/src/egress_gate/cli.py | 7 ++++--- 4 files changed, 12 insertions(+), 15 deletions(-) diff --git a/docs/documentation/index.md b/docs/documentation/index.md index 4a608d6c..7df90347 100644 --- a/docs/documentation/index.md +++ b/docs/documentation/index.md @@ -9,5 +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 that evaluates - provider-bound HTTP requests before OpenShell attaches credentials. +- [Egress Gate](egress-gate/index.md): extensible middleware for provider-bound + HTTP requests. diff --git a/projects/README.md b/projects/README.md index ce6e299e..85865c22 100644 --- a/projects/README.md +++ b/projects/README.md @@ -8,8 +8,8 @@ Current projects: - `openshell-middleware-manager`: `omm` CLI that creates and updates version-matched Python and Rust OpenShell supervisor middleware projects. -- `egress-gate`: Extensible OpenShell supervisor middleware that evaluates - provider-bound HTTP requests before OpenShell attaches credentials. +- `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/docs/evaluation.md b/projects/egress-gate/docs/evaluation.md index 9becd4e3..a3d7696a 100644 --- a/projects/egress-gate/docs/evaluation.md +++ b/projects/egress-gate/docs/evaluation.md @@ -43,14 +43,10 @@ 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 │ -╰────────┴──────────────────────────────────────────┴────────────────────╯ +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 ``` diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 98ffc9e9..ee16ed8f 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -16,7 +16,6 @@ import typer import yaml from pydantic import ValidationError, field_validator, model_validator -from rich import box from rich.console import Console from rich.panel import Panel from rich.syntax import Syntax @@ -784,10 +783,12 @@ def _render_evaluation(summary: _EvaluationSummary) -> None: """Render content-safe case results and their aggregate.""" table = Table( title="Policy evaluation", - box=box.ROUNDED, + box=None, + pad_edge=False, + padding=(0, 2), header_style="bold cyan", title_style="bold", - show_lines=True, + title_justify="left", ) table.add_column("Status", no_wrap=True) table.add_column("Case", ratio=2) From 6eec148c7e0ddb6449e3fdd9fc54c155e14644ff Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 5 Aug 2026 01:17:06 +0000 Subject: [PATCH 33/46] Balance documentation header controls --- docs/stylesheets/dev-notes.css | 44 +++++++++++++++++++++------------- 1 file changed, 28 insertions(+), 16 deletions(-) diff --git a/docs/stylesheets/dev-notes.css b/docs/stylesheets/dev-notes.css index 896c2064..6a4de141 100644 --- a/docs/stylesheets/dev-notes.css +++ b/docs/stylesheets/dev-notes.css @@ -10,6 +10,8 @@ :root { --openshell-sidebar-width: 15.25rem; + --openshell-header-control-size: 2.4rem; + --openshell-header-icon-size: 1.2rem; --openshell-green: #76b900; --openshell-green-soft: #8dc63f; --openshell-accent: #3c626b; @@ -101,9 +103,32 @@ body { display: inline-flex; align-items: center; justify-content: center; + width: var(--openshell-header-control-size); + height: var(--openshell-header-control-size); + margin: 0; + padding: 0.6rem; order: -2; } +.md-header__option .md-header__button, +.md-header__inner > [for="__search"], +.md-header__source .md-source { + box-sizing: border-box; + width: var(--openshell-header-control-size); + height: var(--openshell-header-control-size); + margin: 0; + padding: 0.6rem; +} + +.md-header__option .md-header__button svg, +.md-header__inner > [for="__search"] svg, +.openshell-drawer-button svg, +.md-header__source .md-source__icon, +.md-header__source .md-source__icon svg { + width: var(--openshell-header-icon-size); + height: var(--openshell-header-icon-size); +} + .openshell-drawer-button:focus-visible { border-radius: 0.2rem; outline: 2px solid var(--openshell-accent); @@ -111,8 +136,6 @@ body { } .openshell-drawer-button svg { - width: 1.35rem; - height: 1.35rem; fill: none; stroke: currentColor; stroke-width: 1.7; @@ -174,6 +197,7 @@ body { .md-header__source .md-source { display: inline-flex; align-items: center; + justify-content: center; color: var(--openshell-ink); } @@ -187,13 +211,9 @@ body { display: inline-flex; align-items: center; justify-content: center; - width: 1.8rem; - height: 1.8rem; } .md-header__source .md-source__icon svg { - width: 1.1rem; - height: 1.1rem; margin: 0; } @@ -252,23 +272,15 @@ body { .md-header__inner > .openshell-drawer-button { position: fixed; z-index: 7; - top: 0.675rem; + top: 0.65rem; left: 0.85rem; - width: 2.35rem; - height: 2.35rem; - margin: 0; - color: var(--md-default-fg-color--light); + color: var(--openshell-ink); background: transparent; border: 0; border-radius: 0.35rem; transition: color 120ms ease, background-color 120ms ease; } - .md-header__inner > .openshell-drawer-button svg { - width: 1.1rem; - height: 1.1rem; - } - .md-header__inner > .openshell-drawer-button:hover { color: var(--md-default-fg-color); background: color-mix(in srgb, var(--md-default-fg-color) 8%, transparent); From 259ce87250691d3a80646392cffa1001045f9998 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 5 Aug 2026 01:25:19 +0000 Subject: [PATCH 34/46] Keep documentation header width consistent --- docs/stylesheets/dev-notes.css | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/docs/stylesheets/dev-notes.css b/docs/stylesheets/dev-notes.css index 6a4de141..c2aa71fb 100644 --- a/docs/stylesheets/dev-notes.css +++ b/docs/stylesheets/dev-notes.css @@ -590,8 +590,12 @@ body { /* Landing page ------------------------------------------------------------ */ -body:has(.dev-notes-page) .md-grid, -body:has(.openshell-home-page) .md-grid { +body:has(.dev-notes-page) .md-main__inner.md-grid, +body:has(.dev-notes-page) .md-footer__inner.md-grid, +body:has(.dev-notes-page) .md-footer-meta__inner.md-grid, +body:has(.openshell-home-page) .md-main__inner.md-grid, +body:has(.openshell-home-page) .md-footer__inner.md-grid, +body:has(.openshell-home-page) .md-footer-meta__inner.md-grid { max-width: 66rem; } From 1cf756c5aa8c67633cd9a891f5b8803624f6c10a Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 5 Aug 2026 01:29:06 +0000 Subject: [PATCH 35/46] Harden documentation layout consistency --- docs/stylesheets/dev-notes.css | 23 ++++++++++++++++++----- tests/test_page_navigation.py | 10 ++++++++++ 2 files changed, 28 insertions(+), 5 deletions(-) diff --git a/docs/stylesheets/dev-notes.css b/docs/stylesheets/dev-notes.css index c2aa71fb..ae1eea31 100644 --- a/docs/stylesheets/dev-notes.css +++ b/docs/stylesheets/dev-notes.css @@ -10,6 +10,7 @@ :root { --openshell-sidebar-width: 15.25rem; + --openshell-header-height: 3.7rem; --openshell-header-control-size: 2.4rem; --openshell-header-icon-size: 1.2rem; --openshell-green: #76b900; @@ -95,7 +96,7 @@ body { } .md-header__inner { - height: 3.7rem; + height: var(--openshell-header-height); } .md-header__inner > [for="__drawer"], @@ -272,7 +273,7 @@ body { .md-header__inner > .openshell-drawer-button { position: fixed; z-index: 7; - top: 0.65rem; + top: calc((var(--openshell-header-height) - var(--openshell-header-control-size)) / 2); left: 0.85rem; color: var(--openshell-ink); background: transparent; @@ -306,10 +307,10 @@ body { } .md-sidebar--primary { - top: 3.7rem !important; + top: var(--openshell-header-height) !important; bottom: 0 !important; left: 0 !important; - height: calc(100vh - 3.7rem) !important; + height: calc(100vh - var(--openshell-header-height)) !important; border-width: 0 1px 0 0; border-radius: 0; box-shadow: none; @@ -628,7 +629,7 @@ body:has(.openshell-home-page) .md-path { } .research-masthead { - min-height: min(30rem, calc(100vh - 3.7rem)); + min-height: min(30rem, calc(100vh - var(--openshell-header-height))); padding: clamp(3.5rem, 9vw, 7.5rem) 0 clamp(3.2rem, 7vw, 5.8rem); border-bottom: 1px solid var(--openshell-rule-strong); } @@ -1382,6 +1383,18 @@ body[data-md-color-scheme="slate"] .openshell-home-brand__dark { html { scroll-behavior: auto; } + + .openshell-drawer-button, + .md-overlay, + .md-sidebar--primary, + .md-main, + .md-footer, + .dev-note-card__visual::before, + .dev-note-card__visual::after, + .dev-note-card__visual-image, + .dev-note-card__read::after { + transition: none !important; + } } /* Dev note diagrams (SVG figures) */ diff --git a/tests/test_page_navigation.py b/tests/test_page_navigation.py index 8295fd12..2649c026 100644 --- a/tests/test_page_navigation.py +++ b/tests/test_page_navigation.py @@ -18,6 +18,16 @@ class PageNavigationTests(unittest.TestCase): + def test_landing_page_width_does_not_change_the_shared_header(self) -> None: + styles = STYLES.read_text(encoding="utf-8") + + self.assertNotIn("body:has(.dev-notes-page) .md-grid", styles) + self.assertNotIn("body:has(.openshell-home-page) .md-grid", styles) + self.assertIn( + "body:has(.openshell-home-page) .md-main__inner.md-grid", + styles, + ) + def test_footer_navigation_is_enabled(self) -> None: config = CONFIG.read_text(encoding="utf-8") styles = STYLES.read_text(encoding="utf-8") From aa74572e0081aa0038dffd5134763b0db57b5cad Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 5 Aug 2026 02:58:08 +0000 Subject: [PATCH 36/46] Clarify Egress Gate request mutations --- projects/egress-gate/AGENTS.md | 21 +++--- projects/egress-gate/README.md | 10 ++- .../egress-gate/docs/architecture/index.md | 21 +++--- .../docs/architecture/request-lifecycle.md | 35 +++++----- .../docs/architecture/service-boundary.md | 31 ++++++--- .../diagrams/component-architecture.svg | 10 +-- .../assets/diagrams/processing-pipeline.svg | 16 ++--- .../assets/diagrams/request-lifecycle.svg | 34 +++++----- .../docs/assets/diagrams/request-path.svg | 8 +-- projects/egress-gate/docs/configuration.md | 10 +-- projects/egress-gate/docs/evaluation.md | 6 +- projects/egress-gate/docs/gates/custom.md | 4 ++ projects/egress-gate/docs/gates/index.md | 15 ++-- projects/egress-gate/docs/gates/regex.md | 8 ++- projects/egress-gate/docs/index.md | 36 +++++++--- projects/egress-gate/docs/operations.md | 6 +- .../docs/reference/limits-and-failures.md | 14 ++-- .../egress-gate/src/egress_gate/gates/base.py | 13 ++-- .../src/egress_gate/gates/regex.py | 6 +- .../egress-gate/src/egress_gate/request.py | 10 +-- .../src/egress_gate/request_processor.py | 68 +++++++++++-------- .../egress-gate/src/egress_gate/result.py | 20 +++--- .../src/egress_gate/service/servicer.py | 8 +-- .../egress-gate/tests/gates/test_regex.py | 14 ++-- .../tests/service/test_servicer.py | 10 +-- projects/egress-gate/tests/test_request.py | 22 +++--- .../tests/test_request_processor.py | 38 +++++------ projects/egress-gate/tests/test_result.py | 13 ++-- 28 files changed, 292 insertions(+), 215 deletions(-) diff --git a/projects/egress-gate/AGENTS.md b/projects/egress-gate/AGENTS.md index 66098eed..bae53fcf 100644 --- a/projects/egress-gate/AGENTS.md +++ b/projects/egress-gate/AGENTS.md @@ -2,7 +2,8 @@ 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, deny, or mutation result. +request-level gates, and returns an explicit allow or deny result with optional +request mutations. ## Development commands @@ -39,7 +40,7 @@ Run focused tests while working and `make check` before handoff. - `src/egress_gate/gates/`: `Gate`, helper bases, registry, and the regex gate - `src/egress_gate/config.py`: strict `pipeline.gates` and `default_decision` policy models -- `src/egress_gate/request.py`: protobuf-free request and ordered patch models +- `src/egress_gate/request.py`: protobuf-free request and request-mutation models - `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 @@ -69,10 +70,11 @@ 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. A proceeding patch is applied before the -next gate; body replacement intent is preserved even when replacement bytes are -equal to the input. Runtime provenance is added by `RequestProcessor`, never by -gate configuration or gate-produced findings. +`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 @@ -88,9 +90,10 @@ UTF-8 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 is -runtime-internal in `SourcedFinding` and `DecisionSource`; do not serialize -source or attributes or encode them into labels or result metadata. +`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 diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index fcd320cc..d61e309c 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -3,11 +3,17 @@ 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 return a validated mutation. +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 runtime. Egress Gate does not add provenance to findings or labels. +inside the pipeline processor. Egress Gate does not add provenance to findings +or labels. ## Quickstart diff --git a/projects/egress-gate/docs/architecture/index.md b/projects/egress-gate/docs/architecture/index.md index 3805f6bb..d7c0ac02 100644 --- a/projects/egress-gate/docs/architecture/index.md +++ b/projects/egress-gate/docs/architecture/index.md @@ -6,38 +6,38 @@ agent_markdown: true # System architecture -Egress Gate has one transport adapter and one protobuf-free runtime. +Egress Gate has one transport adapter and one protobuf-free pipeline processor.
- Egress Gate has separate transport, policy, request-processing, and request-gate layers. -
Transport code stays outside the protobuf-free runtime and gate contract.
+ 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 ordered `RequestPatch` | +| `request.py` | Immutable request, headers, and `RequestMutations` | | `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.py` | Typed scan and action selection, bounded matching, overlap handling, caching, and body replacement | | `config.py` | Strict `pipeline.gates` and required default decision | -| `request_processor.py` | Shared deadline, current-request mutation, control flow, aggregation, and provenance | +| `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 processor and -gates receive domain values and can be tested offline. +Only `service/` imports generated protobuf/gRPC bindings. The pipeline processor +and gates receive domain values and can be tested offline. ## Pipeline execution
- A request moves through runtime controls and an ordered gate pipeline before Egress Gate returns a result. -
Each gate sees the current request. A proceed result makes a validated patch visible to the next gate.
+ 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 @@ -47,7 +47,8 @@ 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 `RequestProcessor` are active at a time. +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 diff --git a/projects/egress-gate/docs/architecture/request-lifecycle.md b/projects/egress-gate/docs/architecture/request-lifecycle.md index 77d277a3..a5bd737b 100644 --- a/projects/egress-gate/docs/architecture/request-lifecycle.md +++ b/projects/egress-gate/docs/architecture/request-lifecycle.md @@ -7,8 +7,8 @@ agent_markdown: true # Request lifecycle
- The request lifecycle validates the transport and policy, runs the gate pipeline, and returns either a result or an RPC failure. -
Input failures end the RPC. Policy decisions and runtime-limit denials return normal middleware results.
+ 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 @@ -27,26 +27,28 @@ candidate only after a final deadline check. ## 3. Execute the pipeline -For each configured gate: +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. Reconstruct and validate the returned `GateEvaluation`. -4. Add a content-safe `GateTrace` and runtime-owned `SourcedFinding` values. -5. On `proceed`, validate the patch and construct the next request snapshot. +4. Add a content-safe `GateTrace` and `SourcedFinding` values owned by the + pipeline processor. +5. On `proceed`, validate the request mutations and construct the next request + snapshot. 6. On terminal `allow` or `deny`, stop without invoking later gates. -The processor never changes a request object in place. It keeps the original -request private, constructs a new snapshot after each validated patch, and -passes that snapshot to the next gate. The final allowed patch combines these -changes in order for the service to return to OpenShell. A denied result always -has an empty patch. Body replacement `None` and `b""` remain distinct. Header -mutation variants use the required `kind` values `write` and `remove`. +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`. If every gate proceeds, `default_decision` controls the result. Default deny uses `egress_gate_default_deny`. Default allow has no reason code. -## 4. Handle runtime limits +## 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 @@ -55,7 +57,8 @@ Gate contract and execution failures remain gRPC failures. ## 5. Serialize the result -The service converts the protobuf-free `EgressResult` to the current OpenShell -wire contract. It serializes exactly five finding fields and never puts source -or attributes into labels or result metadata. An explicit empty replacement -sets `has_body=true` with an empty body. +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 index 06ac15ec..00f3ed4c 100644 --- a/projects/egress-gate/docs/architecture/service-boundary.md +++ b/projects/egress-gate/docs/architecture/service-boundary.md @@ -10,6 +10,11 @@ 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 | @@ -37,20 +42,28 @@ runs in a worker. The worker owns its slot until it exits. The current OpenShell `Finding` contains exactly `type`, `label`, `count`, `confidence`, and `severity`. `SourcedFinding.source_gate`, decision sources, -and traces are runtime values and are not serialized. Decision sources use a -strict `kind`-discriminated union. The adapter rechecks protobuf finding and -header sizes before returning a response. +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. -`RequestPatch` operations serialize in their validated order. `None` means no -replacement, while empty bytes are emitted with `has_body=true`. +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 -processor. An equal configuration reuses the active processor. The service -prepares a changed candidate before it publishes that candidate. An invalid -candidate does not replace the active policy. +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 runtime-limit deny is not a gRPC failure. It uses +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/diagrams/component-architecture.svg b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg index 7d69b2bd..93b6cbec 100644 --- a/projects/egress-gate/docs/assets/diagrams/component-architecture.svg +++ b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg @@ -1,6 +1,6 @@ Egress Gate component architecture - Four architecture layers show transport and startup adapters, typed configuration and registry, request processing, and request-level gates. Downward arrows show configuration flowing into the request processor and the processor invoking concrete gates through their shared wrapper. + 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. A provider-bound request moves from a sandbox application through OpenShell and Egress Gate before it reaches the provider. -
OpenShell calls Egress Gate before it attaches provider credentials.
+ 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 builds local request snapshots. The OpenShell supervisor owns and updates the intercepted request.
-Only `service/` imports generated bindings. Gate and processor code is +Only `service/` imports generated bindings. Gate and pipeline processor code is protobuf-free and can be evaluated offline. ## Quickstart @@ -52,19 +67,18 @@ upstream provider. ## Core rules - A policy has one through ten named gates and a required `default_decision`. -- Each gate receives a read-only request snapshot that includes validated - patches from earlier gates. -- A `proceed` result can propose a patch. The runtime validates it and creates - the snapshot for the next gate. Terminal `allow` and `deny` require empty - patches. +- Each gate receives the current read-only request snapshot. +- Only `proceed` can propose request mutations. Terminal `allow` and `deny` + require an empty mutation set. - `None` body replacement means no replacement. `b""` is an explicit empty replacement. -- When a runtime safety limit occurs, Egress Gate denies the request. The result - uses source `runtime_limit` and code `egress_gate_limit_exceeded`. +- 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 runtime-internal. + decision provenance remain internal to the pipeline processor. ## Further reading diff --git a/projects/egress-gate/docs/operations.md b/projects/egress-gate/docs/operations.md index b4a503d4..881b0899 100644 --- a/projects/egress-gate/docs/operations.md +++ b/projects/egress-gate/docs/operations.md @@ -51,12 +51,12 @@ Successful policy outcomes are distinct from gRPC failures: | --- | --- | | Gate deny | deny, gate-owned reason code | | Pipeline default deny | deny, `egress_gate_default_deny` | -| Runtime safety limit | deny, `egress_gate_limit_exceeded` | +| Pipeline processor reaches a safety limit | deny, `egress_gate_limit_exceeded` | | Invalid request or config | gRPC `INVALID_ARGUMENT` | | Gate or service failure | gRPC `INTERNAL` | -Runtime limit results contain no partial patch or findings. A failed candidate -does not replace the active policy. See +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 diff --git a/projects/egress-gate/docs/reference/limits-and-failures.md b/projects/egress-gate/docs/reference/limits-and-failures.md index a683929d..93f74379 100644 --- a/projects/egress-gate/docs/reference/limits-and-failures.md +++ b/projects/egress-gate/docs/reference/limits-and-failures.md @@ -19,7 +19,7 @@ limits. | Result metadata entries | 64 | | Result metadata aggregate strings | 32 KiB | | Gate traces per result | 10 | -| Header mutations per patch | 64 | +| Header mutations per gate evaluation | 64 | | Processing timeout | 30 seconds maximum | | Concurrent processing slots | 4 | @@ -34,19 +34,19 @@ the first rejected value. | --- | --- | | Invalid phase, envelope, policy, or input encoding | gRPC `INVALID_ARGUMENT` | | Gate contract or unexpected execution failure | gRPC `INTERNAL` | -| Deadline or runtime limit | deny, source `runtime_limit`, code `egress_gate_limit_exceeded` | +| 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 | -Runtime-limit results contain no partial patch, 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. +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. ## Finding contract -The released OpenShell wire contract has five fields. The runtime's +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/src/egress_gate/gates/base.py b/projects/egress-gate/src/egress_gate/gates/base.py index 4e219c27..136821a8 100644 --- a/projects/egress-gate/src/egress_gate/gates/base.py +++ b/projects/egress-gate/src/egress_gate/gates/base.py @@ -221,9 +221,11 @@ def _evaluate( result = self._evaluate_text(text, timeout=timeout) if not isinstance(result, GateEvaluation): raise GateContractError("UTF-8 body gate output is invalid") - if result.patch.replacement_body is not None: + if result.request_mutations.replacement_body is not None: try: - result.patch.replacement_body.decode("utf-8", errors="strict") + result.request_mutations.replacement_body.decode( + "utf-8", errors="strict" + ) except UnicodeDecodeError: raise GateContractError( "UTF-8 body gate returned a non-UTF-8 replacement" @@ -246,9 +248,12 @@ def _validate_gate_output( finding_types: tuple[FindingTypeDefinition, ...], result: GateEvaluation, ) -> None: - if result.patch.replacement_body is not None and not capabilities.replaces_body: + if ( + result.request_mutations.replacement_body is not None + and not capabilities.replaces_body + ): raise GateContractError("gate returned an undeclared body replacement") - if result.patch.header_mutations and not capabilities.mutates_headers: + if result.request_mutations.header_mutations and not capabilities.mutates_headers: raise GateContractError("gate returned undeclared header mutations") if result.findings and not capabilities.produces_findings: raise GateContractError("gate returned undeclared findings") diff --git a/projects/egress-gate/src/egress_gate/gates/regex.py b/projects/egress-gate/src/egress_gate/gates/regex.py index b44b2a75..18ef30da 100644 --- a/projects/egress-gate/src/egress_gate/gates/regex.py +++ b/projects/egress-gate/src/egress_gate/gates/regex.py @@ -48,7 +48,7 @@ ) from egress_gate.gates.base import Gate, GateCapabilities, GateConfig from egress_gate.logging import get_logger -from egress_gate.request import HeaderName, HttpRequest, RequestPatch +from egress_gate.request import HeaderName, HttpRequest, RequestMutations from egress_gate.result import Finding, FindingTypeDefinition, GateEvaluation from egress_gate.string_validators import ScalarString, validate_scalar_string from egress_gate.timeout import Timeout @@ -337,7 +337,9 @@ def _evaluate( action.template, ) return GateEvaluation.proceed( - patch=RequestPatch(replacement_body=output_text.encode("utf-8")), + request_mutations=RequestMutations( + replacement_body=output_text.encode("utf-8") + ), findings=findings, ) diff --git a/projects/egress-gate/src/egress_gate/request.py b/projects/egress-gate/src/egress_gate/request.py index f36f1ad6..9fe191fe 100644 --- a/projects/egress-gate/src/egress_gate/request.py +++ b/projects/egress-gate/src/egress_gate/request.py @@ -140,7 +140,7 @@ class RemoveHeaderMutation(StrictDomainModel): ] -class RequestPatch(StrictDomainModel): +class RequestMutations(StrictDomainModel): """Validated body and header mutations proposed by one gate.""" replacement_body: bytes | None = Field( @@ -154,7 +154,7 @@ class RequestPatch(StrictDomainModel): ) @model_validator(mode="after") - def _mutations_are_bounded(self) -> RequestPatch: + def _mutations_are_bounded(self) -> RequestMutations: data_size = sum( len(mutation.name.encode("utf-8")) + ( @@ -165,12 +165,12 @@ def _mutations_are_bounded(self) -> RequestPatch: for mutation in self.header_mutations ) if data_size > MAX_HEADER_MUTATION_DATA_BYTES: - raise ValueError("request patch header data exceeds the size limit") + raise ValueError("request mutation header data exceeds the size limit") return self @property def is_empty(self) -> bool: - """Whether this patch proposes no mutation.""" + """Whether this set proposes no request mutation.""" return self.replacement_body is None and not self.header_mutations @@ -185,6 +185,6 @@ def is_empty(self) -> bool: "Process", "RemoveHeaderMutation", "RequestContext", - "RequestPatch", + "RequestMutations", "WriteHeaderMutation", ] diff --git a/projects/egress-gate/src/egress_gate/request_processor.py b/projects/egress-gate/src/egress_gate/request_processor.py index a4a1b180..2acdbbf8 100644 --- a/projects/egress-gate/src/egress_gate/request_processor.py +++ b/projects/egress-gate/src/egress_gate/request_processor.py @@ -32,7 +32,7 @@ HttpHeader, HttpRequest, RemoveHeaderMutation, - RequestPatch, + RequestMutations, WriteHeaderMutation, ) from egress_gate.result import ( @@ -96,7 +96,7 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: if not isinstance(timeout, Timeout): raise EgressGateError(ErrorCode.GATE_OUTPUT_INVALID) current_request = request - accumulated_patch = RequestPatch() + accumulated_mutations = RequestMutations() sourced_findings: list[SourcedFinding] = [] traces: list[GateTrace] = [] @@ -105,7 +105,7 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: timeout.raise_if_expired() started = monotonic() evaluation = gate.evaluate(current_request, timeout=timeout) - mutation_kinds = _mutation_kinds(evaluation.patch) + mutation_kinds = _mutation_kinds(evaluation.request_mutations) trace_finding_count = sum( finding.count for finding in evaluation.findings ) @@ -154,19 +154,19 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: gate_name=gate_name, gate_type=gate_type, ), - patch=accumulated_patch, + request_mutations=accumulated_mutations, findings=sourced_findings, fingerprint=self._policy_fingerprint, traces=traces, ) - if not evaluation.patch.is_empty: - current_request = apply_request_patch( + if not evaluation.request_mutations.is_empty: + current_request = apply_request_mutations( current_request, - evaluation.patch, + evaluation.request_mutations, ) - accumulated_patch = _compose_patches( - accumulated_patch, - evaluation.patch, + accumulated_mutations = _compose_request_mutations( + accumulated_mutations, + evaluation.request_mutations, ) timeout.raise_if_expired() except TimeoutExpiredError: @@ -198,7 +198,7 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: source=PipelineDefaultDecisionSource( kind=DecisionSourceKind.PIPELINE_DEFAULT ), - patch=accumulated_patch, + request_mutations=accumulated_mutations, findings=sourced_findings, fingerprint=self._policy_fingerprint, traces=traces, @@ -217,13 +217,22 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: return result -def apply_request_patch(request: HttpRequest, patch: RequestPatch) -> HttpRequest: - """Apply one validated patch to the current request in operation order.""" - if not isinstance(request, HttpRequest) or not isinstance(patch, RequestPatch): - raise GateContractError("request patch input is invalid") - body = request.body if patch.replacement_body is None else patch.replacement_body +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 patch.header_mutations: + for mutation in request_mutations.header_mutations: if isinstance(mutation, WriteHeaderMutation): _validate_write_mutation(mutation) matching = _header_indexes(headers, mutation.name) @@ -245,7 +254,7 @@ def apply_request_patch(request: HttpRequest, patch: RequestPatch) -> HttpReques header for index, header in enumerate(headers) if index not in matching ] else: - raise GateContractError("request patch mutation is invalid") + raise GateContractError("request mutation is invalid") try: return HttpRequest( context=request.context, @@ -287,9 +296,12 @@ def _append_findings( output.append(SourcedFinding(source_gate=gate_name, finding=finding)) -def _compose_patches(first: RequestPatch, second: RequestPatch) -> RequestPatch: +def _compose_request_mutations( + first: RequestMutations, + second: RequestMutations, +) -> RequestMutations: try: - return RequestPatch( + return RequestMutations( replacement_body=( second.replacement_body if second.replacement_body is not None @@ -299,15 +311,15 @@ def _compose_patches(first: RequestPatch, second: RequestPatch) -> RequestPatch: ) except (TypeError, ValueError, ValidationError): raise GateLimitExceededError( - "composed request patch exceeds a runtime limit" + "composed request mutations exceed a runtime limit" ) from None -def _mutation_kinds(patch: RequestPatch) -> tuple[MutationKind, ...]: +def _mutation_kinds(request_mutations: RequestMutations) -> tuple[MutationKind, ...]: kinds: list[MutationKind] = [] - if patch.replacement_body is not None: + if request_mutations.replacement_body is not None: kinds.append(MutationKind.BODY) - if patch.header_mutations: + if request_mutations.header_mutations: kinds.append(MutationKind.HEADERS) return tuple(kinds) @@ -316,7 +328,7 @@ def _result( *, decision: EgressDecision, source: DecisionSource, - patch: RequestPatch | None = None, + request_mutations: RequestMutations | None = None, findings: Sequence[SourcedFinding] = (), reason_code: str | None = None, fingerprint: str | None, @@ -325,7 +337,9 @@ def _result( return EgressResult( decision=decision, decision_source=source, - patch=RequestPatch() if patch is None else patch, + request_mutations=( + RequestMutations() if request_mutations is None else request_mutations + ), findings=tuple(findings), reason_code=reason_code, policy_fingerprint=fingerprint, @@ -380,5 +394,5 @@ def _validate_remove_mutation(mutation: RemoveHeaderMutation) -> None: __all__ = [ "RequestProcessor", - "apply_request_patch", + "apply_request_mutations", ] diff --git a/projects/egress-gate/src/egress_gate/result.py b/projects/egress-gate/src/egress_gate/result.py index 8175d656..304bd61c 100644 --- a/projects/egress-gate/src/egress_gate/result.py +++ b/projects/egress-gate/src/egress_gate/result.py @@ -28,7 +28,7 @@ MAX_TRACE_MUTATION_KINDS, REASON_CODE_PATTERN, ) -from egress_gate.request import RequestPatch +from egress_gate.request import RequestMutations from egress_gate.string_validators import BoundedMetadataString ReasonCode = Annotated[str, Field(pattern=REASON_CODE_PATTERN)] @@ -137,7 +137,7 @@ class GateEvaluation(StrictDomainModel): """Validated output of one gate invocation.""" control: GateControl - patch: RequestPatch = Field(default_factory=RequestPatch) + request_mutations: RequestMutations = Field(default_factory=RequestMutations) findings: tuple[Finding, ...] = Field( default=(), max_length=MAX_PROTO_FINDING_GROUPS, @@ -150,8 +150,8 @@ def _control_contract_is_valid(self) -> Self: if self.reason_code is not None: raise ValueError("proceed evaluations cannot carry a reason code") return self - if not self.patch.is_empty: - raise ValueError("terminal evaluations cannot carry a patch") + 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: @@ -162,13 +162,15 @@ def _control_contract_is_valid(self) -> Self: def proceed( cls, *, - patch: RequestPatch | None = None, + request_mutations: RequestMutations | None = None, findings: tuple[Finding, ...] = (), ) -> Self: """Create a non-terminal evaluation.""" return cls( control=GateControl.PROCEED, - patch=RequestPatch() if patch is None else patch, + request_mutations=( + RequestMutations() if request_mutations is None else request_mutations + ), findings=findings, ) @@ -218,7 +220,7 @@ class EgressResult(StrictDomainModel): decision: EgressDecision decision_source: DecisionSource - patch: RequestPatch = Field(default_factory=RequestPatch) + request_mutations: RequestMutations = Field(default_factory=RequestMutations) findings: tuple[SourcedFinding, ...] = Field( default=(), max_length=MAX_PROTO_FINDING_GROUPS, @@ -244,8 +246,8 @@ def _result_contract_is_valid(self) -> Self: raise ValueError("result metadata exceeds the size limit") source_kind = self.decision_source.kind if self.decision is EgressDecision.DENY: - if not self.patch.is_empty: - raise ValueError("denied results cannot carry a patch") + 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: diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index 78b51cef..e48758d0 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -528,12 +528,12 @@ def _serialize_result(result: EgressResult) -> pb2.HttpRequestResult: if len(response.findings) > MAX_PROTO_FINDING_GROUPS: return _limit_deny() if result.decision is EgressDecision.ALLOW: - if result.patch.replacement_body is not None: - if len(result.patch.replacement_body) > MAX_BODY_BYTES: + 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.patch.replacement_body + response.body = result.request_mutations.replacement_body response.has_body = True - for mutation in result.patch.header_mutations: + 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} diff --git a/projects/egress-gate/tests/gates/test_regex.py b/projects/egress-gate/tests/gates/test_regex.py index 5a4669ed..e35126aa 100644 --- a/projects/egress-gate/tests/gates/test_regex.py +++ b/projects/egress-gate/tests/gates/test_regex.py @@ -97,7 +97,7 @@ def test_detect_action_reports_overlaps_without_mutating_the_body() -> None: ) assert evaluation.control is GateControl.PROCEED - assert evaluation.patch.replacement_body is None + 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"} @@ -130,7 +130,7 @@ def test_deny_action_is_terminal_and_uses_the_stable_gate_reason() -> None: assert evaluation.control is GateControl.DENY assert evaluation.reason_code == "egress_gate_regex_denied" - assert evaluation.patch.is_empty + assert evaluation.request_mutations.is_empty assert len(evaluation.findings) == 1 @@ -144,9 +144,9 @@ def test_replace_action_preserves_explicit_replacement_intent() -> None: changed = _run(config, "contains secret") unchanged = _run(config, "no match") - assert changed.patch.replacement_body == b"contains [token]" - assert unchanged.patch.replacement_body == b"no match" - assert not unchanged.patch.is_empty + 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( @@ -182,7 +182,7 @@ def test_detect_action_matches_the_configured_request_scan( assert evaluation.control is GateControl.PROCEED assert len(evaluation.findings) == 1 - assert evaluation.patch.is_empty + assert evaluation.request_mutations.is_empty def test_header_scan_matches_each_selected_repeated_value() -> None: @@ -411,7 +411,7 @@ def test_replacement_selects_ranked_non_overlapping_winners() -> None: "abc", ) - assert evaluation.patch.replacement_body == b"a" + assert evaluation.request_mutations.replacement_body == b"a" assert len(evaluation.findings) == 2 diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 6895254d..38752f06 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -37,7 +37,7 @@ ) from egress_gate.request import ( ExistingHeaderAction, - RequestPatch, + RequestMutations, WriteHeaderMutation, ) from egress_gate.result import ( @@ -224,7 +224,7 @@ def test_result_adapter_serializes_only_five_finding_fields_and_empty_body_inten decision_source=PipelineDefaultDecisionSource( kind=DecisionSourceKind.PIPELINE_DEFAULT ), - patch=RequestPatch(replacement_body=b""), + request_mutations=RequestMutations(replacement_body=b""), findings=(SourcedFinding(source_gate="body", finding=finding),), ) @@ -249,7 +249,7 @@ def test_result_adapter_preserves_ordered_header_mutations_and_deny_reason() -> decision_source=PipelineDefaultDecisionSource( kind=DecisionSourceKind.PIPELINE_DEFAULT ), - patch=RequestPatch( + request_mutations=RequestMutations( header_mutations=( WriteHeaderMutation( kind="write", @@ -420,8 +420,8 @@ def test_in_flight_processor_reference_survives_policy_replacement() -> None: finally: asyncio.run(middleware.close()) - assert old_result.patch.replacement_body is None - assert replacement_result.patch.replacement_body == b"[token]" + assert old_result.request_mutations.replacement_body is None + assert replacement_result.request_mutations.replacement_body == b"[token]" @pytest.mark.asyncio diff --git a/projects/egress-gate/tests/test_request.py b/projects/egress-gate/tests/test_request.py index 0462db8f..e4ccd575 100644 --- a/projects/egress-gate/tests/test_request.py +++ b/projects/egress-gate/tests/test_request.py @@ -23,7 +23,7 @@ Process, RemoveHeaderMutation, RequestContext, - RequestPatch, + RequestMutations, WriteHeaderMutation, ) @@ -97,21 +97,21 @@ def test_header_count_and_data_boundaries() -> None: _request(headers=(HttpHeader(name="x", value="x" * MAX_PROTO_HEADERS_BYTES),)) -def test_request_patch_distinguishes_no_replacement_from_empty_body() -> None: - no_replacement = RequestPatch() - empty_replacement = RequestPatch(replacement_body=b"") +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_patch_preserves_ordered_discriminated_header_mutations() -> None: +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" - patch = RequestPatch( + request_mutations = RequestMutations( header_mutations=( WriteHeaderMutation( kind="write", @@ -123,8 +123,8 @@ def test_request_patch_preserves_ordered_discriminated_header_mutations() -> Non ) ) - assert patch.header_mutations[0].kind == "write" - assert patch.header_mutations[1].kind == "remove" + 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"}) @@ -132,15 +132,15 @@ def test_request_patch_preserves_ordered_discriminated_header_mutations() -> Non adapter.validate_python({"operation": "remove", "name": "x-test"}) -def test_request_patch_rejects_invalid_mutation_bounds() -> None: +def test_request_mutations_reject_invalid_bounds() -> None: mutation = RemoveHeaderMutation(kind="remove", name="x-test") with pytest.raises(ValidationError): - RequestPatch( + RequestMutations( header_mutations=tuple(mutation for _ in range(MAX_HEADER_MUTATIONS + 1)) ) with pytest.raises(ValidationError): - RequestPatch( + RequestMutations( header_mutations=( WriteHeaderMutation( kind="write", diff --git a/projects/egress-gate/tests/test_request_processor.py b/projects/egress-gate/tests/test_request_processor.py index 2c5e5ca4..6493f2e7 100644 --- a/projects/egress-gate/tests/test_request_processor.py +++ b/projects/egress-gate/tests/test_request_processor.py @@ -34,10 +34,10 @@ HttpTarget, RemoveHeaderMutation, RequestContext, - RequestPatch, + RequestMutations, WriteHeaderMutation, ) -from egress_gate.request_processor import RequestProcessor, apply_request_patch +from egress_gate.request_processor import RequestProcessor, apply_request_mutations from egress_gate.result import ( DecisionSourceKind, EgressDecision, @@ -121,7 +121,7 @@ def _evaluate( ), ) return GateEvaluation.proceed( - patch=RequestPatch( + request_mutations=RequestMutations( replacement_body=( None if self.config.replacement is None @@ -219,7 +219,7 @@ def test_processor_process_requires_the_service_created_timeout() -> None: assert result.decision is EgressDecision.ALLOW -def test_processor_applies_patches_to_the_current_request_and_preserves_intent() -> ( +def test_processor_applies_mutations_to_the_current_request_and_preserves_intent() -> ( None ): processor = _processor( @@ -248,8 +248,8 @@ def test_processor_applies_patches_to_the_current_request_and_preserves_intent() assert result.decision is EgressDecision.ALLOW assert result.decision_source.kind is DecisionSourceKind.PIPELINE_DEFAULT - assert result.patch.replacement_body == b"redacted" - mutation = result.patch.header_mutations[0] + 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] == [ @@ -263,7 +263,7 @@ def test_processor_applies_patches_to_the_current_request_and_preserves_intent() assert result.policy_fingerprint == "policy-fingerprint" -def test_regex_gate_sees_header_patches_from_an_earlier_gate() -> None: +def test_regex_gate_sees_header_mutations_from_an_earlier_gate() -> None: processor = _processor( ( ( @@ -292,7 +292,7 @@ def test_regex_gate_sees_header_patches_from_an_earlier_gate() -> None: assert result.decision is EgressDecision.DENY assert isinstance(result.decision_source, GateDecisionSource) assert result.decision_source.gate_name == "inspect-header" - assert result.patch.is_empty + assert result.request_mutations.is_empty def test_processor_aggregates_equivalent_findings_by_gate_provenance() -> None: @@ -357,7 +357,7 @@ def test_terminal_decisions_skip_later_gates() -> None: assert allowed.decision_source.gate_name == "allow" -def test_default_deny_owns_its_reason_and_discards_accumulated_patch() -> None: +def test_default_deny_owns_its_reason_and_discards_accumulated_mutations() -> None: processor = _processor( (("redact", {"kind": "test-control", "replacement": "redacted"}),), default_decision=DefaultDecision.DENY, @@ -368,7 +368,7 @@ def test_default_deny_owns_its_reason_and_discards_accumulated_patch() -> None: 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.patch.is_empty + assert result.request_mutations.is_empty def test_expired_shared_timeout_returns_atomic_runtime_limit_result() -> None: @@ -382,7 +382,7 @@ def test_expired_shared_timeout_returns_atomic_runtime_limit_result() -> None: assert result.decision is EgressDecision.DENY assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT assert result.reason_code == LIMIT_REASON_CODE - assert result.patch.is_empty + assert result.request_mutations.is_empty def test_regex_finding_group_overflow_is_an_atomic_runtime_limit() -> None: @@ -417,7 +417,7 @@ def test_regex_finding_group_overflow_is_an_atomic_runtime_limit() -> None: assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT assert result.reason_code == LIMIT_REASON_CODE assert not result.findings - assert result.patch.is_empty + assert result.request_mutations.is_empty def test_composed_header_mutation_overflow_is_an_atomic_runtime_limit() -> None: @@ -442,7 +442,7 @@ def test_composed_header_mutation_overflow_is_an_atomic_runtime_limit() -> None: assert result.decision is EgressDecision.DENY assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT assert result.reason_code == LIMIT_REASON_CODE - assert result.patch.is_empty + assert result.request_mutations.is_empty assert result.findings == () assert result.traces == () @@ -467,7 +467,7 @@ def test_trace_finding_count_overflow_is_an_atomic_runtime_limit() -> None: assert result.decision is EgressDecision.DENY assert result.decision_source.kind is DecisionSourceKind.RUNTIME_LIMIT assert result.reason_code == LIMIT_REASON_CODE - assert result.patch.is_empty + assert result.request_mutations.is_empty assert result.findings == () assert result.traces == () @@ -496,14 +496,14 @@ def test_prepared_gate_type_is_part_of_the_processor_contract() -> None: ) -def test_header_patch_operations_are_ordered_and_protected() -> None: +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"), ) ) - patch = RequestPatch( + request_mutations = RequestMutations( header_mutations=( WriteHeaderMutation( kind="write", @@ -526,7 +526,7 @@ def test_header_patch_operations_are_ordered_and_protected() -> None: RemoveHeaderMutation(kind="remove", name="x-other"), ) ) - updated = apply_request_patch(original, patch) + updated = apply_request_mutations(original, request_mutations) assert updated.headers == ( HttpHeader(name="x-openshell-middleware-test", value="new"), @@ -534,9 +534,9 @@ def test_header_patch_operations_are_ordered_and_protected() -> None: ) with pytest.raises(GateContractError): - apply_request_patch( + apply_request_mutations( original, - RequestPatch( + RequestMutations( header_mutations=( WriteHeaderMutation( kind="write", diff --git a/projects/egress-gate/tests/test_result.py b/projects/egress-gate/tests/test_result.py index 1ab13d89..bcdcff37 100644 --- a/projects/egress-gate/tests/test_result.py +++ b/projects/egress-gate/tests/test_result.py @@ -18,7 +18,7 @@ MAX_RESULT_METADATA_ENTRIES, MAX_TRACE_MUTATION_KINDS, ) -from egress_gate.request import RequestPatch +from egress_gate.request import RequestMutations from egress_gate.result import ( DecisionSource, DecisionSourceKind, @@ -89,14 +89,15 @@ def test_finding_encoded_size_has_an_exact_four_kibibyte_boundary() -> None: def test_gate_evaluation_helpers_and_control_invariants() -> None: finding = _finding() assert GateEvaluation.proceed(findings=(finding,)).control is GateControl.PROCEED - assert GateEvaluation.allow().patch.is_empty + 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, patch=RequestPatch(replacement_body=b"x") + control=GateControl.ALLOW, + request_mutations=RequestMutations(replacement_body=b"x"), ) with pytest.raises(ValidationError): GateEvaluation(control=GateControl.DENY) @@ -142,10 +143,10 @@ def test_egress_result_suppresses_mutations_on_deny_by_rejecting_them() -> None: decision_source=GateDecisionSource( kind=DecisionSourceKind.GATE, gate_name="identifiers", gate_type="regex" ), - patch=RequestPatch(replacement_body=b"redacted"), + request_mutations=RequestMutations(replacement_body=b"redacted"), findings=(finding,), ) - assert allowed.patch.replacement_body == b"redacted" + assert allowed.request_mutations.replacement_body == b"redacted" with pytest.raises(ValidationError): EgressResult( @@ -153,7 +154,7 @@ def test_egress_result_suppresses_mutations_on_deny_by_rejecting_them() -> None: decision_source=RuntimeLimitDecisionSource( kind=DecisionSourceKind.RUNTIME_LIMIT ), - patch=RequestPatch(replacement_body=b"must-not-leak"), + request_mutations=RequestMutations(replacement_body=b"must-not-leak"), reason_code="egress_gate_limit_exceeded", ) with pytest.raises(ValidationError): From 13f58d57ada4f3880c6fde124a262e47abc9b663 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 5 Aug 2026 15:14:15 +0000 Subject: [PATCH 37/46] Address Egress Gate QA findings --- projects/egress-gate/README.md | 39 +- .../analysis/qa-reports/2026-08-05.html | 490 ++++++++++++++++++ projects/egress-gate/docs/operations.md | 65 ++- projects/egress-gate/src/egress_gate/cli.py | 119 ++++- .../egress-gate/src/egress_gate/errors.py | 19 + .../src/egress_gate/gates/registry.py | 182 ++++++- .../egress-gate/src/egress_gate/logging.py | 9 +- .../src/egress_gate/service/server.py | 61 +++ .../egress-gate/tests/gates/test_registry.py | 6 + .../tests/service/test_grpc_integration.py | 36 +- .../egress-gate/tests/service/test_server.py | 3 + projects/egress-gate/tests/test_cli.py | 129 ++++- projects/egress-gate/tests/test_errors.py | 16 + projects/egress-gate/tests/test_logging.py | 24 +- 14 files changed, 1145 insertions(+), 53 deletions(-) create mode 100644 projects/egress-gate/analysis/qa-reports/2026-08-05.html diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index d61e309c..cfff5d56 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -15,11 +15,24 @@ The current released OpenShell `Finding` contract has five fields: inside the pipeline processor. Egress Gate does not add provenance to findings or labels. -## Quickstart +## Installed quickstart -Requirements: Python 3.11+ and `uv` 0.11+. +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: -`uv run` prepares the project environment before it starts the command. +```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 @@ -86,16 +99,16 @@ through slot acquisition, policy preparation, and `RequestProcessor.process`. ## Documentation and examples -- [Overview](docs/index.md) -- [Configuration](docs/configuration.md) -- [Test policies offline](docs/evaluation.md) -- [Operations](docs/operations.md) -- [Gate authoring](docs/gates/custom.md) -- [Regex gate](docs/gates/regex.md) -- [Architecture](docs/architecture/index.md) -- [Limits and failures](docs/reference/limits-and-failures.md) -- [Regex redaction composition](examples/regex-redaction/README.md) -- [Minimal custom gate](examples/custom-gate/README.md) +- [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) +- [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) +- [Minimal custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom-gate) ## Development diff --git a/projects/egress-gate/analysis/qa-reports/2026-08-05.html b/projects/egress-gate/analysis/qa-reports/2026-08-05.html new file mode 100644 index 00000000..acb2d604 --- /dev/null +++ b/projects/egress-gate/analysis/qa-reports/2026-08-05.html @@ -0,0 +1,490 @@ + + + + + + + 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.
  • +
+
+
+ +
+

Prepared from isolated QA sessions against commit aa74572 and updated after integrated remediation. This report is self-contained, responsive, printable, and uses no external assets or scripts.

+
+ + diff --git a/projects/egress-gate/docs/operations.md b/projects/egress-gate/docs/operations.md index 881b0899..1e0dbc47 100644 --- a/projects/egress-gate/docs/operations.md +++ b/projects/egress-gate/docs/operations.md @@ -40,10 +40,61 @@ The generated OpenShell middleware timeout is five seconds. Keep the Egress Gate `--timeout-seconds` below it so queueing, preparation, and transport have headroom. +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. +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: @@ -67,6 +118,18 @@ 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: diff --git a/projects/egress-gate/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index ee16ed8f..98eec68f 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -10,6 +10,7 @@ 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 @@ -38,7 +39,11 @@ MAX_TIMEOUT_SECONDS, ) from egress_gate.errors import EgressGateError -from egress_gate.gates.registry import GateRegistry, create_builtin_registry +from egress_gate.gates.registry import ( + GateRegistry, + PolicyValidationError, + create_builtin_registry, +) from egress_gate.gateway_config import ( MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES, GatewayConfigError, @@ -61,19 +66,34 @@ "Run the OpenShell middleware, test policies offline, manage the OpenShell " "gateway registration, and inspect installed gates." ), - no_args_is_help=True, + 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.add_typer(gates_app, name="gates") @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_factory: Annotated[ str | None, typer.Option( @@ -92,11 +112,17 @@ def configure_cli( ] = 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_factory)) + if context.invoked_subcommand is None: + _CONSOLE.print(context.get_help()) + raise typer.Exit -@app.command("serve") +@app.command("serve", short_help="Start the Egress Gate gRPC service.") def serve( context: typer.Context, listen: Annotated[ @@ -139,7 +165,10 @@ def serve( raise typer.Exit(code=1) from None -@app.command("add-gateway-registration") +@app.command( + "add-gateway-registration", + short_help="Register Egress Gate with OpenShell.", +) def add_gateway_registration( host_ip: Annotated[ str, @@ -238,7 +267,10 @@ def add_gateway_registration( ) -@app.command("remove-gateway-registration") +@app.command( + "remove-gateway-registration", + short_help="Remove an OpenShell registration.", +) def remove_gateway_registration( name: Annotated[ str, @@ -324,7 +356,7 @@ def gate_schema(context: typer.Context) -> None: ) -@app.command("validate") +@app.command("validate", short_help="Check a policy against installed gates.") def validate_policy( context: typer.Context, policy: Annotated[ @@ -347,6 +379,14 @@ def validate_policy( 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", @@ -361,7 +401,7 @@ def validate_policy( _CONSOLE.print("[bold green]✓[/bold green] Policy is valid") -@app.command("evaluate") +@app.command("evaluate", short_help="Test policy cases without starting the service.") def evaluate( context: typer.Context, policy: Annotated[ @@ -424,6 +464,23 @@ def evaluate( corpus, timeout_seconds=validated_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 @@ -456,6 +513,22 @@ 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.""" @@ -766,10 +839,17 @@ def _run_corpus( ) evaluations: list[_CaseEvaluation] = [] for case in corpus.cases: - result = processor.process( - case.request.to_http_request(), - timeout=Timeout.from_seconds(validated_timeout), - ) + 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, @@ -779,10 +859,14 @@ def _run_corpus( return _EvaluationSummary(cases=tuple(evaluations)) -def _render_evaluation(summary: _EvaluationSummary) -> None: +def _render_evaluation( + summary: _EvaluationSummary, + *, + title: str = "Policy evaluation", +) -> None: """Render content-safe case results and their aggregate.""" table = Table( - title="Policy evaluation", + title=title, box=None, pad_edge=False, padding=(0, 2), @@ -1068,6 +1152,13 @@ def _load_registry(factory_reference: str | None) -> GateRegistry: 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): diff --git a/projects/egress-gate/src/egress_gate/errors.py b/projects/egress-gate/src/egress_gate/errors.py index 54b848f9..01696f46 100644 --- a/projects/egress-gate/src/egress_gate/errors.py +++ b/projects/egress-gate/src/egress_gate/errors.py @@ -29,6 +29,8 @@ 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" @@ -135,6 +137,23 @@ class _ErrorSpec: "`egress-gate gates schema`, then check the pipeline, 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, diff --git a/projects/egress-gate/src/egress_gate/gates/registry.py b/projects/egress-gate/src/egress_gate/gates/registry.py index 039b2244..f5b7a54e 100644 --- a/projects/egress-gate/src/egress_gate/gates/registry.py +++ b/projects/egress-gate/src/egress_gate/gates/registry.py @@ -8,6 +8,7 @@ import re from collections.abc import Mapping, Sequence from dataclasses import dataclass +from enum import StrEnum from functools import reduce from operator import getitem, or_ from typing import ( @@ -55,6 +56,68 @@ class GateDescription: 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: """Register trusted gates and finalize their exact pipeline union.""" @@ -140,12 +203,17 @@ def validate_config(self, values: object) -> EgressGateConfig[GateConfig]: raise EgressGateError(ErrorCode.CONFIG_INVALID) try: config_value = self._require_config_adapter().validate_python(dict(values)) - except (TypeError, ValueError, ValidationError): + 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 configured_gate in config.pipeline.gates: + for gate_index, configured_gate in enumerate(config.pipeline.gates): registration = self._resolve_registration(configured_gate.config) try: registration.gate_type.validate_config( @@ -153,7 +221,10 @@ def validate_config(self, values: object) -> EgressGateConfig[GateConfig]: registration.resources, ) except GateConfigurationError: - raise EgressGateError(ErrorCode.CONFIG_INVALID) from None + raise PolicyValidationError( + path=("pipeline", "gates", gate_index, "config"), + category=PolicyValidationCategory.INVALID_VALUE, + ) from None return config def create_gate( @@ -198,13 +269,11 @@ def prepare_processor( gate_type = getattr(configured_gate.config, "kind", None) if not isinstance(gate_type, str): raise GateRegistryError("gate config discriminator is invalid") - prepared.append( - ( - configured_gate.name, - gate_type, - self.create_gate(configured_gate.config, timeout=timeout), - ) - ) + try: + gate = self.create_gate(configured_gate.config, 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, @@ -214,7 +283,9 @@ def prepare_processor( def configuration_json_schema(self) -> dict[str, object]: """Return the finalized complete pipeline JSON Schema.""" - return self._require_config_adapter().json_schema() + return _humanize_schema_definition_names( + self._require_config_adapter().json_schema() + ) def describe_gates(self) -> tuple[GateDescription, ...]: """Return safe gate metadata without constructing runtime gates.""" @@ -343,11 +414,100 @@ def _gate_description(gate_type: type[object]) -> str: 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) + + +def _humanize_schema_definition_names( + schema: dict[str, object], +) -> dict[str, object]: + definitions = schema.get("$defs") + if not isinstance(definitions, Mapping): + return schema + + definition_names = tuple(key for key in definitions if isinstance(key, str)) + replacements: dict[str, str] = {} + used_names = set(definition_names) + for original in definition_names: + prefix = next( + ( + candidate + for candidate in ("ConfiguredGate", "PipelineConfig") + if original.startswith(f"{candidate}_") + ), + None, + ) + if prefix is None: + continue + candidate = prefix + suffix = 2 + while candidate in used_names: + candidate = f"{prefix}{suffix}" + suffix += 1 + replacements[original] = candidate + used_names.add(candidate) + + def replace(value: object) -> object: + if isinstance(value, Mapping): + replaced_mapping: dict[str, object] = {} + for key, nested in value.items(): + if not isinstance(key, str): + raise TypeError("JSON Schema keys must be strings") + replaced_mapping[replacements.get(key, key)] = replace(nested) + return replaced_mapping + if isinstance(value, list): + return [replace(nested) for nested in value] + if isinstance(value, str) and value.startswith("#/$defs/"): + name = value.removeprefix("#/$defs/") + return f"#/$defs/{replacements.get(name, name)}" + return value + + replaced = replace(schema) + if not isinstance(replaced, dict): + raise TypeError("JSON Schema root must be an object") + result: dict[str, object] = {} + for key, value in replaced.items(): + if not isinstance(key, str): + raise TypeError("JSON Schema keys must be strings") + result[key] = value + return result + + _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/logging.py b/projects/egress-gate/src/egress_gate/logging.py index 156e9c1a..6459483f 100644 --- a/projects/egress-gate/src/egress_gate/logging.py +++ b/projects/egress-gate/src/egress_gate/logging.py @@ -4,6 +4,7 @@ import copy import logging +import os from dataclasses import dataclass from enum import StrEnum from typing import TextIO @@ -51,10 +52,10 @@ def configure_logging( handler.close() handler = _EgressGateStreamHandler(config.stream) - use_colors = ( - handler.stream.isatty() - if config.color_mode is ColorMode.AUTO - else config.color_mode is ColorMode.ALWAYS + 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) diff --git a/projects/egress-gate/src/egress_gate/service/server.py b/projects/egress-gate/src/egress_gate/service/server.py index f5256a62..57febbc9 100644 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ b/projects/egress-gate/src/egress_gate/service/server.py @@ -3,8 +3,11 @@ 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 ( @@ -69,6 +72,7 @@ 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),), ) @@ -76,6 +80,50 @@ def _create_grpc_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: @@ -86,6 +134,19 @@ async def _stop_grpc_server(server: grpc.aio.Server) -> None: 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) diff --git a/projects/egress-gate/tests/gates/test_registry.py b/projects/egress-gate/tests/gates/test_registry.py index 2cf00fad..b9549475 100644 --- a/projects/egress-gate/tests/gates/test_registry.py +++ b/projects/egress-gate/tests/gates/test_registry.py @@ -93,6 +93,12 @@ def test_builtin_registry_is_finalized_and_contains_only_regex() -> None: assert "pipeline" in str(schema.get("properties")) definitions = schema["$defs"] assert isinstance(definitions, dict) + assert "ConfiguredGate" in definitions + assert "PipelineConfig" in definitions + assert all(isinstance(key, str) for key in definitions) + definition_names = [key for key in definitions if isinstance(key, str)] + assert not any(key.startswith("ConfiguredGate_") for key in definition_names) + assert not any(key.startswith("PipelineConfig_") for key in definition_names) regex_schema = next( value for key, value in definitions.items() if key == "RegexConfig" ) diff --git a/projects/egress-gate/tests/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index 06124482..03413d37 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -14,6 +14,7 @@ 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 @@ -77,15 +78,14 @@ def _evaluation( @asynccontextmanager async def _running_stub( middleware: EgressGateMiddleware, -) -> AsyncIterator[pb2_grpc.SupervisorMiddlewareStub]: - server = grpc.aio.server() - pb2_grpc.add_SupervisorMiddlewareServicer_to_server(middleware, server) +) -> 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) + yield pb2_grpc.SupervisorMiddlewareStub(channel), channel finally: await channel.close() await server.stop(grace=0) @@ -95,7 +95,7 @@ async def _running_stub( @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: + async with _running_stub(middleware) as (stub, _): empty_message_type = message_factory.GetMessageClass( empty_pb2.DESCRIPTOR.message_types_by_name["Empty"] ) @@ -125,7 +125,7 @@ async def test_generated_stub_round_trip_covers_manifest_and_gate_actions() -> N @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: + async with _running_stub(middleware) as (stub, _): request = _evaluation(b"body") request.phase = pb2.SUPERVISOR_MIDDLEWARE_PHASE_UNSPECIFIED @@ -136,6 +136,28 @@ async def test_generated_stub_maps_invalid_phase_to_invalid_argument() -> None: 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, @@ -147,7 +169,7 @@ def fail_processing(*args: object, **kwargs: object) -> object: raise EgressGateError(ErrorCode.GATE_EXECUTION_FAILED) monkeypatch.setattr(middleware, "_prepare_and_process", fail_processing) - async with _running_stub(middleware) as stub: + async with _running_stub(middleware) as (stub, _): with pytest.raises(grpc.aio.AioRpcError) as error: await stub.EvaluateHttpRequest(_evaluation(b"body")) diff --git a/projects/egress-gate/tests/service/test_server.py b/projects/egress-gate/tests/service/test_server.py index 2f23ee40..e3e7aa85 100644 --- a/projects/egress-gate/tests/service/test_server.py +++ b/projects/egress-gate/tests/service/test_server.py @@ -69,9 +69,12 @@ def test_server_sets_transport_limits_and_registers_middleware( 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 diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 4ac1e716..6992576e 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -26,6 +26,36 @@ def test_cli_does_not_offer_request_content_logging() -> None: 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 "--host…" not in result.stdout + assert "--conf…" not in result.stdout + + def test_cli_gates_describes_the_request_level_builtin() -> None: result = CliRunner().invoke(app, ["gates", "list"]) @@ -175,10 +205,107 @@ def test_cli_validate_rejects_invalid_policy(tmp_path: Path) -> None: assert result.exit_code == 1 assert "Policy validation failed [config_invalid]" in result.stderr - assert "does not match the schema for the installed gates" in result.stderr + assert "Policy field pipeline: 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( + """pipeline: + gates: + - name: one + config: + 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 pipeline.gates[0].config.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( + """pipeline: + gates: + - name: identifiers + config: + 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(tmp_path: Path) -> None: config = tmp_path / "gateway.toml" result = CliRunner().invoke( diff --git a/projects/egress-gate/tests/test_errors.py b/projects/egress-gate/tests/test_errors.py index 82067f84..ee44af3a 100644 --- a/projects/egress-gate/tests/test_errors.py +++ b/projects/egress-gate/tests/test_errors.py @@ -37,5 +37,21 @@ def test_config_error_explains_the_transport_size_limit() -> None: 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_logging.py b/projects/egress-gate/tests/test_logging.py index f1cd15bd..35c578a7 100644 --- a/projects/egress-gate/tests/test_logging.py +++ b/projects/egress-gate/tests/test_logging.py @@ -53,7 +53,10 @@ def test_default_logging_config_uses_info_and_terminal_aware_colors() -> None: ) -def test_configure_logging_colors_interactive_output() -> None: +def test_configure_logging_colors_interactive_output( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.delenv("NO_COLOR", raising=False) stream = _TerminalStream() configure_logging(LoggingConfig(stream=stream)) @@ -65,6 +68,20 @@ def test_configure_logging_colors_interactive_output() -> None: 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)) @@ -74,7 +91,10 @@ def test_configure_logging_can_disable_terminal_colors() -> None: assert "\033[" not in stream.getvalue() -def test_configure_logging_can_force_colors_for_redirected_output() -> None: +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)) From 0100f110b8e9a92e54f966d3ccaf81eea8a46b0e Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 5 Aug 2026 18:35:58 +0000 Subject: [PATCH 38/46] Flatten Egress Gate policy schema --- docs/documentation/index.md | 4 +- projects/egress-gate/AGENTS.md | 6 +- projects/egress-gate/README.md | 22 ++- .../egress-gate/docs/architecture/index.md | 2 +- .../diagrams/component-architecture.svg | 2 +- .../docs/assets/diagrams/request-path.svg | 4 +- projects/egress-gate/docs/configuration.md | 38 ++--- projects/egress-gate/docs/gates/custom.md | 12 +- projects/egress-gate/docs/gates/regex.md | 2 + projects/egress-gate/docs/index.md | 98 +++++++----- .../examples/custom-gate/README.md | 5 +- .../custom-gate/egress-gate-config.yaml | 12 +- .../examples/regex-redaction/README.md | 2 +- .../regex-redaction/egress-gate-config.yaml | 32 ++-- .../examples/regex-redaction/policy.yaml | 22 ++- projects/egress-gate/src/egress_gate/cli.py | 4 +- .../egress-gate/src/egress_gate/config.py | 63 ++++---- .../egress-gate/src/egress_gate/errors.py | 2 +- .../egress-gate/src/egress_gate/gates/base.py | 11 +- .../src/egress_gate/gates/registry.py | 113 +++++--------- .../src/egress_gate/request_processor.py | 10 +- .../egress-gate/src/egress_gate/result.py | 12 +- .../src/egress_gate/service/servicer.py | 4 +- projects/egress-gate/tests/gates/test_base.py | 15 +- .../egress-gate/tests/gates/test_regex.py | 6 + .../egress-gate/tests/gates/test_registry.py | 145 ++++++++++++++---- .../tests/service/test_grpc_integration.py | 42 +++-- .../tests/service/test_servicer.py | 6 +- projects/egress-gate/tests/test_cli.py | 57 ++++--- projects/egress-gate/tests/test_config.py | 84 +++++----- .../tests/test_request_processor.py | 14 +- 31 files changed, 452 insertions(+), 399 deletions(-) diff --git a/docs/documentation/index.md b/docs/documentation/index.md index 7df90347..e5ee2124 100644 --- a/docs/documentation/index.md +++ b/docs/documentation/index.md @@ -9,5 +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 provider-bound - HTTP requests. +- [Egress Gate](egress-gate/index.md): extensible middleware for applying gates + to outgoing HTTP requests. diff --git a/projects/egress-gate/AGENTS.md b/projects/egress-gate/AGENTS.md index bae53fcf..c571b2df 100644 --- a/projects/egress-gate/AGENTS.md +++ b/projects/egress-gate/AGENTS.md @@ -38,7 +38,7 @@ Run focused tests while working and `make check` before handoff. ## Project map - `src/egress_gate/gates/`: `Gate`, helper bases, registry, and the regex gate -- `src/egress_gate/config.py`: strict `pipeline.gates` and `default_decision` +- `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/result.py`: gate evaluations, five-field findings, provenance, @@ -63,6 +63,10 @@ an optional typed `GateResources` bundle, `GateCapabilities`, and its exact discriminated pipeline schema for the installed gates 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 diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index cfff5d56..d3b1a7b1 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -54,18 +54,16 @@ listen port to trusted networks. The registry builds an exact strict schema from installed gate types: ```yaml -pipeline: - gates: - - name: identifiers - config: - kind: regex - scan: - kind: body - action: - kind: replace - template: "[{entity}]" - pattern_catalog: patterns.yaml - default_decision: allow +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 body, diff --git a/projects/egress-gate/docs/architecture/index.md b/projects/egress-gate/docs/architecture/index.md index d7c0ac02..7b0f4f79 100644 --- a/projects/egress-gate/docs/architecture/index.md +++ b/projects/egress-gate/docs/architecture/index.md @@ -22,7 +22,7 @@ Egress Gate has one transport adapter and one protobuf-free pipeline processor. | `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.py` | Typed scan and action selection, bounded matching, overlap handling, caching, and body replacement | -| `config.py` | Strict `pipeline.gates` and required default decision | +| `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 | diff --git a/projects/egress-gate/docs/assets/diagrams/component-architecture.svg b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg index 93b6cbec..0f5db97c 100644 --- a/projects/egress-gate/docs/assets/diagrams/component-architecture.svg +++ b/projects/egress-gate/docs/assets/diagrams/component-architecture.svg @@ -51,7 +51,7 @@ config.py · ordered gates · default decision Gate registry - config union · resources · gate construction + gate union · resources · gate construction PIPELINE PROCESSOR diff --git a/projects/egress-gate/docs/assets/diagrams/request-path.svg b/projects/egress-gate/docs/assets/diagrams/request-path.svg index e7d2740b..60aa7957 100644 --- a/projects/egress-gate/docs/assets/diagrams/request-path.svg +++ b/projects/egress-gate/docs/assets/diagrams/request-path.svg @@ -1,6 +1,6 @@ Egress Gate request path - The OpenShell supervisor sends an intercepted request to Egress Gate. The Egress Gate pipeline processor runs gates on local immutable snapshots and returns an allow with final mutations or a deny. The supervisor applies allowed mutations, attaches credentials, and sends the request to the provider. + 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. 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 builds local request snapshots. The OpenShell supervisor owns and updates the intercepted request.
- - -Only `service/` imports generated bindings. Gate and pipeline processor code is -protobuf-free and can be evaluated offline. +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 `projects/egress-gate/`: +From the +[`projects/egress-gate/`](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate) +directory in a source checkout: -`uv run` prepares the project environment before each command. +First, inspect the installed gates and their configuration schema: -```bash title="Inspect, validate, and serve" +```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 -file-backed catalog. +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 snapshot. -- Only `proceed` can propose request mutations. Terminal `allow` and `deny` - require an empty mutation set. +- 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 diff --git a/projects/egress-gate/examples/custom-gate/README.md b/projects/egress-gate/examples/custom-gate/README.md index 18e6d4c5..d3472838 100644 --- a/projects/egress-gate/examples/custom-gate/README.md +++ b/projects/egress-gate/examples/custom-gate/README.md @@ -39,8 +39,9 @@ The `block-secret-keyword` gate denies the first corpus case. The second gate evaluation proceeds. The explicit `default_decision: allow` then determines the result. -This is a teaching example, not a robust content classifier. The runtime -already checks the `HttpRequest` limits. Do not check those limits again. +This is a teaching example, not a robust content classifier. The pipeline +processor already checks the `HttpRequest` limits. Do not check those limits +again. A production gate must define its text-decoding and matching behavior. Add limits only for work that belongs to the gate. Do not put request content in diff --git a/projects/egress-gate/examples/custom-gate/egress-gate-config.yaml b/projects/egress-gate/examples/custom-gate/egress-gate-config.yaml index fbf8f726..05186e04 100644 --- a/projects/egress-gate/examples/custom-gate/egress-gate-config.yaml +++ b/projects/egress-gate/examples/custom-gate/egress-gate-config.yaml @@ -1,7 +1,5 @@ -pipeline: - gates: - - name: block-secret-keyword - config: - kind: keyword-deny - keyword: SECRET - default_decision: allow +gates: + - name: block-secret-keyword + kind: keyword-deny + keyword: SECRET +default_decision: allow diff --git a/projects/egress-gate/examples/regex-redaction/README.md b/projects/egress-gate/examples/regex-redaction/README.md index dc370060..d574f071 100644 --- a/projects/egress-gate/examples/regex-redaction/README.md +++ b/projects/egress-gate/examples/regex-redaction/README.md @@ -23,7 +23,7 @@ uv run egress-gate serve --listen 0.0.0.0:50051 --timeout-seconds 4 Register that address with the OpenShell gateway using a reachable host IPv4 address, then create a sandbox with `policy.yaml`. The policy embeds the -`pipeline.gates` configuration and uses `egress-gate-redaction` as the +flat `gates` configuration and uses `egress-gate-redaction` as the middleware registration name. This composition selects `scan.kind: body` and diff --git a/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml b/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml index d2c9837b..dfda8764 100644 --- a/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml +++ b/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml @@ -1,17 +1,15 @@ -pipeline: - gates: - - name: identifiers - config: - kind: regex - scan: - kind: body - action: - kind: replace - template: "[{entity}]" - pattern_catalog: - entities: - - name: email - rules: - - pattern: '(? Mapping[str, object]: - """Load one bounded strict YAML pipeline policy.""" + """Load one bounded strict YAML policy.""" values = _load_yaml(path) if not isinstance(values, Mapping): raise _EvaluationCorpusError diff --git a/projects/egress-gate/src/egress_gate/config.py b/projects/egress-gate/src/egress_gate/config.py index 6f7fcbe5..4110deec 100644 --- a/projects/egress-gate/src/egress_gate/config.py +++ b/projects/egress-gate/src/egress_gate/config.py @@ -1,16 +1,15 @@ -"""Strict pipeline policy configuration.""" +"""Strict Egress Gate policy configuration.""" from __future__ import annotations from enum import StrEnum -from typing import Generic, Self, TypeVar +from typing import Generic, TypeVar -from pydantic import Field, field_validator, model_validator +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.result import GateName from egress_gate.string_validators import validate_scalar_string @@ -24,28 +23,37 @@ class DefaultDecision(StrEnum): _GateConfigT = TypeVar("_GateConfigT", bound=GateConfig) -class ConfiguredGate(StrictDomainModel, Generic[_GateConfigT]): - """One named pipeline entry and its exact gate configuration.""" - - name: GateName - config: _GateConfigT = Field(repr=False) - - -class PipelineConfig(StrictDomainModel, Generic[_GateConfigT]): - """Ordered configured gates and the required final default.""" - - gates: tuple[ConfiguredGate[_GateConfigT], ...] = Field(repr=False) - default_decision: DefaultDecision +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) or not value: - raise ValueError("pipeline gates must be a non-empty list") - if len(value) > MAX_PIPELINE_GATES: - raise ValueError("pipeline has too many gates") + 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: @@ -53,23 +61,8 @@ def _parse_default_decision(cls, value: object) -> DefaultDecision: return value return DefaultDecision(validate_scalar_string(value)) - @model_validator(mode="after") - def _gate_names_are_unique(self) -> Self: - names = tuple(gate.name for gate in self.gates) - if len(names) != len(set(names)): - raise ValueError("pipeline gate names must be unique") - return self - - -class EgressGateConfig(StrictDomainModel, Generic[_GateConfigT]): - """Complete strict Egress Gate policy configuration.""" - - pipeline: PipelineConfig[_GateConfigT] = Field(repr=False) - __all__ = [ - "ConfiguredGate", "DefaultDecision", "EgressGateConfig", - "PipelineConfig", ] diff --git a/projects/egress-gate/src/egress_gate/errors.py b/projects/egress-gate/src/egress_gate/errors.py index 01696f46..3d99f5cf 100644 --- a/projects/egress-gate/src/egress_gate/errors.py +++ b/projects/egress-gate/src/egress_gate/errors.py @@ -134,7 +134,7 @@ class _ErrorSpec: "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 pipeline, gates, " + "`egress-gate gates schema`, then check the gates, " "pattern catalogs, replacements, and default decision.", ), ErrorCode.CONFIG_PREPARATION_FAILED: _ErrorSpec( diff --git a/projects/egress-gate/src/egress_gate/gates/base.py b/projects/egress-gate/src/egress_gate/gates/base.py index 136821a8..38b27d15 100644 --- a/projects/egress-gate/src/egress_gate/gates/base.py +++ b/projects/egress-gate/src/egress_gate/gates/base.py @@ -6,7 +6,7 @@ from types import NoneType from typing import ClassVar, Generic, TypeGuard, final, get_args, get_origin -from pydantic import ValidationError +from pydantic import Field, ValidationError from typing_extensions import TypeVar from egress_gate.base import StrictDomainModel @@ -22,12 +22,17 @@ from egress_gate.result import ( FindingTypeDefinition, GateEvaluation, + GateName, ) from egress_gate.timeout import Timeout class GateConfig(StrictDomainModel): - """Base for an exact gate config with one required literal ``kind``.""" + """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: @@ -107,7 +112,7 @@ def get_config_type(cls) -> type[GateConfig]: @classmethod def get_resources_type(cls) -> type[GateResources] | None: - """Return the concrete runtime-resource type, if any.""" + """Return the concrete operational-resource type, if any.""" _, resources_type = _declared_gate_types(cls) return resources_type diff --git a/projects/egress-gate/src/egress_gate/gates/registry.py b/projects/egress-gate/src/egress_gate/gates/registry.py index f5b7a54e..0d25dcc4 100644 --- a/projects/egress-gate/src/egress_gate/gates/registry.py +++ b/projects/egress-gate/src/egress_gate/gates/registry.py @@ -160,6 +160,7 @@ def register( 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") @@ -182,7 +183,7 @@ def register( ) def finalize(self) -> Self: - """Freeze registration and build the exact pipeline config union.""" + """Freeze registration and build the exact policy gate union.""" if self.is_finalized: return self try: @@ -213,16 +214,16 @@ def validate_config(self, values: object) -> EgressGateConfig[GateConfig]: if not _is_egress_gate_config(config_value): raise EgressGateError(ErrorCode.CONFIG_INVALID) config = config_value - for gate_index, configured_gate in enumerate(config.pipeline.gates): - registration = self._resolve_registration(configured_gate.config) + for gate_index, configured_gate in enumerate(config.gates): + registration = self._resolve_registration(configured_gate) try: registration.gate_type.validate_config( - configured_gate.config, + configured_gate, registration.resources, ) except GateConfigurationError: raise PolicyValidationError( - path=("pipeline", "gates", gate_index, "config"), + path=("gates", gate_index), category=PolicyValidationCategory.INVALID_VALUE, ) from None return config @@ -264,13 +265,13 @@ def prepare_processor( raise GateRegistryError("processor preparation timeout is invalid") prepared: list[tuple[str, str, Gate[GateConfig, GateResources | None]]] = [] - for configured_gate in validated_config.pipeline.gates: + for configured_gate in validated_config.gates: timeout.raise_if_expired() - gate_type = getattr(configured_gate.config, "kind", None) + 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.config, timeout=timeout) + 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)) @@ -282,13 +283,16 @@ def prepare_processor( ) def configuration_json_schema(self) -> dict[str, object]: - """Return the finalized complete pipeline JSON Schema.""" - return _humanize_schema_definition_names( - self._require_config_adapter().json_schema() + """Return the finalized 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 runtime gates.""" + """Return safe gate metadata without constructing gate instances.""" return tuple( GateDescription( gate_type=gate_kind, @@ -359,12 +363,12 @@ def _build_egress_gate_config_type( Annotated, (registered_union, Field(discriminator="kind")), ) - pipeline_type: object = getattr(EgressGateConfig, "__class_getitem__")( + config_type: object = getattr(EgressGateConfig, "__class_getitem__")( registered_config ) - if not _is_egress_gate_config_type(pipeline_type): - raise TypeError("Pydantic did not construct a pipeline config type") - return pipeline_type + if not _is_egress_gate_config_type(config_type): + raise TypeError("Pydantic did not construct an Egress Gate config type") + return config_type def _is_gate_type( @@ -406,6 +410,28 @@ def _gate_kind(config_type: type[GateConfig]) -> str: 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 "" @@ -446,61 +472,6 @@ def _validation_error_category(error_type: object) -> PolicyValidationCategory: }.get(error_type, PolicyValidationCategory.INVALID_VALUE) -def _humanize_schema_definition_names( - schema: dict[str, object], -) -> dict[str, object]: - definitions = schema.get("$defs") - if not isinstance(definitions, Mapping): - return schema - - definition_names = tuple(key for key in definitions if isinstance(key, str)) - replacements: dict[str, str] = {} - used_names = set(definition_names) - for original in definition_names: - prefix = next( - ( - candidate - for candidate in ("ConfiguredGate", "PipelineConfig") - if original.startswith(f"{candidate}_") - ), - None, - ) - if prefix is None: - continue - candidate = prefix - suffix = 2 - while candidate in used_names: - candidate = f"{prefix}{suffix}" - suffix += 1 - replacements[original] = candidate - used_names.add(candidate) - - def replace(value: object) -> object: - if isinstance(value, Mapping): - replaced_mapping: dict[str, object] = {} - for key, nested in value.items(): - if not isinstance(key, str): - raise TypeError("JSON Schema keys must be strings") - replaced_mapping[replacements.get(key, key)] = replace(nested) - return replaced_mapping - if isinstance(value, list): - return [replace(nested) for nested in value] - if isinstance(value, str) and value.startswith("#/$defs/"): - name = value.removeprefix("#/$defs/") - return f"#/$defs/{replacements.get(name, name)}" - return value - - replaced = replace(schema) - if not isinstance(replaced, dict): - raise TypeError("JSON Schema root must be an object") - result: dict[str, object] = {} - for key, value in replaced.items(): - if not isinstance(key, str): - raise TypeError("JSON Schema keys must be strings") - result[key] = value - return result - - _GATE_KIND_PATTERN = re.compile(r"[a-z][a-z0-9-]{0,127}\Z") diff --git a/projects/egress-gate/src/egress_gate/request_processor.py b/projects/egress-gate/src/egress_gate/request_processor.py index 2acdbbf8..60d2361e 100644 --- a/projects/egress-gate/src/egress_gate/request_processor.py +++ b/projects/egress-gate/src/egress_gate/request_processor.py @@ -68,10 +68,8 @@ def __init__( 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.pipeline.gates) - policy_types = tuple( - getattr(item.config, "kind", None) for item in config.pipeline.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: @@ -192,7 +190,7 @@ def process(self, request: HttpRequest, *, timeout: Timeout) -> EgressResult: except Exception: raise EgressGateError(ErrorCode.GATE_EXECUTION_FAILED) from None - if self._config.pipeline.default_decision is DefaultDecision.ALLOW: + if self._config.default_decision is DefaultDecision.ALLOW: result = _result( decision=EgressDecision.ALLOW, source=PipelineDefaultDecisionSource( @@ -311,7 +309,7 @@ def _compose_request_mutations( ) except (TypeError, ValueError, ValidationError): raise GateLimitExceededError( - "composed request mutations exceed a runtime limit" + "composed request mutations exceed a pipeline processor limit" ) from None diff --git a/projects/egress-gate/src/egress_gate/result.py b/projects/egress-gate/src/egress_gate/result.py index 304bd61c..4258abfc 100644 --- a/projects/egress-gate/src/egress_gate/result.py +++ b/projects/egress-gate/src/egress_gate/result.py @@ -1,8 +1,8 @@ """Immutable gate evaluations and Egress Gate result models. These models deliberately contain no protobuf or gRPC types. ``SourcedFinding`` -keeps gate provenance inside the runtime; the current OpenShell wire contract -serializes only the five fields on ``Finding``. +keeps gate provenance inside the pipeline processor; the current OpenShell wire +contract serializes only the five fields on ``Finding``. """ from __future__ import annotations @@ -95,7 +95,7 @@ def _wire_size_is_bounded(self) -> Self: class FindingTypeDefinition(StrictDomainModel): - """A runtime-owned declaration for one possible finding type.""" + """A processor-owned declaration for one possible finding type.""" type: FindingType @@ -122,7 +122,7 @@ class PipelineDefaultDecisionSource(StrictDomainModel): class RuntimeLimitDecisionSource(StrictDomainModel): - """A fail-closed decision caused by a runtime safety limit.""" + """A fail-closed decision caused by a pipeline processor safety limit.""" kind: Literal[DecisionSourceKind.RUNTIME_LIMIT] @@ -195,7 +195,7 @@ def deny( class GateTrace(StrictDomainModel): - """Content-safe runtime trace data for one configured gate.""" + """Content-safe processor trace data for one configured gate.""" gate_name: GateName gate_type: GateType @@ -209,7 +209,7 @@ class GateTrace(StrictDomainModel): class ResultMetadata(StrictDomainModel): - """One bounded runtime-owned result metadata entry.""" + """One bounded processor-owned result metadata entry.""" key: BoundedMetadataString value: BoundedMetadataString diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index e48758d0..e16f96bf 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -1,4 +1,4 @@ -"""gRPC boundary for the protobuf-free Egress Gate pipeline runtime.""" +"""gRPC boundary for the protobuf-free Egress Gate pipeline processor.""" from __future__ import annotations @@ -119,7 +119,7 @@ async def ValidateConfig( pb2.ValidateConfigResponse, ], ) -> pb2.ValidateConfigResponse: - """Validate expanded configuration without preparing runtime state.""" + """Validate expanded configuration without preparing processor state.""" return await self._run_in_worker(lambda: self._validate_config(request)) async def EvaluateHttpRequest( diff --git a/projects/egress-gate/tests/gates/test_base.py b/projects/egress-gate/tests/gates/test_base.py index 87e58891..1d3319e2 100644 --- a/projects/egress-gate/tests/gates/test_base.py +++ b/projects/egress-gate/tests/gates/test_base.py @@ -124,7 +124,7 @@ def _request(*, body: bytes = b"payload", host: str = "example.com") -> HttpRequ def test_gate_uses_exact_config_and_resource_types() -> None: - config = _RequestConfig(kind="test-request") + config = _RequestConfig(name="test", kind="test-request") gate = _RequestGate(config, None) assert gate.config is config @@ -141,13 +141,13 @@ def test_gate_uses_exact_config_and_resource_types() -> None: def test_gate_public_wrapper_enforces_declared_output_capabilities() -> None: with pytest.raises(GateContractError, match="undeclared finding"): - _UndeclaredOutputGate(_RequestConfig(kind="test-request"), None).evaluate( - _request(), timeout=Timeout.from_seconds(1) - ) + _UndeclaredOutputGate( + _RequestConfig(name="test", kind="test-request"), None + ).evaluate(_request(), timeout=Timeout.from_seconds(1)) with pytest.raises(GateContractError, match="undeclared finding"): _CapabilityBypassGate( - _RequestConfig(kind="test-request"), + _RequestConfig(name="test", kind="test-request"), None, ).evaluate(_request(), timeout=Timeout.from_seconds(1)) @@ -155,7 +155,7 @@ def test_gate_public_wrapper_enforces_declared_output_capabilities() -> None: def test_gate_public_wrapper_classifies_invalid_models_as_contract_errors() -> None: with pytest.raises(GateContractError, match="gate output is invalid"): _InvalidEvaluationGate( - _RequestConfig(kind="test-request"), + _RequestConfig(name="test", kind="test-request"), None, ).evaluate(_request(), timeout=Timeout.from_seconds(1)) @@ -163,6 +163,7 @@ def test_gate_public_wrapper_classifies_invalid_models_as_contract_errors() -> N 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": { @@ -184,7 +185,7 @@ def test_gate_rejects_invalid_utf8_as_gate_input() -> None: def test_resource_backed_gate_is_safe_for_concurrent_evaluations() -> None: resources = _CounterResources() - gate = _CounterGate(_CounterConfig(kind="test-counter"), resources) + gate = _CounterGate(_CounterConfig(name="counter", kind="test-counter"), resources) def evaluate(_: int) -> GateEvaluation: return gate.evaluate(_request(), timeout=Timeout.from_seconds(1)) diff --git a/projects/egress-gate/tests/gates/test_regex.py b/projects/egress-gate/tests/gates/test_regex.py index e35126aa..03084103 100644 --- a/projects/egress-gate/tests/gates/test_regex.py +++ b/projects/egress-gate/tests/gates/test_regex.py @@ -35,6 +35,7 @@ def _config( action["template"] = template scan_values["action"] = action values: dict[str, object] = { + "name": "regex", "kind": "regex", "scan": scan_values, "pattern_catalog": { @@ -258,6 +259,7 @@ def test_action_shape_rejects_missing_kinds_and_unrelated_template_fields() -> N with pytest.raises(ValidationError): RegexConfig.model_validate( { + "name": "regex", "kind": "regex", "scan": {"kind": "body", "action": {}}, "pattern_catalog": _catalog("x"), @@ -276,6 +278,7 @@ 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"), @@ -678,6 +681,7 @@ def test_relative_yaml_catalog_loading_rejects_aliases_and_traversal( config = RegexConfig.model_validate( { + "name": "regex", "kind": "regex", "scan": {"kind": "body", "action": {"kind": "detect"}}, "pattern_catalog": "patterns.yaml", @@ -688,6 +692,7 @@ def test_relative_yaml_catalog_loading_rejects_aliases_and_traversal( with pytest.raises(ValidationError): RegexConfig.model_validate( { + "name": "regex", "kind": "regex", "scan": {"kind": "body", "action": {"kind": "detect"}}, "pattern_catalog": "../patterns.yaml", @@ -705,6 +710,7 @@ def test_relative_yaml_catalog_loading_rejects_aliases_and_traversal( 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 index b9549475..e475414a 100644 --- a/projects/egress-gate/tests/gates/test_registry.py +++ b/projects/egress-gate/tests/gates/test_registry.py @@ -6,8 +6,9 @@ from typing import Literal import pytest -from pydantic import Field +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, @@ -76,10 +77,8 @@ def _evaluate( def _pipeline(config: dict[str, object]) -> dict[str, object]: return { - "pipeline": { - "gates": [{"name": "one", "config": config}], - "default_decision": "allow", - } + "gates": [{"name": "one", **config}], + "default_decision": "allow", } @@ -90,23 +89,30 @@ def test_builtin_registry_is_finalized_and_contains_only_regex() -> None: assert tuple(item.gate_type for item in registry.describe_gates()) == ("regex",) schema = registry.configuration_json_schema() assert _discriminator_names(schema) == {"kind"} - assert "pipeline" in str(schema.get("properties")) + 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 "ConfiguredGate" in definitions - assert "PipelineConfig" in definitions assert all(isinstance(key, str) for key in definitions) - definition_names = [key for key in definitions if isinstance(key, str)] - assert not any(key.startswith("ConfiguredGate_") for key in definition_names) - assert not any(key.startswith("PipelineConfig_") for key in definition_names) regex_schema = next( value for key, value in definitions.items() if key == "RegexConfig" ) - assert isinstance(regex_schema, Mapping) + 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" ) @@ -143,9 +149,9 @@ def test_registry_validates_exact_pipeline_and_gate_config() -> None: _pipeline({"kind": "registry-test", "answer": 42}) ) - assert config.pipeline.default_decision.value == "allow" - assert type(config.pipeline.gates[0].config) is _RegistryConfig - gate = registry.create_gate(config.pipeline.gates[0].config) + 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 @@ -192,6 +198,87 @@ def _evaluate( 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 = GateCapabilities() + 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 = GateCapabilities() + 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 = GateCapabilities() + 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 = GateCapabilities() + 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) @@ -201,7 +288,7 @@ def test_registry_forwards_the_shared_preparation_timeout() -> None: ) timeout = Timeout.from_seconds(1) - gate = registry.create_gate(config.pipeline.gates[0].config, timeout=timeout) + gate = registry.create_gate(config.gates[0], timeout=timeout) assert isinstance(gate, _RegistryGate) assert gate.preparation_timeout is timeout @@ -230,7 +317,7 @@ def test_registry_injects_typed_application_resources() -> None: registry.finalize() config = registry.validate_config(_pipeline({"kind": "resource-test"})) - gate = registry.create_gate(config.pipeline.gates[0].config) + gate = registry.create_gate(config.gates[0]) assert gate.resources is resources @@ -251,11 +338,7 @@ def test_registry_rejects_unknown_policy_shapes() -> None: _pipeline({"kind": "missing", "answer": 1}), _pipeline({"kind": "registry-test", "answer": 1, "extra": True}), { - "pipeline": { - "gates": [ - {"name": "one", "config": {"kind": "registry-test", "answer": 1}} - ], - } + "gates": [{"name": "one", "kind": "registry-test", "answer": 1}], }, ): with pytest.raises(EgressGateError): @@ -283,13 +366,11 @@ def test_registry_rejects_duplicate_gate_names_before_preparation() -> None: registry.register(_RegistryGate) registry.finalize() values = { - "pipeline": { - "gates": [ - {"name": "same", "config": {"kind": "registry-test", "answer": 1}}, - {"name": "same", "config": {"kind": "registry-test", "answer": 2}}, - ], - "default_decision": "allow", - } + "gates": [ + {"name": "same", "kind": "registry-test", "answer": 1}, + {"name": "same", "kind": "registry-test", "answer": 2}, + ], + "default_decision": "allow", } with pytest.raises(EgressGateError): @@ -312,3 +393,9 @@ def _discriminator_names(value: object) -> set[object]: 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/service/test_grpc_integration.py b/projects/egress-gate/tests/service/test_grpc_integration.py index 03413d37..bad72f9a 100644 --- a/projects/egress-gate/tests/service/test_grpc_integration.py +++ b/projects/egress-gate/tests/service/test_grpc_integration.py @@ -23,31 +23,27 @@ def _config(*, action_kind: str = "replace") -> Message: if action_kind == "replace": action["template"] = "[{entity}]" values: dict[str, object] = { - "pipeline": { - "gates": [ - { - "name": "identifiers", - "config": { - "kind": "regex", - "scan": {"kind": "body", "action": action}, - "pattern_catalog": { - "entities": [ + "gates": [ + { + "name": "identifiers", + "kind": "regex", + "scan": {"kind": "body", "action": action}, + "pattern_catalog": { + "entities": [ + { + "name": "email", + "rules": [ { - "name": "email", - "rules": [ - { - "pattern": r"[a-z]+@[a-z]+\.[a-z]+", - "confidence": "high", - } - ], + "pattern": r"[a-z]+@[a-z]+\.[a-z]+", + "confidence": "high", } - ] - }, - }, - } - ], - "default_decision": "allow", - } + ], + } + ] + }, + } + ], + "default_decision": "allow", } request = pb2.ValidateConfigRequest() json_format.ParseDict(values, request.config) diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 38752f06..08ec9baa 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -73,10 +73,8 @@ def _values( }, } return { - "pipeline": { - "gates": [{"name": "body", "config": config}], - "default_decision": default_decision, - } + "gates": [{"name": "body", **config}], + "default_decision": default_decision, } diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 6992576e..2a5d85c0 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -71,13 +71,15 @@ def test_cli_gates_describes_the_request_level_builtin() -> None: assert "Python resources" not in result.stdout -def test_cli_configuration_schema_exposes_pipeline_only() -> None: +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 "pipeline" in schema["properties"] - assert "default_decision" in str(schema) + 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_factory_loader_requires_a_finalized_gate_registry( @@ -205,7 +207,7 @@ def test_cli_validate_rejects_invalid_policy(tmp_path: Path) -> None: assert result.exit_code == 1 assert "Policy validation failed [config_invalid]" in result.stderr - assert "Policy field pipeline: required field is missing" in result.stderr + assert "Policy field gates: required field is missing" in result.stderr assert "egress-gate gates schema" in result.stderr @@ -213,24 +215,19 @@ 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( - """pipeline: - gates: - - name: one - config: - kind: regex - scna-sensitive-sentinel: {} - pattern_catalog: {} - default_decision: allow + """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 pipeline.gates[0].config.scan: required field is missing" - in result.stderr - ) + assert "Policy field gates[0].scan: required field is missing" in result.stderr assert sentinel not in result.output @@ -238,21 +235,19 @@ def test_cli_evaluate_catalogs_regex_preparation_failures(tmp_path: Path) -> Non project_dir = Path(__file__).parents[1] policy = tmp_path / "named-group.yaml" policy.write_text( - """pipeline: - gates: - - name: identifiers - config: - kind: regex - scan: - kind: body - action: {kind: detect} - pattern_catalog: - entities: - - name: token - rules: - - pattern: '(?Psecret)' - confidence: high - default_decision: allow + """gates: + - name: identifiers + kind: regex + scan: + kind: body + action: {kind: detect} + pattern_catalog: + entities: + - name: token + rules: + - pattern: '(?Psecret)' + confidence: high +default_decision: allow """ ) diff --git a/projects/egress-gate/tests/test_config.py b/projects/egress-gate/tests/test_config.py index a5ad8d1d..3a91fc7f 100644 --- a/projects/egress-gate/tests/test_config.py +++ b/projects/egress-gate/tests/test_config.py @@ -5,11 +5,7 @@ import pytest from pydantic import ValidationError -from egress_gate.config import ( - ConfiguredGate, - DefaultDecision, - EgressGateConfig, -) +from egress_gate.config import DefaultDecision, EgressGateConfig from egress_gate.constants import MAX_PIPELINE_GATES from egress_gate.gates import RegexConfig @@ -30,32 +26,28 @@ def _regex_config() -> dict[str, object]: def _values(*, default_decision: str = "allow") -> dict[str, object]: - pipeline: dict[str, object] = { - "gates": [{"name": "body", "config": _regex_config()}], + return { + "gates": [{"name": "body", **_regex_config()}], "default_decision": default_decision, } - return {"pipeline": pipeline} def test_pipeline_uses_required_default_and_exact_gate_entries() -> None: config = EgressGateConfig[RegexConfig].model_validate(_values()) - assert config.pipeline.default_decision is DefaultDecision.ALLOW - assert config.pipeline.gates[0].name == "body" - assert type(config.pipeline.gates[0].config) is RegexConfig - assert ConfiguredGate.model_fields["config"].is_required() + 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.pipeline.default_decision is DefaultDecision.DENY + assert config.default_decision is DefaultDecision.DENY missing_default = { - "pipeline": { - "gates": [{"name": "body", "config": _regex_config()}], - } + "gates": [{"name": "body", **_regex_config()}], } with pytest.raises(ValidationError): EgressGateConfig[RegexConfig].model_validate(missing_default) @@ -63,51 +55,57 @@ def test_pipeline_default_deny_is_explicit() -> None: def test_pipeline_rejects_unknown_fields_and_duplicate_names() -> None: unknown = { - "pipeline": { - "gates": [{"name": "body", "config": _regex_config()}], - "default_decision": "allow", - "unexpected": True, - } + "gates": [{"name": "body", **_regex_config()}], + "default_decision": "allow", + "unexpected": True, } with pytest.raises(ValidationError): EgressGateConfig[RegexConfig].model_validate(unknown) duplicate = { - "pipeline": { - "gates": [ - {"name": "body", "config": _regex_config()}, - {"name": "body", "config": _regex_config()}, - ], - "default_decision": "allow", - } + "gates": [ + {"name": "body", **_regex_config()}, + {"name": "body", **_regex_config()}, + ], + "default_decision": "allow", } - with pytest.raises(ValidationError): + 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}", "config": _regex_config()} + {"name": f"body-{index}", **_regex_config()} for index in range(MAX_PIPELINE_GATES) ] exact = { - "pipeline": { - "gates": exact_gates, - "default_decision": "allow", - } + "gates": exact_gates, + "default_decision": "allow", } config = EgressGateConfig[RegexConfig].model_validate(exact) - assert len(config.pipeline.gates) == MAX_PIPELINE_GATES + assert len(config.gates) == MAX_PIPELINE_GATES too_many_gates = [ *exact_gates, - {"name": "body-over", "config": _regex_config()}, + {"name": "body-over", **_regex_config()}, ] too_many = { - "pipeline": { - "gates": too_many_gates, - "default_decision": "allow", - } + "gates": too_many_gates, + "default_decision": "allow", } with pytest.raises(ValidationError): EgressGateConfig[RegexConfig].model_validate(too_many) @@ -123,9 +121,7 @@ def test_regex_scan_structurally_restricts_header_actions() -> None: with pytest.raises(ValidationError): EgressGateConfig[RegexConfig].model_validate( { - "pipeline": { - "gates": [{"name": "header", "config": invalid}], - "default_decision": "allow", - } + "gates": [{"name": "header", **invalid}], + "default_decision": "allow", } ) diff --git a/projects/egress-gate/tests/test_request_processor.py b/projects/egress-gate/tests/test_request_processor.py index 6493f2e7..35cc51dd 100644 --- a/projects/egress-gate/tests/test_request_processor.py +++ b/projects/egress-gate/tests/test_request_processor.py @@ -185,20 +185,16 @@ def _processor( registry.register(_ControlGate) registry.finalize() values = { - "pipeline": { - "gates": [{"name": name, "config": config} for name, config in gate_values], - "default_decision": default_decision.value, - } + "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.pipeline.gates: - gate_type = getattr(entry.config, "kind", None) + 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.config)) - ) + prepared_items.append((entry.name, gate_type, registry.create_gate(entry))) prepared = tuple(prepared_items) return RequestProcessor( config, From 68b8fe90a731327dd82100fbfe092e4049321c9f Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 5 Aug 2026 20:12:15 +0000 Subject: [PATCH 39/46] Simplify Egress Gate extensibility --- projects/egress-gate/AGENTS.md | 16 +- projects/egress-gate/README.md | 10 +- projects/egress-gate/docs/configuration.md | 15 +- projects/egress-gate/docs/evaluation.md | 5 +- projects/egress-gate/docs/gates/custom.md | 97 +++++++--- projects/egress-gate/docs/gates/regex.md | 2 +- .../docs/reference/limits-and-failures.md | 6 +- .../examples/custom-gate/README.md | 22 ++- .../examples/custom-gate/keyword_gate.py | 44 +++-- projects/egress-gate/src/egress_gate/cli.py | 118 +++++++----- .../egress-gate/src/egress_gate/constants.py | 4 - .../src/egress_gate/gates/__init__.py | 4 +- .../egress-gate/src/egress_gate/gates/base.py | 61 +++--- .../src/egress_gate/gates/regex.py | 119 +----------- .../src/egress_gate/gates/registry.py | 129 ++++++++----- .../src/egress_gate/service/server.py | 2 +- .../src/egress_gate/service/servicer.py | 36 +--- projects/egress-gate/tests/gates/test_base.py | 10 +- .../tests/gates/test_function_gate.py | 159 ++++++++++++++++ .../egress-gate/tests/gates/test_regex.py | 175 +----------------- .../egress-gate/tests/gates/test_registry.py | 40 ++-- .../egress-gate/tests/service/test_server.py | 14 +- .../tests/service/test_servicer.py | 31 ++-- projects/egress-gate/tests/test_cli.py | 36 ++-- .../tests/test_request_processor.py | 30 +-- 25 files changed, 588 insertions(+), 597 deletions(-) create mode 100644 projects/egress-gate/tests/gates/test_function_gate.py diff --git a/projects/egress-gate/AGENTS.md b/projects/egress-gate/AGENTS.md index c571b2df..83c2a9fc 100644 --- a/projects/egress-gate/AGENTS.md +++ b/projects/egress-gate/AGENTS.md @@ -58,10 +58,11 @@ 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, `GateCapabilities`, and its -`FindingTypeDefinition` declarations. `GateRegistry.finalize()` creates the -exact discriminated pipeline schema for the installed gates and prepares -validated gate instances from trusted application-owned resources. +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 @@ -85,6 +86,11 @@ 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 body, path, @@ -108,7 +114,7 @@ middleware phase. ## Plan boundaries The current implementation covers the gate contract, strict pipeline -configuration, finalized registry, regex behavior, request processing, +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 diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index d3b1a7b1..2795fc01 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -71,11 +71,15 @@ path, query, or selected request headers. Each scan contains its `action`. Every scan supports `detect` and `deny`. A body scan also supports `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-factory`. +equal the input. Add custom trusted gates through `--registry`. + +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-factory my_gates:create_registry gates list -uv run egress-gate --registry-factory my_gates:create_registry serve +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 diff --git a/projects/egress-gate/docs/configuration.md b/projects/egress-gate/docs/configuration.md index 005accce..06134b11 100644 --- a/projects/egress-gate/docs/configuration.md +++ b/projects/egress-gate/docs/configuration.md @@ -48,7 +48,7 @@ The shipped registry contains only `regex`. See templates. `scan.kind` selects the body, path, query, or named headers. `scan.action.kind` selects `detect` or `deny`; a body scan can also select `replace`. The schema does not permit `replace` for another scan kind. A -trusted application registry factory supplies other behavior. +trusted application registry supplies other behavior. ## Inspect the installed registry @@ -62,18 +62,19 @@ uv run egress-gate gates schema uv run egress-gate validate --policy path/to/policy.yaml ``` -Custom registries use the same factory for inspection and serving: +Custom registries use the same module attribute for inspection and serving: ```bash title="Inspect a custom registry" uv run egress-gate \ - --registry-factory my_gates:create_registry gates list + --registry my_gates:registry gates list uv run egress-gate \ - --registry-factory my_gates:create_registry gates schema + --registry my_gates:registry gates schema ``` -The factory must return a finalized `GateRegistry`. It owns trusted gate -classes and typed `GateResources`. Policy configuration cannot import Python, -choose a resource implementation, or provide credentials. +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 diff --git a/projects/egress-gate/docs/evaluation.md b/projects/egress-gate/docs/evaluation.md index cf6339f5..a6650a28 100644 --- a/projects/egress-gate/docs/evaluation.md +++ b/projects/egress-gate/docs/evaluation.md @@ -127,12 +127,11 @@ base64, and values that exceed pipeline processor limits. These checks keep test repeatable and ensure that test requests follow the same bounds as service requests. -Use `--registry-factory` when the policy contains application-owned custom -gates: +Use `--registry` when the policy contains application-owned custom gates: ```bash title="Test a custom gate" uv run egress-gate \ - --registry-factory examples.custom-gate.keyword_gate:create_registry \ + --registry examples.custom-gate.keyword_gate:registry \ evaluate \ --policy examples/custom-gate/egress-gate-config.yaml \ --cases examples/custom-gate/cases.yaml diff --git a/projects/egress-gate/docs/gates/custom.md b/projects/egress-gate/docs/gates/custom.md index d5eb8fcf..325fd68f 100644 --- a/projects/egress-gate/docs/gates/custom.md +++ b/projects/egress-gate/docs/gates/custom.md @@ -8,7 +8,9 @@ agent_markdown: true 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. +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. The repository includes a runnable [minimal custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom-gate) @@ -18,20 +20,25 @@ automatically: ```bash title="Run the custom-gate example" uv run egress-gate \ - --registry-factory examples.custom-gate.keyword_gate:create_registry \ + --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:factory` reference from the -working directory. A packaged deployment can resolve the same reference from -an installed custom-gate package. +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 Gate, GateCapabilities, GateConfig, GateRegistry +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 @@ -42,28 +49,36 @@ class KeywordDenyConfig(GateConfig): keyword: str -class KeywordDenyGate(Gate[KeywordDenyConfig, None]): - capabilities = GateCapabilities(reads_body=True, may_deny=True) - finding_types = () +registry = GateRegistry(include_builtin_gates=True) - 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.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() -def create_registry() -> GateRegistry: - registry = GateRegistry(include_builtin_gates=True) - registry.register(KeywordDenyGate) - return registry.finalize() ``` -`GateRegistry.finalize()` constructs the exact discriminated `gates` schema -from the registered config types. Registry factories supply typed -`GateResources` objects for deployment-owned clients or profiles. Policy +`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 @@ -72,16 +87,50 @@ 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 output capabilities and finding types accurately. The public wrapper +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="Equivalent class-based gate" +from egress_gate.gates import Gate, GateCapability + + +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() +``` + 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 diff --git a/projects/egress-gate/docs/gates/regex.md b/projects/egress-gate/docs/gates/regex.md index 53315381..4dc836c7 100644 --- a/projects/egress-gate/docs/gates/regex.md +++ b/projects/egress-gate/docs/gates/regex.md @@ -51,7 +51,7 @@ 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 -`mutates_headers` capability. +`GateCapability.MUTATE_HEADERS` capability. A catalog can be inline or in a relative `.yaml` or `.yml` file. The gate rejects absolute paths, path traversal, symlinks, YAML aliases, duplicate keys, diff --git a/projects/egress-gate/docs/reference/limits-and-failures.md b/projects/egress-gate/docs/reference/limits-and-failures.md index 93f74379..2bec400a 100644 --- a/projects/egress-gate/docs/reference/limits-and-failures.md +++ b/projects/egress-gate/docs/reference/limits-and-failures.md @@ -24,9 +24,9 @@ limits. | Concurrent processing slots | 4 | Request context and target aggregates, headers, replacement bodies, regex -catalogs, compiled cache weight, and diagnostic strings have additional -bounded limits in `constants.py`. Tests cover exact accepted boundaries and -the first rejected value. +catalogs, individual patterns, and diagnostic strings have additional bounded +limits in `constants.py`. Tests cover exact accepted boundaries and the first +rejected value. ## Outcomes diff --git a/projects/egress-gate/examples/custom-gate/README.md b/projects/egress-gate/examples/custom-gate/README.md index d3472838..e71ae76e 100644 --- a/projects/egress-gate/examples/custom-gate/README.md +++ b/projects/egress-gate/examples/custom-gate/README.md @@ -8,32 +8,32 @@ The implementation has three pieces: 1. `KeywordDenyConfig` defines the exact policy fields and the stable `kind: keyword-deny` discriminator. -2. `KeywordDenyGate` declares what it reads and may return, then implements - `_evaluate`. -3. `create_registry` registers the trusted Python class and finalizes the - configuration schema. +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/`. `uv run` prepares the project environment before each command: ```bash uv run egress-gate \ - --registry-factory examples.custom-gate.keyword_gate:create_registry \ + --registry examples.custom-gate.keyword_gate:registry \ gates list uv run egress-gate \ - --registry-factory examples.custom-gate.keyword_gate:create_registry \ + --registry examples.custom-gate.keyword_gate:registry \ validate --policy examples/custom-gate/egress-gate-config.yaml uv run egress-gate \ - --registry-factory examples.custom-gate.keyword_gate:create_registry \ + --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:factory` reference from the -working directory. An installed custom-gate package works the same way. +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 `block-secret-keyword` gate denies the first corpus case. The second gate evaluation proceeds. The explicit `default_decision: allow` then determines @@ -43,6 +43,10 @@ This is a teaching example, not a robust content classifier. The pipeline processor already checks the `HttpRequest` limits. Do not check those limits again. +The bound decorator is a helper for small, stateless gates. The class-based +`Gate` API remains available for reusable initialization, helper bases, and +typed operational resources. + A production gate must define its text-decoding 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 diff --git a/projects/egress-gate/examples/custom-gate/keyword_gate.py b/projects/egress-gate/examples/custom-gate/keyword_gate.py index 8e97fe37..6cdad089 100644 --- a/projects/egress-gate/examples/custom-gate/keyword_gate.py +++ b/projects/egress-gate/examples/custom-gate/keyword_gate.py @@ -2,7 +2,11 @@ from typing import Literal -from egress_gate.gates import Gate, GateCapabilities, GateConfig, GateRegistry +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 @@ -15,27 +19,21 @@ class KeywordDenyConfig(GateConfig): keyword: str -class KeywordDenyGate(Gate[KeywordDenyConfig, None]): - """Deny requests whose body contains the configured UTF-8 keyword.""" - - capabilities = GateCapabilities(reads_body=True, may_deny=True) - 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) -def create_registry() -> GateRegistry: - """Build the trusted registry loaded by ``--registry-factory``.""" - - registry = GateRegistry(include_builtin_gates=True) - registry.register(KeywordDenyGate) - return registry.finalize() +@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/src/egress_gate/cli.py b/projects/egress-gate/src/egress_gate/cli.py index 4a8efc1d..eae9189e 100644 --- a/projects/egress-gate/src/egress_gate/cli.py +++ b/projects/egress-gate/src/egress_gate/cli.py @@ -38,7 +38,8 @@ MAX_PROTO_FINDING_GROUPS, MAX_TIMEOUT_SECONDS, ) -from egress_gate.errors import EgressGateError +from egress_gate.errors import EgressGateError, GateRegistryError +from egress_gate.gates.base import GateCapability from egress_gate.gates.registry import ( GateRegistry, PolicyValidationError, @@ -94,12 +95,13 @@ def configure_cli( is_eager=True, ), ] = False, - registry_factory: Annotated[ + registry: Annotated[ str | None, typer.Option( + "--registry", help=( - "Load a trusted MODULE:FACTORY callable that returns a finalized " - "GateRegistry. This option applies to every command." + "Load a trusted MODULE:ATTRIBUTE containing a GateRegistry or a " + "zero-argument registry factory. This option applies to every command." ), ), ] = None, @@ -116,7 +118,7 @@ def configure_cli( _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_factory)) + context.obj = _CommandOptions(registry=_load_registry(registry)) if context.invoked_subcommand is None: _CONSOLE.print(context.get_help()) raise typer.Exit @@ -918,17 +920,24 @@ def _render_gates(registry: GateRegistry) -> None: ", ".join(item.type for item in description.finding_types) or "None declared" ) - capability_values = description.capabilities.model_dump() request_access = ", ".join( label - for name, label in _REQUEST_ACCESS_LABELS.items() - if capability_values[name] + for capability, label in _REQUEST_ACCESS_LABELS.items() + if capability in description.capabilities ) - possible_results = ", ".join( + possible_result_labels = [ label - for name, label in _RESULT_CAPABILITY_LABELS.items() - if capability_values[name] + 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() @@ -1005,17 +1014,18 @@ def _render_cli_error( _REQUEST_ACCESS_LABELS = { - "reads_target": "target", - "reads_context": "request context", - "reads_headers": "headers", - "reads_body": "body", + 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", } -_RESULT_CAPABILITY_LABELS = { - "replaces_body": "body replacement", - "mutates_headers": "header changes", - "produces_findings": "findings", - "may_allow": "allow decision", - "may_deny": "deny decision", +_DECISION_CAPABILITY_LABELS = { + GateCapability.ALLOW: "allow decision", + GateCapability.DENY: "deny decision", } @@ -1098,14 +1108,16 @@ def _format_value(value: object) -> str: ) -def _load_registry(factory_reference: str | None) -> GateRegistry: - if factory_reference is None: - return create_builtin_registry() - module_name, separator, factory_name = factory_reference.partition(":") - if not separator or not module_name or not factory_name: +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:FACTORY, for example my_gates:create_registry.", - param_hint="--registry-factory", + "Use MODULE:ATTRIBUTE, for example my_gates:registry.", + param_hint="--registry", ) working_directory = str(Path.cwd()) if working_directory not in sys.path: @@ -1114,41 +1126,47 @@ def _load_registry(factory_reference: str | None) -> GateRegistry: module = importlib.import_module(module_name) except Exception: raise typer.BadParameter( - "Could not import the registry module. Check MODULE:FACTORY and the " + "Could not import the registry module. Check MODULE:ATTRIBUTE and the " "module's dependencies.", - param_hint="--registry-factory", + param_hint="--registry", ) from None try: - factory = getattr(module, factory_name) + candidate = getattr(module, attribute_name) except Exception: raise typer.BadParameter( - "Could not find the registry factory. Check the callable name in " - "MODULE:FACTORY.", - param_hint="--registry-factory", + "Could not find the registry attribute. Check the attribute name in " + "MODULE:ATTRIBUTE.", + param_hint="--registry", ) from None - if not callable(factory): + 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 factory must be callable.", - param_hint="--registry-factory", + "The registry attribute must be a GateRegistry or a zero-argument factory.", + param_hint="--registry", ) - try: - registry = factory() - except Exception: - raise typer.BadParameter( - "The registry factory raised an exception. Run it directly to inspect " - "the startup failure.", - param_hint="--registry-factory", - ) from None if not isinstance(registry, GateRegistry): raise typer.BadParameter( "The registry factory must return a GateRegistry.", - param_hint="--registry-factory", + param_hint="--registry", ) - if not registry.is_finalized: + try: + registry.configuration_json_schema() + except GateRegistryError: raise typer.BadParameter( - "The registry factory must call finalize() before returning.", - param_hint="--registry-factory", - ) + "The registry could not prepare its policy schema. Register at least one " + "valid gate before loading it.", + param_hint="--registry", + ) from None return registry diff --git a/projects/egress-gate/src/egress_gate/constants.py b/projects/egress-gate/src/egress_gate/constants.py index 0e55a4ea..06716351 100644 --- a/projects/egress-gate/src/egress_gate/constants.py +++ b/projects/egress-gate/src/egress_gate/constants.py @@ -61,10 +61,6 @@ MAX_REGEX_CATALOG_FILE_BYTES = 16 * 1024 * 1024 MAX_REGEX_CATALOG_PATH_BYTES = 1024 -# Prepared-state cache budget. The entry-count cap remains a secondary guard. -MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES = 32 * 1024 * 1024 -REGEX_COMPILED_RULE_WEIGHT_BYTES = 4 * 1024 - # Service concurrency and transport limits. MAX_CONCURRENT_PROCESSING = 4 MAX_CONCURRENT_RPCS = 16 diff --git a/projects/egress-gate/src/egress_gate/gates/__init__.py b/projects/egress-gate/src/egress_gate/gates/__init__.py index 2ffe8581..95ee1ae8 100644 --- a/projects/egress-gate/src/egress_gate/gates/__init__.py +++ b/projects/egress-gate/src/egress_gate/gates/__init__.py @@ -2,7 +2,7 @@ from egress_gate.gates.base import ( Gate, - GateCapabilities, + GateCapability, GateConfig, GateResources, Utf8BodyGate, @@ -35,7 +35,7 @@ __all__ = [ "ConfidenceLevel", "Gate", - "GateCapabilities", + "GateCapability", "GateConfig", "GateDescription", "GateRegistry", diff --git a/projects/egress-gate/src/egress_gate/gates/base.py b/projects/egress-gate/src/egress_gate/gates/base.py index 38b27d15..46543ce3 100644 --- a/projects/egress-gate/src/egress_gate/gates/base.py +++ b/projects/egress-gate/src/egress_gate/gates/base.py @@ -3,6 +3,7 @@ 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 @@ -41,19 +42,17 @@ class GateResources: __slots__ = () -class GateCapabilities(StrictDomainModel): - """Declarative gate reads and mechanically enforced output capabilities.""" +class GateCapability(StrEnum): + """One declared request access or permitted gate result.""" - reads_target: bool = False - reads_context: bool = False - reads_headers: bool = False - reads_body: bool = False - replaces_body: bool = False - mutates_headers: bool = False - produces_findings: bool = False - may_allow: bool = False - may_deny: bool = False - uses_resources: bool = False + 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) @@ -67,7 +66,7 @@ class GateCapabilities(StrictDomainModel): class Gate(ABC, Generic[GateConfigT, GateResourcesT]): """Typed request-level gate with a validated public evaluation wrapper.""" - capabilities: ClassVar[GateCapabilities] + capabilities: ClassVar[frozenset[GateCapability]] finding_types: ClassVar[tuple[FindingTypeDefinition, ...]] @final @@ -160,7 +159,9 @@ def evaluate( @classmethod def _validate_class_contract(cls) -> None: capabilities = getattr(cls, "capabilities", None) - if not isinstance(capabilities, GateCapabilities): + 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( @@ -170,17 +171,9 @@ def _validate_class_contract(cls) -> None: 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, resources_type = _declared_gate_types(cls) + config_type, _ = _declared_gate_types(cls) if config_type is GateConfig: raise GateConfigurationError("gate config type is not concrete") - if capabilities.uses_resources is not (resources_type is not None): - raise GateConfigurationError( - "gate resource capability does not match its generic resource type" - ) - if capabilities.produces_findings != bool(finding_types): - raise GateConfigurationError( - "gate finding capability does not match its declarations" - ) @classmethod def _validate_config( @@ -209,7 +202,7 @@ class Utf8BodyGate( ): """Gate helper that exposes one strict UTF-8 body to an implementation.""" - capabilities = GateCapabilities(reads_body=True) + capabilities = frozenset({GateCapability.READ_BODY}) finding_types: ClassVar[tuple[FindingTypeDefinition, ...]] = () @final @@ -249,22 +242,23 @@ def _evaluate_text( def _validate_gate_output( - capabilities: GateCapabilities, + capabilities: frozenset[GateCapability], finding_types: tuple[FindingTypeDefinition, ...], result: GateEvaluation, ) -> None: if ( result.request_mutations.replacement_body is not None - and not capabilities.replaces_body + and GateCapability.REPLACE_BODY not in capabilities ): raise GateContractError("gate returned an undeclared body replacement") - if result.request_mutations.header_mutations and not capabilities.mutates_headers: + if ( + result.request_mutations.header_mutations + and GateCapability.MUTATE_HEADERS not in capabilities + ): raise GateContractError("gate returned undeclared header mutations") - if result.findings and not capabilities.produces_findings: - raise GateContractError("gate returned undeclared findings") - if result.control.value == "allow" and not capabilities.may_allow: + 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 not capabilities.may_deny: + 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): @@ -274,6 +268,9 @@ def _validate_gate_output( 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) @@ -366,7 +363,7 @@ def _is_valid_resources( __all__ = [ "Gate", - "GateCapabilities", + "GateCapability", "GateConfig", "GateConfigT", "GateResources", diff --git a/projects/egress-gate/src/egress_gate/gates/regex.py b/projects/egress-gate/src/egress_gate/gates/regex.py index 18ef30da..0dd10b9e 100644 --- a/projects/egress-gate/src/egress_gate/gates/regex.py +++ b/projects/egress-gate/src/egress_gate/gates/regex.py @@ -2,17 +2,14 @@ from __future__ import annotations -import json import os from collections import OrderedDict from collections.abc import Iterator, Mapping -from contextlib import contextmanager from dataclasses import dataclass from enum import StrEnum from pathlib import Path from stat import S_ISREG from string import Formatter -from threading import RLock from typing import Annotated, Literal, Protocol, Self, TypeAlias import regex @@ -32,22 +29,18 @@ MAX_PROTO_HEADERS, MAX_REGEX_CATALOG_FILE_BYTES, MAX_REGEX_CATALOG_PATH_BYTES, - MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES, MAX_REGEX_ENTITIES_PER_CATALOG, MAX_REGEX_NAME_BYTES, MAX_REGEX_PATTERN_BYTES, MAX_REGEX_RULES_PER_CATALOG, - REGEX_COMPILED_RULE_WEIGHT_BYTES, ) from egress_gate.errors import ( GateConfigurationError, GateContractError, GateInputError, GateLimitExceededError, - TimeoutExpiredError, ) -from egress_gate.gates.base import Gate, GateCapabilities, GateConfig -from egress_gate.logging import get_logger +from egress_gate.gates.base import Gate, GateCapability, GateConfig from egress_gate.request import HeaderName, HttpRequest, RequestMutations from egress_gate.result import Finding, FindingTypeDefinition, GateEvaluation from egress_gate.string_validators import ScalarString, validate_scalar_string @@ -281,13 +274,14 @@ def _patterns_are_valid(self) -> Self: class RegexGate(Gate[RegexConfig, None]): """Scan the request body, path, query, or selected headers with regex rules.""" - capabilities = GateCapabilities( - reads_target=True, - reads_headers=True, - reads_body=True, - replaces_body=True, - produces_findings=True, - may_deny=True, + capabilities = frozenset( + { + GateCapability.READ_TARGET, + GateCapability.READ_HEADERS, + GateCapability.READ_BODY, + GateCapability.REPLACE_BODY, + GateCapability.DENY, + } ) finding_types = (FindingTypeDefinition(type="regex_match"),) @@ -624,12 +618,6 @@ def _compile_pattern_catalog( timeout: Timeout | None = None, ) -> tuple[_CompiledRule, ...]: _raise_if_expired(timeout) - with _compiled_pattern_cache_lock(timeout): - cached = _COMPILED_PATTERN_CACHE.get(catalog) - if cached is not None: - _COMPILED_PATTERN_CACHE.move_to_end(catalog) - return cached[0] - rules_list: list[_CompiledRule] = [] for global_index, (entity, rule_index, rule) in enumerate( _iter_catalog_rules(catalog) @@ -644,60 +632,8 @@ def _compile_pattern_catalog( timeout=timeout, ) ) - rules = tuple(rules_list) _raise_if_expired(timeout) - weight_bytes = _compiled_pattern_weight(catalog, len(rules)) - if weight_bytes > MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES: - _LOGGER.debug( - "egress_gate_cache_skip cache=regex_compiled " - "weight_bytes=%d budget_bytes=%d", - weight_bytes, - MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES, - ) - return rules - - evicted_entries = 0 - evicted_weight_bytes = 0 - with _compiled_pattern_cache_lock(timeout): - cached = _COMPILED_PATTERN_CACHE.get(catalog) - if cached is not None: - _COMPILED_PATTERN_CACHE.move_to_end(catalog) - return cached[0] - - global _COMPILED_PATTERN_CACHE_WEIGHT_BYTES - _COMPILED_PATTERN_CACHE[catalog] = (rules, weight_bytes) - _COMPILED_PATTERN_CACHE_WEIGHT_BYTES += weight_bytes - while ( - len(_COMPILED_PATTERN_CACHE) > _MAX_CACHED_COMPILED_CATALOGS - or _COMPILED_PATTERN_CACHE_WEIGHT_BYTES - > MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES - ): - _, (_, evicted_weight) = _COMPILED_PATTERN_CACHE.popitem(last=False) - _COMPILED_PATTERN_CACHE_WEIGHT_BYTES -= evicted_weight - evicted_weight_bytes += evicted_weight - evicted_entries += 1 - if evicted_entries: - _LOGGER.debug( - "egress_gate_cache_eviction cache=regex_compiled " - "entries=%d weight_bytes=%d", - evicted_entries, - evicted_weight_bytes, - ) - return rules - - -@contextmanager -def _compiled_pattern_cache_lock(timeout: Timeout | None) -> Iterator[None]: - if timeout is None: - _COMPILED_PATTERN_CACHE_LOCK.acquire() - elif not _COMPILED_PATTERN_CACHE_LOCK.acquire(timeout=timeout.remaining_seconds()): - raise TimeoutExpiredError - try: - _raise_if_expired(timeout) - yield - _raise_if_expired(timeout) - finally: - _COMPILED_PATTERN_CACHE_LOCK.release() + return tuple(rules_list) def _raise_if_expired(timeout: Timeout | None) -> None: @@ -705,29 +641,6 @@ def _raise_if_expired(timeout: Timeout | None) -> None: timeout.raise_if_expired() -def _compiled_pattern_weight( - catalog: RegexPatternCatalog, - rule_count: int, -) -> int: - catalog_bytes = len( - json.dumps( - catalog.model_dump(mode="json"), - allow_nan=False, - ensure_ascii=False, - separators=(",", ":"), - sort_keys=True, - ).encode("utf-8") - ) - return catalog_bytes + rule_count * REGEX_COMPILED_RULE_WEIGHT_BYTES - - -def _clear_compiled_pattern_cache() -> None: - global _COMPILED_PATTERN_CACHE_WEIGHT_BYTES - with _COMPILED_PATTERN_CACHE_LOCK: - _COMPILED_PATTERN_CACHE.clear() - _COMPILED_PATTERN_CACHE_WEIGHT_BYTES = 0 - - def _compile_rule( entity: RegexEntity, rule: RegexRule, @@ -883,18 +796,6 @@ def _rendered_template_size(template: str, entity: str) -> int: ConfidenceLevel.MEDIUM: 1, ConfidenceLevel.HIGH: 2, } -_MAX_CACHED_COMPILED_CATALOGS = 128 -# Python dict preserves insertion order, but this LRU must move cache hits to the -# newest position and efficiently evict the oldest entry. -_COMPILED_PATTERN_CACHE: OrderedDict[ - RegexPatternCatalog, - tuple[tuple[_CompiledRule, ...], int], -] = OrderedDict() -_COMPILED_PATTERN_CACHE_WEIGHT_BYTES = 0 -_COMPILED_PATTERN_CACHE_LOCK = RLock() -_LOGGER = get_logger(__name__) - - __all__ = [ "ConfidenceLevel", "RegexBodyAction", diff --git a/projects/egress-gate/src/egress_gate/gates/registry.py b/projects/egress-gate/src/egress_gate/gates/registry.py index 0d25dcc4..4fd7946c 100644 --- a/projects/egress-gate/src/egress_gate/gates/registry.py +++ b/projects/egress-gate/src/egress_gate/gates/registry.py @@ -1,4 +1,4 @@ -"""Gate registration and finalized pipeline-schema construction.""" +"""Gate registration and lazy policy-schema construction.""" from __future__ import annotations @@ -6,7 +6,7 @@ import inspect import json import re -from collections.abc import Mapping, Sequence +from collections.abc import Callable, Mapping, Sequence from dataclasses import dataclass from enum import StrEnum from functools import reduce @@ -15,13 +15,14 @@ TYPE_CHECKING, Annotated, Literal, - Self, + Protocol, TypeGuard, get_args, get_origin, ) from pydantic import Field, TypeAdapter, ValidationError +from typing_extensions import TypeVar from egress_gate.errors import ( EgressGateError, @@ -31,12 +32,14 @@ ) from egress_gate.gates.base import ( Gate, - GateCapabilities, + GateCapability, GateConfig, + GateConfigT, GateResources, ) from egress_gate.gates.regex import RegexGate -from egress_gate.result import FindingTypeDefinition +from egress_gate.request import HttpRequest +from egress_gate.result import FindingTypeDefinition, GateEvaluation from egress_gate.timeout import Timeout if TYPE_CHECKING: @@ -50,7 +53,7 @@ class GateDescription: gate_type: str description: str - capabilities: GateCapabilities + capabilities: frozenset[GateCapability] finding_types: tuple[FindingTypeDefinition, ...] resource_type: str | None config_type: str @@ -119,7 +122,7 @@ def from_validation_error( class GateRegistry: - """Register trusted gates and finalize their exact pipeline union.""" + """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] = {} @@ -127,11 +130,6 @@ def __init__(self, *, include_builtin_gates: bool = False) -> None: if include_builtin_gates: self.register(RegexGate) - @property - def is_finalized(self) -> bool: - """Whether registration is closed and the policy schema is ready.""" - return self._config_adapter is not None - def register( self, gate_type: type[object], @@ -139,8 +137,8 @@ def register( resources: object = None, ) -> None: """Register one gate and its application-owned resources.""" - if self.is_finalized: - raise GateRegistryError("cannot register after finalization") + 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__: @@ -182,21 +180,40 @@ def register( resources=resources, ) - def finalize(self) -> Self: - """Freeze registration and build the exact policy gate union.""" - if self.is_finalized: - return self - try: - config_type = _build_egress_gate_config_type( - tuple( - registration.config_type - for registration in self._registrations.values() - ) - ) - except (TypeError, ValueError): - raise GateRegistryError("cannot finalize an empty gate registry") from None - self._config_adapter = TypeAdapter[object](config_type) - return self + 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.""" @@ -259,6 +276,7 @@ def prepare_processor( """ 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): @@ -293,6 +311,7 @@ def configuration_json_schema(self) -> dict[str, object]: 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, @@ -323,8 +342,7 @@ def policy_fingerprint(config: EgressGateConfig[GateConfig]) -> str: return hashlib.sha256(canonical).hexdigest() def _resolve_registration(self, config: GateConfig) -> _Registration: - if not self.is_finalized: - raise GateRegistryError("gate registry is not finalized") + self._require_config_adapter() try: gate_kind = getattr(config, "kind") if not isinstance(gate_kind, str): @@ -335,13 +353,24 @@ def _resolve_registration(self, config: GateConfig) -> _Registration: def _require_config_adapter(self) -> TypeAdapter[object]: if self._config_adapter is None: - raise GateRegistryError("gate registry is not finalized") + 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 finalized registry shipped by the base package.""" - return GateRegistry(include_builtin_gates=True).finalize() + """Build the registry shipped by the base package.""" + return GateRegistry(include_builtin_gates=True) @dataclass(frozen=True) @@ -351,6 +380,23 @@ class _Registration: 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: @@ -363,12 +409,7 @@ def _build_egress_gate_config_type( Annotated, (registered_union, Field(discriminator="kind")), ) - config_type: object = getattr(EgressGateConfig, "__class_getitem__")( - registered_config - ) - if not _is_egress_gate_config_type(config_type): - raise TypeError("Pydantic did not construct an Egress Gate config type") - return config_type + return getattr(EgressGateConfig, "__class_getitem__")(registered_config) def _is_gate_type( @@ -385,14 +426,6 @@ def _is_egress_gate_config( return isinstance(value, EgressGateConfig) -def _is_egress_gate_config_type( - value: object, -) -> TypeGuard[type[EgressGateConfig[GateConfig]]]: - from egress_gate.config import EgressGateConfig - - return isinstance(value, type) and issubclass(value, EgressGateConfig) - - def _gate_kind(config_type: type[GateConfig]) -> str: field = config_type.model_fields.get("kind") if field is None: diff --git a/projects/egress-gate/src/egress_gate/service/server.py b/projects/egress-gate/src/egress_gate/service/server.py index 57febbc9..cd5857a4 100644 --- a/projects/egress-gate/src/egress_gate/service/server.py +++ b/projects/egress-gate/src/egress_gate/service/server.py @@ -24,7 +24,7 @@ class EgressGateServer: - """One-shot programmatic server for a finalized gate registry.""" + """One-shot programmatic server for an application gate registry.""" def __init__( self, diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index e16f96bf..b6125eb2 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -77,8 +77,7 @@ def __init__( *, timeout_seconds: float = DEFAULT_TIMEOUT_SECONDS, ) -> None: - if not registry.is_finalized: - raise GateRegistryError("middleware requires a finalized gate registry") + registry.configuration_json_schema() self._registry = registry self._timeout_seconds = validate_timeout_seconds(timeout_seconds) self._policy = _ActivePolicy(registry) @@ -161,7 +160,7 @@ async def _evaluate_rpc( source_kind = "none" try: timeout = Timeout.from_seconds(self._timeout_seconds) - response, source_kind = await self._evaluate_http_request_with_source( + response, source_kind = await self._evaluate_http_request( request, timeout, ) @@ -208,14 +207,6 @@ async def _evaluate_http_request( self, request: pb2.HttpRequestEvaluation, timeout: Timeout, - ) -> pb2.HttpRequestResult: - response, _ = await self._evaluate_http_request_with_source(request, timeout) - return response - - async def _evaluate_http_request_with_source( - 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) @@ -233,7 +224,7 @@ async def _evaluate_http_request_with_source( on_cancel=publication_cancelled.set, ) timeout.raise_if_expired() - response, source_kind = _result_to_proto_with_source(result) + response, source_kind = _result_to_proto(result) timeout.raise_if_expired() return response, source_kind @@ -318,7 +309,7 @@ def processor_for( timeout.raise_if_expired() if config == self._config and self._processor is not None: return self._processor - processor = self._build_processor(config, timeout=timeout) + processor = self._registry.prepare_processor(config, timeout=timeout) timeout.raise_if_expired() if publication_cancelled is not None and publication_cancelled.is_set(): raise _PolicyPublicationCancelled @@ -330,18 +321,6 @@ def processor_for( finally: self._lock.release() - def _build_processor( - self, - config: EgressGateConfig[GateConfig], - *, - timeout: Timeout, - ) -> RequestProcessor: - """Delegate production preparation to the finalized registry.""" - return self._registry.prepare_processor( - config, - timeout=timeout, - ) - def clear(self) -> None: """Release the active policy.""" with self._lock: @@ -493,12 +472,7 @@ def _varint_size(value: int) -> int: return size -def _result_to_proto(result: EgressResult) -> pb2.HttpRequestResult: - response, _ = _result_to_proto_with_source(result) - return response - - -def _result_to_proto_with_source( +def _result_to_proto( result: EgressResult, ) -> tuple[pb2.HttpRequestResult, str]: response = _serialize_result(result) diff --git a/projects/egress-gate/tests/gates/test_base.py b/projects/egress-gate/tests/gates/test_base.py index 1d3319e2..85f0086b 100644 --- a/projects/egress-gate/tests/gates/test_base.py +++ b/projects/egress-gate/tests/gates/test_base.py @@ -11,7 +11,7 @@ from egress_gate.errors import GateContractError, GateInputError from egress_gate.gates import ( Gate, - GateCapabilities, + GateCapability, GateConfig, GateResources, RegexConfig, @@ -27,7 +27,7 @@ class _RequestConfig(GateConfig): class _RequestGate(Gate[_RequestConfig, None]): - capabilities = GateCapabilities(reads_target=True, may_deny=True) + capabilities = frozenset({GateCapability.READ_TARGET, GateCapability.DENY}) finding_types = () def _evaluate( @@ -55,7 +55,7 @@ class _CounterConfig(GateConfig): class _CounterGate(Gate[_CounterConfig, _CounterResources]): - capabilities = GateCapabilities(uses_resources=True, reads_body=True) + capabilities = frozenset({GateCapability.READ_BODY}) finding_types = () def _evaluate( @@ -73,7 +73,7 @@ def _evaluate( class _UndeclaredOutputGate(Gate[_RequestConfig, None]): - capabilities = GateCapabilities() + capabilities = frozenset() finding_types = () def _evaluate( @@ -94,7 +94,7 @@ def _validate_output(self, result: GateEvaluation) -> None: class _InvalidEvaluationGate(Gate[_RequestConfig, None]): - capabilities = GateCapabilities(may_deny=True) + capabilities = frozenset({GateCapability.DENY}) finding_types = () def _evaluate( diff --git a/projects/egress-gate/tests/gates/test_function_gate.py b/projects/egress-gate/tests/gates/test_function_gate.py new file mode 100644 index 00000000..8416c72c --- /dev/null +++ b/projects/egress-gate/tests/gates/test_function_gate.py @@ -0,0 +1,159 @@ +"""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 index 03084103..37f40a89 100644 --- a/projects/egress-gate/tests/gates/test_regex.py +++ b/projects/egress-gate/tests/gates/test_regex.py @@ -2,10 +2,9 @@ from __future__ import annotations -import logging from concurrent.futures import ThreadPoolExecutor from pathlib import Path -from threading import Barrier +from unittest.mock import Mock import pytest from pydantic import ValidationError @@ -466,186 +465,28 @@ def test_pattern_search_has_an_enforceable_timeout() -> None: def test_patterns_compile_during_preparation_not_validation_or_each_run( monkeypatch: pytest.MonkeyPatch, ) -> None: - regex_module._clear_compiled_pattern_cache() - compile_count = 0 original_compile = regex_module.regex.compile - - def recording_compile(pattern: str, flags: int = 0) -> object: - nonlocal compile_count - compile_count += 1 - return original_compile(pattern, flags) - + recording_compile = Mock(wraps=original_compile) monkeypatch.setattr(regex_module.regex, "compile", recording_compile) config = _config([{"pattern": "x", "confidence": "high"}]) - assert compile_count == 0 - RegexGate(config, None, timeout=Timeout.from_seconds(1)) - prepared_count = compile_count + assert recording_compile.call_count == 0 + gate = RegexGate(config, None, timeout=Timeout.from_seconds(1)) + prepared_count = recording_compile.call_count - _run(config, "x") + 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 compile_count == prepared_count + assert recording_compile.call_count == prepared_count def test_gate_preparation_honors_an_expired_timeout() -> None: - regex_module._clear_compiled_pattern_cache() config = _config([{"pattern": "x", "confidence": "high"}]) with pytest.raises(TimeoutExpiredError): RegexGate(config, None, timeout=Timeout(deadline=0)) -def test_compiled_catalog_cache_wait_honors_preparation_timeout() -> None: - regex_module._clear_compiled_pattern_cache() - catalog = _catalog("cache-contention") - regex_module._COMPILED_PATTERN_CACHE_LOCK.acquire() - try: - with ThreadPoolExecutor(max_workers=1) as executor: - future = executor.submit( - regex_module._compile_pattern_catalog, - catalog, - timeout=Timeout.from_seconds(0.01), - ) - with pytest.raises(TimeoutExpiredError): - future.result(timeout=1) - finally: - regex_module._COMPILED_PATTERN_CACHE_LOCK.release() - regex_module._clear_compiled_pattern_cache() - - -def test_compiled_catalog_cache_evicts_least_recently_used_entry( - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - regex_module._clear_compiled_pattern_cache() - catalogs = tuple(_catalog(f"sensitive-pattern-{suffix}") for suffix in "abc") - - try: - first_rules = regex_module._compile_pattern_catalog(catalogs[0]) - entry_weight = regex_module._COMPILED_PATTERN_CACHE[catalogs[0]][1] - monkeypatch.setattr( - regex_module, - "MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES", - entry_weight * 2, - ) - with caplog.at_level( - logging.DEBUG, - logger="egress_gate.gates.regex", - ): - regex_module._compile_pattern_catalog(catalogs[1]) - assert regex_module._compile_pattern_catalog(catalogs[0]) is first_rules - regex_module._compile_pattern_catalog(catalogs[2]) - - assert tuple(regex_module._COMPILED_PATTERN_CACHE) == ( - catalogs[0], - catalogs[2], - ) - assert regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES == sum( - entry[1] for entry in regex_module._COMPILED_PATTERN_CACHE.values() - ) - assert ( - "egress_gate_cache_eviction cache=regex_compiled entries=1" in caplog.text - ) - assert "sensitive-pattern" not in caplog.text - finally: - regex_module._clear_compiled_pattern_cache() - - -def test_compiled_catalog_cache_skips_oversized_valid_entry( - monkeypatch: pytest.MonkeyPatch, - caplog: pytest.LogCaptureFixture, -) -> None: - regex_module._clear_compiled_pattern_cache() - monkeypatch.setattr(regex_module, "MAX_REGEX_COMPILED_CACHE_WEIGHT_BYTES", 1) - catalog = _catalog("sensitive-oversized-pattern") - - try: - with caplog.at_level( - logging.DEBUG, - logger="egress_gate.gates.regex", - ): - first = regex_module._compile_pattern_catalog(catalog) - second = regex_module._compile_pattern_catalog(catalog) - - assert first is not second - assert regex_module._COMPILED_PATTERN_CACHE == {} - assert regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES == 0 - assert caplog.text.count("egress_gate_cache_skip cache=regex_compiled") == 2 - assert "sensitive-oversized-pattern" not in caplog.text - finally: - regex_module._clear_compiled_pattern_cache() - - -def test_compiled_catalog_failure_preserves_existing_weight( - monkeypatch: pytest.MonkeyPatch, -) -> None: - regex_module._clear_compiled_pattern_cache() - retained_catalog = _catalog("retained") - regex_module._compile_pattern_catalog(retained_catalog) - retained_weight = regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES - retained_entries = tuple(regex_module._COMPILED_PATTERN_CACHE) - - def fail_compile(*args: object, **kwargs: object) -> object: - del args, kwargs - raise ValueError("expected test failure") - - monkeypatch.setattr(regex_module, "_compile_rule", fail_compile) - try: - with pytest.raises(ValueError, match="expected test failure"): - regex_module._compile_pattern_catalog(_catalog("failing")) - - assert tuple(regex_module._COMPILED_PATTERN_CACHE) == retained_entries - assert regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES == retained_weight - finally: - regex_module._clear_compiled_pattern_cache() - - -def test_compiled_catalog_same_key_race_accounts_once( - monkeypatch: pytest.MonkeyPatch, -) -> None: - regex_module._clear_compiled_pattern_cache() - worker_count = 4 - workers_ready = Barrier(worker_count) - catalog = _catalog("same-key") - original_compile_rule = regex_module._compile_rule - - def synchronized_compile( - entity: regex_module.RegexEntity, - rule: regex_module.RegexRule, - catalog_index: int, - entity_rule_index: int, - *, - timeout: Timeout | None = None, - ) -> regex_module._CompiledRule: - workers_ready.wait(timeout=5) - return original_compile_rule( - entity, - rule, - catalog_index, - entity_rule_index, - timeout=timeout, - ) - - monkeypatch.setattr(regex_module, "_compile_rule", synchronized_compile) - try: - with ThreadPoolExecutor(max_workers=worker_count) as executor: - results = tuple( - executor.map( - lambda _: regex_module._compile_pattern_catalog(catalog), - range(worker_count), - ) - ) - - assert all(result is results[0] for result in results) - assert len(regex_module._COMPILED_PATTERN_CACHE) == 1 - assert ( - regex_module._COMPILED_PATTERN_CACHE_WEIGHT_BYTES - == next(iter(regex_module._COMPILED_PATTERN_CACHE.values()))[1] - ) - finally: - regex_module._clear_compiled_pattern_cache() - - def test_regex_gate_is_safe_for_concurrent_runs() -> None: gate = RegexGate( _config([{"pattern": "x", "confidence": "high"}]), diff --git a/projects/egress-gate/tests/gates/test_registry.py b/projects/egress-gate/tests/gates/test_registry.py index e475414a..63046506 100644 --- a/projects/egress-gate/tests/gates/test_registry.py +++ b/projects/egress-gate/tests/gates/test_registry.py @@ -12,7 +12,7 @@ from egress_gate.errors import EgressGateError, GateRegistryError from egress_gate.gates import ( Gate, - GateCapabilities, + GateCapability, GateConfig, GateRegistry, GateResources, @@ -32,7 +32,7 @@ class _RegistryConfig(GateConfig): class _RegistryGate(Gate[_RegistryConfig, None]): """A small resource-free gate used to exercise registry assembly.""" - capabilities = GateCapabilities(reads_context=True) + capabilities = frozenset({GateCapability.READ_CONTEXT}) finding_types = () def _initialize(self, *, timeout: Timeout | None = None) -> None: @@ -61,7 +61,7 @@ def __init__(self, name: str) -> None: class _ResourceGate(Gate[_ResourceConfig, _ResourceBundle]): - capabilities = GateCapabilities(uses_resources=True) + capabilities = frozenset() finding_types = () def _evaluate( @@ -82,10 +82,9 @@ def _pipeline(config: dict[str, object]) -> dict[str, object]: } -def test_builtin_registry_is_finalized_and_contains_only_regex() -> None: +def test_builtin_registry_seals_on_first_use_and_contains_only_regex() -> None: registry = create_builtin_registry() - assert registry.is_finalized assert tuple(item.gate_type for item in registry.describe_gates()) == ("regex",) schema = registry.configuration_json_schema() assert _discriminator_names(schema) == {"kind"} @@ -138,12 +137,13 @@ def test_builtin_registry_is_finalized_and_contains_only_regex() -> None: } ) ) + 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) - registry.finalize() config = registry.validate_config( _pipeline({"kind": "registry-test", "answer": 42}) @@ -161,7 +161,7 @@ class DefaultedConfig(GateConfig): kind: Literal["defaulted"] = "defaulted" class DefaultedGate(Gate[DefaultedConfig, None]): - capabilities = GateCapabilities() + capabilities = frozenset() finding_types = () def _evaluate( @@ -182,7 +182,7 @@ class FactoryDefaultedConfig(GateConfig): ) class FactoryDefaultedGate(Gate[FactoryDefaultedConfig, None]): - capabilities = GateCapabilities() + capabilities = frozenset() finding_types = () def _evaluate( @@ -204,7 +204,7 @@ class DefaultNameConfig(GateConfig): kind: Literal["default-name"] class DefaultNameGate(Gate[DefaultNameConfig, None]): - capabilities = GateCapabilities() + capabilities = frozenset() finding_types = () def _evaluate( @@ -221,7 +221,7 @@ class IntegerNameConfig(GateConfig): kind: Literal["integer-name"] class IntegerNameGate(Gate[IntegerNameConfig, None]): - capabilities = GateCapabilities() + capabilities = frozenset() finding_types = () def _evaluate( @@ -238,7 +238,7 @@ class UnboundedNameConfig(GateConfig): kind: Literal["unbounded-name"] class UnboundedNameGate(Gate[UnboundedNameConfig, None]): - capabilities = GateCapabilities() + capabilities = frozenset() finding_types = () def _evaluate( @@ -263,7 +263,7 @@ class AliasedConfig(GateConfig): value: str class AliasedGate(Gate[AliasedConfig, None]): - capabilities = GateCapabilities() + capabilities = frozenset() finding_types = () def _evaluate( @@ -282,7 +282,6 @@ def _evaluate( def test_registry_forwards_the_shared_preparation_timeout() -> None: registry = GateRegistry() registry.register(_RegistryGate) - registry.finalize() config = registry.validate_config( _pipeline({"kind": "registry-test", "answer": 42}) ) @@ -297,7 +296,6 @@ def test_registry_forwards_the_shared_preparation_timeout() -> None: def test_registry_prepares_the_production_processor_from_validated_config() -> None: registry = GateRegistry() registry.register(_RegistryGate) - registry.finalize() config = registry.validate_config( _pipeline({"kind": "registry-test", "answer": 42}) ) @@ -314,7 +312,6 @@ def test_registry_injects_typed_application_resources() -> None: resources = _ResourceBundle("shared-client") registry = GateRegistry() registry.register(_ResourceGate, resources=resources) - registry.finalize() config = registry.validate_config(_pipeline({"kind": "resource-test"})) gate = registry.create_gate(config.gates[0]) @@ -330,7 +327,6 @@ def test_registry_injects_typed_application_resources() -> None: def test_registry_rejects_unknown_policy_shapes() -> None: registry = GateRegistry() registry.register(_RegistryGate) - registry.finalize() for values in ( {"unexpected": {}}, @@ -347,16 +343,15 @@ def test_registry_rejects_unknown_policy_shapes() -> None: def test_registry_lifecycle_and_fingerprint_are_deterministic() -> None: registry = GateRegistry() - with pytest.raises(GateRegistryError): - registry.finalize() + with pytest.raises(GateRegistryError, match="no registered gates"): + registry.configuration_json_schema() registry.register(_RegistryGate) - registry.finalize() - with pytest.raises(GateRegistryError): - registry.register(_ResourceGate) - 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) @@ -364,7 +359,6 @@ def test_registry_lifecycle_and_fingerprint_are_deterministic() -> None: def test_registry_rejects_duplicate_gate_names_before_preparation() -> None: registry = GateRegistry() registry.register(_RegistryGate) - registry.finalize() values = { "gates": [ {"name": "same", "kind": "registry-test", "answer": 1}, diff --git a/projects/egress-gate/tests/service/test_server.py b/projects/egress-gate/tests/service/test_server.py index e3e7aa85..736ce38f 100644 --- a/projects/egress-gate/tests/service/test_server.py +++ b/projects/egress-gate/tests/service/test_server.py @@ -40,8 +40,8 @@ async def stop(self, grace: float | None) -> None: self.stop_graces.append(grace) -def test_server_requires_a_finalized_gate_registry() -> None: - with pytest.raises(GateRegistryError, match="finalized"): +def test_server_rejects_a_registry_without_gates() -> None: + with pytest.raises(GateRegistryError, match="no registered gates"): EgressGateServer(GateRegistry()) @@ -60,6 +60,16 @@ def test_server_keeps_timeout_ownership_at_the_service_boundary() -> None: 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: diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 08ec9baa..5d894f80 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -7,6 +7,7 @@ from concurrent.futures import ThreadPoolExecutor from threading import Barrier, Event from typing import Never +from unittest.mock import Mock import grpc import pytest @@ -226,7 +227,7 @@ def test_result_adapter_serializes_only_five_finding_fields_and_empty_body_inten findings=(SourcedFinding(source_gate="body", finding=finding),), ) - response = servicer_module._result_to_proto(result) + response, _ = servicer_module._result_to_proto(result) assert response.decision == pb2.DECISION_ALLOW assert response.has_body is True @@ -266,8 +267,8 @@ def test_result_adapter_preserves_ordered_header_mutations_and_deny_reason() -> reason_code=LIMIT_REASON_CODE, ) - allowed_response = servicer_module._result_to_proto(allowed) - denied_response = servicer_module._result_to_proto(denied) + 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" @@ -303,20 +304,18 @@ def test_concurrent_same_candidate_is_prepared_once( workers_ready = Barrier(2) build_started = Event() release_build = Event() - create_count = 0 - def counted_create_gate( + def blocked_create_gate( config: GateConfig, *, timeout: Timeout | None = None, ) -> object: - nonlocal create_count - create_count += 1 build_started.set() assert release_build.wait(2) return original_create_gate(config, timeout=timeout) - monkeypatch.setattr(middleware._registry, "create_gate", counted_create_gate) + 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) @@ -340,7 +339,7 @@ def resolve_candidate() -> object: asyncio.run(middleware.close()) assert second is first - assert create_count == 1 + assert create_gate.call_count == 1 def test_failed_candidate_leaves_the_old_policy_active( @@ -433,7 +432,7 @@ async def test_cancelled_candidate_keeps_its_slot_and_is_not_published( old = middleware._policy.processor_for(_values(), timeout=Timeout.from_seconds(1)) started = Event() release = Event() - original_build = middleware._policy._build_processor + original_build = middleware._registry.prepare_processor def blocked_build( config: EgressGateConfig[GateConfig], @@ -444,7 +443,7 @@ def blocked_build( assert release.wait(2) return original_build(config, timeout=timeout) - monkeypatch.setattr(middleware._policy, "_build_processor", blocked_build) + 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( @@ -485,7 +484,7 @@ async def test_result_serialization_is_bracketed_by_the_shared_timeout( ), ) events: list[str] = [] - original_serialize = servicer_module._result_to_proto_with_source + original_serialize = servicer_module._result_to_proto async def return_result(*args: object, **kwargs: object) -> EgressResult: del args, kwargs @@ -505,7 +504,7 @@ def record_serialization( monkeypatch.setattr(Timeout, "raise_if_expired", record_deadline_check) monkeypatch.setattr( servicer_module, - "_result_to_proto_with_source", + "_result_to_proto", record_serialization, ) try: @@ -562,7 +561,7 @@ async def return_limit( monkeypatch.setattr( middleware, - "_evaluate_http_request_with_source", + "_evaluate_http_request", return_limit, ) try: @@ -588,7 +587,7 @@ def test_serialized_limit_result_reports_runtime_limit_source() -> None: reason_code=LIMIT_REASON_CODE, ) - response, source_kind = servicer_module._result_to_proto_with_source(result) + response, source_kind = servicer_module._result_to_proto(result) assert response.reason_code == LIMIT_REASON_CODE assert source_kind == DecisionSourceKind.RUNTIME_LIMIT.value @@ -636,6 +635,6 @@ def test_default_deny_reason_is_wire_safe() -> None: ), reason_code=DEFAULT_DENY_REASON_CODE, ) - response = servicer_module._result_to_proto(result) + 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 index 2a5d85c0..3edad076 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -82,33 +82,35 @@ def test_cli_configuration_schema_exposes_flat_policy() -> None: assert schema["properties"]["gates"]["maxItems"] == 10 -def test_registry_factory_loader_requires_a_finalized_gate_registry( +def test_registry_loader_accepts_a_singleton_or_factory_and_seals_it( monkeypatch: pytest.MonkeyPatch, ) -> None: - module = ModuleType("test_registry_factory") + module = ModuleType("test_registry_source") + singleton = create_builtin_registry() + module.__dict__["registry"] = singleton module.__dict__["create_registry"] = create_builtin_registry - module.__dict__["unfinished"] = lambda: GateRegistry() + module.__dict__["empty"] = GateRegistry() monkeypatch.setitem(sys.modules, module.__name__, module) - assert _load_registry("test_registry_factory:create_registry").is_finalized - with pytest.raises(Exception, match=r"call finalize\(\)"): - _load_registry("test_registry_factory:unfinished") + 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_factory:missing"], + ["missing-separator", "test_registry_source:missing"], ) -def test_registry_factory_loader_rejects_invalid_references(reference: str) -> None: +def test_registry_loader_rejects_invalid_references(reference: str) -> None: with pytest.raises(Exception): _load_registry(reference) -def test_unfinalized_registry_cannot_be_used_by_the_middleware() -> None: - with pytest.raises(GateRegistryError): - create_builtin_registry().register(object) - - def test_cli_evaluate_runs_the_builtin_policy_corpus() -> None: project_dir = Path(__file__).parents[1] result = CliRunner().invoke( @@ -134,8 +136,8 @@ def test_cli_evaluate_runs_the_custom_gate_example() -> None: result = CliRunner().invoke( app, [ - "--registry-factory", - "examples.custom-gate.keyword_gate:create_registry", + "--registry", + "examples.custom-gate.keyword_gate:registry", "evaluate", "--policy", str(project_dir / "examples/custom-gate/egress-gate-config.yaml"), @@ -157,8 +159,8 @@ def test_installed_executable_loads_a_registry_from_the_working_directory() -> N result = subprocess.run( [ executable, - "--registry-factory", - "examples.custom-gate.keyword_gate:create_registry", + "--registry", + "examples.custom-gate.keyword_gate:registry", "gates", "list", ], diff --git a/projects/egress-gate/tests/test_request_processor.py b/projects/egress-gate/tests/test_request_processor.py index 35cc51dd..c47ce22b 100644 --- a/projects/egress-gate/tests/test_request_processor.py +++ b/projects/egress-gate/tests/test_request_processor.py @@ -23,7 +23,7 @@ ) from egress_gate.gates import ( Gate, - GateCapabilities, + GateCapability, GateConfig, GateRegistry, ) @@ -63,13 +63,14 @@ class _ControlConfig(GateConfig): class _ControlGate(Gate[_ControlConfig, None]): - capabilities = GateCapabilities( - reads_body=True, - replaces_body=True, - mutates_headers=True, - produces_findings=True, - may_allow=True, - may_deny=True, + capabilities = frozenset( + { + GateCapability.READ_BODY, + GateCapability.REPLACE_BODY, + GateCapability.MUTATE_HEADERS, + GateCapability.ALLOW, + GateCapability.DENY, + } ) finding_types = (FindingTypeDefinition(type="test_observation"),) @@ -183,7 +184,6 @@ def _processor( ) -> RequestProcessor: registry = GateRegistry(include_builtin_gates=include_regex) registry.register(_ControlGate) - registry.finalize() values = { "gates": [{"name": name, **config} for name, config in gate_values], "default_decision": default_decision.value, @@ -481,9 +481,15 @@ def test_invalid_utf8_is_translated_to_the_stable_input_error() -> None: def test_prepared_gate_type_is_part_of_the_processor_contract() -> None: - processor = _processor((("one", {"kind": "test-control", "control": "proceed"}),)) - config = processor._config - gate = processor._gates[0][2] + 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( From 6756086694cb21f2228383f37b016944014a15b7 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 5 Aug 2026 20:19:15 +0000 Subject: [PATCH 40/46] Clarify automatic registry sealing --- projects/egress-gate/docs/architecture/request-lifecycle.md | 2 +- projects/egress-gate/src/egress_gate/gates/registry.py | 2 +- projects/egress-gate/src/egress_gate/service/servicer.py | 2 +- projects/egress-gate/tests/gates/test_registry.py | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/projects/egress-gate/docs/architecture/request-lifecycle.md b/projects/egress-gate/docs/architecture/request-lifecycle.md index a5bd737b..47e42d56 100644 --- a/projects/egress-gate/docs/architecture/request-lifecycle.md +++ b/projects/egress-gate/docs/architecture/request-lifecycle.md @@ -19,7 +19,7 @@ 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 finalized +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 diff --git a/projects/egress-gate/src/egress_gate/gates/registry.py b/projects/egress-gate/src/egress_gate/gates/registry.py index 4fd7946c..7208d954 100644 --- a/projects/egress-gate/src/egress_gate/gates/registry.py +++ b/projects/egress-gate/src/egress_gate/gates/registry.py @@ -301,7 +301,7 @@ def prepare_processor( ) def configuration_json_schema(self) -> dict[str, object]: - """Return the finalized complete policy JSON Schema.""" + """Return the complete policy JSON Schema.""" schema = self._require_config_adapter().json_schema() schema["title"] = "EgressGateConfig" schema["description"] = ( diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index b6125eb2..37f4af4c 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -97,7 +97,7 @@ async def Describe( request: object, context: grpc.aio.ServicerContext[object, pb2.MiddlewareManifest], ) -> pb2.MiddlewareManifest: - """Advertise the binding and its finalized policy schema.""" + """Advertise the binding and its complete policy schema.""" return pb2.MiddlewareManifest( name=SERVICE_NAME, service_version=SERVICE_VERSION, diff --git a/projects/egress-gate/tests/gates/test_registry.py b/projects/egress-gate/tests/gates/test_registry.py index 63046506..736219c4 100644 --- a/projects/egress-gate/tests/gates/test_registry.py +++ b/projects/egress-gate/tests/gates/test_registry.py @@ -1,4 +1,4 @@ -"""Registry finalization and exact pipeline-schema tests.""" +"""Registry sealing and exact pipeline-schema tests.""" from __future__ import annotations From 8918414cdc1aa0f7f8dbcb1b33d93651903539cb Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 5 Aug 2026 20:47:46 +0000 Subject: [PATCH 41/46] Improve Egress Gate examples --- projects/egress-gate/README.md | 3 +- projects/egress-gate/docs/gates/custom.md | 42 +++++++++--- .../examples/class-based-gate/README.md | 44 +++++++++++++ .../examples/class-based-gate/cases.yaml | 53 +++++++++++++++ .../class-based-gate/egress-gate-config.yaml | 5 ++ .../examples/class-based-gate/keyword_gate.py | 37 +++++++++++ .../examples/custom-gate/README.md | 38 +++++------ .../examples/regex-redaction/README.md | 66 +++++++++++++------ projects/egress-gate/tests/test_cli.py | 18 +++-- 9 files changed, 254 insertions(+), 52 deletions(-) create mode 100644 projects/egress-gate/examples/class-based-gate/README.md create mode 100644 projects/egress-gate/examples/class-based-gate/cases.yaml create mode 100644 projects/egress-gate/examples/class-based-gate/egress-gate-config.yaml create mode 100644 projects/egress-gate/examples/class-based-gate/keyword_gate.py diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index 2795fc01..dd6ea09b 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -110,7 +110,8 @@ through slot acquisition, policy preparation, and `RequestProcessor.process`. - [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) -- [Minimal custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom-gate) +- [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 diff --git a/projects/egress-gate/docs/gates/custom.md b/projects/egress-gate/docs/gates/custom.md index 325fd68f..4fc9a701 100644 --- a/projects/egress-gate/docs/gates/custom.md +++ b/projects/egress-gate/docs/gates/custom.md @@ -12,13 +12,15 @@ 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. -The repository includes a runnable -[minimal custom gate](https://github.com/NVIDIA/OpenShell-Research/tree/main/projects/egress-gate/examples/custom-gate) -that pairs the implementation below with a policy and two offline evaluation -cases. Run it from `projects/egress-gate/`; `uv` prepares the project environment -automatically: +The repository includes runnable examples for both extension styles: -```bash title="Run the custom-gate example" +- [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 \ @@ -109,8 +111,18 @@ The function helper does not replace the class-based extension API. Implement helper base such as `Utf8BodyGate`, or typed `GateResources`. Resource-free class-based gates use `registry.register(GateType)`. -```python title="Equivalent class-based gate" -from egress_gate.gates import Gate, GateCapability +```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]): @@ -129,6 +141,20 @@ class KeywordDenyGate(Gate[KeywordDenyConfig, None]): 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 diff --git a/projects/egress-gate/examples/class-based-gate/README.md b/projects/egress-gate/examples/class-based-gate/README.md new file mode 100644 index 00000000..4334ff21 --- /dev/null +++ b/projects/egress-gate/examples/class-based-gate/README.md @@ -0,0 +1,44 @@ +# 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. + +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 new file mode 100644 index 00000000..4a911910 --- /dev/null +++ b/projects/egress-gate/examples/class-based-gate/cases.yaml @@ -0,0 +1,53 @@ +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 new file mode 100644 index 00000000..05186e04 --- /dev/null +++ b/projects/egress-gate/examples/class-based-gate/egress-gate-config.yaml @@ -0,0 +1,5 @@ +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 new file mode 100644 index 00000000..a3eee4bb --- /dev/null +++ b/projects/egress-gate/examples/class-based-gate/keyword_gate.py @@ -0,0 +1,37 @@ +"""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/custom-gate/README.md b/projects/egress-gate/examples/custom-gate/README.md index e71ae76e..e46a64ce 100644 --- a/projects/egress-gate/examples/custom-gate/README.md +++ b/projects/egress-gate/examples/custom-gate/README.md @@ -1,4 +1,4 @@ -# Minimal custom gate +# 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 @@ -12,18 +12,18 @@ The implementation has three pieces: 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/`. `uv run` prepares the project -environment before each command: +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 +``` -uv run egress-gate \ - --registry examples.custom-gate.keyword_gate:registry \ - validate --policy examples/custom-gate/egress-gate-config.yaml +Then test the policy against two saved requests: +```bash uv run egress-gate \ --registry examples.custom-gate.keyword_gate:registry \ evaluate \ @@ -35,19 +35,19 @@ 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 `block-secret-keyword` gate denies the first corpus case. The second gate -evaluation proceeds. The explicit `default_decision: allow` then determines -the result. +The first case contains the configured keyword and is denied. The second gate +evaluation proceeds, so `default_decision: allow` determines its result. -This is a teaching example, not a robust content classifier. The pipeline -processor already checks the `HttpRequest` limits. Do not check those limits -again. +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 bound decorator is a helper for small, stateless gates. The class-based -`Gate` API remains available for reusable initialization, helper bases, and -typed operational resources. +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 text-decoding 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. +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/regex-redaction/README.md b/projects/egress-gate/examples/regex-redaction/README.md index d574f071..c010523c 100644 --- a/projects/egress-gate/examples/regex-redaction/README.md +++ b/projects/egress-gate/examples/regex-redaction/README.md @@ -1,33 +1,59 @@ -# Regex redaction composition +# Regex redaction -This example runs the built-in `regex` gate with a body scan and a replace -action. The standalone configuration contains a small email catalog. You can -validate or evaluate it from any working directory. The OpenShell `policy.yaml` -shows the equivalent file-backed catalog with email and customer-ID patterns. -Both keep request-derived content out of findings. +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. -Inspect the installed gate and exact policy schema: +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 -cd projects/egress-gate uv run egress-gate gates list -uv run egress-gate gates schema +uv run egress-gate evaluate \ + --policy egress-gate-config.yaml \ + --cases cases.yaml ``` -Start the middleware: +## Run it with OpenShell + +Start Egress Gate in one terminal. The working directory contains the pattern +catalog referenced by `policy.yaml`. ```bash -cd projects/egress-gate/examples/regex-redaction uv run egress-gate serve --listen 0.0.0.0:50051 --timeout-seconds 4 ``` -Register that address with the OpenShell gateway using a reachable host IPv4 -address, then create a sandbox with `policy.yaml`. The policy embeds the -flat `gates` configuration and uses `egress-gate-redaction` as the -middleware registration name. +In another terminal, add the registration to your default OpenShell 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-gate-redaction \ + --port 50051 +``` + +Restart the OpenShell gateway, then create a sandbox with `policy.yaml`. The +policy refers to the same `egress-gate-redaction` registration name. + +To remove the example registration from the default gateway configuration, +run this command and restart the gateway: + +```bash +uv run egress-gate remove-gateway-registration \ + --name egress-gate-redaction +``` + +## 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. -This composition selects `scan.kind: body` and -`scan.action.kind: replace`. The gate strictly decodes the body bytes as UTF-8 -before it finds and replaces matches. A body scan also supports `detect` and -`deny` actions. The same built-in can detect or deny matches in a path, query, -or selected header values. +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/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 3edad076..b3411165 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -131,18 +131,28 @@ def test_cli_evaluate_runs_the_builtin_policy_corpus() -> None: assert "2 passed · 0 failed · 2 total" in result.stdout -def test_cli_evaluate_runs_the_custom_gate_example() -> None: +@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", - "examples.custom-gate.keyword_gate:registry", + registry_reference, "evaluate", "--policy", - str(project_dir / "examples/custom-gate/egress-gate-config.yaml"), + str(project_dir / f"examples/{example_directory}/egress-gate-config.yaml"), "--cases", - str(project_dir / "examples/custom-gate/cases.yaml"), + str(project_dir / f"examples/{example_directory}/cases.yaml"), ], ) From a8418236b6605807073847180d0c184a240db4a3 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Wed, 5 Aug 2026 21:20:56 +0000 Subject: [PATCH 42/46] Improve Egress Gate setup workflows --- projects/egress-gate/docs/operations.md | 8 ++ .../examples/class-based-gate/README.md | 69 +++++++++++ .../examples/class-based-gate/policy.yaml | 47 ++++++++ .../examples/custom-gate/README.md | 69 +++++++++++ .../examples/custom-gate/policy.yaml | 47 ++++++++ .../examples/regex-redaction/README.md | 53 +++++++-- .../regex-redaction/egress-gate-config.yaml | 8 +- .../examples/regex-redaction/policy.yaml | 8 +- projects/egress-gate/src/egress_gate/cli.py | 76 +++++++++--- .../src/egress_gate/gateway_config.py | 49 +++++++- projects/egress-gate/tests/test_cli.py | 112 ++++++++++++++++++ .../egress-gate/tests/test_gateway_config.py | 51 ++++++++ 12 files changed, 567 insertions(+), 30 deletions(-) create mode 100644 projects/egress-gate/examples/class-based-gate/policy.yaml create mode 100644 projects/egress-gate/examples/custom-gate/policy.yaml diff --git a/projects/egress-gate/docs/operations.md b/projects/egress-gate/docs/operations.md index 1e0dbc47..0c157e76 100644 --- a/projects/egress-gate/docs/operations.md +++ b/projects/egress-gate/docs/operations.md @@ -32,6 +32,14 @@ The command updates `OPENSHELL_GATEWAY_CONFIG`, then `~/.config/openshell/gateway.toml`. Use `--config PATH` for another file. Restart the OpenShell gateway after changing registrations. Remove one 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 ``` diff --git a/projects/egress-gate/examples/class-based-gate/README.md b/projects/egress-gate/examples/class-based-gate/README.md index 4334ff21..03affdfc 100644 --- a/projects/egress-gate/examples/class-based-gate/README.md +++ b/projects/egress-gate/examples/class-based-gate/README.md @@ -31,6 +31,75 @@ uv run egress-gate \ 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-seconds 4 +``` + +In another terminal, register the service in your default OpenShell 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 +``` + +Restart the OpenShell gateway, 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. Clean up the sandbox and registration, then restart the +gateway: + +```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 diff --git a/projects/egress-gate/examples/class-based-gate/policy.yaml b/projects/egress-gate/examples/class-based-gate/policy.yaml new file mode 100644 index 00000000..b82b4064 --- /dev/null +++ b/projects/egress-gate/examples/class-based-gate/policy.yaml @@ -0,0 +1,47 @@ +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 index e46a64ce..b8350cbb 100644 --- a/projects/egress-gate/examples/custom-gate/README.md +++ b/projects/egress-gate/examples/custom-gate/README.md @@ -38,6 +38,75 @@ 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-seconds 4 +``` + +In another terminal, register the service in your default OpenShell 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 +``` + +Restart the OpenShell gateway, 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. Clean up the sandbox and registration, then restart the +gateway: + +```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 diff --git a/projects/egress-gate/examples/custom-gate/policy.yaml b/projects/egress-gate/examples/custom-gate/policy.yaml new file mode 100644 index 00000000..796ca0c9 --- /dev/null +++ b/projects/egress-gate/examples/custom-gate/policy.yaml @@ -0,0 +1,47 @@ +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/README.md b/projects/egress-gate/examples/regex-redaction/README.md index c010523c..a8476753 100644 --- a/projects/egress-gate/examples/regex-redaction/README.md +++ b/projects/egress-gate/examples/regex-redaction/README.md @@ -20,11 +20,13 @@ uv run egress-gate evaluate \ ## Run it with OpenShell -Start Egress Gate in one terminal. The working directory contains the pattern -catalog referenced by `policy.yaml`. +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 serve --listen 0.0.0.0:50051 --timeout-seconds 4 +uv run egress-gate --debug serve \ + --listen 0.0.0.0:50051 \ + --timeout-seconds 4 ``` In another terminal, add the registration to your default OpenShell gateway @@ -34,21 +36,58 @@ gateway and sandbox supervisors can reach. ```bash uv run egress-gate add-gateway-registration \ --host-ip YOUR_HOST_IPV4 \ - --name egress-gate-redaction \ + --name eg-regex \ --port 50051 ``` -Restart the OpenShell gateway, then create a sandbox with `policy.yaml`. The -policy refers to the same `egress-gate-redaction` registration name. +Restart the OpenShell gateway, 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 from the default gateway configuration, run this command and restart the gateway: ```bash uv run egress-gate remove-gateway-registration \ - --name egress-gate-redaction + --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 diff --git a/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml b/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml index dfda8764..bec57150 100644 --- a/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml +++ b/projects/egress-gate/examples/regex-redaction/egress-gate-config.yaml @@ -10,6 +10,12 @@ gates: entities: - name: email rules: - - pattern: '(? 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.", @@ -277,10 +309,7 @@ def remove_gateway_registration( name: Annotated[ str, typer.Option( - help=( - "Registration name to remove. OpenShell allows " - f"1-{MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES} ASCII bytes." - ), + help="Exact registration name to remove from the gateway config.", ), ], config: Annotated[ @@ -294,19 +323,11 @@ def remove_gateway_registration( ] = None, ) -> None: """Remove a named registration from an OpenShell gateway TOML file.""" - try: - validated_name = validate_middleware_name(name) - except GatewayConfigError as error: - raise typer.BadParameter( - str(error), - param_hint="--name", - ) from None - config_path = config or default_gateway_config_path() try: result = remove_gateway_config( config_path, - middleware_name=validated_name, + middleware_name=name, ) except GatewayConfigError as error: _render_cli_error( @@ -320,14 +341,14 @@ def remove_gateway_registration( _render_registration( title="Gateway registration was removed", config_path=config_path, - name=validated_name, + 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=validated_name, + name=name, status_style="bold yellow", ) @@ -987,6 +1008,31 @@ def _render_registration( _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") + for registration in registrations: + table.add_row(registration.name, registration.endpoint 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( diff --git a/projects/egress-gate/src/egress_gate/gateway_config.py b/projects/egress-gate/src/egress_gate/gateway_config.py index d0e90821..c6b0eef8 100644 --- a/projects/egress-gate/src/egress_gate/gateway_config.py +++ b/projects/egress-gate/src/egress_gate/gateway_config.py @@ -8,6 +8,7 @@ import stat import tempfile import tomllib +from dataclasses import dataclass from enum import Enum from pathlib import Path @@ -32,9 +33,17 @@ 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 + + # Mirrors OpenShell's stable-identifier byte limit for external middleware # registrations. -MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES = 128 +MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES = 19 def default_gateway_config_path() -> Path: @@ -50,6 +59,41 @@ def default_gateway_config_path() -> Path: return Path.home() / ".config" / "openshell" / "gateway.toml" +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") + 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." + ) + registrations.append( + GatewayMiddlewareRegistration(name=name, endpoint=endpoint) + ) + return tuple(registrations) + + def update_gateway_config( path: Path, *, @@ -131,7 +175,6 @@ def remove_gateway_config( middleware_name: str, ) -> GatewayConfigRemoval: """Remove one named Egress Gate middleware registration.""" - validate_middleware_name(middleware_name) try: original = path.read_text(encoding="utf-8") except FileNotFoundError: @@ -385,10 +428,12 @@ def _write_atomically(path: Path, contents: str) -> None: __all__ = [ "GatewayConfigError", + "GatewayMiddlewareRegistration", "GatewayConfigRemoval", "GatewayConfigUpdate", "MAX_MIDDLEWARE_REGISTRATION_NAME_BYTES", "default_gateway_config_path", + "list_gateway_registrations", "remove_gateway_config", "update_gateway_config", "validate_middleware_name", diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index b3411165..055d3ff6 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -9,12 +9,14 @@ 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: @@ -162,6 +164,74 @@ def test_cli_evaluate_runs_the_custom_gate_examples( 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 + + 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") @@ -336,6 +406,48 @@ def test_cli_add_gateway_registration_reports_the_result(tmp_path: Path) -> None assert "Next: Start Egress Gate" in result.stdout +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\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 "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" diff --git a/projects/egress-gate/tests/test_gateway_config.py b/projects/egress-gate/tests/test_gateway_config.py index 2735be8e..a064fee0 100644 --- a/projects/egress-gate/tests/test_gateway_config.py +++ b/projects/egress-gate/tests/test_gateway_config.py @@ -12,7 +12,9 @@ GatewayConfigError, GatewayConfigRemoval, GatewayConfigUpdate, + GatewayMiddlewareRegistration, default_gateway_config_path, + list_gateway_registrations, remove_gateway_config, update_gateway_config, validate_middleware_name, @@ -55,6 +57,7 @@ def test_default_gateway_config_path_honors_openshell_override( 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 @@ -101,6 +104,35 @@ def test_update_gateway_config_creates_minimal_default_config( } +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\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", + ), + GatewayMiddlewareRegistration(name="other-service", endpoint=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: @@ -238,6 +270,25 @@ def test_remove_gateway_config_removes_only_the_named_registration( ] +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, From 466215b2da76c5bd91e68b6542db3bdf48d64cb0 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 6 Aug 2026 02:20:44 +0000 Subject: [PATCH 43/46] docs(egress-gate): clarify gateway restart workflow --- projects/egress-gate/docs/operations.md | 12 +++++++++++- .../egress-gate/examples/class-based-gate/README.md | 13 +++++++++---- projects/egress-gate/examples/custom-gate/README.md | 13 +++++++++---- .../egress-gate/examples/regex-redaction/README.md | 13 +++++++++---- projects/egress-gate/tests/test_cli.py | 5 +++++ 5 files changed, 43 insertions(+), 13 deletions(-) diff --git a/projects/egress-gate/docs/operations.md b/projects/egress-gate/docs/operations.md index 0c157e76..4ff39f8c 100644 --- a/projects/egress-gate/docs/operations.md +++ b/projects/egress-gate/docs/operations.md @@ -22,6 +22,10 @@ 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 @@ -30,7 +34,11 @@ uv run egress-gate add-gateway-registration \ The command updates `OPENSHELL_GATEWAY_CONFIG`, then `$XDG_CONFIG_HOME/openshell/gateway.toml`, then `~/.config/openshell/gateway.toml`. Use `--config PATH` for another file. -Restart the OpenShell gateway after changing registrations. Remove one with: +Start the gateways again with the same commands or service managers that you +normally use. + +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 @@ -44,6 +52,8 @@ the registration you no longer need: uv run egress-gate remove-gateway-registration --name egress-gate ``` +Start the gateways again after the command completes. + The generated OpenShell middleware timeout is five seconds. Keep the Egress Gate `--timeout-seconds` below it so queueing, preparation, and transport have headroom. diff --git a/projects/egress-gate/examples/class-based-gate/README.md b/projects/egress-gate/examples/class-based-gate/README.md index 03affdfc..fecbe9d5 100644 --- a/projects/egress-gate/examples/class-based-gate/README.md +++ b/projects/egress-gate/examples/class-based-gate/README.md @@ -42,7 +42,11 @@ uv run egress-gate \ serve --listen 0.0.0.0:50051 --timeout-seconds 4 ``` -In another terminal, register the service in your default OpenShell gateway +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. @@ -53,7 +57,8 @@ uv run egress-gate add-gateway-registration \ --port 50051 ``` -Restart the OpenShell gateway, then create a sandbox and launch Claude Code: +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 \ @@ -89,8 +94,8 @@ 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. Clean up the sandbox and registration, then restart the -gateway: +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 diff --git a/projects/egress-gate/examples/custom-gate/README.md b/projects/egress-gate/examples/custom-gate/README.md index b8350cbb..38238df1 100644 --- a/projects/egress-gate/examples/custom-gate/README.md +++ b/projects/egress-gate/examples/custom-gate/README.md @@ -49,7 +49,11 @@ uv run egress-gate \ serve --listen 0.0.0.0:50051 --timeout-seconds 4 ``` -In another terminal, register the service in your default OpenShell gateway +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. @@ -60,7 +64,8 @@ uv run egress-gate add-gateway-registration \ --port 50051 ``` -Restart the OpenShell gateway, then create a sandbox and launch Claude Code: +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 \ @@ -96,8 +101,8 @@ 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. Clean up the sandbox and registration, then restart the -gateway: +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 diff --git a/projects/egress-gate/examples/regex-redaction/README.md b/projects/egress-gate/examples/regex-redaction/README.md index a8476753..9190b796 100644 --- a/projects/egress-gate/examples/regex-redaction/README.md +++ b/projects/egress-gate/examples/regex-redaction/README.md @@ -29,7 +29,11 @@ uv run egress-gate --debug serve \ --timeout-seconds 4 ``` -In another terminal, add the registration to your default OpenShell gateway +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. @@ -40,7 +44,8 @@ uv run egress-gate add-gateway-registration \ --port 50051 ``` -Restart the OpenShell gateway, then create a sandbox and launch Claude Code: +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 \ @@ -77,8 +82,8 @@ Exit Claude Code, then delete the sandbox when the test is complete: openshell sandbox delete eg-regex ``` -To remove the example registration from the default gateway configuration, -run this command and restart the gateway: +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 \ diff --git a/projects/egress-gate/tests/test_cli.py b/projects/egress-gate/tests/test_cli.py index 055d3ff6..e25a19e5 100644 --- a/projects/egress-gate/tests/test_cli.py +++ b/projects/egress-gate/tests/test_cli.py @@ -230,6 +230,11 @@ def test_example_workflows_use_one_registration_and_sandbox_name( 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: From ec744bcc6ea5a42056a31f3a6a6ef38180de3fb8 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 6 Aug 2026 02:44:27 +0000 Subject: [PATCH 44/46] fix(egress-gate): address final review findings --- docs/javascripts/navigation-drawer.js | 2 +- plans/egress-gate-refactor.md | 8 +-- projects/egress-gate/README.md | 2 +- projects/egress-gate/docs/configuration.md | 4 ++ projects/egress-gate/docs/gates/regex.md | 9 ++-- .../src/egress_gate/gates/registry.py | 15 ++++++ .../src/egress_gate/request_processor.py | 14 ++++- .../src/egress_gate/service/servicer.py | 45 ++++++++++++---- .../egress-gate/tests/gates/test_registry.py | 35 ++++++++++++ .../tests/service/test_servicer.py | 54 +++++++++++++++++++ .../tests/test_request_processor.py | 40 +++++++++++++- tests/navigation-drawer.test.js | 6 ++- 12 files changed, 211 insertions(+), 23 deletions(-) diff --git a/docs/javascripts/navigation-drawer.js b/docs/javascripts/navigation-drawer.js index 06e89d0e..1644d407 100644 --- a/docs/javascripts/navigation-drawer.js +++ b/docs/javascripts/navigation-drawer.js @@ -126,7 +126,7 @@ const onKeyDown = (event) => { if ( document.activeElement === button && - event.key === " " + (event.key === " " || event.key === "Enter") ) { event.preventDefault(); onButtonClick(event); diff --git a/plans/egress-gate-refactor.md b/plans/egress-gate-refactor.md index 4e04edde..16c98e32 100644 --- a/plans/egress-gate-refactor.md +++ b/plans/egress-gate-refactor.md @@ -2,9 +2,11 @@ ## Status -Implemented design specification. The phased sequence below records the -intended construction and acceptance boundaries; it is not a remaining-work -checklist and must not be replayed against the completed refactor. +Historical implementation plan. The public contract evolved while the refactor +was implemented, so names and configuration examples below may be superseded. +Use the project documentation and source as the canonical references. The +phased sequence records the original construction and acceptance boundaries; +it is not a remaining-work checklist and must not be replayed. This plan intentionally makes no provision for backwards compatibility. The superseded package name, Python imports, CLI, policy schema, public classes, diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index dd6ea09b..e8cef894 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -92,7 +92,7 @@ written by a harness to disk. from egress_gate.gates import create_builtin_registry from egress_gate.service import EgressGateServer -server = EgressGateServer(create_builtin_registry(), timeout_seconds=5) +server = EgressGateServer(create_builtin_registry(), timeout_seconds=4) server.serve_sync("127.0.0.1:50051") ``` diff --git a/projects/egress-gate/docs/configuration.md b/projects/egress-gate/docs/configuration.md index 06134b11..b0e48cdc 100644 --- a/projects/egress-gate/docs/configuration.md +++ b/projects/egress-gate/docs/configuration.md @@ -32,6 +32,10 @@ network_middlewares: 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. diff --git a/projects/egress-gate/docs/gates/regex.md b/projects/egress-gate/docs/gates/regex.md index 4dc836c7..4ae31031 100644 --- a/projects/egress-gate/docs/gates/regex.md +++ b/projects/egress-gate/docs/gates/regex.md @@ -53,9 +53,12 @@ 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. -A catalog can be inline or in a relative `.yaml` or `.yml` file. The gate -rejects absolute paths, path traversal, symlinks, YAML aliases, duplicate keys, -invalid body UTF-8, unsafe patterns, and oversized catalogs. +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`, diff --git a/projects/egress-gate/src/egress_gate/gates/registry.py b/projects/egress-gate/src/egress_gate/gates/registry.py index 7208d954..711c5c9a 100644 --- a/projects/egress-gate/src/egress_gate/gates/registry.py +++ b/projects/egress-gate/src/egress_gate/gates/registry.py @@ -444,6 +444,21 @@ def _gate_kind(config_type: type[GateConfig]) -> str: def _validate_common_gate_config_fields(config_type: type[GateConfig]) -> None: + required_model_config = { + "extra": "forbid", + "strict": True, + "frozen": True, + "hide_input_in_errors": True, + "validate_default": True, + } + if any( + config_type.model_config.get(setting) != value + for setting, value in required_model_config.items() + ): + raise GateRegistryError( + "gate config must retain the strict immutable model configuration" + ) + for ancestor in config_type.__mro__: if ancestor is GateConfig: break diff --git a/projects/egress-gate/src/egress_gate/request_processor.py b/projects/egress-gate/src/egress_gate/request_processor.py index 60d2361e..ed421dc7 100644 --- a/projects/egress-gate/src/egress_gate/request_processor.py +++ b/projects/egress-gate/src/egress_gate/request_processor.py @@ -285,9 +285,21 @@ def _append_findings( total = sourced.finding.count + finding.count if total > MAX_FINDING_COUNT: raise GateLimitExceededError("finding count exceeds the limit") + try: + combined_finding = Finding( + type=finding.type, + label=finding.label, + count=total, + confidence=finding.confidence, + severity=finding.severity, + ) + except ValidationError: + raise GateLimitExceededError( + "aggregated finding exceeds the encoded size limit" + ) from None output[index] = SourcedFinding( source_gate=gate_name, - finding=sourced.finding.model_copy(update={"count": total}), + finding=combined_finding, ) break else: diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index 37f4af4c..db4ee995 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -8,7 +8,7 @@ import time from collections.abc import Callable, Iterable from concurrent.futures import Future, ThreadPoolExecutor -from threading import Event, Lock +from threading import Lock from typing import Never, Protocol, TypedDict, TypeVar import grpc @@ -213,15 +213,15 @@ async def _evaluate_http_request( if len(request.body) > MAX_BODY_BYTES: raise EgressGateError(ErrorCode.REQUEST_BODY_TOO_LARGE) _validate_evaluation_envelope(request) - publication_cancelled = Event() + publication = _PolicyPublicationGuard() result = await self._run_in_worker( lambda: self._prepare_and_process( request, timeout, - publication_cancelled=publication_cancelled, + publication=publication, ), timeout=timeout, - on_cancel=publication_cancelled.set, + on_cancel=publication.cancel, ) timeout.raise_if_expired() response, source_kind = _result_to_proto(result) @@ -233,14 +233,14 @@ def _prepare_and_process( request: pb2.HttpRequestEvaluation, timeout: Timeout, *, - publication_cancelled: Event | None = None, + publication: _PolicyPublicationGuard | None = None, ) -> EgressResult: domain_request = _request_from_proto(request) values = _mapping_from_proto(request.config) processor = self._policy.processor_for( values, timeout=timeout, - publication_cancelled=publication_cancelled, + publication=publication, ) return processor.process(domain_request, timeout=timeout) @@ -298,7 +298,7 @@ def processor_for( values: object, *, timeout: Timeout, - publication_cancelled: Event | None = None, + publication: _PolicyPublicationGuard | None = None, ) -> RequestProcessor: """Validate and activate a complete candidate under the shared deadline.""" config = self._registry.validate_config(values) @@ -311,10 +311,15 @@ def processor_for( return self._processor processor = self._registry.prepare_processor(config, timeout=timeout) timeout.raise_if_expired() - if publication_cancelled is not None and publication_cancelled.is_set(): - raise _PolicyPublicationCancelled - self._config = config - self._processor = processor + + def activate() -> None: + self._config = config + self._processor = processor + + if publication is None: + activate() + else: + publication.publish(activate) return processor except (GateConfigurationError, GateRegistryError): raise EgressGateError(ErrorCode.CONFIG_INVALID) from None @@ -342,6 +347,24 @@ class _AbortContext(Protocol): async def abort(self, code: grpc.StatusCode, details: str) -> Never: ... +class _PolicyPublicationGuard: + """Order RPC cancellation and active-policy publication atomically.""" + + def __init__(self) -> None: + self._lock = Lock() + self._cancelled = False + + def cancel(self) -> None: + with self._lock: + self._cancelled = True + + def publish(self, operation: Callable[[], None]) -> None: + with self._lock: + if self._cancelled: + raise _PolicyPublicationCancelled + operation() + + class _EvaluationLogExtra(TypedDict): request_id: str duration_ms: float diff --git a/projects/egress-gate/tests/gates/test_registry.py b/projects/egress-gate/tests/gates/test_registry.py index 736219c4..2ac6da10 100644 --- a/projects/egress-gate/tests/gates/test_registry.py +++ b/projects/egress-gate/tests/gates/test_registry.py @@ -279,6 +279,41 @@ def _evaluate( GateRegistry().register(AliasedGate) +@pytest.mark.parametrize( + "override", + ( + ConfigDict(extra="allow"), + ConfigDict(strict=False), + ConfigDict(frozen=False), + ConfigDict(hide_input_in_errors=False), + ConfigDict(validate_default=False), + ), +) +def test_registry_requires_the_strict_immutable_gate_config_contract( + override: ConfigDict, +) -> None: + class LoosenedConfig(GateConfig): + model_config = override + + kind: Literal["loosened"] + + class LoosenedGate(Gate[LoosenedConfig, None]): + capabilities = frozenset() + finding_types = () + + def _evaluate( + self, + request: HttpRequest, + *, + timeout: Timeout, + ) -> GateEvaluation: + del request, timeout + return GateEvaluation.proceed() + + with pytest.raises(GateRegistryError, match="strict immutable"): + GateRegistry().register(LoosenedGate) + + def test_registry_forwards_the_shared_preparation_timeout() -> None: registry = GateRegistry() registry.register(_RegistryGate) diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 5d894f80..52242d31 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -4,6 +4,7 @@ import asyncio import logging +from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from threading import Barrier, Event from typing import Never @@ -472,6 +473,59 @@ def blocked_build( await middleware.close() +@pytest.mark.asyncio +async def test_cancellation_at_publication_boundary_keeps_the_active_policy( + monkeypatch: pytest.MonkeyPatch, +) -> None: + middleware = EgressGateMiddleware( + create_builtin_registry(), + timeout_seconds=5, + ) + old = middleware._policy.processor_for(_values(), timeout=Timeout.from_seconds(1)) + started = Event() + release = Event() + original_publish = servicer_module._PolicyPublicationGuard.publish + + def blocked_publish( + publication: servicer_module._PolicyPublicationGuard, + operation: Callable[[], None], + ) -> None: + started.set() + assert release.wait(2) + original_publish(publication, operation) + + monkeypatch.setattr( + servicer_module._PolicyPublicationGuard, + "publish", + blocked_publish, + ) + 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 + + release.set() + for _ in range(100): + if middleware._processing_slots._value == 4: + break + await asyncio.sleep(0.01) + + assert middleware._processing_slots._value == 4 + assert middleware._policy._processor is old + finally: + release.set() + await middleware.close() + + @pytest.mark.asyncio async def test_result_serialization_is_bracketed_by_the_shared_timeout( monkeypatch: pytest.MonkeyPatch, diff --git a/projects/egress-gate/tests/test_request_processor.py b/projects/egress-gate/tests/test_request_processor.py index c47ce22b..05dcd094 100644 --- a/projects/egress-gate/tests/test_request_processor.py +++ b/projects/egress-gate/tests/test_request_processor.py @@ -48,6 +48,8 @@ ) from egress_gate.timeout import Timeout +_BOUNDARY_FINDING_TYPE = "t" * 1024 + class _ControlConfig(GateConfig): kind: Literal["test-control"] @@ -59,6 +61,7 @@ class _ControlConfig(GateConfig): finding_label: str | None = None finding_count: int = 1 emit_twice: bool = False + boundary_finding: bool = False reason_code: str | None = None @@ -72,7 +75,10 @@ class _ControlGate(Gate[_ControlConfig, None]): GateCapability.DENY, } ) - finding_types = (FindingTypeDefinition(type="test_observation"),) + finding_types = ( + FindingTypeDefinition(type="test_observation"), + FindingTypeDefinition(type=_BOUNDARY_FINDING_TYPE), + ) def _evaluate( self, @@ -87,7 +93,16 @@ def _evaluate( ): raise AssertionError("later gate did not see the current request") findings: tuple[Finding, ...] = () - if self.config.finding_label is not None: + 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, @@ -307,6 +322,27 @@ def test_processor_aggregates_equivalent_findings_by_gate_provenance() -> None: 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( ( diff --git a/tests/navigation-drawer.test.js b/tests/navigation-drawer.test.js index 2710f2b6..d018b976 100644 --- a/tests/navigation-drawer.test.js +++ b/tests/navigation-drawer.test.js @@ -255,7 +255,11 @@ test("keyboard control, Escape, and visible focus endpoints work", () => { fixture.button.focus(); fixture.document.dispatchEvent(new TestEvent("keydown", { key: "Enter" })); - assert.equal(fixture.toggle.checked, false, "Zensical owns Enter activation"); + assert.equal(fixture.toggle.checked, true); + + fixture.button.focus(); + fixture.document.dispatchEvent(new TestEvent("keydown", { key: "Enter" })); + assert.equal(fixture.toggle.checked, false); fixture.document.dispatchEvent(new TestEvent("keydown", { key: " " })); assert.equal(fixture.toggle.checked, true); From decc13d98f4a7146ec47ab9ba0afb933734c946c Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 6 Aug 2026 02:51:48 +0000 Subject: [PATCH 45/46] refactor(egress-gate): simplify final review fixes --- projects/egress-gate/README.md | 2 +- .../src/egress_gate/request_processor.py | 14 ++--- .../egress-gate/src/egress_gate/result.py | 11 +++- .../src/egress_gate/service/servicer.py | 47 +-------------- .../tests/service/test_servicer.py | 58 +------------------ projects/egress-gate/tests/test_result.py | 2 +- 6 files changed, 18 insertions(+), 116 deletions(-) diff --git a/projects/egress-gate/README.md b/projects/egress-gate/README.md index e8cef894..84f37e35 100644 --- a/projects/egress-gate/README.md +++ b/projects/egress-gate/README.md @@ -92,7 +92,7 @@ written by a harness to disk. from egress_gate.gates import create_builtin_registry from egress_gate.service import EgressGateServer -server = EgressGateServer(create_builtin_registry(), timeout_seconds=4) +server = EgressGateServer(create_builtin_registry()) server.serve_sync("127.0.0.1:50051") ``` diff --git a/projects/egress-gate/src/egress_gate/request_processor.py b/projects/egress-gate/src/egress_gate/request_processor.py index ed421dc7..06e39618 100644 --- a/projects/egress-gate/src/egress_gate/request_processor.py +++ b/projects/egress-gate/src/egress_gate/request_processor.py @@ -12,6 +12,7 @@ DEFAULT_DENY_REASON_CODE, LIMIT_REASON_CODE, MAX_FINDING_COUNT, + MAX_PROTO_FINDING_BYTES, MAX_PROTO_FINDING_GROUPS, ) from egress_gate.errors import ( @@ -285,18 +286,11 @@ def _append_findings( total = sourced.finding.count + finding.count if total > MAX_FINDING_COUNT: raise GateLimitExceededError("finding count exceeds the limit") - try: - combined_finding = Finding( - type=finding.type, - label=finding.label, - count=total, - confidence=finding.confidence, - severity=finding.severity, - ) - except ValidationError: + 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" - ) from None + ) output[index] = SourcedFinding( source_gate=gate_name, finding=combined_finding, diff --git a/projects/egress-gate/src/egress_gate/result.py b/projects/egress-gate/src/egress_gate/result.py index 4258abfc..8266f7af 100644 --- a/projects/egress-gate/src/egress_gate/result.py +++ b/projects/egress-gate/src/egress_gate/result.py @@ -77,8 +77,9 @@ class Finding(StrictDomainModel): confidence: BoundedMetadataString | None = None severity: BoundedMetadataString | None = None - @model_validator(mode="after") - def _wire_size_is_bounded(self) -> Self: + @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) @@ -89,7 +90,11 @@ def _wire_size_is_bounded(self) -> Self: encoded_size += _encoded_string_field_size(self.confidence) if self.severity is not None: encoded_size += _encoded_string_field_size(self.severity) - if encoded_size > MAX_PROTO_FINDING_BYTES: + 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 diff --git a/projects/egress-gate/src/egress_gate/service/servicer.py b/projects/egress-gate/src/egress_gate/service/servicer.py index db4ee995..965365be 100644 --- a/projects/egress-gate/src/egress_gate/service/servicer.py +++ b/projects/egress-gate/src/egress_gate/service/servicer.py @@ -213,15 +213,12 @@ async def _evaluate_http_request( if len(request.body) > MAX_BODY_BYTES: raise EgressGateError(ErrorCode.REQUEST_BODY_TOO_LARGE) _validate_evaluation_envelope(request) - publication = _PolicyPublicationGuard() result = await self._run_in_worker( lambda: self._prepare_and_process( request, timeout, - publication=publication, ), timeout=timeout, - on_cancel=publication.cancel, ) timeout.raise_if_expired() response, source_kind = _result_to_proto(result) @@ -232,15 +229,12 @@ def _prepare_and_process( self, request: pb2.HttpRequestEvaluation, timeout: Timeout, - *, - publication: _PolicyPublicationGuard | None = None, ) -> EgressResult: domain_request = _request_from_proto(request) values = _mapping_from_proto(request.config) processor = self._policy.processor_for( values, timeout=timeout, - publication=publication, ) return processor.process(domain_request, timeout=timeout) @@ -249,7 +243,6 @@ async def _run_in_worker( operation: Callable[[], _WorkerResultT], *, timeout: Timeout | None = None, - on_cancel: Callable[[], None] | None = None, ) -> _WorkerResultT: """Run one bounded synchronous operation without blocking the event loop.""" try: @@ -271,12 +264,7 @@ async def _run_in_worker( self._processing_slots.release() raise future.add_done_callback(self._worker_finished) - try: - return await asyncio.shield(future) - except asyncio.CancelledError: - if on_cancel is not None: - on_cancel() - raise + return await asyncio.shield(future) def _worker_finished(self, future: asyncio.Future[object]) -> None: self._processing_slots.release() @@ -298,7 +286,6 @@ def processor_for( values: object, *, timeout: Timeout, - publication: _PolicyPublicationGuard | None = None, ) -> RequestProcessor: """Validate and activate a complete candidate under the shared deadline.""" config = self._registry.validate_config(values) @@ -312,14 +299,8 @@ def processor_for( processor = self._registry.prepare_processor(config, timeout=timeout) timeout.raise_if_expired() - def activate() -> None: - self._config = config - self._processor = processor - - if publication is None: - activate() - else: - publication.publish(activate) + self._config = config + self._processor = processor return processor except (GateConfigurationError, GateRegistryError): raise EgressGateError(ErrorCode.CONFIG_INVALID) from None @@ -347,24 +328,6 @@ class _AbortContext(Protocol): async def abort(self, code: grpc.StatusCode, details: str) -> Never: ... -class _PolicyPublicationGuard: - """Order RPC cancellation and active-policy publication atomically.""" - - def __init__(self) -> None: - self._lock = Lock() - self._cancelled = False - - def cancel(self) -> None: - with self._lock: - self._cancelled = True - - def publish(self, operation: Callable[[], None]) -> None: - with self._lock: - if self._cancelled: - raise _PolicyPublicationCancelled - operation() - - class _EvaluationLogExtra(TypedDict): request_id: str duration_ms: float @@ -602,8 +565,4 @@ def _limit_deny() -> pb2.HttpRequestResult: _MAX_PROTO_SAFE_INTEGER = (1 << 53) - 1 -class _PolicyPublicationCancelled(Exception): - """Signal that a disconnected RPC no longer owns candidate publication.""" - - __all__ = ["EgressGateMiddleware"] diff --git a/projects/egress-gate/tests/service/test_servicer.py b/projects/egress-gate/tests/service/test_servicer.py index 52242d31..018c95df 100644 --- a/projects/egress-gate/tests/service/test_servicer.py +++ b/projects/egress-gate/tests/service/test_servicer.py @@ -4,7 +4,6 @@ import asyncio import logging -from collections.abc import Callable from concurrent.futures import ThreadPoolExecutor from threading import Barrier, Event from typing import Never @@ -423,14 +422,13 @@ def test_in_flight_processor_reference_survives_policy_replacement() -> None: @pytest.mark.asyncio -async def test_cancelled_candidate_keeps_its_slot_and_is_not_published( +async def test_cancelled_candidate_keeps_its_slot_until_worker_exits( monkeypatch: pytest.MonkeyPatch, ) -> None: middleware = EgressGateMiddleware( create_builtin_registry(), timeout_seconds=5, ) - old = middleware._policy.processor_for(_values(), timeout=Timeout.from_seconds(1)) started = Event() release = Event() original_build = middleware._registry.prepare_processor @@ -467,60 +465,6 @@ def blocked_build( await asyncio.sleep(0.01) assert middleware._processing_slots._value == 4 - assert middleware._policy._processor is old - finally: - release.set() - await middleware.close() - - -@pytest.mark.asyncio -async def test_cancellation_at_publication_boundary_keeps_the_active_policy( - monkeypatch: pytest.MonkeyPatch, -) -> None: - middleware = EgressGateMiddleware( - create_builtin_registry(), - timeout_seconds=5, - ) - old = middleware._policy.processor_for(_values(), timeout=Timeout.from_seconds(1)) - started = Event() - release = Event() - original_publish = servicer_module._PolicyPublicationGuard.publish - - def blocked_publish( - publication: servicer_module._PolicyPublicationGuard, - operation: Callable[[], None], - ) -> None: - started.set() - assert release.wait(2) - original_publish(publication, operation) - - monkeypatch.setattr( - servicer_module._PolicyPublicationGuard, - "publish", - blocked_publish, - ) - 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 - - release.set() - for _ in range(100): - if middleware._processing_slots._value == 4: - break - await asyncio.sleep(0.01) - - assert middleware._processing_slots._value == 4 - assert middleware._policy._processor is old finally: release.set() await middleware.close() diff --git a/projects/egress-gate/tests/test_result.py b/projects/egress-gate/tests/test_result.py index bcdcff37..3dbd31ad 100644 --- a/projects/egress-gate/tests/test_result.py +++ b/projects/egress-gate/tests/test_result.py @@ -74,7 +74,7 @@ def test_finding_encoded_size_has_an_exact_four_kibibyte_boundary() -> None: confidence="c" * 1024, severity="s" * 1010, ) - assert exact.model_dump() + assert exact.encoded_size_bytes == MAX_PROTO_FINDING_BYTES with pytest.raises(ValidationError): Finding( From fcc11005380ccc5d6affd901811a0dbbf603bc18 Mon Sep 17 00:00:00 2001 From: Johnny Greco Date: Thu, 6 Aug 2026 02:55:03 +0000 Subject: [PATCH 46/46] refactor(egress-gate): trust inherited config defaults --- .../src/egress_gate/gates/registry.py | 15 -------- .../egress-gate/tests/gates/test_registry.py | 35 ------------------- 2 files changed, 50 deletions(-) diff --git a/projects/egress-gate/src/egress_gate/gates/registry.py b/projects/egress-gate/src/egress_gate/gates/registry.py index 711c5c9a..7208d954 100644 --- a/projects/egress-gate/src/egress_gate/gates/registry.py +++ b/projects/egress-gate/src/egress_gate/gates/registry.py @@ -444,21 +444,6 @@ def _gate_kind(config_type: type[GateConfig]) -> str: def _validate_common_gate_config_fields(config_type: type[GateConfig]) -> None: - required_model_config = { - "extra": "forbid", - "strict": True, - "frozen": True, - "hide_input_in_errors": True, - "validate_default": True, - } - if any( - config_type.model_config.get(setting) != value - for setting, value in required_model_config.items() - ): - raise GateRegistryError( - "gate config must retain the strict immutable model configuration" - ) - for ancestor in config_type.__mro__: if ancestor is GateConfig: break diff --git a/projects/egress-gate/tests/gates/test_registry.py b/projects/egress-gate/tests/gates/test_registry.py index 2ac6da10..736219c4 100644 --- a/projects/egress-gate/tests/gates/test_registry.py +++ b/projects/egress-gate/tests/gates/test_registry.py @@ -279,41 +279,6 @@ def _evaluate( GateRegistry().register(AliasedGate) -@pytest.mark.parametrize( - "override", - ( - ConfigDict(extra="allow"), - ConfigDict(strict=False), - ConfigDict(frozen=False), - ConfigDict(hide_input_in_errors=False), - ConfigDict(validate_default=False), - ), -) -def test_registry_requires_the_strict_immutable_gate_config_contract( - override: ConfigDict, -) -> None: - class LoosenedConfig(GateConfig): - model_config = override - - kind: Literal["loosened"] - - class LoosenedGate(Gate[LoosenedConfig, None]): - capabilities = frozenset() - finding_types = () - - def _evaluate( - self, - request: HttpRequest, - *, - timeout: Timeout, - ) -> GateEvaluation: - del request, timeout - return GateEvaluation.proceed() - - with pytest.raises(GateRegistryError, match="strict immutable"): - GateRegistry().register(LoosenedGate) - - def test_registry_forwards_the_shared_preparation_timeout() -> None: registry = GateRegistry() registry.register(_RegistryGate)